Compare commits

..

257 Commits

Author SHA1 Message Date
Julien Fontanet
d274c34b6b fix(xapi): support ISO 8601 datetime format
Fixes #6082
2022-01-13 15:23:59 +01:00
Florent BEAUCHAMP
b78a946458 feat(proxy): implement reverse proxies (#6072) 2022-01-13 14:54:10 +01:00
Julien Fontanet
e8a5694d51 feat(backups/_cleanVm): clean orphan mergeState (#6087)
Fixes zammad#4778
2022-01-13 10:41:39 +01:00
Julien Fontanet
514fa72ee2 fix(package.json/jest): vhd-lib no longer has a build step
Introduced by 3a74c71f1
2022-01-12 22:50:49 +01:00
Julien Fontanet
e9ca13aa12 fix(backups/cleanVm): handle zstd-compressed XVAs
Related to zammad#4300
2022-01-12 11:31:09 +01:00
Julien Fontanet
57f1ec6716 chore(backups/_cleanVm/listVhds): make vhds directly a Set 2022-01-11 15:31:56 +01:00
Julien Fontanet
02e32cc9b9 chore(backups/_cleanVm/listVhds): minor simplification
This also removes the incorrect handling of an optional dir in `INTERRUPTED_VHDS_REG`.
2022-01-11 15:09:18 +01:00
Julien Fontanet
902abd5d94 chore: update deps 2022-01-06 13:59:31 +01:00
Julien Fontanet
53380802ec feat(xo-server): limit VM migration concurrency (#6076)
Related to #6065
2022-01-06 09:32:42 +01:00
Julien Fontanet
af5d8d02b6 feat: release 5.66.2 2022-01-05 11:30:29 +01:00
Julien Fontanet
7abba76f03 feat(CHANGELOG): integrate released changes 2022-01-05 10:36:05 +01:00
Julien Fontanet
79b22057d9 feat(xo-web): 5.91.2 2022-01-05 10:34:30 +01:00
Julien Fontanet
366daef718 feat(xo-server): 5.86.3 2022-01-05 10:33:30 +01:00
Julien Fontanet
a5ff0ba799 feat(@xen-orchestra/proxy): 0.17.3 2022-01-05 10:32:42 +01:00
Julien Fontanet
c2c6febb88 feat(@xen-orchestra/backups): 0.18.3 2022-01-05 10:18:02 +01:00
Julien Fontanet
f119c72a7f feat(xo-vmdk-to-vhd): 2.0.3 2022-01-05 10:16:47 +01:00
Julien Fontanet
8aee897d23 feat(vhd-lib): 3.0.0 2022-01-05 10:15:45 +01:00
Florent BEAUCHAMP
729db5c662 fix(backups): race condition in checkBaseVdi preventing delta backup (#6075)
Fixes zammad#4751, zammad#4729, zammad#4665 and zammad#4300
2022-01-05 09:58:06 +01:00
Julien Fontanet
61c46df7bf chore(xo-server): dont pass (unused) httpServer to app 2022-01-03 16:04:18 +01:00
Julien Fontanet
9b1a04338d chore(xo-server): attach express before creating app 2022-01-03 15:46:30 +01:00
Julien Fontanet
d307134d22 chore(xapi/_assertHealthyVdiChain): clearer warnings in case of missing VDI 2021-12-28 18:14:32 +01:00
Julien Fontanet
5bc44363f9 fix(xo-vmdk-to-vhd): fix createReadableSparseStream import
Introduced by 3a74c71f1

Fixes #6068
2021-12-23 23:40:58 +01:00
Julien Fontanet
68c4fac3ab chore: update deps 2021-12-23 13:25:48 +01:00
Julien Fontanet
6ad9245019 feat: release 5.66.1 2021-12-23 13:25:08 +01:00
Julien Fontanet
763cf771fb feat(CHANGELOG): integrate released changes 2021-12-23 12:18:50 +01:00
Julien Fontanet
3160b08637 feat(xo-web): 5.91.1 2021-12-23 12:18:14 +01:00
Julien Fontanet
f8949958a3 feat(xo-server): 5.86.2 2021-12-23 12:17:54 +01:00
Julien Fontanet
8b7ac07d2d feat(@xen-orchestra/proxy): 0.17.2 2021-12-23 12:17:25 +01:00
Julien Fontanet
044df9adba feat(@xen-orchestra/backups): 0.18.2 2021-12-23 12:16:53 +01:00
Julien Fontanet
040139f4cc fix(backups/cleanVm): computeVhdSize can return undefined 2021-12-23 12:09:11 +01:00
Julien Fontanet
7b73bb9df0 chore: format with Prettier 2021-12-23 12:06:11 +01:00
Julien Fontanet
24c8370daa fix(xo-server-test): add missing ESLint config 2021-12-23 11:58:14 +01:00
Julien Fontanet
029c4921d7 fix(backups/RemoteAdapter#isMergeableParent): #useVhdDirectory is a function (#6070)
Fixes zammad#4646
Fixes https://xcp-ng.org/forum/topic/5371/delta-backup-changes-in-5-66

Introduced by 5d605d1bd
2021-12-23 11:57:51 +01:00
Julien Fontanet
3a74c71f1a chore(vhd-lib): remove build step
BREAKING:
- removes `dist/` in the path of sub-modules
- requires Node >=12
2021-12-23 10:31:29 +01:00
Julien Fontanet
6022a1bbaa feat(normalize-packages): delete unused Babel configs 2021-12-23 09:26:00 +01:00
Julien Fontanet
4e88c993f7 chore: update dev deps 2021-12-22 11:07:25 +01:00
Julien Fontanet
c9a61f467c fix(xo-web/Dashboard/Health): handle no default_SR
Fixes zammad#4640

Introduced by 7bacd781c
2021-12-22 10:33:18 +01:00
Julien Fontanet
e6a5f42f63 feat: release 5.66.0 2021-12-21 18:00:39 +01:00
Julien Fontanet
a373823eea feat(xo-server): 5.86.1 2021-12-21 17:58:02 +01:00
Julien Fontanet
b5e010eac8 feat(@xen-orchestra/proxy): 0.17.1 2021-12-21 17:57:47 +01:00
Julien Fontanet
50ffe58655 feat(@xen-orchestra/backups): 0.18.1 2021-12-21 17:56:55 +01:00
Julien Fontanet
07eb3b59b3 feat(@xen-orchestra/mixins): 0.1.2 2021-12-21 17:56:52 +01:00
Julien Fontanet
5177b5e142 chore(backups/RemoteAdapter): remove default value for vhdDirectoryCompression
Introduced by 3c984e21c
2021-12-21 17:51:23 +01:00
Julien Fontanet
3c984e21cd fix({proxy,xo-server}): add backup.vhdDirectoryCompression setting
Introduced by 5d605d1bd
2021-12-21 17:49:43 +01:00
Julien Fontanet
aa2b27e22b fix(mixins/Config#get): fix missing entry error message 2021-12-21 17:37:07 +01:00
Julien Fontanet
14a7f00c90 chore(CHANGELOG): remove non-breakable spaces 2021-12-21 17:31:51 +01:00
Julien Fontanet
56f98601bd feat(CHANGELOG): integrate released changes 2021-12-21 17:24:19 +01:00
Julien Fontanet
027a8c675e feat(@xen-orchestra/proxy): 0.17.0 2021-12-21 17:22:29 +01:00
Julien Fontanet
bdaba9a767 feat(xo-server): 5.86.0 2021-12-21 17:22:07 +01:00
Julien Fontanet
4e9090f60d feat(@xen-orchestra/backups): 0.18.0 2021-12-21 17:21:37 +01:00
Julien Fontanet
73b445d371 feat(xo-vmdk-to-vhd): 2.0.2 2021-12-21 17:21:10 +01:00
Julien Fontanet
75bfc283af feat(vhd-lib): 2.1.0 2021-12-21 17:20:36 +01:00
Julien Fontanet
727de19b89 feat(@xen-orchestra/xapi): 0.8.5 2021-12-21 17:20:06 +01:00
Florent BEAUCHAMP
5d605d1bd7 feat(backups): compress VHDs on S3 (#5932) 2021-12-21 17:18:27 +01:00
Julien Fontanet
ffdd1dfd6f fix(xo-vmdk-to-vhd): avoid requiring whole vhd-lib
This library is used in the browser and a lot of parts of `vhd-lib` are not intended to be used in (or bundled for) the browser.
2021-12-21 17:10:33 +01:00
Julien Fontanet
d45418eb29 fix(backups/cleanVm): metadata.vhds is an object, not an array
Introduced by 93069159d
2021-12-21 16:23:03 +01:00
Julien Fontanet
6ccc9d1ade fix(xapi/VM_create): support NVRAM field (#6062)
Fixes #6054
Fixes https://xcp-ng.org/forum/topic/5319/bug-uefi-boot-parameters-not-preserved-with-delta-backups
2021-12-20 16:30:41 +01:00
Florent BEAUCHAMP
93069159dd fix(backups/cleanVm): don't warn on size change due to merged VHDs (#6010) 2021-12-20 14:57:54 +01:00
Julien Fontanet
8c4780131f feat: release 5.65.3 2021-12-20 10:50:51 +01:00
Julien Fontanet
02ae8bceda fix(backups/cleanVm): dont fail on broken metadata 2021-12-20 09:49:27 +01:00
Julien Fontanet
bb10bbc945 chore(backups/cleanVm): remove deleted files from jsons 2021-12-20 09:46:09 +01:00
Florent BEAUCHAMP
478d88e97f fix(fs/s3#_rmtree): infinite loop (#6067) 2021-12-17 16:01:57 +01:00
Florent BEAUCHAMP
6fb397a729 fix(vhd-lib): parseVhdStream int overflow when rebuilding the bat (#6066)
BAT should contain sector address, not byte address

We were not really rebuilding the BAT, since we were using the data read in the old bat and write it as is in the new one
2021-12-17 14:28:48 +01:00
Julien Fontanet
18dae34778 feat(vhd-lib/parseVhdStream): new public method (#6063)
Extracted from `createVhdDirectoryFromStream`

Co-authored-by: Florent Beauchamp <flo850@free.fr>
2021-12-17 10:08:29 +01:00
Julien Fontanet
243566e936 fix(xen-api): named import for @vates/coalesce-calls
Introduced by 87f4fd675
2021-12-16 14:00:49 +01:00
Julien Fontanet
87f4fd675d fix(xen-api): fix coalesceCalls
Introduced by dec6b59a9
2021-12-16 13:26:31 +01:00
Julien Fontanet
dec6b59a9f chore(xen-api): use @vates/coalesce-calls 2021-12-16 12:03:07 +01:00
Rajaa.BARHTAOUI
e51baedf7f feat: technical release (#6060) 2021-12-16 12:01:57 +01:00
Julien Fontanet
530da14e24 feat(@vates/decorate-with): 1.0.0 2021-12-16 11:49:29 +01:00
Julien Fontanet
02da7c272f feat(decorate-with): perInstance helper 2021-12-16 11:48:48 +01:00
Pierre Donias
a07c5418e9 feat(xo-server,xo-web): disable HA during Rolling Pool Update (#6057)
See #5711
2021-12-16 10:29:13 +01:00
Mathieu
c080db814b feat(xo-web/home/backed up VMs): filter out VMs in disabled backup jobs (#6037)
See xoa-support#4294
2021-12-16 10:06:45 +01:00
Julien Fontanet
3abe13c006 chore(backups/RemoteAdapter#deleteVmBackups): report unsupported backup modes
It was removed in 7e302fd1c
2021-12-16 10:05:08 +01:00
Julien Fontanet
fb331c0a2c fix(backups/RemoteAdapter#deleteVmBackups): dont delete undefined
Fixes https://xcp-ng.org/forum/topic/5331/backup-smart-mode-broken/6
Introduced by 7e302fd1c
2021-12-16 10:03:16 +01:00
Julien Fontanet
19ea78afc5 fix(xo-server): fix job matching for smart mode
Fixes https://xcp-ng.org/forum/topic/5331/backup-smart-mode-broken
Fixes #6058

Introduced by cf9f0da6e

XO VM objects have a `other` field instead of `other_config`.
2021-12-15 23:25:04 +01:00
Julien Fontanet
2096c782e3 feat(xo-server/api): new method backupNg.deleteVmBackups
Related to 7e302fd1c
2021-12-15 17:36:47 +01:00
Julien Fontanet
79a6a8a10c feat(proxy/api): new method backup.deleteVmBackups
Related to 7e302fd1c
2021-12-15 17:27:08 +01:00
Julien Fontanet
5a933bad93 fix(vhd-lib/merge): dont fail on invalid state file
Fixes zammad#4227
2021-12-15 16:36:18 +01:00
Julien Fontanet
7e302fd1cb feat(backups/RemoteAdapter): new method deleteVmBackups()
It's usually best to delete multiple backups at once instead of one by one because it allows some optimizations, for instance when merging unused VHDs.

This was already possible in private methods but not exposed in the public API.
2021-12-15 16:34:35 +01:00
Julien Fontanet
cf9f0da6e5 fix(backups): ignore VMs created by current job
See xoa-support#4271
2021-12-14 12:07:51 +01:00
Pierre Donias
10ac23e265 feat(docs/Netbox): specify minimum required permissions (#6047)
See https://xcp-ng.org/forum/topic/5300/
2021-12-14 11:48:19 +01:00
Rajaa.BARHTAOUI
dc2e1cba1f feat(xo-web/pool,VM/advanced): ability to set suspend SR (#6044)
Fixes #4163
2021-12-14 10:13:11 +01:00
Julien Fontanet
7bfd190c22 fix(backups/_VmBackup): no base VM when no base VDIs found
Introduced by 5b188f35b
2021-12-13 17:55:54 +01:00
Manon Mercier
c3bafeb468 fix(docs/xoa): must reboot after changing password (#6056)
Co-authored-by: Jon Sands <fohdeesha@gmail.com>
2021-12-13 17:48:14 +01:00
Mathieu
7bacd781cf feat(xo-web/health): display non shared default SRs (#6033)
Fixes #5871
2021-12-13 10:00:24 +01:00
Julien Fontanet
ee005c3679 fix(xo-server-usage-report): csv-stringify usage
Fixes #6053

Introduced by b179dc1d5
2021-12-13 09:39:00 +01:00
Florent BEAUCHAMP
315f54497a fix(vhd-lib/resolveAlias): limit size (#6004)
Current resolution loads whole file in memory. It can lead to crash is the alias is malformed (for example a full VHD named `.alias.vhd`).
2021-12-12 14:19:40 +01:00
Julien Fontanet
e30233347b feat: release 5.65.2 2021-12-10 17:19:54 +01:00
Julien Fontanet
56d4a7f01e fix(xo-web/about): show commit iff available & always versions
Fixes #6052
2021-12-10 16:40:26 +01:00
Julien Fontanet
7c110eebd8 feat(CHANGELOG): integrate released changes 2021-12-10 12:06:26 +01:00
Julien Fontanet
39394f8c09 feat(@xen-orchestra/proxy): 0.15.5 2021-12-10 12:04:33 +01:00
Julien Fontanet
3283130dfc feat(xo-server): 5.84.3 2021-12-10 12:04:33 +01:00
Julien Fontanet
3146a591d0 feat(@xen-orchestra/backups): 0.16.2 2021-12-10 12:04:30 +01:00
Julien Fontanet
e478b1ec04 feat(vhd-lib): 2.0.3 2021-12-10 12:04:00 +01:00
Julien Fontanet
7bc4d14f46 feat(@xen-orchestra/fs): 0.19.2 2021-12-10 11:38:20 +01:00
Florent BEAUCHAMP
f3eeeef389 fix(fs/S3#list): should not look into all the file tree (#6048) 2021-12-09 14:06:21 +01:00
Florent BEAUCHAMP
8d69208197 fix(backups/MixinBackupWriter#_cleanVm): always returns an object (#6050) 2021-12-09 10:38:31 +01:00
Julien Fontanet
2c689af1a9 fix(backups): add random suffixes to task files to avoid collitions
See https://xcp-ng.org/forum/post/44661
2021-12-08 18:03:13 +01:00
Julien Fontanet
cb2a34c765 feat(backups/RemoteAdapter#cleanVm): show missing VHDs
Related to investigation on zammad#4156
2021-12-08 11:41:18 +01:00
Julien Fontanet
465c8f9009 feat(xo-web/about): show build commit in sources version (#6045) 2021-12-08 09:57:15 +01:00
Florent BEAUCHAMP
8ea4c1c1fd fix(vhd-lib): output parent locator in VhdAbstract#stream (#6035) 2021-12-07 14:14:39 +01:00
Julien Fontanet
ba0f7df9e8 feat(xapi-explore-sr): 0.4.1 2021-12-06 17:59:27 +01:00
Julien Fontanet
e47dd723b0 fix(xapi-explore-sr): add missing @xen-orchestra/defined dep
Introduced by 2412f8b1e
2021-12-06 17:43:47 +01:00
Julien Fontanet
fca6e2f6bf fix(fs/S3#_list): throw if result is truncated 2021-12-06 14:25:55 +01:00
Florent BEAUCHAMP
faa7ba6f24 fix(vhd-lib, fs): use rmtree and not rmTree (#6041) 2021-12-06 14:25:09 +01:00
Julien Fontanet
fc2dbbe3ee feat: release 5.65.1 2021-12-03 16:42:11 +01:00
Julien Fontanet
cc98b81825 fix(CHANGELOG): incorrect secion name Packages to release
Introduced in ae24b10da
2021-12-03 15:29:48 +01:00
Julien Fontanet
eb4a7069d4 feat(CHANGELOG): integrate released changes 2021-12-03 15:27:54 +01:00
Julien Fontanet
4f65d9214e feat(xo-server): 5.84.2 2021-12-03 15:23:59 +01:00
Julien Fontanet
4d3c8ee63c feat(@xen-orchestra/proxy): 0.15.4 2021-12-03 15:23:26 +01:00
Julien Fontanet
e41c1b826a feat(@xen-orchestra/backups): 0.16.1 2021-12-03 15:23:02 +01:00
Julien Fontanet
644bb48135 feat(xo-vmdk-to-vhd): 2.0.1 2021-12-03 15:22:23 +01:00
Julien Fontanet
c9809285f6 feat(vhd-lib): 2.0.2 2021-12-03 15:19:07 +01:00
Julien Fontanet
5704949f4d feat(@vates/compose): 2.1.0 2021-12-03 15:17:47 +01:00
Julien Fontanet
a19e00fbc0 fix(backups/_VmBackup#_selectBaseVm): cant read .uuid of undefined srcVdi (#6034)
See xoa-support#4263

The debug message is now clearer and has correct associated data.
2021-12-03 10:22:17 +01:00
Julien Fontanet
470a9b3e27 chore(decorate-with/README): document usage with @vates/compose 2021-12-02 21:37:25 +01:00
Julien Fontanet
ace31dc566 feat(compose): supports attaching extra params 2021-12-02 21:37:25 +01:00
Julien Fontanet
ed252276cb fix(compose): dont mutate passed functions array 2021-12-02 21:37:25 +01:00
Julien Fontanet
26d0ff3c9a fix(vhd-lib/VhdAbtract#stream): explicitely ignore differencing
Because parentLocator entries handling are broken.
2021-12-02 16:48:19 +01:00
Florent Beauchamp
ff806a3ff9 fix(vhd-lib): use parent locator of root disk in VhdSynthetic 2021-12-02 16:48:19 +01:00
Florent Beauchamp
949b17dee6 fix(vhd-lib): fix footer and header accessor in vhd hierarchy 2021-12-02 16:48:19 +01:00
Florent Beauchamp
b1fdc68623 fix(vhd-lib): platformDataSpace in sectors not bytes 2021-12-02 16:48:19 +01:00
Florent BEAUCHAMP
f502facfd1 fix(backup): createAlias to data instead of circular alias (#6029) 2021-12-02 13:56:20 +01:00
Mathieu
bf0a74d709 fix(xo-web/SortedTable): properly disable collapsed actions (#6023) 2021-12-02 13:48:22 +01:00
Julien Fontanet
7296d98313 fix(backups/RemoteAdapter#_createSyntheticStream): only dispose once
See https://xcp-ng.org/forum/topic/5257/problems-building-from-source/20
2021-12-01 13:24:46 +01:00
Julien Fontanet
30568ced49 fix(vhd-lib/VhdSynthetic): fix parent UUID assert
See https://xcp-ng.org/forum/topic/5257/problems-building-from-source
2021-12-01 12:54:00 +01:00
Julien Fontanet
5e1284a9e0 chore: refresh yarn.lock
Introduced by 03d6e3356 due to extra files in my repo…
2021-12-01 12:33:43 +01:00
Julien Fontanet
27d2de872a chore: update to lint-staged@^12.0.3
See https://xcp-ng.org/forum/topic/5257/problems-building-from-source

Fix missing peer dependency `inquirer`.
2021-12-01 12:19:19 +01:00
Julien Fontanet
03d6e3356b chore: refresh yarn.lock 2021-12-01 12:17:52 +01:00
Julien Fontanet
ca8baa62fb fix(xo-vmdk-to-vhd): remove duplicate promise-toolbox dep
See https://xcp-ng.org/forum/topic/5257/problems-building-from-source
2021-12-01 12:17:25 +01:00
Florent BEAUCHAMP
2f607357c6 feat: release 5.65 (#6028) 2021-11-30 17:45:31 +01:00
Julien Fontanet
2de80f7aff feat(xo-server): 5.84.1 2021-11-30 17:04:37 +01:00
Julien Fontanet
386058ed88 chore(CHANGELOG): update vhd-lib version
Introduced by 033fa9e067
2021-11-30 17:04:25 +01:00
Julien Fontanet
033fa9e067 feat(vhd-lib): 2.0.1 2021-11-30 17:00:49 +01:00
Julien Fontanet
e8104420b5 fix(vhd-lib): add missing @vates/async-each dep
Introduced by 56c3d70149
2021-11-30 16:59:01 +01:00
Florent BEAUCHAMP
ae24b10da0 feat: technical release (#6025) 2021-11-30 15:45:36 +01:00
Florent BEAUCHAMP
407b05b643 fix(backups): use the full VHD hierarchy for restore (#6027) 2021-11-30 15:27:54 +01:00
Julien Fontanet
79bf8bc9f6 fix(xo-server): add missing complex-matcher dep
Introduced by 65d6dca52
2021-11-30 09:35:10 +01:00
Julien Fontanet
65d6dca52c feat(xo-server/xo.getAllObjects): add complex-matcher support
See https://xcp-ng.org/forum/topic/5238/xo-cli-command-list-vms-which-ha-snapshots
2021-11-29 19:00:44 +01:00
Julien Fontanet
66eeefbd7b feat(xo-server/vm.set): suspendSr support
See #4163
2021-11-29 14:44:02 +01:00
Mathieu
c10bbcde00 feat(xo-web,xo-server/snapshot): ability to export snapshot memory (#6015)
See xoa-support#4113
2021-11-29 14:08:02 +01:00
Julien Fontanet
fe69928bcc feat(xo-server/pool.set): suspendSr support
See #4163
2021-11-29 10:53:49 +01:00
Florent BEAUCHAMP
3ad8508ea5 feat(vhd-lib/VhdDirectory#_writeChunk): use outputFile (#6019)
This is much faster than manually creating parent directories.
2021-11-29 09:52:47 +01:00
Florent BEAUCHAMP
1f1ae759e0 feat(fs): use keepalive for queries to s3 (#6018) 2021-11-27 10:10:19 +01:00
Mathieu
6e4bfe8f0f feat(xo-web,xo-server): ability to create a cloud config network template (#5979)
Fixes #5931
2021-11-26 10:28:22 +01:00
Rajaa.BARHTAOUI
6276c48768 fix(xo-server/proxies): remove state cache after the proxy update (#6013) 2021-11-26 10:02:30 +01:00
Julien Fontanet
f6005baf1a feat(vhd-cli info): human format some fields 2021-11-25 18:29:25 +01:00
Julien Fontanet
b62fdbc6a6 feat(vhd-lib/Constants): make disk types and platorms maps
BREAKING
2021-11-25 18:02:26 +01:00
Florent BEAUCHAMP
bbd3d31b6a fix(backups/writeVhd): await outputStream (#6017) 2021-11-25 16:34:21 +01:00
Julien Fontanet
481ac92bf8 fix(backups/RemoteAdapter): dont use .dir suffix (#6016)
An alias can point to any kind of VHD, file or directory.

Also, for now, aliases are only used for VHD directories.
2021-11-25 15:31:25 +01:00
Florent BEAUCHAMP
a2f2b50f57 feat(s3): allow self signed certificate (#5961) 2021-11-25 11:32:08 +01:00
Julien Fontanet
bbab9d0f36 fix(xapi/vm/_assertHealthyVdiChain): ignore unused unmanaged VDIs
Fixes xoa-support#4280
2021-11-25 11:28:50 +01:00
Florent BEAUCHAMP
7f8190056d fix(backups/RemoteAdapter): unused import and path in writeVhd (#6014) 2021-11-25 11:26:59 +01:00
Julien Fontanet
8f4737c5f1 chore: upgrade to jsonrpc-websocket-client@0.7.2 2021-11-25 10:33:39 +01:00
Julien Fontanet
c5adba3c97 fix(xo-lib): upgrade to jsonrpc-websocket-client@^0.7.2
Fix default value for `protocols` option.
2021-11-25 10:28:40 +01:00
Julien Fontanet
d91eb9e396 fix(CHANGELOG.unreleased): fix duplicate package
Introduced by d5f21bc27c
2021-11-25 10:27:01 +01:00
Julien Fontanet
1b47102d6c chore: refresh yarn.lock 2021-11-25 00:06:01 +01:00
Julien Fontanet
cd147f3fc5 feat(xo-cli): 0.12.0 2021-11-25 00:03:21 +01:00
Julien Fontanet
c3acdc8cbd feat(xo-cli register): --allowUnauthorized flag
See https://xcp-ng.org/forum/topic/5226/xo-cli-and-using-self-signed-certificates
2021-11-25 00:02:08 +01:00
Julien Fontanet
c3d755dc7b feat(xo-lib): 0.11.0 2021-11-24 23:59:05 +01:00
Julien Fontanet
6f49c48bd4 feat(xo-lib): upgrade to jsonrpc-websocket-client@0.7.1
Use secure protocol (`wss`) by default and contains a fix for `rejectUnauthorized` option.
2021-11-24 23:55:23 +01:00
Julien Fontanet
446f390b3d feat(xo-lib): allow passing opts to JsonRpcWebSocketClient 2021-11-24 23:53:33 +01:00
Julien Fontanet
966091593a chore(vhd-lib): rename build{Footer,Header} to unpack{Footer,Header}
To make it clearer that it unpacks a binary footer/header to a JS object.
2021-11-24 23:34:23 +01:00
Florent Beauchamp
d5f21bc27c feat(backups): handle the choice of the vhd type to use during backup 2021-11-24 21:08:15 +01:00
Florent Beauchamp
8c3b452c0d feat(backup): DeltaBackupWriter can handle any type of vhd 2021-11-24 21:08:15 +01:00
Florent Beauchamp
9cacb92c2c feat(backups): remoteadapter can delete any type of vhd 2021-11-24 21:08:15 +01:00
Florent Beauchamp
7a1b56db87 feat(backups): checkvhd can handle all vhd types 2021-11-24 21:08:15 +01:00
Florent Beauchamp
56c3d70149 feat(vhd-lib): generate a vhd directory from a vhd stream 2021-11-24 21:08:15 +01:00
Florent Beauchamp
1ec8fcc73f feat(vhd-lib): extract computeSectorsPerBlock, computeBlockBitmapSize and computeSectorOfBitmap to utils 2021-11-24 21:08:15 +01:00
Rajaa.BARHTAOUI
060b16c5ca feat(xo-web/backup/logs): identify XAPI errors (#6001)
See xoa-support#3977
2021-11-24 15:25:27 +01:00
Yannick Achy
0acc52e3e9 fix(docs): move NOBAK from Delta to general concepts (#6012)
Co-authored-by: yannick Achy <yannick.achy@vates.fr>
2021-11-24 09:10:35 +01:00
Florent Beauchamp
a9c2c9b6ba refator(vhd-lib): move createSyntheticStream to backup, move stream() tests to vhdabstracts 2021-11-23 15:56:25 +01:00
Florent Beauchamp
5b2a6bc56b chore(vhd-lib/createSyntheticStream): based on VhdSynthetic#stream() 2021-11-23 15:56:25 +01:00
Florent Beauchamp
19c8693b62 fix(vhd-lib/VhdSynthetic#readHeaderAndFooter()): root vhd can be a dynamic and check chaining 2021-11-23 15:56:25 +01:00
Florent Beauchamp
c4720e1215 fix(vhd-lib/VhdAbstract#stream()): stream.length should contain blocks 2021-11-23 15:56:25 +01:00
Florent BEAUCHAMP
b6d4c8044c feat(backups/cleanVm) : support VHD dirs and aliases (#6000) 2021-11-22 17:14:29 +01:00
Florent BEAUCHAMP
57dd6ebfba chore(vhd-lib): use openVhd for chain and checkChain (#5997) 2021-11-22 15:50:30 +01:00
Julien Fontanet
c75569f278 feat(proxy/authentication.setToken): API method to change auth token 2021-11-18 18:14:19 +01:00
Julien Fontanet
a8757f9074 chore(proxy/authentication): use private field for auth token
More idiomatic and potentially more secure.
2021-11-18 18:02:26 +01:00
Julien Fontanet
f5c3bf72e5 fix(mixins/Config): dont create multiple stop listeners 2021-11-18 16:41:46 +01:00
Florent BEAUCHAMP
d7ee13f98d feat(vhd-lib/merge): use Vhd* classes (#5950) 2021-11-18 11:30:04 +01:00
Julien Fontanet
1f47aa491d fix(xo-server/pool.mergeInto): dont export masterPassword on error
Fixes xoa-support#4265
2021-11-17 22:42:00 +01:00
Julien Fontanet
ffe430758e feat(async-each): run async fn for each item in (async) iterable 2021-11-17 22:27:43 +01:00
Florent BEAUCHAMP
a4bb453401 feat(vhd-lib): add VhdAbstract#{stream,rawContent}() methods (#5992) 2021-11-17 09:16:34 +01:00
Florent BEAUCHAMP
5c8ebce9eb feat(vhd-lib): add vhd synthetic class (#5990) 2021-11-17 09:15:13 +01:00
Julien Fontanet
8b0cee5e6f feat(@xen-orchestra/backups-cli): 0.6.1 2021-11-16 14:26:50 +01:00
Julien Fontanet
e5f4f825b6 fix(xapi): group retry options together
- it does not make sense to only set the delay or the number of tries without the other
- it allow using any options either as default or in config without worrying about incompatibilities (e.g. `tries` & `retries`)
2021-11-16 14:26:11 +01:00
Julien Fontanet
b179dc1d56 chore: update dev deps 2021-11-15 23:43:20 +01:00
Julien Fontanet
7281c9505d fix(CHANGELOG.unreleased): new release backups-cli
`vhd-cli@^1` compat was broken by 7ef89d504
2021-11-15 14:46:51 +01:00
Julien Fontanet
4db82f447d fix(xo-web/about): update link to create issue
Related to 71b8e625f

See #5977
2021-11-15 14:22:46 +01:00
Julien Fontanet
834da3d2b4 fix(vhd-lib/VhdAbstract): remove duplicate field declarations
Introduced in c6c3a33dc
2021-11-10 16:04:59 +01:00
Julien Fontanet
c6c3a33dcc feat(vhd-cli/VhdAbstract): make derived values getters
It makes them read-only, make sure they are always up-to-date with the header and avoid duplicating their logic.
2021-11-10 15:45:42 +01:00
Julien Fontanet
fb720d9b05 fix(docs/xoa): use wget instead of curl
The version of curl installed on XCP-ng 8.2.0, (curl 7.29.0) does not support any encryption algos available on https://xoa.io
2021-11-09 19:55:49 +01:00
Florent Beauchamp
547d318e55 fix(vhd-lib): write parent locator before the blocks 2021-11-08 18:03:46 +01:00
Florent Beauchamp
cb5a2c18f2 fix(vhd-lib): ensure block allocation table is written after modifying it in tests 2021-11-08 18:03:46 +01:00
Florent Beauchamp
e01ca3ad07 refactor(vhd-lib): use method from test/utils when possible 2021-11-08 18:03:46 +01:00
Florent Beauchamp
314d193f35 fix(vhd-lib): set platform code when setting unique parent locator 2021-11-08 18:03:46 +01:00
Florent Beauchamp
e0200bb730 refactor(vhd-lib): split tests 2021-11-08 18:03:46 +01:00
Florent BEAUCHAMP
2a3f4a6f97 feat(vhd-lib): handle file alias (#5962) 2021-11-08 14:46:00 +01:00
Nicolas Raynaud
88628bbdc0 chore(xo-vmdk-to-vhd): fix tests (#5981)
Introduced by fdf52a3d59

Follow-up of b00750bfa3
2021-11-07 15:38:45 +01:00
Olivier Lambert
cb7b695a72 feat(docs/netbox): add how to add a custom field in Netbox 3 (#5984) 2021-11-07 13:44:02 +01:00
Julien Fontanet
ae549e2a88 fix(jest): dont use fake timers by default
Introduced by 844efb88d

The upgrade to Jest 27 (15630aee5) revealed this issue.
2021-11-05 13:24:51 +01:00
Julien Fontanet
7f9a970714 fix(log/USAGE): document filter array
Introduced by d3cb31f1a
2021-11-04 10:45:58 +01:00
Julien Fontanet
7661d3372d fix(xen-api/USAGE): add httpProxy option
Introduced by 2412f8b1e
2021-11-04 10:38:22 +01:00
Julien Fontanet
dbb4f34015 chore(xapi/VDI_destroy): decorate with retry.wrap()
- more efficient than creating a function at each call
- better logging
2021-11-03 23:10:58 +01:00
Julien Fontanet
8f15a4c29d feat(ISSUE_TEMPLATE/bug_report): add hypervisor version 2021-11-03 16:55:17 +01:00
Florent BEAUCHAMP
1b0a885ac3 feat(vhd-cli): use any remote for copy and compare (#5927) 2021-11-03 15:45:52 +01:00
Nicolas Raynaud
f7195bad88 fix(xo-server): fix ova multipart upload (#5976)
Introduced by 0451aaeb5c
2021-11-02 17:43:45 +01:00
Julien Fontanet
15630aee5e chore: update dev deps 2021-11-02 13:43:49 +01:00
Florent BEAUCHAMP
a950a1fe24 refactor(vhd-lib): centralize test methods (#5968) 2021-11-02 09:53:30 +01:00
Julien Fontanet
71b8e625fe chore: update issue templates (#5974) 2021-10-30 15:06:51 +02:00
Julien Fontanet
e7391675fb feat(@xen-orchestra/proxy): 0.15.2 2021-10-29 17:41:02 +02:00
Julien Fontanet
84fdd3fe4b fix(proxy/api/ndJsonStream): send header for empty iterables
Introduced by ed987e161
2021-10-29 17:05:05 +02:00
Julien Fontanet
4dc4b635f2 feat(@xen-orchestra/proxy): 0.15.1 2021-10-29 15:50:42 +02:00
Julien Fontanet
ee0c6d7f8b feat(xen-api): 0.35.1 2021-10-29 15:50:05 +02:00
Julien Fontanet
a637af395d fix(xen-api): add missing dep proxy-agent
Introduced by 2412f8b1e
2021-10-29 15:40:25 +02:00
Julien Fontanet
59fb612315 feat(@xen-orchestra/proxy): 0.15.0 2021-10-29 15:20:09 +02:00
Mathieu
59b21c7a3e feat: release 5.64 (#5971) 2021-10-29 11:40:16 +02:00
Mathieu
40f881c2ac feat: technical release (#5970) 2021-10-28 16:30:00 +02:00
Rajaa.BARHTAOUI
1d069683ca feat(xo-web/host): manage evacuation failure during host shutdown (#5966) 2021-10-28 14:23:43 +02:00
Julien Fontanet
de1d942b90 fix(xo-server/listPoolsMatchingCriteria): check{Sr,Pool}Name is not a function
Fixes xoa-support#4193

Introduced by cd8c618f0
2021-10-28 13:29:32 +02:00
Rajaa.BARHTAOUI
fc73971d63 feat(xo-server,xo-web/menu): proxy upgrade notification (#5930)
See xoa-support#4105
2021-10-28 10:52:23 +02:00
Rajaa.BARHTAOUI
eb238bf107 feat(xo-web/pool/advanced, xen-api/{get,put}Resource): introduce backup network (#5957) 2021-10-28 10:21:48 +02:00
Florent BEAUCHAMP
2412f8b1e2 feat(xen-api): add HTTP proxy support (#5958)
See #5436

Using an IP address as HTTPS proxy show this warning: `DeprecationWarning: Setting the TLS ServerName to an IP address is not permitted by RFC 6066`

The corresponding issue is there : TooTallNate/node-https-proxy-agent#127
2021-10-27 17:30:41 +02:00
Pierre Donias
0c87dee31c fix(xo-web/xoa): handle string expiration dates (#5967)
See xoa-support#4114
See xoa-support#4192

www-xo may return a string instead of a number in some rare cases
2021-10-27 16:59:59 +02:00
Mathieu
215146f663 feat(xo-web/vm/export): allow to copy the export URL (#5948) 2021-10-27 16:58:09 +02:00
Mathieu
9fe1069df0 feat(xo-web/host): format logs (#5943)
See xoa-support#4100
2021-10-27 15:41:29 +02:00
Julien Fontanet
d2c5b52bf1 feat(backups): enable merge worker by default
Related to 47f9da216

It can still be disabled in case of problems:

```toml
[backups]
disableMergeWorker = true
```
2021-10-27 09:29:50 +02:00
Pierre Donias
12153a414d fix(xo-server/{clone,copy}Vm): force is_a_template to false on the new VM (#5955)
See xoa-support#4137
2021-10-26 16:53:09 +02:00
Pierre Donias
5ec1092a83 fix(xo-server-netbox/test): perform test with a 50-character name (#5963)
See https://xcp-ng.org/forum/topic/5111
See https://netbox.readthedocs.io/en/stable/release-notes/version-2.10/#other-changes > #5011

Versions of Netbox <2.10 only allow cluster type names of length <= 50.
2021-10-26 15:55:11 +02:00
Julien Fontanet
284169a2f2 chore(vhd-lib/VhdAbstract): format with Prettier
Introduced by 7ef89d504
2021-10-25 16:12:49 +02:00
Julien Fontanet
838bfbb75f fix(backups/cleanVm): wait for merge to finish
Introduced by 9c83e70a2
2021-10-25 09:14:38 +02:00
Julien Fontanet
a448da77c9 fix(backups/cleanVm): mergeLimiter support
Introduced by 9c83e70a2
2021-10-25 09:13:58 +02:00
Rajaa.BARHTAOUI
268fb22d5f feat(xo-web/host/advanced): add button to disable/enable host (#5952) 2021-10-20 16:39:54 +02:00
Julien Fontanet
07cc4c853d fix(vhd-lib): fix block table properties & accessors
Fixes #5956

Introduced by 7ef89d504
2021-10-18 23:13:55 +02:00
Florent BEAUCHAMP
c62d727cbe feat(vhd-cli compare): compare metadata and content of two VHDs (#5920) 2021-10-18 16:21:40 +02:00
Florent BEAUCHAMP
7ef89d5043 feat(vhd-{cli,lib}): implement chunking and copy command (#5919) 2021-10-18 14:56:58 +02:00
Mathieu
9ceba1d6e8 feat(xo-web/jobs): add button to copy jobs IDs (#5951)
Useful to create a `job.runSequence` job. Follow-up of #5944.
2021-10-15 14:25:02 +02:00
Pierre Donias
e2e453985f fix(xo-web/job): properly handle array arguments (#5944)
See https://xcp-ng.org/forum/topic/5010

When creating/editing a job, properties of type `array` must not go through the
cross product builder, they must be saved as arrays.
2021-10-15 10:42:33 +02:00
Florent BEAUCHAMP
84dccd800f feat(backups): clean up other schedules snapshots (#5949)
Fixes xoa-support#4129
2021-10-14 14:44:40 +02:00
Julien Fontanet
f9734d202b chore(backups/_VmBackup): remove unused import 2021-10-14 13:51:29 +02:00
Julien Fontanet
d3cb0f4672 feat(xo-server): 5.82.4 2021-10-14 09:47:39 +02:00
Julien Fontanet
c198bbb6fa feat(@xen-orchestra/backups): 0.14.0 2021-10-14 09:45:20 +02:00
Julien Fontanet
c965a89509 feat(xo-server-netbox): 0.3.2 2021-10-14 09:43:38 +02:00
Julien Fontanet
47f9da2160 feat(backups/MixinBackupWriter): use merge worker if not disabled 2021-10-13 16:26:12 +02:00
Julien Fontanet
348a75adb4 feat(backups): merge worker implementation
This CLI must be run directly in the directory where the remote is mounted.

It's only compatible with local remote at the moment.

To start the worker:

```js
const MergeWorker = require('@xen-orchestra/backups/merge-worker/index.js')

await MergeWorker.run(remotePath)
```

To register a VM backup dir to be clean (thus merging its unused VHD), create a file in the queue directory containing the VM UUID:

```
> echo cc700fe2-724e-44a5-8663-5f8f88e05e34 > .queue/clean-vm/20211013T142401Z
```

The queue directory is available as `MergeWorker.CLEAN_VM_QUEUE`.
2021-10-13 16:25:21 +02:00
Julien Fontanet
332218a7f7 feat(backups): move merge responsability to cleanVm 2021-10-13 16:10:19 +02:00
Julien Fontanet
6d7a26d2b9 chore(backups/MixinBackupWriter): use private fields 2021-10-13 10:02:57 +02:00
Pierre Donias
d19a748f0c fix(xo-server-netbox): support older versions of Netbox (#5946)
Fixes #5898
See https://netbox.readthedocs.io/en/stable/release-notes/version-2.7/#api-choice-fields-now-use-string-values-3569
2021-10-13 09:28:46 +02:00
Julien Fontanet
9c83e70a28 feat(backups/RemoteAdapter#cleanVm): configurable merge limiter 2021-10-12 09:17:42 +02:00
Rajaa.BARHTAOUI
abcabb736b feat(xo-web/tasks): filter out short tasks with a default filter (#5941)
See xoa-support#4096
2021-10-08 16:42:16 +02:00
Julien Fontanet
0451aaeb5c fix(xo-server/vm.import): restore non-multipart upload (#5936)
See xoa-support#4085

Introduced by fdf52a3d5

Required by `xo-cli`.
2021-10-08 15:24:21 +02:00
Julien Fontanet
880c45830c fix(xo-cli): http-request-plus@0.12 has no longer default export
Introduced by 62e5ab699
2021-10-07 17:11:54 +02:00
Julien Fontanet
5fa16d2344 chore: format with Prettier 2021-10-07 14:40:41 +02:00
Julien Fontanet
9e50b5dd83 feat(proxy): logging is now dynamically configurable
It was done for xo-server in f20d5cd8d
2021-10-06 16:54:57 +02:00
Julien Fontanet
29d8753574 chore(backups/VmBackup#_selectBaseVm): add debug logs 2021-10-06 16:48:42 +02:00
Pierre Donias
f93e1e1695 feat: release 5.63.0 (#5925) 2021-09-30 15:25:34 +02:00
271 changed files with 9311 additions and 10789 deletions

34
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,34 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'status: triaging :triangular_flag_on_post:, type: bug :bug:'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- Node: [e.g. 16.12.1]
- xo-server: [e.g. 5.82.3]
- xo-web: [e.g. 5.87.0]
- hypervisor: [e.g. XCP-ng 8.2.0]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

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

View File

@@ -0,0 +1,68 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/async-each
[![Package Version](https://badgen.net/npm/v/@vates/async-each)](https://npmjs.org/package/@vates/async-each) ![License](https://badgen.net/npm/license/@vates/async-each) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/async-each)](https://bundlephobia.com/result?p=@vates/async-each) [![Node compatibility](https://badgen.net/npm/node/@vates/async-each)](https://npmjs.org/package/@vates/async-each)
> Run async fn for each item in (async) iterable
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/async-each):
```
> npm install --save @vates/async-each
```
## Usage
### `asyncEach(iterable, iteratee, [opts])`
Executes `iteratee` in order for each value yielded by `iterable`.
Returns a promise wich rejects as soon as a call to `iteratee` throws or a promise returned by it rejects, and which resolves when all promises returned by `iteratee` have resolved.
`iterable` must be an iterable or async iterable.
`iteratee` is called with the same `this` value as `asyncEach`, and with the following arguments:
- `value`: the value yielded by `iterable`
- `index`: the 0-based index for this value
- `iterable`: the iterable itself
`opts` is an object that can contains the following options:
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `1`
- `signal`: an abort signal to stop the iteration
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'
const contents = []
await asyncEach(
['foo.txt', 'bar.txt', 'baz.txt'],
async function (filename, i) {
contents[i] = await readFile(filename)
},
{
// reads two files at a time
concurrency: 2,
}
)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -0,0 +1,35 @@
### `asyncEach(iterable, iteratee, [opts])`
Executes `iteratee` in order for each value yielded by `iterable`.
Returns a promise wich rejects as soon as a call to `iteratee` throws or a promise returned by it rejects, and which resolves when all promises returned by `iteratee` have resolved.
`iterable` must be an iterable or async iterable.
`iteratee` is called with the same `this` value as `asyncEach`, and with the following arguments:
- `value`: the value yielded by `iterable`
- `index`: the 0-based index for this value
- `iterable`: the iterable itself
`opts` is an object that can contains the following options:
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `1`
- `signal`: an abort signal to stop the iteration
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'
const contents = []
await asyncEach(
['foo.txt', 'bar.txt', 'baz.txt'],
async function (filename, i) {
contents[i] = await readFile(filename)
},
{
// reads two files at a time
concurrency: 2,
}
)
```

View File

@@ -0,0 +1,99 @@
'use strict'
const noop = Function.prototype
class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.errors = errors
}
}
exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 1, signal, stopOnError = true } = {}) {
return new Promise((resolve, reject) => {
const it = (iterable[Symbol.iterator] || iterable[Symbol.asyncIterator]).call(iterable)
const errors = []
let running = 0
let index = 0
let onAbort
if (signal !== undefined) {
onAbort = () => {
onRejectedWrapper(new Error('asyncEach aborted'))
}
signal.addEventListener('abort', onAbort)
}
const clean = () => {
onFulfilled = onRejected = noop
if (onAbort !== undefined) {
signal.removeEventListener('abort', onAbort)
}
}
resolve = (resolve =>
function resolveAndClean(value) {
resolve(value)
clean()
})(resolve)
reject = (reject =>
function rejectAndClean(reason) {
reject(reason)
clean()
})(reject)
let onFulfilled = value => {
--running
next()
}
const onFulfilledWrapper = value => onFulfilled(value)
let onRejected = stopOnError
? reject
: error => {
--running
errors.push(error)
next()
}
const onRejectedWrapper = reason => onRejected(reason)
let nextIsRunning = false
let next = async () => {
if (nextIsRunning) {
return
}
nextIsRunning = true
if (running < concurrency) {
const cursor = await it.next()
if (cursor.done) {
next = () => {
if (running === 0) {
if (errors.length === 0) {
resolve()
} else {
reject(new AggregateError(errors))
}
}
}
} else {
++running
try {
const result = iteratee.call(this, cursor.value, index++, iterable)
let then
if (result != null && typeof result === 'object' && typeof (then = result.then) === 'function') {
then.call(result, onFulfilledWrapper, onRejectedWrapper)
} else {
onFulfilled(result)
}
} catch (error) {
onRejected(error)
}
}
nextIsRunning = false
return next()
}
nextIsRunning = false
}
next()
})
}

View File

@@ -0,0 +1,99 @@
'use strict'
/* eslint-env jest */
const { asyncEach } = require('./')
const randomDelay = (max = 10) =>
new Promise(resolve => {
setTimeout(resolve, Math.floor(Math.random() * max + 1))
})
const rejectionOf = p =>
new Promise((resolve, reject) => {
p.then(reject, resolve)
})
describe('asyncEach', () => {
const thisArg = 'qux'
const values = ['foo', 'bar', 'baz']
Object.entries({
'sync iterable': () => values,
'async iterable': async function* () {
for (const value of values) {
await randomDelay()
yield value
}
},
}).forEach(([what, getIterable]) =>
describe('with ' + what, () => {
let iterable
beforeEach(() => {
iterable = getIterable()
})
it('works', async () => {
const iteratee = jest.fn(async () => {})
await asyncEach.call(thisArg, iterable, iteratee)
expect(iteratee.mock.instances).toEqual(Array.from(values, () => thisArg))
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
})
;[1, 2, 4].forEach(concurrency => {
it('respects a concurrency of ' + concurrency, async () => {
let running = 0
await asyncEach(
values,
async () => {
++running
expect(running).toBeLessThanOrEqual(concurrency)
await randomDelay()
--running
},
{ concurrency }
)
})
})
it('stops on first error when stopOnError is true', async () => {
const error = new Error()
const iteratee = jest.fn((_, i) => {
if (i === 1) {
throw error
}
})
expect(await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: true }))).toBe(error)
expect(iteratee).toHaveBeenCalledTimes(2)
})
it('rejects AggregateError when stopOnError is false', async () => {
const errors = []
const iteratee = jest.fn(() => {
const error = new Error()
errors.push(error)
throw error
})
const error = await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: false }))
expect(error.errors).toEqual(errors)
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
})
it('can be interrupted with an AbortSignal', async () => {
const ac = new AbortController()
const iteratee = jest.fn((_, i) => {
if (i === 1) {
ac.abort()
}
})
await expect(asyncEach(iterable, iteratee, { signal: ac.signal })).rejects.toThrow('asyncEach aborted')
expect(iteratee).toHaveBeenCalledTimes(2)
})
})
)
})

View File

@@ -0,0 +1,34 @@
{
"private": false,
"name": "@vates/async-each",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/async-each",
"description": "Run async fn for each item in (async) iterable",
"keywords": [
"array",
"async",
"collection",
"each",
"for",
"foreach",
"iterable",
"iterator"
],
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/async-each",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"
}
}

View File

@@ -65,6 +65,23 @@ const f = compose(
)
```
Functions can receive extra parameters:
```js
const isIn = (value, min, max) => min <= value && value <= max
// Only compatible when `fns` is passed as an array!
const f = compose([
[add, 2],
[isIn, 3, 10],
])
console.log(f(1))
// → true
```
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -46,3 +46,20 @@ const f = compose(
[add2, mul3]
)
```
Functions can receive extra parameters:
```js
const isIn = (value, min, max) => min <= value && value <= max
// Only compatible when `fns` is passed as an array!
const f = compose([
[add, 2],
[isIn, 3, 10],
])
console.log(f(1))
// → true
```
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.

View File

@@ -4,11 +4,13 @@ const defaultOpts = { async: false, right: false }
exports.compose = function compose(opts, fns) {
if (Array.isArray(opts)) {
fns = opts
fns = opts.slice() // don't mutate passed array
opts = defaultOpts
} else if (typeof opts === 'object') {
opts = Object.assign({}, defaultOpts, opts)
if (!Array.isArray(fns)) {
if (Array.isArray(fns)) {
fns = fns.slice() // don't mutate passed array
} else {
fns = Array.prototype.slice.call(arguments, 1)
}
} else {
@@ -20,6 +22,24 @@ exports.compose = function compose(opts, fns) {
if (n === 0) {
throw new TypeError('at least one function must be passed')
}
for (let i = 0; i < n; ++i) {
const entry = fns[i]
if (Array.isArray(entry)) {
const fn = entry[0]
const args = entry.slice()
args[0] = undefined
fns[i] = function composeWithArgs(value) {
args[0] = value
try {
return fn.apply(this, args)
} finally {
args[0] = undefined
}
}
}
}
if (n === 1) {
return fns[0]
}

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "2.0.0",
"version": "2.1.0",
"engines": {
"node": ">=7.6"
},

View File

@@ -59,6 +59,36 @@ decorateMethodsWith(Foo, {
The decorated class is returned, so you can export it directly.
To apply multiple transforms to a method, you can either call `decorateMethodsWith` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
```js
decorateMethodsWith(Foo, {
bar: compose([
[lodash.debounce, 150]
lodash.curry,
])
})
```
### `perInstance(fn, ...args)`
Helper to decorate the method by instance instead of for the whole class.
This is often necessary for caching or deduplicating calls.
```js
import { perInstance } from '@vates/decorateWith'
class Foo {
@decorateWith(perInstance, lodash.memoize)
bar() {
// body
}
}
```
Because it's a normal function, it can also be used with `decorateMethodsWith`, with `compose` or even by itself.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -40,3 +40,33 @@ decorateMethodsWith(Foo, {
```
The decorated class is returned, so you can export it directly.
To apply multiple transforms to a method, you can either call `decorateMethodsWith` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
```js
decorateMethodsWith(Foo, {
bar: compose([
[lodash.debounce, 150]
lodash.curry,
])
})
```
### `perInstance(fn, ...args)`
Helper to decorate the method by instance instead of for the whole class.
This is often necessary for caching or deduplicating calls.
```js
import { perInstance } from '@vates/decorateWith'
class Foo {
@decorateWith(perInstance, lodash.memoize)
bar() {
// body
}
}
```
Because it's a normal function, it can also be used with `decorateMethodsWith`, with `compose` or even by itself.

View File

@@ -19,3 +19,15 @@ exports.decorateMethodsWith = function decorateMethodsWith(klass, map) {
}
return klass
}
exports.perInstance = function perInstance(fn, decorator, ...args) {
const map = new WeakMap()
return function () {
let decorated = map.get(this)
if (decorated === undefined) {
decorated = decorator(fn, ...args)
map.set(this, decorated)
}
return decorated.apply(this, arguments)
}
}

View File

@@ -20,7 +20,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"version": "1.0.0",
"engines": {
"node": ">=8.10"
},

View File

@@ -30,7 +30,7 @@
"rimraf": "^3.0.0"
},
"dependencies": {
"@vates/decorate-with": "^0.1.0",
"@vates/decorate-with": "^1.0.0",
"@xen-orchestra/log": "^0.3.0",
"golike-defer": "^0.5.1",
"object-hash": "^2.0.1"

View File

@@ -46,7 +46,7 @@ module.exports = function (pkg, configs = {}) {
return {
comments: !__PROD__,
ignore: __PROD__ ? [/\.spec\.js$/] : undefined,
ignore: __PROD__ ? [/\btests?\//, /\.spec\.js$/] : undefined,
plugins: Object.keys(plugins)
.map(plugin => [plugin, plugins[plugin]])
.sort(([a], [b]) => {
@@ -56,22 +56,14 @@ module.exports = function (pkg, configs = {}) {
}),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
targets: (() => {
const targets = {}
if (pkg.browserslist !== undefined) {
targets.browsers = pkg.browserslist
}
let node = (pkg.engines || {}).node
if (node !== undefined) {
const trimChars = '^=>~'
while (trimChars.includes(node[0])) {
node = node.slice(1)
}
targets.node = node
}
return targets
return { browsers: pkg.browserslist, node }
})(),
}
}

View File

@@ -7,12 +7,12 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.13.0",
"@xen-orchestra/fs": "^0.18.0",
"@xen-orchestra/backups": "^0.18.3",
"@xen-orchestra/fs": "^0.19.3",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
"promise-toolbox": "^0.19.2"
"promise-toolbox": "^0.20.0"
},
"engines": {
"node": ">=7.10.1"
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "0.6.0",
"version": "0.6.1",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -3,19 +3,20 @@ const Disposable = require('promise-toolbox/Disposable.js')
const fromCallback = require('promise-toolbox/fromCallback.js')
const fromEvent = require('promise-toolbox/fromEvent.js')
const pDefer = require('promise-toolbox/defer.js')
const pump = require('pump')
const { basename, dirname, join, normalize, resolve } = require('path')
const groupBy = require('lodash/groupBy.js')
const { dirname, join, normalize, resolve } = require('path')
const { createLogger } = require('@xen-orchestra/log')
const { createSyntheticStream, mergeVhd, default: Vhd } = require('vhd-lib')
const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = require('vhd-lib')
const { deduped } = require('@vates/disposable/deduped.js')
const { execFile } = require('child_process')
const { readdir, stat } = require('fs-extra')
const { v4: uuidv4 } = require('uuid')
const { ZipFile } = require('yazl')
const { BACKUP_DIR } = require('./_getVmBackupDir.js')
const { cleanVm } = require('./_cleanVm.js')
const { getTmpDir } = require('./_getTmpDir.js')
const { isMetadataFile, isVhdFile } = require('./_backupType.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')
@@ -67,58 +68,17 @@ const debounceResourceFactory = factory =>
}
class RemoteAdapter {
constructor(handler, { debounceResource = res => res, dirMode } = {}) {
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression } = {}) {
this._debounceResource = debounceResource
this._dirMode = dirMode
this._handler = handler
this._vhdDirectoryCompression = vhdDirectoryCompression
}
get handler() {
return this._handler
}
async _deleteVhd(path) {
const handler = this._handler
const vhds = await asyncMapSettled(
await handler.list(dirname(path), {
filter: isVhdFile,
prependDir: true,
}),
async path => {
try {
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
return {
footer: vhd.footer,
header: vhd.header,
path,
}
} catch (error) {
// Do not fail on corrupted VHDs (usually uncleaned temporary files),
// they are probably inconsequent to the backup process and should not
// fail it.
warn(`BackupNg#_deleteVhd ${path}`, { error })
}
}
)
const base = basename(path)
const child = vhds.find(_ => _ !== undefined && _.header.parentUnicodeName === base)
if (child === undefined) {
await handler.unlink(path)
return 0
}
try {
const childPath = child.path
const mergedDataSize = await mergeVhd(handler, path, handler, childPath)
await handler.rename(path, childPath)
return mergedDataSize
} catch (error) {
handler.unlink(path).catch(warn)
throw error
}
}
async _findPartition(devicePath, partitionId) {
const partitions = await listPartitions(devicePath)
const partition = partitions.find(_ => _.id === partitionId)
@@ -232,6 +192,22 @@ class RemoteAdapter {
return files
}
// check if we will be allowed to merge a a vhd created in this adapter
// with the vhd at path `path`
async isMergeableParent(packedParentUid, path) {
return await Disposable.use(openVhd(this.handler, path), vhd => {
// this baseUuid is not linked with this vhd
if (!vhd.footer.uuid.equals(packedParentUid)) {
return false
}
const isVhdDirectory = vhd instanceof VhdDirectory
return isVhdDirectory
? this.#useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
: !this.#useVhdDirectory()
})
}
fetchPartitionFiles(diskId, partitionId, paths) {
const { promise, reject, resolve } = pDefer()
Disposable.use(
@@ -253,16 +229,9 @@ class RemoteAdapter {
async deleteDeltaVmBackups(backups) {
const handler = this._handler
let mergedDataSize = 0
await asyncMapSettled(backups, ({ _filename, vhds }) =>
Promise.all([
handler.unlink(_filename),
asyncMap(Object.values(vhds), async _ => {
mergedDataSize += await this._deleteVhd(resolveRelativeFromFile(_filename, _))
}),
])
)
return mergedDataSize
// unused VHDs will be detected by `cleanVm`
await asyncMapSettled(backups, ({ _filename }) => VhdAbstract.unlink(handler, _filename))
}
async deleteMetadataBackup(backupId) {
@@ -292,17 +261,34 @@ class RemoteAdapter {
)
}
async deleteVmBackup(filename) {
const metadata = JSON.parse(String(await this._handler.readFile(filename)))
metadata._filename = filename
deleteVmBackup(file) {
return this.deleteVmBackups([file])
}
if (metadata.mode === 'delta') {
await this.deleteDeltaVmBackups([metadata])
} else if (metadata.mode === 'full') {
await this.deleteFullVmBackups([metadata])
} else {
throw new Error(`no deleter for backup mode ${metadata.mode}`)
async deleteVmBackups(files) {
const { delta, full, ...others } = groupBy(await asyncMap(files, file => this.readVmBackupMetadata(file)), 'mode')
const unsupportedModes = Object.keys(others)
if (unsupportedModes.length !== 0) {
throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
}
await Promise.all([
delta !== undefined && this.deleteDeltaVmBackups(delta),
full !== undefined && this.deleteFullVmBackups(full),
])
}
#getCompressionType() {
return this._vhdDirectoryCompression
}
#useVhdDirectory() {
return this.handler.type === 's3'
}
#useAlias() {
return this.#useVhdDirectory()
}
getDisk = Disposable.factory(this.getDisk)
@@ -361,6 +347,14 @@ class RemoteAdapter {
return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
}
// if we use alias on this remote, we have to name the file alias.vhd
getVhdFileName(baseName) {
if (this.#useAlias()) {
return `${baseName}.alias.vhd`
}
return `${baseName}.vhd`
}
async listAllVmBackups() {
const handler = this._handler
@@ -505,6 +499,25 @@ class RemoteAdapter {
return backups.sort(compareTimestamp)
}
async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
const handler = this._handler
if (this.#useVhdDirectory()) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
await createVhdDirectoryFromStream(handler, dataPath, input, {
concurrency: 16,
compression: this.#getCompressionType(),
async validator() {
await input.task
return validator.apply(this, arguments)
},
})
await VhdAbstract.createAlias(handler, path, dataPath)
} else {
await this.outputStream(path, input, { checksum, validator })
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
await this._handler.outputStream(path, input, {
checksum,
@@ -516,6 +529,52 @@ class RemoteAdapter {
})
}
async _createSyntheticStream(handler, paths) {
let disposableVhds = []
// if it's a path : open all hierarchy of parent
if (typeof paths === 'string') {
let vhd,
vhdPath = paths
do {
const disposable = await openVhd(handler, vhdPath)
vhd = disposable.value
disposableVhds.push(disposable)
vhdPath = resolveRelativeFromFile(vhdPath, vhd.header.parentUnicodeName)
} while (vhd.footer.diskType !== Constants.DISK_TYPES.DYNAMIC)
} else {
// only open the list of path given
disposableVhds = paths.map(path => openVhd(handler, path))
}
// I don't want the vhds to be disposed on return
// but only when the stream is done ( or failed )
const disposables = await Disposable.all(disposableVhds)
const vhds = disposables.value
let disposed = false
const disposeOnce = async () => {
if (!disposed) {
disposed = true
try {
await disposables.dispose()
} catch (error) {
warn('_createSyntheticStream: failed to dispose VHDs', { error })
}
}
}
const synthetic = new VhdSynthetic(vhds)
await synthetic.readHeaderAndFooter()
await synthetic.readBlockAllocationTable()
const stream = await synthetic.stream()
stream.on('end', disposeOnce)
stream.on('close', disposeOnce)
stream.on('error', disposeOnce)
return stream
}
async readDeltaVmBackup(metadata) {
const handler = this._handler
const { vbds, vdis, vhds, vifs, vm } = metadata
@@ -523,7 +582,7 @@ class RemoteAdapter {
const streams = {}
await asyncMapSettled(Object.keys(vdis), async id => {
streams[`${id}.vhd`] = await createSyntheticStream(handler, join(dir, vhds[id]))
streams[`${id}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[id]))
})
return {

View File

@@ -1,9 +1,10 @@
const assert = require('assert')
const findLast = require('lodash/findLast.js')
const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
@@ -35,6 +36,11 @@ const forkDeltaExport = deltaExport =>
exports.VmBackup = class VmBackup {
constructor({ config, getSnapshotNameLabel, job, remoteAdapters, remotes, schedule, settings, srs, vm }) {
if (vm.other_config['xo:backup:job'] === job.id) {
// otherwise replicated VMs would be matched and 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
@@ -284,17 +290,28 @@ exports.VmBackup = class VmBackup {
}
async _removeUnusedSnapshots() {
// TODO: handle all schedules (no longer existing schedules default to 0 retention)
const { scheduleId } = this
const scheduleSnapshots = this._jobSnapshots.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
const jobSettings = this.job.settings
const baseVmRef = this._baseVm?.$ref
const { config } = this
const baseSettings = {
...config.defaultSettings,
...config.metadata.defaultSettings,
...jobSettings[''],
}
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
const xapi = this._xapi
await asyncMap(getOldEntries(this._settings.snapshotRetention, scheduleSnapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
const settings = {
...baseSettings,
...jobSettings[scheduleId],
...jobSettings[this.vm.uuid],
}
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
}
})
})
}
@@ -303,12 +320,14 @@ exports.VmBackup = class VmBackup {
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
}
@@ -319,10 +338,17 @@ exports.VmBackup = class VmBackup {
const baseUuidToSrcVdi = new Map()
await asyncMap(await baseVm.$getDisks(), async baseRef => {
const snapshotOf = await xapi.getField('VDI', baseRef, 'snapshot_of')
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(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
baseUuidToSrcVdi.set(baseUuid, srcVdi)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
})
}
})
@@ -333,9 +359,23 @@ exports.VmBackup = class VmBackup {
false
)
if (presentBaseVdis.size === 0) {
debug('no base VM found')
return
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
if (!presentBaseVdis.has(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)
}
})

View File

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

View File

@@ -0,0 +1,407 @@
/* eslint-env jest */
const rimraf = require('rimraf')
const tmp = require('tmp')
const fs = require('fs-extra')
const { getHandler } = require('@xen-orchestra/fs')
const { pFromCallback } = require('promise-toolbox')
const crypto = require('crypto')
const { RemoteAdapter } = require('./RemoteAdapter')
const { VHDFOOTER, VHDHEADER } = require('./tests.fixtures.js')
const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
let tempDir, adapter, handler, jobId, vdiId, basePath
jest.setTimeout(60000)
beforeEach(async () => {
tempDir = await pFromCallback(cb => tmp.dir(cb))
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
adapter = new RemoteAdapter(handler)
jobId = uniqueId()
vdiId = uniqueId()
basePath = `vdis/${jobId}/${vdiId}`
await fs.mkdirp(`${tempDir}/${basePath}`)
})
afterEach(async () => {
await pFromCallback(cb => rimraf(tempDir, cb))
await handler.forget()
})
const uniqueId = () => crypto.randomBytes(16).toString('hex')
async function generateVhd(path, opts = {}) {
let vhd
const dataPath = opts.useAlias ? path + '.data' : path
if (opts.mode === 'directory') {
await handler.mkdir(dataPath)
vhd = new VhdDirectory(handler, dataPath)
} else {
const fd = await handler.openFile(dataPath, 'wx')
vhd = new VhdFile(handler, fd)
}
vhd.header = { ...VHDHEADER, ...opts.header }
vhd.footer = { ...VHDFOOTER, ...opts.footer }
vhd.footer.uuid = Buffer.from(crypto.randomBytes(16))
if (vhd.header.parentUnicodeName) {
vhd.footer.diskType = Constants.DISK_TYPES.DIFFERENCING
} else {
vhd.footer.diskType = Constants.DISK_TYPES.DYNAMIC
}
if (opts.useAlias === true) {
await VhdAbstract.createAlias(handler, path + '.alias.vhd', dataPath)
}
await vhd.writeBlockAllocationTable()
await vhd.writeHeader()
await vhd.writeFooter()
return vhd
}
test('It remove broken vhd', async () => {
// todo also tests a directory and an alias
await handler.writeFile(`${basePath}/notReallyAVhd.vhd`, 'I AM NOT A VHD')
expect((await handler.list(basePath)).length).toEqual(1)
let loggued = ''
const onLog = message => {
loggued += message
}
await adapter.cleanVm('/', { remove: false, onLog })
expect(loggued).toEqual(`error while checking the VHD with path /${basePath}/notReallyAVhd.vhd`)
// not removed
expect((await handler.list(basePath)).length).toEqual(1)
// really remove it
await adapter.cleanVm('/', { remove: true, onLog })
expect((await handler.list(basePath)).length).toEqual(0)
})
test('it remove vhd with missing or multiple ancestors', async () => {
// one with a broken parent
await generateVhd(`${basePath}/abandonned.vhd`, {
header: {
parentUnicodeName: 'gone.vhd',
parentUid: Buffer.from(crypto.randomBytes(16)),
},
})
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
// clean
let loggued = ''
const onLog = message => {
loggued += message + '\n'
}
await adapter.cleanVm('/', { remove: true, onLog })
const deletedOrphanVhd = loggued.match(/deleting orphan VHD/g) || []
expect(deletedOrphanVhd.length).toEqual(1) // only one vhd should have been deleted
const deletedAbandonnedVhd = loggued.match(/abandonned.vhd is missing/g) || []
expect(deletedAbandonnedVhd.length).toEqual(1) // and it must be abandonned.vhd
// we don't test the filew on disk, since they will all be marker as unused and deleted without a metadata.json file
})
test('it remove backup meta data referencing a missing vhd in delta backup', async () => {
// create a metadata file marking child and orphan as ok
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/orphan.vhd`,
`${basePath}/child.vhd`,
// abandonned.json is not here
],
})
)
await generateVhd(`${basePath}/abandonned.vhd`)
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
let loggued = ''
const onLog = message => {
loggued += message + '\n'
}
await adapter.cleanVm('/', { remove: true, onLog })
let matched = loggued.match(/deleting unused VHD /g) || []
expect(matched.length).toEqual(1) // only one vhd should have been deleted
matched = loggued.match(/abandonned.vhd is unused/g) || []
expect(matched.length).toEqual(1) // and it must be abandonned.vhd
// a missing vhd cause clean to remove all vhds
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/deleted.vhd`, // in metadata but not in vhds
`${basePath}/orphan.vhd`,
`${basePath}/child.vhd`,
// abandonned.json is not here
],
}),
{ flags: 'w' }
)
loggued = ''
await adapter.cleanVm('/', { remove: true, onLog })
matched = loggued.match(/deleting unused VHD /g) || []
expect(matched.length).toEqual(2) // all vhds (orphan and child ) should have been deleted
})
test('it merges delta of non destroyed chain', async () => {
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
size: 12000, // a size too small
vhds: [
`${basePath}/grandchild.vhd`, // grand child should not be merged
`${basePath}/child.vhd`,
// orphan is not here, he should be merged in child
],
})
)
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
const child = await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
// a grand child
await generateVhd(`${basePath}/grandchild.vhd`, {
header: {
parentUnicodeName: 'child.vhd',
parentUid: child.footer.uuid,
},
})
let loggued = []
const onLog = message => {
loggued.push(message)
}
await adapter.cleanVm('/', { remove: true, onLog })
expect(loggued[0]).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused`)
expect(loggued[1]).toEqual(`incorrect size in metadata: 12000 instead of 209920`)
loggued = []
await adapter.cleanVm('/', { remove: true, merge: true, onLog })
const [unused, merging] = loggued
expect(unused).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused`)
expect(merging).toEqual(`merging /${basePath}/child.vhd into /${basePath}/orphan.vhd`)
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
// size should be the size of children + grand children after the merge
expect(metadata.size).toEqual(209920)
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
const remainingVhds = await handler.list(basePath)
expect(remainingVhds.length).toEqual(2)
expect(remainingVhds.includes('child.vhd')).toEqual(true)
expect(remainingVhds.includes('grandchild.vhd')).toEqual(true)
})
test('it finish unterminated merge ', async () => {
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
size: undefined,
vhds: [
`${basePath}/orphan.vhd`, // grand child should not be merged
`${basePath}/child.vhd`,
// orphan is not here, he should be merged in child
],
})
)
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan
const child = await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUid: orphan.footer.uuid,
},
})
// a merge in progress file
await handler.writeFile(
`${basePath}/.orphan.vhd.merge.json`,
JSON.stringify({
parent: {
header: orphan.header.checksum,
},
child: {
header: child.header.checksum,
},
})
)
// a unfinished merging
await adapter.cleanVm('/', { remove: true, merge: true })
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
const remainingVhds = await handler.list(basePath)
expect(remainingVhds.length).toEqual(1)
expect(remainingVhds.includes('child.vhd')).toEqual(true)
})
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
// the message an resulting files should be identical to the output with vhd files which is tested independantly
describe('tests mulitple combination ', () => {
for (const useAlias of [true, false]) {
for (const vhdMode of ['file', 'directory']) {
test(`alias : ${useAlias}, mode: ${vhdMode}`, async () => {
// a broken VHD
const brokenVhdDataPath = basePath + useAlias ? 'broken.data' : 'broken.vhd'
if (vhdMode === 'directory') {
await handler.mkdir(brokenVhdDataPath)
} else {
await handler.writeFile(brokenVhdDataPath, 'notreallyavhd')
}
if (useAlias) {
await VhdAbstract.createAlias(handler, 'broken.alias.vhd', brokenVhdDataPath)
}
// a vhd non referenced in metada
await generateVhd(`${basePath}/nonreference.vhd`, { useAlias, mode: vhdMode })
// an abandonded delta vhd without its parent
await generateVhd(`${basePath}/abandonned.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'gone.vhd',
parentUid: crypto.randomBytes(16),
},
})
// an ancestor of a vhd present in metadata
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, {
useAlias,
mode: vhdMode,
})
const child = await generateVhd(`${basePath}/child.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'ancestor.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUid: ancestor.footer.uuid,
},
})
// a grand child vhd in metadata
await generateVhd(`${basePath}/grandchild.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'child.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUid: child.footer.uuid,
},
})
// an older parent that was merging in clean
const cleanAncestor = await generateVhd(`${basePath}/cleanAncestor.vhd`, {
useAlias,
mode: vhdMode,
})
// a clean vhd in metadata
const clean = await generateVhd(`${basePath}/clean.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'cleanAncestor.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUid: cleanAncestor.footer.uuid,
},
})
await handler.writeFile(
`${basePath}/.cleanAncestor.vhd${useAlias ? '.alias.vhd' : ''}.merge.json`,
JSON.stringify({
parent: {
header: cleanAncestor.header.checksum,
},
child: {
header: clean.header.checksum,
},
})
)
// the metadata file
await handler.writeFile(
`metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
`${basePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
`${basePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
],
})
)
await adapter.cleanVm('/', { remove: true, merge: true })
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
// size should be the size of children + grand children + clean after the merge
expect(metadata.size).toEqual(vhdMode === 'file' ? 314880 : undefined)
// broken vhd, non referenced, abandonned should be deleted ( alias and data)
// ancestor and child should be merged
// grand child and clean vhd should not have changed
const survivors = await handler.list(basePath)
// console.log(survivors)
if (useAlias) {
// the goal of the alias : do not move a full folder
expect(survivors).toContain('ancestor.vhd.data')
expect(survivors).toContain('grandchild.vhd.data')
expect(survivors).toContain('cleanAncestor.vhd.data')
expect(survivors).toContain('clean.vhd.alias.vhd')
expect(survivors).toContain('child.vhd.alias.vhd')
expect(survivors).toContain('grandchild.vhd.alias.vhd')
expect(survivors.length).toEqual(6)
} else {
expect(survivors).toContain('clean.vhd')
expect(survivors).toContain('child.vhd')
expect(survivors).toContain('grandchild.vhd')
expect(survivors.length).toEqual(3)
}
})
}
}
})
test('it cleans orphan merge states ', async () => {
await handler.writeFile(`${basePath}/.orphan.vhd.merge.json`, '')
await adapter.cleanVm('/', { remove: true })
expect(await handler.list(basePath)).toEqual([])
})

View File

@@ -1,17 +1,38 @@
const assert = require('assert')
const sum = require('lodash/sum')
const { asyncMap } = require('@xen-orchestra/async-map')
const { default: Vhd, mergeVhd } = require('vhd-lib')
const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
const { dirname, resolve } = require('path')
const { DISK_TYPE_DIFFERENCING } = require('vhd-lib/dist/_constants.js')
const { DISK_TYPES } = Constants
const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
const { limitConcurrency } = require('limit-concurrency-decorator')
const { Task } = require('./Task.js')
const { Disposable } = require('promise-toolbox')
// checking the size of a vhd directory is costly
// 1 Http Query per 1000 blocks
// we only check size of all the vhd are VhdFiles
function shouldComputeVhdsSize(vhds) {
return vhds.every(vhd => vhd instanceof VhdFile)
}
const computeVhdsSize = (handler, vhdPaths) =>
Disposable.use(
vhdPaths.map(vhdPath => openVhd(handler, vhdPath)),
async vhds => {
if (shouldComputeVhdsSize(vhds)) {
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
return sum(sizes)
}
}
)
// chain is an array of VHDs from child to parent
//
// the whole chain will be merged into parent, parent will be renamed to child
// and all the others will deleted
const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
assert(chain.length >= 2)
let child = chain[0]
@@ -44,7 +65,7 @@ const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, {
}
}, 10e3)
await mergeVhd(
const mergedSize = await mergeVhd(
handler,
parent,
handler,
@@ -63,24 +84,26 @@ const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, {
clearInterval(handle)
await Promise.all([
handler.rename(parent, child),
VhdAbstract.rename(handler, parent, child),
asyncMap(children.slice(0, -1), child => {
onLog(`the VHD ${child} is unused`)
if (remove) {
onLog(`deleting unused VHD ${child}`)
return handler.unlink(child)
return VhdAbstract.unlink(handler, child)
}
}),
])
return mergedSize
}
})
}
const noop = Function.prototype
const INTERRUPTED_VHDS_REG = /^(?:(.+)\/)?\.(.+)\.merge.json$/
const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
const listVhds = async (handler, vmDir) => {
const vhds = []
const interruptedVhds = new Set()
const vhds = new Set()
const interruptedVhds = new Map()
await asyncMap(
await handler.list(`${vmDir}/vdis`, {
@@ -95,16 +118,14 @@ const listVhds = async (handler, vmDir) => {
async vdiDir => {
const list = await handler.list(vdiDir, {
filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
prependDir: true,
})
list.forEach(file => {
const res = INTERRUPTED_VHDS_REG.exec(file)
if (res === null) {
vhds.push(file)
vhds.add(`${vdiDir}/${file}`)
} else {
const [, dir, file] = res
interruptedVhds.add(`${dir}/${file}`)
interruptedVhds.set(`${vdiDir}/${res[1]}`, `${vdiDir}/${file}`)
}
})
}
@@ -114,65 +135,91 @@ const listVhds = async (handler, vmDir) => {
return { vhds, interruptedVhds }
}
exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, onLog = noop }) {
const defaultMergeLimiter = limitConcurrency(1)
exports.cleanVm = async function cleanVm(
vmDir,
{ fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
) {
const limitedMergeVhdChain = mergeLimiter(mergeVhdChain)
const handler = this._handler
const vhds = new Set()
const vhdsToJSons = new Set()
const vhdParents = { __proto__: null }
const vhdChildren = { __proto__: null }
const vhdsList = await listVhds(handler, vmDir)
const { vhds, interruptedVhds } = await listVhds(handler, vmDir)
// remove broken VHDs
await asyncMap(vhdsList.vhds, async path => {
await asyncMap(vhds, async path => {
try {
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter(!vhdsList.interruptedVhds.has(path))
vhds.add(path)
if (vhd.footer.diskType === DISK_TYPE_DIFFERENCING) {
const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
vhdParents[path] = parent
if (parent in vhdChildren) {
const error = new Error('this script does not support multiple VHD children')
error.parent = parent
error.child1 = vhdChildren[parent]
error.child2 = path
throw error // should we throw?
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
vhdParents[path] = parent
if (parent in vhdChildren) {
const error = new Error('this script does not support multiple VHD children')
error.parent = parent
error.child1 = vhdChildren[parent]
error.child2 = path
throw error // should we throw?
}
vhdChildren[parent] = path
}
vhdChildren[parent] = path
}
})
} catch (error) {
vhds.delete(path)
onLog(`error while checking the VHD with path ${path}`, { error })
if (error?.code === 'ERR_ASSERTION' && remove) {
onLog(`deleting broken ${path}`)
await handler.unlink(path)
return VhdAbstract.unlink(handler, path)
}
}
})
// remove interrupted merge states for missing VHDs
for (const interruptedVhd of interruptedVhds.keys()) {
if (!vhds.has(interruptedVhd)) {
const statePath = interruptedVhds.get(interruptedVhd)
interruptedVhds.delete(interruptedVhd)
onLog('orphan merge state', {
mergeStatePath: statePath,
missingVhdPath: interruptedVhd,
})
if (remove) {
onLog(`deleting orphan merge state ${statePath}`)
await handler.unlink(statePath)
}
}
}
// @todo : add check for data folder of alias not referenced in a valid alias
// remove VHDs with missing ancestors
{
const deletions = []
// return true if the VHD has been deleted or is missing
const deleteIfOrphan = vhd => {
const parent = vhdParents[vhd]
const deleteIfOrphan = vhdPath => {
const parent = vhdParents[vhdPath]
if (parent === undefined) {
return
}
// no longer needs to be checked
delete vhdParents[vhd]
delete vhdParents[vhdPath]
deleteIfOrphan(parent)
if (!vhds.has(parent)) {
vhds.delete(vhd)
vhds.delete(vhdPath)
onLog(`the parent ${parent} of the VHD ${vhd} is missing`)
onLog(`the parent ${parent} of the VHD ${vhdPath} is missing`)
if (remove) {
onLog(`deleting orphan VHD ${vhd}`)
deletions.push(handler.unlink(vhd))
onLog(`deleting orphan VHD ${vhdPath}`)
deletions.push(VhdAbstract.unlink(handler, vhdPath))
}
}
}
@@ -188,7 +235,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
await Promise.all(deletions)
}
const jsons = []
const jsons = new Set()
const xvas = new Set()
const xvaSums = []
const entries = await handler.list(vmDir, {
@@ -196,7 +243,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
})
entries.forEach(path => {
if (isMetadataFile(path)) {
jsons.push(path)
jsons.add(path)
} else if (isXvaFile(path)) {
xvas.add(path)
} else if (isXvaSumFile(path)) {
@@ -218,22 +265,25 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
// compile the list of unused XVAs and VHDs, and remove backup metadata which
// reference a missing XVA/VHD
await asyncMap(jsons, async json => {
const metadata = JSON.parse(await handler.readFile(json))
let metadata
try {
metadata = JSON.parse(await handler.readFile(json))
} catch (error) {
onLog(`failed to read metadata file ${json}`, { error })
jsons.delete(json)
return
}
const { mode } = metadata
let size
if (mode === 'full') {
const linkedXva = resolve('/', vmDir, metadata.xva)
if (xvas.has(linkedXva)) {
unusedXvas.delete(linkedXva)
size = await handler.getSize(linkedXva).catch(error => {
onLog(`failed to get size of ${json}`, { error })
})
} else {
onLog(`the XVA linked to the metadata ${json} is missing`)
if (remove) {
onLog(`deleting incomplete backup ${json}`)
jsons.delete(json)
await handler.unlink(json)
}
}
@@ -243,42 +293,29 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
})()
const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
// FIXME: find better approach by keeping as much of the backup as
// possible (existing disks) even if one disk is missing
if (linkedVhds.every(_ => vhds.has(_))) {
if (missingVhds.length === 0) {
linkedVhds.forEach(_ => unusedVhds.delete(_))
size = await asyncMap(linkedVhds, vhd => handler.getSize(vhd)).then(sum, error => {
onLog(`failed to get size of ${json}`, { error })
linkedVhds.forEach(path => {
vhdsToJSons[path] = json
})
} else {
onLog(`Some VHDs linked to the metadata ${json} are missing`)
onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
if (remove) {
onLog(`deleting incomplete backup ${json}`)
jsons.delete(json)
await handler.unlink(json)
}
}
}
const metadataSize = metadata.size
if (size !== undefined && metadataSize !== size) {
onLog(`incorrect size in metadata: ${metadataSize ?? 'none'} instead of ${size}`)
// don't update if the the stored size is greater than found files,
// it can indicates a problem
if (fixMetadata && (metadataSize === undefined || metadataSize < size)) {
try {
metadata.size = size
await handler.writeFile(json, JSON.stringify(metadata), { flags: 'w' })
} catch (error) {
onLog(`failed to update size in backup metadata ${json}`, { error })
}
}
}
})
// TODO: parallelize by vm/job/vdi
const unusedVhdsDeletion = []
const toMerge = []
{
// VHD chains (as list from child to ancestor) to merge indexed by last
// ancestor
@@ -312,7 +349,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
onLog(`the VHD ${vhd} is unused`)
if (remove) {
onLog(`deleting unused VHD ${vhd}`)
unusedVhdsDeletion.push(handler.unlink(vhd))
unusedVhdsDeletion.push(VhdAbstract.unlink(handler, vhd))
}
}
@@ -321,22 +358,31 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
})
// merge interrupted VHDs
if (merge) {
vhdsList.interruptedVhds.forEach(parent => {
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
})
for (const parent of interruptedVhds.keys()) {
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
}
Object.keys(vhdChainsToMerge).forEach(key => {
const chain = vhdChainsToMerge[key]
Object.values(vhdChainsToMerge).forEach(chain => {
if (chain !== undefined) {
unusedVhdsDeletion.push(mergeVhdChain(chain, { handler, onLog, remove, merge }))
toMerge.push(chain)
}
})
}
const metadataWithMergedVhd = {}
const doMerge = async () => {
await asyncMap(toMerge, async chain => {
const merged = await limitedMergeVhdChain(chain, { handler, onLog, remove, merge })
if (merged !== undefined) {
const metadataPath = vhdsToJSons[chain[0]] // all the chain should have the same metada file
metadataWithMergedVhd[metadataPath] = true
}
})
}
await Promise.all([
...unusedVhdsDeletion,
toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : doMerge()),
asyncMap(unusedXvas, path => {
onLog(`the XVA ${path} is unused`)
if (remove) {
@@ -355,4 +401,55 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
}
}),
])
// update size for delta metadata with merged VHD
// check for the other that the size is the same as the real file size
await asyncMap(jsons, async metadataPath => {
const metadata = JSON.parse(await handler.readFile(metadataPath))
let fileSystemSize
const merged = metadataWithMergedVhd[metadataPath] !== undefined
const { mode, size, vhds, xva } = metadata
try {
if (mode === 'full') {
// a full backup : check size
const linkedXva = resolve('/', vmDir, xva)
fileSystemSize = await handler.getSize(linkedXva)
} else if (mode === 'delta') {
const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
fileSystemSize = await computeVhdsSize(handler, linkedVhds)
// the size is not computed in some cases (e.g. VhdDirectory)
if (fileSystemSize === undefined) {
return
}
// don't warn if the size has changed after a merge
if (!merged && fileSystemSize !== size) {
onLog(`incorrect size in metadata: ${size ?? 'none'} instead of ${fileSystemSize}`)
}
}
} catch (error) {
onLog(`failed to get size of ${metadataPath}`, { error })
return
}
// systematically update size after a merge
if ((merged || fixMetadata) && size !== fileSystemSize) {
metadata.size = fileSystemSize
try {
await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
} catch (error) {
onLog(`failed to update size in backup metadata ${metadataPath} after merge`, { error })
}
}
})
return {
// boolean whether some VHDs were merged (or should be merged)
merge: toMerge.length !== 0,
}
}

View File

@@ -1,11 +1,24 @@
const assert = require('assert')
const isGzipFile = async (handler, fd) => {
const COMPRESSED_MAGIC_NUMBERS = [
// https://tools.ietf.org/html/rfc1952.html#page-5
const magicNumber = Buffer.allocUnsafe(2)
Buffer.from('1F8B', 'hex'),
assert.strictEqual((await handler.read(fd, magicNumber, 0)).bytesRead, magicNumber.length)
return magicNumber[0] === 31 && magicNumber[1] === 139
// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#zstandard-frames
Buffer.from('28B52FFD', 'hex'),
]
const MAGIC_NUMBER_MAX_LENGTH = Math.max(...COMPRESSED_MAGIC_NUMBERS.map(_ => _.length))
const isCompressedFile = async (handler, fd) => {
const header = Buffer.allocUnsafe(MAGIC_NUMBER_MAX_LENGTH)
assert.strictEqual((await handler.read(fd, header, 0)).bytesRead, header.length)
for (const magicNumber of COMPRESSED_MAGIC_NUMBERS) {
if (magicNumber.compare(header, 0, magicNumber.length) === 0) {
return true
}
}
return false
}
// TODO: better check?
@@ -43,8 +56,8 @@ async function isValidXva(path) {
return false
}
return (await isGzipFile(handler, fd))
? true // gzip files cannot be validated at this time
return (await isCompressedFile(handler, fd))
? true // compressed files cannot be validated at this time
: await isValidTar(handler, size, fd)
} finally {
handler.closeFile(fd).catch(noop)

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
const { createLogger } = require('@xen-orchestra/log')
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')
const { RemoteAdapter } = require('../RemoteAdapter.js')
const { CLEAN_VM_QUEUE } = require('./index.js')
// -------------------------------------------------------------------
catchGlobalErrors(createLogger('xo:backups:mergeWorker'))
const { fatal, info, warn } = createLogger('xo:backups:mergeWorker')
// -------------------------------------------------------------------
const main = Disposable.wrap(async function* main(args) {
const handler = yield getSyncedHandler({ url: 'file://' + process.cwd() })
yield handler.lock(CLEAN_VM_QUEUE)
const adapter = new RemoteAdapter(handler)
const listRetry = async () => {
const timeoutResolver = resolve => setTimeout(resolve, 10e3)
for (let i = 0; i < 10; ++i) {
const entries = await handler.list(CLEAN_VM_QUEUE)
if (entries.length !== 0) {
return entries
}
await new Promise(timeoutResolver)
}
}
let taskFiles
while ((taskFiles = await listRetry()) !== undefined) {
const taskFileBasename = min(taskFiles)
const taskFile = join(CLEAN_VM_QUEUE, '_' + taskFileBasename)
// move this task to the end
await handler.rename(join(CLEAN_VM_QUEUE, taskFileBasename), taskFile)
try {
const vmDir = getVmBackupDir(String(await handler.readFile(taskFile)))
await adapter.cleanVm(vmDir, { merge: true, onLog: info, remove: true })
handler.unlink(taskFile).catch(error => warn('deleting task failure', { error }))
} catch (error) {
warn('failure handling task', { error })
}
}
})
info('starting')
main(process.argv.slice(2)).then(
() => {
info('bye :-)')
},
error => {
fatal(error)
process.exit(1)
}
)

View File

@@ -0,0 +1,25 @@
const { join, resolve } = require('path')
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 = resolve(__dirname, 'cli.js')
exports.run = async function runMergeWorker(remotePath) {
try {
// 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))) {
// already locked, don't start another worker
return
}
spawn(CLI_PATH, {
cwd: remotePath,
detached: true,
stdio: 'inherit',
}).unref()
} catch (error) {
// we usually don't want to throw if the merge worker failed to start
return error
}
}

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.13.0",
"version": "0.18.3",
"engines": {
"node": ">=14.6"
},
@@ -16,14 +16,14 @@
"postversion": "npm publish --access public"
},
"dependencies": {
"@vates/compose": "^2.0.0",
"@vates/compose": "^2.1.0",
"@vates/disposable": "^0.1.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^0.18.0",
"@xen-orchestra/fs": "^0.19.3",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^3.6.0",
"compare-versions": "^4.0.1",
"d3-time-format": "^3.0.0",
"end-of-stream": "^1.4.4",
"fs-extra": "^10.0.0",
@@ -32,13 +32,15 @@
"lodash": "^4.17.20",
"node-zone": "^0.4.0",
"parse-pairs": "^1.1.0",
"promise-toolbox": "^0.19.2",
"promise-toolbox": "^0.20.0",
"proper-lockfile": "^4.1.2",
"pump": "^3.0.0",
"vhd-lib": "^1.2.0",
"uuid": "^8.3.2",
"vhd-lib": "^3.0.0",
"yazl": "^2.5.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^0.7.0"
"@xen-orchestra/xapi": "^0.8.5"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -0,0 +1,92 @@
// a valid footer of a 2
exports.VHDFOOTER = {
cookie: 'conectix',
features: 2,
fileFormatVersion: 65536,
dataOffset: 512,
timestamp: 0,
creatorApplication: 'caml',
creatorVersion: 1,
creatorHostOs: 0,
originalSize: 53687091200,
currentSize: 53687091200,
diskGeometry: { cylinders: 25700, heads: 16, sectorsPerTrackCylinder: 255 },
diskType: 3,
checksum: 4294962945,
uuid: Buffer.from('d8dbcad85265421e8b298d99c2eec551', 'utf-8'),
saved: '',
hidden: '',
reserved: '',
}
exports.VHDHEADER = {
cookie: 'cxsparse',
dataOffset: undefined,
tableOffset: 2048,
headerVersion: 65536,
maxTableEntries: 25600,
blockSize: 2097152,
checksum: 4294964241,
parentUuid: null,
parentTimestamp: 0,
reserved1: 0,
parentUnicodeName: '',
parentLocatorEntry: [
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
{
platformCode: 0,
platformDataSpace: 0,
platformDataLength: 0,
reserved: 0,
platformDataOffset: 0,
},
],
reserved2: '',
}

View File

@@ -3,7 +3,7 @@ const map = require('lodash/map.js')
const mapValues = require('lodash/mapValues.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { chainVhd, checkVhdChain, default: Vhd } = require('vhd-lib')
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
const { createLogger } = require('@xen-orchestra/log')
const { dirname } = require('path')
@@ -16,6 +16,7 @@ const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
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')
@@ -23,6 +24,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
async checkBaseVdis(baseUuidToSrcVdi) {
const { handler } = this._adapter
const backup = this._backup
const adapter = this._adapter
const backupDir = getVmBackupDir(backup.vm.uuid)
const vdisDir = `${backupDir}/vdis/${backup.job.id}`
@@ -34,16 +36,21 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
prependDir: true,
})
const packedBaseUuid = packUuid(baseUuid)
await asyncMap(vhds, async path => {
try {
await checkVhdChain(handler, path)
// Warning, this should not be written as found = found || await adapter.isMergeableParent(packedBaseUuid, path)
//
// since all the checks of a path are done in parallel, found would be containing
// only the last answer of isMergeableParent which is probably not the right one
// this led to the support tickets https://help.vates.fr/#ticket/zoom/4751 , 4729, 4665 and 4300
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
found = found || vhd.footer.uuid.equals(packUuid(baseUuid))
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
found = found || isMergeable
} catch (error) {
warn('checkBaseVdis', { error })
await ignoreErrors.call(handler.unlink(path))
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
}
})
} catch (error) {
@@ -113,19 +120,13 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
async _deleteOldEntries() {
return Task.run({ name: 'merge' }, async () => {
const adapter = this._adapter
const oldEntries = this._oldEntries
const adapter = this._adapter
const oldEntries = this._oldEntries
let size = 0
// delete sequentially from newest to oldest to avoid unnecessary merges
for (let i = oldEntries.length; i-- > 0; ) {
size += await adapter.deleteDeltaVmBackups([oldEntries[i]])
}
return {
size,
}
})
// delete sequentially from newest to oldest to avoid unnecessary merges
for (let i = oldEntries.length; i-- > 0; ) {
await adapter.deleteDeltaVmBackups([oldEntries[i]])
}
}
async _transfer({ timestamp, deltaExport, sizeContainers }) {
@@ -150,7 +151,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
// don't do delta for it
vdi.uuid
: vdi.$snapshot_of$uuid
}/${basename}.vhd`
}/${adapter.getVhdFileName(basename)}`
)
const metadataFilename = `${backupDir}/${basename}.json`
@@ -194,7 +195,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
await checkVhd(handler, parentPath)
}
await adapter.outputStream(path, deltaExport.streams[`${id}.vhd`], {
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
// no checksum for VHDs, because they will be invalidated by
// merges and chainings
checksum: false,
@@ -206,11 +207,11 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
// set the correct UUID in the VHD
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
await Disposable.use(openVhd(handler, path), async vhd => {
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
})
})
)
return {

View File

@@ -1,34 +1,65 @@
const { createLogger } = require('@xen-orchestra/log')
const { join } = require('path')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { BACKUP_DIR, getVmBackupDir } = require('../_getVmBackupDir.js')
const MergeWorker = require('../merge-worker/index.js')
const { formatFilenameDate } = require('../_filenameDate.js')
const { warn } = createLogger('xo:backups:MixinBackupWriter')
exports.MixinBackupWriter = (BaseClass = Object) =>
class MixinBackupWriter extends BaseClass {
#lock
#vmBackupDir
constructor({ remoteId, ...rest }) {
super(rest)
this._adapter = rest.backup.remoteAdapters[remoteId]
this._remoteId = remoteId
this._lock = undefined
this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
}
_cleanVm(options) {
return this._adapter
.cleanVm(getVmBackupDir(this._backup.vm.uuid), { ...options, fixMetadata: true, onLog: warn, lock: false })
.catch(warn)
async _cleanVm(options) {
try {
return await this._adapter.cleanVm(this.#vmBackupDir, {
...options,
fixMetadata: true,
onLog: warn,
lock: false,
})
} catch (error) {
warn(error)
return {}
}
}
async beforeBackup() {
const { handler } = this._adapter
const vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
const vmBackupDir = this.#vmBackupDir
await handler.mktree(vmBackupDir)
this._lock = await handler.lock(vmBackupDir)
this.#lock = await handler.lock(vmBackupDir)
}
async afterBackup() {
await this._cleanVm({ remove: true, merge: true })
await this._lock.dispose()
const { disableMergeWorker } = this._backup.config
const { merge } = await this._cleanVm({ remove: true, merge: disableMergeWorker })
await this.#lock.dispose()
// merge worker only compatible with local remotes
const { handler } = this._adapter
if (merge && !disableMergeWorker && typeof handler._getRealPath === 'function') {
const taskFile =
join(MergeWorker.CLEAN_VM_QUEUE, formatFilenameDate(new Date())) +
'-' +
// add a random suffix to avoid collision in case multiple tasks are created at the same second
Math.random().toString(36).slice(2)
await handler.outputFile(taskFile, this._backup.vm.uuid)
const remotePath = handler._getRealPath()
await MergeWorker.run(remotePath)
}
}
}

View File

@@ -1,5 +1,6 @@
const Vhd = require('vhd-lib').default
const openVhd = require('vhd-lib').openVhd
const Disposable = require('promise-toolbox/Disposable')
exports.checkVhd = async function checkVhd(handler, path) {
await new Vhd(handler, path).readHeaderAndFooter()
await Disposable.use(openVhd(handler, path), () => {})
}

View File

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

View File

@@ -2,6 +2,8 @@
import { createSchedule } from './'
jest.useFakeTimers()
const wrap = value => () => value
describe('issues', () => {

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "0.18.0",
"version": "0.19.3",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -17,7 +17,7 @@
"node": ">=14"
},
"dependencies": {
"@marsaud/smb2": "^0.17.2",
"@marsaud/smb2": "^0.18.0",
"@sindresorhus/df": "^3.1.1",
"@sullux/aws-sdk": "^1.0.5",
"@vates/coalesce-calls": "^0.1.0",
@@ -29,11 +29,11 @@
"get-stream": "^6.0.0",
"limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.4",
"promise-toolbox": "^0.19.2",
"promise-toolbox": "^0.20.0",
"proper-lockfile": "^4.1.2",
"readable-stream": "^3.0.6",
"through2": "^4.0.2",
"xo-remote-parser": "^0.7.0"
"xo-remote-parser": "^0.8.0"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -76,6 +76,7 @@ export default class RemoteHandlerAbstract {
const sharedLimit = limitConcurrency(options.maxParallelOperations ?? DEFAULT_MAX_PARALLEL_OPERATIONS)
this.closeFile = sharedLimit(this.closeFile)
this.copy = sharedLimit(this.copy)
this.getInfo = sharedLimit(this.getInfo)
this.getSize = sharedLimit(this.getSize)
this.list = sharedLimit(this.list)
@@ -307,6 +308,17 @@ export default class RemoteHandlerAbstract {
return p
}
async copy(oldPath, newPath, { checksum = false } = {}) {
oldPath = normalizePath(oldPath)
newPath = normalizePath(newPath)
let p = timeout.call(this._copy(oldPath, newPath), this._timeout)
if (checksum) {
p = Promise.all([p, this._copy(checksumFile(oldPath), checksumFile(newPath))])
}
return p
}
async rmdir(dir) {
await timeout.call(this._rmdir(normalizePath(dir)).catch(ignoreEnoent), this._timeout)
}
@@ -519,6 +531,9 @@ export default class RemoteHandlerAbstract {
async _rename(oldPath, newPath) {
throw new Error('Not implemented')
}
async _copy(oldPath, newPath) {
throw new Error('Not implemented')
}
async _rmdir(dir) {
throw new Error('Not implemented')

View File

@@ -33,6 +33,10 @@ export default class LocalHandler extends RemoteHandlerAbstract {
return fs.close(fd)
}
async _copy(oldPath, newPath) {
return fs.copy(this._getFilePath(oldPath), this._getFilePath(newPath))
}
async _createReadStream(file, options) {
if (typeof file === 'string') {
const stream = fs.createReadStream(this._getFilePath(file), options)

View File

@@ -1,6 +1,7 @@
import aws from '@sullux/aws-sdk'
import assert from 'assert'
import http from 'http'
import https from 'https'
import { parse } from 'xo-remote-parser'
import RemoteHandlerAbstract from './abstract'
@@ -16,7 +17,7 @@ const IDEAL_FRAGMENT_SIZE = Math.ceil(MAX_OBJECT_SIZE / MAX_PARTS_COUNT) // the
export default class S3Handler extends RemoteHandlerAbstract {
constructor(remote, _opts) {
super(remote)
const { host, path, username, password, protocol, region } = parse(remote.url)
const { allowUnauthorized, host, path, username, password, protocol, region } = parse(remote.url)
const params = {
accessKeyId: username,
apiVersion: '2006-03-01',
@@ -29,8 +30,13 @@ export default class S3Handler extends RemoteHandlerAbstract {
},
}
if (protocol === 'http') {
params.httpOptions.agent = new http.Agent()
params.httpOptions.agent = new http.Agent({ keepAlive: true })
params.sslEnabled = false
} else if (protocol === 'https') {
params.httpOptions.agent = new https.Agent({
rejectUnauthorized: !allowUnauthorized,
keepAlive: true,
})
}
if (region !== undefined) {
params.region = region
@@ -51,6 +57,27 @@ export default class S3Handler extends RemoteHandlerAbstract {
return { Bucket: this._bucket, Key: this._dir + file }
}
async _copy(oldPath, newPath) {
const size = await this._getSize(oldPath)
const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
try {
const parts = []
let start = 0
while (start < size) {
const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
const upload = await this._s3.uploadPartCopy(partParams)
parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
start += MAX_PART_SIZE
}
await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
} catch (e) {
await this._s3.abortMultipartUpload(multipartParams)
throw e
}
}
async _isNotEmptyDir(path) {
const result = await this._s3.listObjectsV2({
Bucket: this._bucket,
@@ -125,16 +152,30 @@ export default class S3Handler extends RemoteHandlerAbstract {
const splitPrefix = splitPath(prefix)
const result = await this._s3.listObjectsV2({
Bucket: this._bucket,
Prefix: splitPrefix.join('/'),
Prefix: splitPrefix.join('/') + '/', // need slash at the end with the use of delimiters
Delimiter: '/', // will only return path until delimiters
})
const uniq = new Set()
if (result.isTruncated) {
const error = new Error('more than 1000 objects, unsupported in this implementation')
error.dir = dir
throw error
}
const uniq = []
// sub directories
for (const entry of result.CommonPrefixes) {
const line = splitPath(entry.Prefix)
uniq.push(line[line.length - 1])
}
// files
for (const entry of result.Contents) {
const line = splitPath(entry.Key)
if (line.length > splitPrefix.length) {
uniq.add(line[splitPrefix.length])
}
uniq.push(line[line.length - 1])
}
return [...uniq]
return uniq
}
async _mkdir(path) {
@@ -147,25 +188,9 @@ export default class S3Handler extends RemoteHandlerAbstract {
// nothing to do, directories do not exist, they are part of the files' path
}
// s3 doesn't have a rename operation, so copy + delete source
async _rename(oldPath, newPath) {
const size = await this._getSize(oldPath)
const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
try {
const parts = []
let start = 0
while (start < size) {
const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
const upload = await this._s3.uploadPartCopy(partParams)
parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
start += MAX_PART_SIZE
}
await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
} catch (e) {
await this._s3.abortMultipartUpload(multipartParams)
throw e
}
await this.copy(oldPath, newPath)
await this._s3.deleteObject(this._createParams(oldPath))
}
@@ -183,9 +208,21 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
const params = this._createParams(file)
params.Range = `bytes=${position}-${position + buffer.length - 1}`
const result = await this._s3.getObject(params)
result.Body.copy(buffer)
return { bytesRead: result.Body.length, buffer }
try {
const result = await this._s3.getObject(params)
result.Body.copy(buffer)
return { bytesRead: result.Body.length, buffer }
} catch (e) {
if (e.code === 'NoSuchKey') {
if (await this._isNotEmptyDir(file)) {
const error = new Error(`${file} is a directory`)
error.code = 'EISDIR'
error.path = file
throw error
}
}
throw e
}
}
async _rmdir(path) {
@@ -199,6 +236,28 @@ export default class S3Handler extends RemoteHandlerAbstract {
// nothing to do, directories do not exist, they are part of the files' path
}
// reimplement _rmtree to handle efficiantly path with more than 1000 entries in trees
// @todo : use parallel processing for unlink
async _rmtree(path) {
let NextContinuationToken
do {
const result = await this._s3.listObjectsV2({
Bucket: this._bucket,
Prefix: this._dir + path + '/',
ContinuationToken: NextContinuationToken,
})
NextContinuationToken = result.isTruncated ? result.NextContinuationToken : undefined
for (const { Key } of result.Contents) {
// _unlink will add the prefix, but Key contains everything
// also we don't need to check if we delete a directory, since the list only return files
await this._s3.deleteObject({
Bucket: this._bucket,
Key,
})
}
} while (NextContinuationToken !== undefined)
}
async _write(file, buffer, position) {
if (typeof file !== 'string') {
file = file.fd

View File

@@ -1,6 +0,0 @@
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'), {
'@babel/preset-env': {
exclude: ['@babel/plugin-proposal-dynamic-import', '@babel/plugin-transform-regenerator'],
modules: false,
},
})

View File

@@ -1,32 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
ecmaFeatures: {
jsx: true, // Allows for the parsing of JSX
},
},
settings: {
react: {
version: '17',
},
},
extends: [
'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin
],
rules: {
'eslint-comments/disable-enable-pair': 'off',
// Necessary to pass empty Effects/State to Reaclette
'@typescript-eslint/no-empty-interface': 'off',
// https://github.com/typescript-eslint/typescript-eslint/issues/1071
'@typescript-eslint/no-explicit-any': 'off',
// https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
'@typescript-eslint/no-use-before-define': ['error'],
'no-use-before-define': 'off',
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
'@typescript-eslint/ban-ts-comment': 'off',
},
}

View File

@@ -1,24 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.eslintcache
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -1,71 +0,0 @@
{
"name": "xo-lite",
"version": "0.1.0",
"devDependencies": {
"@babel/core": "^7.13.1",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.0",
"@babel/plugin-proposal-optional-chaining": "^7.13.0",
"@babel/preset-env": "^7.13.5",
"@babel/preset-react": "^7.12.13",
"@babel/preset-typescript": "^7.13.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@mui/icons-material": "^5.0.0",
"@mui/lab": "^5.0.0-alpha.48",
"@mui/material": "^5.0.1",
"@novnc/novnc": "^1.2.0",
"@types/immutable": "^3.8.7",
"@types/js-cookie": "^2.2.6",
"@types/lodash": "^4.14.175",
"@types/node": "^14.14.21",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-helmet": "^6.1.5",
"@types/react-intl": "^3.0.0",
"@types/react-router-dom": "^5.1.7",
"@types/react-syntax-highlighter": "^13.5.0",
"@types/styled-components": "^5.1.9",
"@types/webpack-env": "^1.16.0",
"@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1",
"babel-loader": "^8.2.2",
"classnames": "^2.3.1",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^10.2.0",
"eslint": "^7.21.0",
"eslint-plugin-react": "^7.22.0",
"html-webpack-plugin": "^5.2.0",
"human-format": "^0.11.0",
"immutable": "^4.0.0-rc.12",
"iterable-backoff": "^0.1.0",
"json-rpc-protocol": "^0.13.1",
"lodash": "^4.17.21",
"node-polyfill-webpack-plugin": "^1.0.3",
"process": "^0.11.10",
"promise-toolbox": "^0.16.0",
"reaclette": "^0.10.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-helmet": "^6.1.0",
"react-intl": "^5.10.16",
"react-router-dom": "^5.2.0",
"react-syntax-highlighter": "^15.4.3",
"styled-components": "^5.2.1",
"typescript": "^4.3.1",
"webpack": "^5.24.2",
"webpack-cli": "^4.9.1",
"xen-api": "^0.34.3"
},
"resolutions": {
"styled-components": "^5"
},
"scripts": {
"build": "cross-env NODE_ENV=production webpack",
"start": "cross-env NODE_ENV=development webpack serve",
"start:open": "npm run start -- --open"
},
"browserslist": "> 0.5%, last 2 versions, Firefox ESR, not dead"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Xen Orchestra Lite" />
<title>XO Lite</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,90 +0,0 @@
import React from 'react'
import styled from 'styled-components'
import { Switch, Route, RouteComponentProps } from 'react-router-dom'
import { withState } from 'reaclette'
import { withRouter } from 'react-router'
import Pool from './Pool'
import TabConsole from './TabConsole'
import TreeView from './TreeView'
import { ObjectsByType } from '../libs/xapi'
const Container = styled.div`
display: flex;
overflow: hidden;
`
const LeftPanel = styled.div`
background: #f5f5f5;
min-width: 15em;
overflow-y: scroll;
width: 20%;
`
// FIXME: temporary work-around while investigating flew-grow issue:
// `overflow: hidden` forces the console to shrink to the max available width
// even when the tree component takes more than 20% of the width due to
// `min-width`
const MainPanel = styled.div`
overflow: hidden;
width: 80%;
`
interface ParentState {
objectsByType: ObjectsByType
pool?: string
}
interface State {
selectedObject?: string
selectedVm?: string
}
// For compatibility with 'withRouter'
interface Props extends RouteComponentProps {}
interface ParentEffects {}
interface Effects {
initialize: () => void
}
interface Computed {}
const selectedNodesToArray = (nodes: Array<string> | string | undefined) =>
nodes === undefined ? undefined : Array.isArray(nodes) ? nodes : [nodes]
const Infrastructure = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: props => ({
selectedVm: props.location.pathname.split('/')[3],
}),
computed: {
selectedObject: (state, props) =>
props.location.pathname.startsWith('/infrastructure/pool') ? state.pool : state.selectedVm,
},
},
({ state: { pool, selectedObject } }) => (
<Container>
<LeftPanel>
<TreeView defaultSelectedNodes={selectedNodesToArray(selectedObject)} />
</LeftPanel>
<MainPanel>
<Switch>
<Route exact path={`/infrastructure/pool/${pool}/dashboard`}>
<Pool id={pool} />
</Route>
<Route
path='/infrastructure/vms/:id/console'
render={({
match: {
params: { id },
},
}) => <TabConsole key={id} vmId={id} />}
/>
</Switch>
</MainPanel>
</Container>
)
)
export default withRouter(Infrastructure)

View File

@@ -1,120 +0,0 @@
import Grid from '@mui/material/Grid'
import React from 'react'
import styled from 'styled-components'
import Typography from '@mui/material/Typography'
import { withState } from 'reaclette'
import Icon from '../../../components/Icon'
import IntlMessage from '../../../components/IntlMessage'
import ProgressCircle from '../../../components/ProgressCircle'
interface ParentState {}
interface State {}
interface Props {
nActive?: number
nTotal?: number
type: 'host' | 'VM'
}
interface ParentEffects {}
interface Effects {}
interface Computed {
nInactive?: number
}
const DEFAULT_CAPTION_STYLE = { textTransform: 'uppercase', mt: 2 }
const TYPOGRAPHY_SX = { mb: 2 }
const ObjectStatusContainer = styled.div`
display: flex;
overflow: hidden;
flex-direction: row;
align-content: space-between;
margin-bottom: 1em;
`
const CircularProgressPanel = styled.div`
margin-left: 2em;
`
const GridPanel = styled.div`
margin-left: 2em;
width: 100%;
height: 100%;
`
// TODO: Add a loading page when data is not loaded as it is in the model(figma).
// FIXME: replace the hard-coded colors with the theme colors.
const ObjectStatus = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
nInactive: (state, { nTotal = 0, nActive = 0 }) => nTotal - nActive,
},
},
({ state: { nInactive }, nActive = 0, nTotal = 0, type }) => {
if (nTotal === 0) {
return (
<span>
<IntlMessage id={type === 'VM' ? 'noVms' : 'noHosts'} />
</span>
)
}
return (
<ObjectStatusContainer>
<CircularProgressPanel>
<ProgressCircle max={nTotal} value={nActive} />
</CircularProgressPanel>
<GridPanel>
<Grid container>
<Grid item xs={12}>
<Typography sx={TYPOGRAPHY_SX} variant='h5' component='div'>
<IntlMessage id={type === 'VM' ? 'vms' : 'hosts'} />
</Typography>
</Grid>
<Grid item xs={10}>
<Icon icon='circle' htmlColor='#00BA34' />
&nbsp;
<Typography variant='body2' component='span'>
<IntlMessage id='active' />
</Typography>
</Grid>
<Grid item xs={2}>
<Typography variant='body2' component='div'>
{nActive}
</Typography>
</Grid>
<Grid item xs={10}>
<Icon icon='circle' htmlColor='#E8E8E8' />
&nbsp;
<Typography variant='body2' component='span'>
<IntlMessage id='inactive' />
</Typography>
</Grid>
<Grid item xs={2}>
<Typography variant='body2' component='div'>
{nInactive}
</Typography>
</Grid>
<Grid item xs={10}>
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
<IntlMessage id='total' />
</Typography>
</Grid>
<Grid item xs={2}>
<Typography variant='caption' component='div' sx={DEFAULT_CAPTION_STYLE}>
{nTotal}
</Typography>
</Grid>
</Grid>
</GridPanel>
</ObjectStatusContainer>
)
}
)
export default ObjectStatus

View File

@@ -1,79 +0,0 @@
import Divider from '@mui/material/Divider'
import React from 'react'
import styled from 'styled-components'
import Typography from '@mui/material/Typography'
import { withState } from 'reaclette'
import ObjectStatus from './ObjectStatus'
import IntlMessage from '../../../components/IntlMessage'
import { Host, ObjectsByType, Vm } from '../../../libs/xapi'
interface ParentState {
objectsByType?: ObjectsByType
}
interface State {
hosts?: Map<string, Host>
nRunningHosts?: number
nRunningVms?: number
vms?: Map<string, Vm>
}
interface Props {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const DEFAULT_STYLE = { m: 2 }
const Container = styled.div`
display: flex;
overflow: hidden;
flex-direction: row;
align-content: space-between;
gap: 1.25em;
background: '#E8E8E8';
`
const Panel = styled.div`
background: #ffffff;
border-radius: 0.5em;
box-shadow: 0px 1px 1px 0px #00000014, 0px 2px 1px 0px #0000000f, 0px 1px 3px 0px #0000001a;
margin: 0.5em;
`
const getHostPowerState = (host: Host) => {
const { $metrics } = host
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
}
const Dashboard = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
hosts: state => state.objectsByType?.get('host'),
vms: state =>
state.objectsByType
?.get('VM')
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
nRunningHosts: state => (state.hosts?.filter((host: Host) => getHostPowerState(host) === 'Running')).size,
nRunningVms: state => (state.vms?.filter((vm: Vm) => vm.power_state === 'Running')).size,
},
},
({ state: { hosts, nRunningHosts, nRunningVms, vms } }) => (
<Container>
<Panel>
<Typography variant='h4' component='div' sx={DEFAULT_STYLE}>
<IntlMessage id='status' />
</Typography>
<ObjectStatus nActive={nRunningHosts} nTotal={hosts?.size} type='host' />
<Divider variant='middle' sx={DEFAULT_STYLE} />
<ObjectStatus nActive={nRunningVms} nTotal={vms?.size} type='VM' />
</Panel>
</Container>
)
)
export default Dashboard

View File

@@ -1,46 +0,0 @@
import React from 'react'
import { withState } from 'reaclette'
import Dashboard from './dashboard'
import Icon from '../../components/Icon'
import PanelHeader from '../../components/PanelHeader'
import { ObjectsByType, Pool as PoolType } from '../../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
}
interface State {}
interface Props {
id: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {
pool?: PoolType
}
// TODO: add tabs when https://github.com/vatesfr/xen-orchestra/pull/6096 is merged.
const Pool = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
pool: (state, props) => state.objectsByType?.get('pool')?.get(props.id),
},
},
({ state: { pool } }) => (
<>
<PanelHeader>
<span>
<Icon icon='warehouse' color='primary' /> {pool?.name_label}
</span>
</PanelHeader>
<Dashboard />
</>
)
)
export default Pool

View File

@@ -1,65 +0,0 @@
import React from 'react'
import { Map } from 'immutable'
import { withState } from 'reaclette'
import IntlMessage from '../../components/IntlMessage'
import Table, { Column } from '../../components/Table'
import { ObjectsByType, Pif } from '../../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
objectsFetched: boolean
}
interface State {}
interface Props {
poolId: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {
managementPifs?: Pif[]
pifs?: Map<string, Pif>
}
const COLUMNS: Column<Pif>[] = [
{
header: <IntlMessage id='device' />,
render: pif => pif.device,
},
{
header: <IntlMessage id='dns' />,
render: pif => pif.DNS,
},
{
header: <IntlMessage id='gateway' />,
render: pif => pif.gateway,
},
{
header: <IntlMessage id='ip' />,
render: pif => pif.IP,
},
]
const PoolNetworks = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
managementPifs: state =>
state.pifs
?.filter(pif => pif.management)
.map(pif => ({ ...pif, id: pif.$id }))
.valueSeq()
.toArray(),
pifs: state => state.objectsByType.get('PIF'),
},
},
({ state }) => (
<Table collection={state.managementPifs} columns={COLUMNS} placeholder={<IntlMessage id='noManagementPifs' />} />
)
)
export default PoolNetworks

View File

@@ -1,89 +0,0 @@
import React from 'react'
import humanFormat from 'human-format'
import { withState } from 'reaclette'
import IntlMessage from '../../components/IntlMessage'
import Table, { Column } from '../../components/Table'
import XapiConnection, { ObjectsByType, PoolUpdate } from '../../libs/xapi'
const COLUMN: Column<PoolUpdate>[] = [
{
header: <IntlMessage id='name' />,
render: update => update.name,
},
{
header: <IntlMessage id='description' />,
render: update => update.description,
},
{
header: <IntlMessage id='version' />,
render: update => update.version,
},
{
header: <IntlMessage id='release' />,
render: update => update.release,
},
{
header: <IntlMessage id='size' />,
render: update => humanFormat.bytes(update.size),
},
]
interface ParentState {
objectsByType: ObjectsByType
objectsFetched: boolean
xapi: XapiConnection
}
interface State {}
interface Props {
hostRef: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {
availableUpdates?: PoolUpdate[] | JSX.Element
}
const PoolUpdates = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
availableUpdates: async function (state, { hostRef }) {
try {
const stringifiedPoolUpdates = (await state.xapi.call(
'host.call_plugin',
hostRef,
'updater.py',
'check_update',
{}
)) as string
return JSON.parse(stringifiedPoolUpdates)
} catch (err) {
console.error(err)
return <IntlMessage id='errorOccurred' />
}
},
},
},
({ state: { availableUpdates } }) =>
availableUpdates !== undefined ? (
Array.isArray(availableUpdates) ? (
<>
{availableUpdates.length !== 0 && (
<IntlMessage id='availableUpdates' values={{ nUpdates: availableUpdates.length }} />
)}
<Table collection={availableUpdates} columns={COLUMN} placeholder={<IntlMessage id='noUpdatesAvailable' />} />
</>
) : (
availableUpdates
)
) : (
<IntlMessage id='loading' />
)
)
export default PoolUpdates

View File

@@ -1,53 +0,0 @@
import React from 'react'
import { Map } from 'immutable'
import { withState } from 'reaclette'
import PoolNetworks from './PoolNetworks'
import PoolUpdates from './PoolUpdates'
import IntlMessage from '../../components/IntlMessage'
import { Host, ObjectsByType, Pool } from '../../libs/xapi'
interface ParentState {
objectsFetched: boolean
}
interface State {
objectsByType: ObjectsByType
}
interface Props {}
interface ParentEffects {}
interface Effects {}
interface Computed {
hosts?: Map<string, Host>
pool?: Pool
}
const PoolTab = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
hosts: state => (state.objectsFetched ? state.objectsByType?.get('host') : undefined),
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.first() : undefined),
},
},
({ state }) =>
state.pool !== undefined ? (
<>
<PoolNetworks poolId={state.pool.$id} />
{state.hosts?.valueSeq().map(host => (
<div key={host.$id}>
<p>{host.name_label}</p>
<PoolUpdates hostRef={host.$ref} />
</div>
))}
</>
) : (
<IntlMessage id='loading' />
)
)
export default PoolTab

View File

@@ -1,110 +0,0 @@
import React from 'react'
import styled from 'styled-components'
import { withState } from 'reaclette'
import Button from '../../components/Button'
import Checkbox from '../../components/Checkbox'
import Input from '../../components/Input'
import IntlMessage from '../../components/IntlMessage'
interface ParentState {
error: string
}
interface State {
password: string
rememberMe: boolean
}
interface Props {}
interface ParentEffects {
connectToXapi: (password: string, rememberMe: boolean) => void
}
interface Effects {
setRememberMe: (event: React.ChangeEvent<HTMLInputElement>) => void
setPassword: (event: React.ChangeEvent<HTMLInputElement>) => void
submit: (event: React.MouseEvent<HTMLButtonElement>) => void
}
interface Computed {}
const Wrapper = styled.div`
height: 100vh;
display: flex;
`
const Form = styled.form`
width: 20em;
margin: auto;
text-align: center;
`
const Fieldset = styled.fieldset`
border: 0;
padding-left: 0;
padding-right: 0;
`
const RememberMe = styled(Fieldset)`
text-align: start;
vertical-align: baseline;
`
const Error = styled.p`
color: #a33;
`
const Signin = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
password: '',
rememberMe: false,
}),
effects: {
setRememberMe: function ({ currentTarget: { checked: rememberMe } }) {
this.state.rememberMe = rememberMe
},
setPassword: function ({ currentTarget: { value: password } }) {
this.state.password = password
},
submit: function () {
this.effects.connectToXapi(this.state.password, this.state.rememberMe)
},
},
},
({ effects, state }) => (
<Wrapper>
<Form onSubmit={e => e.preventDefault()}>
<img src='logo.png' />
<h1>Xen Orchestra Lite</h1>
<Fieldset>
<Input disabled label={<IntlMessage id='login' />} value='root' />
</Fieldset>
<Fieldset>
<Input
autoFocus
label={<IntlMessage id='password' />}
onChange={effects.setPassword}
type='password'
value={state.password}
/>
</Fieldset>
<RememberMe>
<label>
<Checkbox onChange={effects.setRememberMe} checked={state.rememberMe} />
&nbsp;
<IntlMessage id='rememberMe' />
</label>
</RememberMe>
<Error>{state.error}</Error>
<Button type='submit' onClick={effects.submit}>
<IntlMessage id='connect' />
</Button>
</Form>
</Wrapper>
)
)
export default Signin

View File

@@ -1,300 +0,0 @@
// https://mui.com/components/material-icons/
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
import DeleteIcon from '@mui/icons-material/Delete'
import React from 'react'
import styled from 'styled-components'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { materialDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { toNumber } from 'lodash'
import { SelectChangeEvent } from '@mui/material'
import { withState } from 'reaclette'
import ActionButton from '../../components/ActionButton'
import Button from '../../components/Button'
import Checkbox from '../../components/Checkbox'
import Icon from '../../components/Icon'
import Input from '../../components/Input'
import ProgressCircle from '../../components/ProgressCircle'
import Select from '../../components/Select'
import Tabs from '../../components/Tabs'
import { alert, confirm } from '../../components/Modal'
interface ParentState {}
interface State {
progressBarValue: number
value: unknown
}
interface Props {}
interface ParentEffects {}
interface Effects {
onChangeProgressBarValue: (e: React.ChangeEvent<HTMLInputElement>) => void
onChangeSelect: (e: SelectChangeEvent<unknown>) => void
sayHello: () => void
sendPromise: (data: Record<string, unknown>) => Promise<void>
showAlertModal: () => void
showConfirmModal: () => void
}
interface Computed {}
const Page = styled.div`
margin: 30px;
`
const Container = styled.div`
display: flex;
column-gap: 10px;
`
const Render = styled.div`
flex: 1;
padding: 20px;
border: solid 1px gray;
border-radius: 3px;
`
const Code = styled(SyntaxHighlighter).attrs(() => ({
language: 'jsx',
style: codeStyle,
}))`
flex: 1;
border-radius: 3px;
margin: 0 !important;
`
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
progressBarValue: 100,
value: '',
}),
effects: {
onChangeProgressBarValue: function (e) {
this.state.progressBarValue = toNumber(e.target.value)
},
onChangeSelect: function (e) {
this.state.value = e.target.value
},
sayHello: () => alert('hello'),
sendPromise: data =>
new Promise(resolve => {
setTimeout(() => {
resolve()
window.alert(data.foo)
}, 1000)
}),
showAlertModal: () => alert({ message: 'This is an alert modal', title: 'Alert modal', icon: 'info' }),
showConfirmModal: () =>
confirm({
message: 'This is a confirm modal test',
title: 'Confirm modal',
icon: 'download',
}),
},
},
({ effects, state }) => (
<Page>
<h2>ActionButton</h2>
<Container>
<Render>
<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
Send promise
</ActionButton>
</Render>
<Code>
{`<ActionButton data-foo='forwarded data props' onClick={effects.sendPromise}>
Send promise
</ActionButton>`}
</Code>
</Container>
<h2>Button</h2>
<Container>
<Render>
<Button color='primary' onClick={effects.sayHello} startIcon={<AccountCircleIcon />}>
Primary
</Button>
<Button color='secondary' endIcon={<DeleteIcon />} onClick={effects.sayHello}>
Secondary
</Button>
<Button color='success' onClick={effects.sayHello}>
Success
</Button>
<Button color='warning' onClick={effects.sayHello}>
Warning
</Button>
<Button color='error' onClick={effects.sayHello}>
Error
</Button>
<Button color='info' onClick={effects.sayHello}>
Info
</Button>
</Render>
<Code>{`<Button color='primary' onClick={doSomething} startIcon={<AccountCircleIcon />}>
Primary
</Button>
<Button color='secondary' endIcon={<DeleteIcon />} onClick={doSomething}>
Secondary
</Button>
<Button color='success' onClick={doSomething}>
Success
</Button>
<Button color='warning' onClick={doSomething}>
Warning
</Button>
<Button color='error' onClick={doSomething}>
Error
</Button>
<Button color='info' onClick={doSomething}>
Info
</Button>`}</Code>
</Container>
<h2>Icon</h2>
<Container>
<Render>
<Icon icon='truck' htmlColor='#0085FF' />
<Icon icon='truck' color='primary' size='2x' />
</Render>
<Code>{`// https://fontawesome.com/icons
<Icon icon='truck' htmlColor='#0085FF'/>
<Icon icon='truck' color='primary' size='2x' />`}</Code>
</Container>
<h2>Input</h2>
<Container>
<Render>
<Input label='Input' />
<Checkbox />
</Render>
<Code>{`<TextInput label='Input' />
<Checkbox />`}</Code>
</Container>
<h2>Modal</h2>
<Container>
<Render>
<Button
color='primary'
onClick={effects.showAlertModal}
sx={{
marginBottom: 1,
}}
>
Alert
</Button>
<Button color='primary' onClick={effects.showConfirmModal}>
Confirm
</Button>
</Render>
<Code>{`<Button
color='primary'
onClick={() =>
alert({
message: 'This is an alert modal',
title: 'Alert modal',
icon: 'info'
})
}
>
Alert
</Button>
<Button
color='primary'
onClick={async () => {
try {
await confirm({
message: 'This is a confirm modal',
title: 'Confirm modal',
icon: 'download',
})
// The modal has been confirmed
} catch (reason) { // "cancel"
// The modal has been closed
}
}}
>
Confirm
</Button>`}</Code>
</Container>
<h2>ProgressCircle</h2>
<Container>
<Render>
<div style={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap' }}>
<div>
<ProgressCircle max={200} value={state.progressBarValue} />
</div>
<div>
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />
</div>
</div>
<input
defaultValue={state.progressBarValue}
max='200'
min='0'
onChange={effects.onChangeProgressBarValue}
step='1'
style={{
display: 'block',
margin: '10px auto',
}}
type='range'
/>
</Render>
<Code>
{`<ProgressCircle max={200} value={state.progressBarValue} />
<ProgressCircle max={200} showLabel={false} size={150} value={state.progressBarValue} />`}
</Code>
</Container>
<h2>Select</h2>
<Container>
<Render>
<Select
onChange={effects.onChangeSelect}
options={[
{ name: 'Bar', value: 1 },
{ name: 'Foo', value: 2 },
]}
value={state.value}
valueRenderer='value'
/>
</Render>
<Code>
{`<Select
onChange={handleChange}
optionRenderer={item => item.name}
options={[
{ name: 'Bar', value: 1 },
{ name: 'Foo', value: 2 },
]}
value={state.value}
valueRenderer='value'
/>`}
</Code>
</Container>
<h2>Tabs</h2>
<Container>
<Render>
<Tabs
tabs={[
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
{ label: 'FOO', pathname: '/styleguide/foo' },
]}
useUrl
/>
</Render>
<Code>
{`<Tabs
tabs={[
{ component: 'Hello BAR!', label: 'BAR', pathname: '/styleguide' },
{ label: 'FOO', pathname: '/styleguide/foo' },
]}
useUrl
/>`}
</Code>
</Container>
</Page>
)
)
export default App

View File

@@ -1,102 +0,0 @@
import React from 'react'
import { withState } from 'reaclette'
import Console from '../components/Console'
import IntlMessage, { translate } from '../components/IntlMessage'
import { ObjectsByType, Vm } from '../libs/xapi'
import PanelHeader from '../components/PanelHeader'
interface ParentState {
objectsByType: ObjectsByType
}
interface State {
consoleScale: number
sendCtrlAltDel?: () => void
}
interface Props {
vmId: string
}
interface ParentEffects {}
interface Effects {
scaleConsole: React.ChangeEventHandler<HTMLInputElement>
setCtrlAltDel: (sendCtrlAltDel: State['sendCtrlAltDel']) => void
showNotImplemented: () => void
}
interface Computed {
vm?: Vm
}
const TabConsole = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
// Value in percent
consoleScale: 100,
sendCtrlAltDel: undefined,
}),
effects: {
scaleConsole: function (e) {
this.state.consoleScale = +e.currentTarget.value
// With "scaleViewport", the canvas occupies all available space of its
// container. But when the size of the container is changed, the canvas
// size isn't updated
// Issue https://github.com/novnc/noVNC/issues/1364
// PR https://github.com/novnc/noVNC/pull/1365
window.dispatchEvent(new UIEvent('resize'))
},
setCtrlAltDel: function (sendCtrlAltDel) {
this.state.sendCtrlAltDel = sendCtrlAltDel
},
showNotImplemented: function () {
alert('Not Implemented')
},
},
computed: {
vm: (state, { vmId }) => state.objectsByType.get('VM')?.get(vmId),
},
},
({ effects, state, vmId }) => (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<PanelHeader
actions={[
{
key: 'start',
icon: 'play',
color: 'primary',
title: translate({ id: 'vmStartLabel' }),
variant: 'contained',
onClick: effects.showNotImplemented,
},
]}
>
{state.vm?.name_label ?? 'loading'}{' '}
</PanelHeader>
{/* Hide scaling and Ctrl+Alt+Del button temporarily */}
{/* <RangeInput max={100} min={1} onChange={effects.scaleConsole} step={1} value={state.consoleScale} />
{state.sendCtrlAltDel !== undefined && (
<Button onClick={state.sendCtrlAltDel}>
<IntlMessage id='ctrlAltDel' />
</Button>
)} */}
{state.vm?.power_state !== 'Running' ? (
<p>
<IntlMessage id='consoleNotAvailable' />
</p>
) : (
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ height: '100%', width: '100%' }}>
<Console vmId={vmId} scale={state.consoleScale} setCtrlAltDel={effects.setCtrlAltDel} />
</div>
</div>
)}
</div>
)
)
export default TabConsole

View File

@@ -1,131 +0,0 @@
import React from 'react'
import { Collection, Map } from 'immutable'
import { withState } from 'reaclette'
import Icon from '../components/Icon'
import IntlMessage from '../components/IntlMessage'
import Tree, { ItemType } from '../components/Tree'
import { Host, ObjectsByType, Pool, Vm } from '../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
}
interface State {}
interface Props {
defaultSelectedNodes?: Array<string>
}
interface ParentEffects {}
interface Effects {}
interface Computed {
collection?: Array<ItemType>
hostsByPool?: Collection.Keyed<string, Collection<string, Host>>
pools?: Map<string, Pool>
vms?: Map<string, Vm>
vmsByContainerRef?: Collection.Keyed<string, Collection<string, Vm>>
}
const getHostPowerState = (host: Host) => {
const { $metrics } = host
return $metrics ? ($metrics.live ? 'Running' : 'Halted') : 'Unknown'
}
const getIconColor = (obj: Host | Vm) => {
const powerState = obj.power_state ?? getHostPowerState(obj as Host)
return powerState === 'Running' ? '#198754' : powerState === 'Halted' ? '#dc3545' : '#6c757d'
}
const TreeView = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
collection: state => {
if (state.pools === undefined) {
return
}
const collection: ItemType[] = []
state.pools.valueSeq().forEach((pool: Pool) => {
const hosts = state.hostsByPool
?.get(pool.$id)
?.valueSeq()
.sortBy(host => host.name_label)
.map((host: Host) => ({
children: state.vmsByContainerRef
?.get(host.$ref)
?.valueSeq()
.sortBy(vm => vm.name_label)
.map((vm: Vm) => ({
id: vm.$id,
label: (
<span>
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
</span>
),
to: `/infrastructure/vms/${vm.$id}/console`,
tooltip: <IntlMessage id={vm.power_state.toLowerCase()} />,
}))
.toArray(),
id: host.$id,
label: (
<span>
<Icon icon='server' htmlColor={getIconColor(host)} /> {host.name_label}
</span>
),
tooltip: <IntlMessage id={getHostPowerState(host).toLowerCase()} />,
}))
.toArray()
const haltedVms = state.vmsByContainerRef
?.get(pool.$ref)
?.valueSeq()
.sortBy((vm: Vm) => vm.name_label)
.map((vm: Vm) => ({
id: vm.$id,
label: (
<span>
<Icon icon='desktop' htmlColor={getIconColor(vm)} /> {vm.name_label}
</span>
),
to: `/infrastructure/vms/${vm.$id}/console`,
tooltip: <IntlMessage id='halted' />,
}))
.toArray()
collection.push({
children: (hosts ?? []).concat(haltedVms ?? []),
id: pool.$id,
label: (
<span>
<Icon icon='warehouse' color='primary' /> {pool.name_label}
</span>
),
to: `/infrastructure/pool/${pool.$id}/dashboard`,
})
})
return collection
},
hostsByPool: state => state.objectsByType?.get('host')?.groupBy((host: Host) => host.$pool.$id),
pools: state => state.objectsByType?.get('pool'),
vms: state =>
state.objectsByType
?.get('VM')
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template),
vmsByContainerRef: state =>
state.vms?.groupBy(({ power_state: powerState, resident_on: host, $pool }: Vm) =>
powerState === 'Running' || powerState === 'Paused' ? host : $pool.$ref
),
},
},
({ state, defaultSelectedNodes }) =>
state.collection === undefined ? null : (
<div style={{ padding: '10px' }}>
<Tree collection={state.collection} defaultSelectedNodes={defaultSelectedNodes} />
</div>
)
)
export default TreeView

View File

@@ -1,506 +0,0 @@
// import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import Container from '@mui/material/Container'
import Cookies from 'js-cookie'
import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import MenuIcon from '@mui/icons-material/Menu'
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'
import MuiDrawer from '@mui/material/Drawer'
import React from 'react'
import styledComponent from 'styled-components'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { HashRouter as Router, Switch, Redirect, Route } from 'react-router-dom'
import { IntlProvider } from 'react-intl'
import { Map } from 'immutable'
import { styled, createTheme, ThemeProvider } from '@mui/material/styles'
import { withState } from 'reaclette'
// import Button from '../components/Button'
import Icon from '../components/Icon'
import Infrastructure from './Infrastructure'
import IntlMessage from '../components/IntlMessage'
import Link from '../components/Link'
import messagesEn from '../lang/en.json'
import Modal from '../components/Modal'
import PoolTab from './PoolTab'
import Signin from './Signin/index'
import StyleGuide from './StyleGuide/index'
import TabConsole from './TabConsole'
import XapiConnection, { ObjectsByType, Pool, Vm } from '../libs/xapi'
const drawerWidth = 240
const redirectPaths = ['/', '/infrastructure']
interface AppBarProps extends MuiAppBarProps {
open?: boolean
}
// -----------------------------------------------------------------------------
// Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard
const AppBar = styled(MuiAppBar, {
shouldForwardProp: prop => prop !== 'open',
})<AppBarProps>(({ theme, open }) => ({
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
}),
}))
const Drawer = styled(MuiDrawer, { shouldForwardProp: prop => prop !== 'open' })(({ theme, open }) => ({
'& .MuiDrawer-paper': {
position: 'relative',
whiteSpace: 'nowrap',
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
boxSizing: 'border-box',
...(!open && {
overflowX: 'hidden',
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
width: theme.spacing(7),
[theme.breakpoints.up('sm')]: {
width: theme.spacing(9),
},
}),
},
}))
const MainListItems = (): JSX.Element => (
<div>
<ListItemButton component='a' href='#infrastructure'>
<ListItemIcon>
<Icon icon='project-diagram' />
</ListItemIcon>
<ListItemText primary={<IntlMessage id='infrastructure' />} />
</ListItemButton>
<ListItemButton component='a' href='#about'>
<ListItemIcon>
<Icon icon='info-circle' />
</ListItemIcon>
<ListItemText primary='About' />
</ListItemButton>
</div>
)
interface SecondaryListItemsParentState {}
interface SecondaryListItemsState {}
interface SecondaryListItemsProps {}
interface SecondaryListItemsParentEffects {}
interface SecondaryListItemsEffects {
disconnect: () => void
}
interface SecondaryListItemsComputed {}
const ICON_STYLE = { fontSize: '1.5em' }
const SecondaryListItems = withState<
SecondaryListItemsState,
SecondaryListItemsProps,
SecondaryListItemsEffects,
SecondaryListItemsComputed,
SecondaryListItemsParentState,
SecondaryListItemsParentEffects
>({}, ({ effects }) => (
<div>
<ListItem button onClick={() => effects.disconnect()}>
<ListItemIcon style={ICON_STYLE}>
<Icon icon='sign-out-alt' />
</ListItemIcon>
<ListItemText primary={<IntlMessage id='disconnect' />} />
</ListItem>
</div>
))
// -----------------------------------------------------------------------------
// Default bootstrap 4 colors
// https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss#L67-L74
const mdTheme = createTheme({
background: {
primary: {
dark: '#111111',
light: '#FFFFFF',
},
},
palette: {
error: {
main: '#dc3545',
},
info: {
main: '#17a2b8',
},
primary: {
dark: '#168FFF',
light: '#0085FF',
main: '#007bff',
},
secondary: {
main: '#6c757d',
},
success: {
main: '#28a745',
},
warning: {
main: '#ffc107',
},
},
components: {
MuiTab: {
styleOverrides: {
root: {
color: '#E8E8E8',
fontStyle: 'medium',
fontSize: '1.25em',
textAlign: 'center',
},
},
},
},
typography: {
fontFamily: 'inter',
h1: {
fontWeight: 500,
fontSize: '3em',
fontStyle: 'medium',
lineHeight: '3.75em',
},
h2: {
fontWeight: 500,
fontSize: '2.25em',
fontStyle: 'medium',
},
h3: {
fontWeight: 500,
fontSize: '1.5em',
fontStyle: 'medium',
lineHeight: '2em',
},
h4: {
fontWeight: 500,
fontSize: '1.25em',
fontStyle: 'medium',
lineHeight: '1.75em',
},
h5: {
fontWeight: 500,
fontSize: '1em',
fontStyle: 'medium',
lineHeight: '1.50em',
},
h6: {
fontWeight: 500,
fontSize: '0.8em',
fontStyle: 'medium',
lineHeight: '1.25em',
},
caption: {
// styleName: Caps / Caps 1 - 14 Semi Bold
fontSize: '0.9em',
fontStyle: 'normal',
fontWeight: 600,
lineHeight: '1.25em',
verticalAlign: 'top',
letterSpacing: '0.04em',
textAlign: 'left',
},
body2: {
// styleName: Paragraph / P2 - 16
fontSize: '1em',
fontStyle: 'normal',
fontWeight: 400,
lineHeight: '1.5em',
letterSpacing: '0em',
textAlign: 'left',
},
},
})
const FullPage = styledComponent.div`
height: 100vh;
display: flex;
flex-direction: column;
`
interface ParentState {
objectsByType: ObjectsByType
xapi: XapiConnection
}
interface State {
connected: boolean
drawerOpen: boolean
error: React.ReactNode
xapiHostname: string
}
interface Props {}
interface ParentEffects {}
interface Effects {
connectToXapi: (password: string, rememberMe: boolean) => void
disconnect: () => void
toggleDrawer: () => void
}
interface Computed {
objectsFetched: boolean
pool?: Pool
url: string
vms?: Map<string, Vm>
}
const App = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
connected: Cookies.get('sessionId') !== undefined,
drawerOpen: false,
error: '',
objectsByType: undefined,
xapi: undefined,
xapiHostname: process.env.XAPI_HOST || window.location.host,
}),
effects: {
initialize: async function () {
const xapi = (this.state.xapi = new XapiConnection())
xapi.on('connected', () => {
this.state.connected = true
})
xapi.on('disconnected', () => {
this.state.connected = false
})
xapi.on('objects', (objectsByType: ObjectsByType) => {
this.state.objectsByType = objectsByType
})
try {
await xapi.reattachSession(this.state.url)
} catch (err) {
if (err?.code !== 'SESSION_INVALID') {
throw err
}
console.log('Session ID is invalid. Asking for credentials.')
}
},
toggleDrawer: function () {
this.state.drawerOpen = !this.state.drawerOpen
},
connectToXapi: async function (password, rememberMe = false) {
try {
await this.state.xapi.connect({
url: this.state.url,
user: 'root',
password,
rememberMe,
})
} catch (err) {
if (err?.code !== 'SESSION_AUTHENTICATION_FAILED') {
throw err
}
this.state.error = <IntlMessage id='badCredentials' />
}
},
disconnect: async function () {
await this.state.xapi.disconnect()
this.state.connected = false
},
},
computed: {
objectsFetched: state => state.objectsByType !== undefined,
pool: state => (state.objectsFetched ? state.objectsByType?.get('pool')?.keySeq().first() : undefined),
vms: state =>
state.objectsFetched
? state.objectsByType
?.get('VM')
?.filter((vm: Vm) => !vm.is_control_domain && !vm.is_a_snapshot && !vm.is_a_template)
: undefined,
url: state => `${window.location.protocol}//${state.xapiHostname}`,
},
},
({ effects, state }) => (
<IntlProvider messages={messagesEn} locale='en'>
{/* Provided by this template: https://github.com/mui-org/material-ui/tree/next/docs/src/pages/getting-started/templates/dashboard */}
<ThemeProvider theme={mdTheme}>
<Modal />
{!state.connected ? (
<Signin />
) : !state.objectsFetched ? (
<IntlMessage id='loading' />
) : (
<>
<Router>
<Switch>
<Route exact path={redirectPaths}>
<Redirect to={`/infrastructure/pool/${state.pool.$id}/dashboard`} />
</Route>
<Route exact path='/vm-list'>
{state.vms !== undefined && (
<>
<p>There are {state.vms.size} VMs!</p>
<ul>
{state.vms.valueSeq().map((vm: Vm) => (
<li key={vm.$id}>
<Link to={vm.$id}>
{vm.name_label} - {vm.name_description} ({vm.power_state})
</Link>
</li>
))}
</ul>
</>
)}
</Route>
<Route exact path='/styleguide'>
<StyleGuide />
</Route>
<Route exact path='/styleguide/foo'>
<StyleGuide />
</Route>
<Route exact path='/pool'>
<PoolTab />
</Route>
<Route path='/'>
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar position='absolute' open={state.drawerOpen}>
<Toolbar
sx={{
pr: '24px', // keep right padding when drawer closed
}}
>
<IconButton
edge='start'
color='inherit'
aria-label='open drawer'
onClick={effects.toggleDrawer}
sx={{
marginRight: '36px',
...(state.drawerOpen && { display: 'none' }),
}}
>
<MenuIcon />
</IconButton>
<Typography component='h1' variant='h6' color='inherit' noWrap sx={{ flexGrow: 1 }}>
<Switch>
<Route path='/infrastructure'>
<IntlMessage id='infrastructure' />
</Route>
<Route path='/about'>
<IntlMessage id='about' />
</Route>
<Route>
<IntlMessage id='notFound' />
</Route>
</Switch>
</Typography>
{/* <IconButton color='inherit'>
<Badge badgeContent={4} color='secondary'>
<NotificationsIcon />
</Badge>
</IconButton> */}
</Toolbar>
</AppBar>
<Drawer variant='permanent' open={state.drawerOpen}>
<Toolbar
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
px: [1],
}}
>
<IconButton onClick={effects.toggleDrawer}>
<ChevronLeftIcon />
</IconButton>
</Toolbar>
<Divider />
<List>
<MainListItems />
</List>
<Divider />
<List>
<SecondaryListItems />
</List>
</Drawer>
<Box
component='main'
sx={{
backgroundColor: theme =>
theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900],
flexGrow: 1,
height: '100vh',
overflow: 'auto',
}}
>
<Switch>
<Route path='/infrastructure'>
<FullPage>
<Toolbar />
<Infrastructure />
</FullPage>
</Route>
<Route path='/about'>
<Toolbar />
<Container maxWidth='lg' sx={{ mt: 4, mb: 4 }}>
<p>
Check out{' '}
<Link to='https://xen-orchestra.com/blog/xen-orchestra-lite/'>Xen Orchestra Lite</Link>{' '}
dev blog.
</p>
<p>
<IntlMessage id='versionValue' values={{ version: process.env.NPM_VERSION }} />
</p>
</Container>
</Route>
<Route>
<Toolbar />
<IntlMessage id='pageNotFound' />
</Route>
</Switch>
</Box>
</Box>
</Route>
</Switch>
</Router>
</>
)}
</ThemeProvider>
</IntlProvider>
)
)
export default App

View File

@@ -1,57 +0,0 @@
import React from 'react'
import LoadingButton, { LoadingButtonProps } from '@mui/lab/LoadingButton'
import { withState } from 'reaclette'
interface ParentState {}
interface State {
isLoading: boolean
}
// Omit the `onClick` props to rewrite its own one.
interface Props extends Omit<LoadingButtonProps, 'onClick'> {
onClick: (data: Record<string, unknown>) => Promise<void>
// to pass props with the following pattern: "data-something"
[key: string]: unknown
}
interface ParentEffects {}
interface Effects {
_onClick: React.MouseEventHandler<HTMLButtonElement>
}
interface Computed {}
const ActionButton = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({ isLoading: false }),
effects: {
_onClick: function () {
this.state.isLoading = true
const data: Record<string, unknown> = {}
Object.keys(this.props).forEach(key => {
if (key.startsWith('data-')) {
data[key.slice(5)] = this.props[key]
}
})
return this.props.onClick(data).finally(() => (this.state.isLoading = false))
},
},
},
({ children, color = 'secondary', effects, onClick, resetState, state, variant = 'contained', ...props }) => (
<LoadingButton
color={color}
disabled={state.isLoading}
fullWidth
loading={state.isLoading}
onClick={effects._onClick}
variant={variant}
{...props}
>
{children}
</LoadingButton>
)
)
export default ActionButton

View File

@@ -1,26 +0,0 @@
import React from 'react'
import { Button as MuiButton, ButtonProps } from '@mui/material'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
interface Props extends ButtonProps {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const Button = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ children, color = 'secondary', effects, resetState, state, variant = 'contained', ...props }) => (
<MuiButton color={color} fullWidth variant={variant} {...props}>
{children}
</MuiButton>
)
)
export default Button

View File

@@ -1,22 +0,0 @@
import React from 'react'
import { CheckboxProps, Checkbox as MuiCheckbox } from '@mui/material'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
interface Props extends CheckboxProps {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const Checkbox = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ effects, resetState, state, ...props }) => <MuiCheckbox {...props} />
)
export default Checkbox

View File

@@ -1,193 +0,0 @@
import React from 'react'
import RFB from '@novnc/novnc/lib/rfb'
import styled from 'styled-components'
import { fibonacci } from 'iterable-backoff'
import { withState } from 'reaclette'
import IntlMessage from './IntlMessage'
import { confirm } from './Modal'
import XapiConnection, { ObjectsByType, Vm } from '../libs/xapi'
interface ParentState {
objectsByType: ObjectsByType
xapi: XapiConnection
}
interface State {
// Type error with HTMLDivElement.
// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/30451
container: React.RefObject<any>
// See https://github.com/vatesfr/xen-orchestra/pull/5722#discussion_r619296074
rfb: any
rfbConnected: boolean
timeout?: NodeJS.Timeout
tryToReconnect: boolean
url?: URL
}
interface Props {
scale: number
setCtrlAltDel: (sendCtrlAltDel: Effects['sendCtrlAltDel']) => void
vmId: string
}
interface ParentEffects {}
interface Effects {
_connect: () => Promise<void>
_handleConnect: () => void
_handleDisconnect: () => Promise<void>
sendCtrlAltDel: () => void
}
interface Computed {}
interface PropsStyledConsole {
scale: number
visible: boolean
}
enum Protocols {
http = 'http:',
https = 'https:',
ws = 'ws:',
wss = 'wss:',
}
const StyledConsole = styled.div<PropsStyledConsole>`
height: ${props => props.scale}%;
margin: auto;
visibility: ${props => (props.visible ? 'visible' : 'hidden')};
width: ${props => props.scale}%;
`
// https://github.com/novnc/noVNC/blob/master/docs/API.md
const Console = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
container: React.createRef(),
rfb: undefined,
rfbConnected: false,
timeout: undefined,
tryToReconnect: true,
url: undefined,
}),
effects: {
initialize: function () {
this.effects._connect()
},
_handleConnect: function () {
this.state.rfbConnected = true
},
_handleDisconnect: async function () {
this.state.rfbConnected = false
const {
state: { objectsByType, url },
effects: { _connect },
} = this
const { protocol } = window.location
if (protocol === Protocols.https) {
try {
await fetch(`${protocol}//${url?.host}`)
} catch (error) {
console.error(error)
try {
await confirm({
icon: 'exclamation-triangle',
message: (
<a href={`${protocol}//${url?.host}`} rel='noopener noreferrer' target='_blank'>
<IntlMessage
id='unreachableHost'
values={{
name: objectsByType.get('host')?.find(host => host.address === url?.host)?.name_label,
}}
/>
</a>
),
title: <IntlMessage id='connectionError' />,
})
} catch {
this.state.tryToReconnect = false
}
}
}
if (this.state.tryToReconnect) {
_connect()
}
},
_connect: async function () {
const { vmId } = this.props
const { objectsByType, rfb, xapi } = this.state
let lastError: unknown
// 8 tries mean 54s
for (const delay of fibonacci().toMs().take(8)) {
try {
const consoles = (objectsByType.get('VM')?.get(vmId) as Vm)?.$consoles.filter(
vmConsole => vmConsole.protocol === 'rfb'
)
if (rfb !== undefined) {
rfb.removeEventListener('connect', this.effects._handleConnect)
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
}
if (consoles === undefined || consoles.length === 0) {
throw new Error('Could not find VM console')
}
if (xapi.sessionId === undefined) {
throw new Error('Not connected to XAPI')
}
this.state.url = new URL(consoles[0].location)
this.state.url.protocol = window.location.protocol === Protocols.https ? Protocols.wss : Protocols.ws
this.state.url.searchParams.set('session_id', xapi.sessionId)
this.state.rfb = new RFB(this.state.container.current, this.state.url, {
wsProtocols: ['binary'],
})
this.state.rfb.addEventListener('connect', this.effects._handleConnect)
this.state.rfb.addEventListener('disconnect', this.effects._handleDisconnect)
this.state.rfb.scaleViewport = true
this.props.setCtrlAltDel(this.effects.sendCtrlAltDel)
return
} catch (error) {
lastError = error
await new Promise(resolve => (this.state.timeout = setTimeout(resolve, delay)))
}
}
throw lastError
},
finalize: function () {
const { rfb, timeout } = this.state
rfb.removeEventListener('connect', this.effects._handleConnect)
rfb.removeEventListener('disconnect', this.effects._handleDisconnect)
if (timeout !== undefined) {
clearTimeout(timeout)
}
},
sendCtrlAltDel: async function () {
await confirm({
message: <IntlMessage id='confirmCtrlAltDel' />,
title: <IntlMessage id='ctrlAltDel' />,
})
this.state.rfb.sendCtrlAltDel()
},
},
},
({ scale, state }) => (
<>
{state.rfb !== undefined && !state.rfbConnected && (
<p>
<IntlMessage id={state.tryToReconnect ? 'reconnectionAttempt' : 'hostUnreachable'} />
</p>
)}
<StyledConsole ref={state.container} scale={scale} visible={state.rfbConnected} />
</>
)
)
export default Console

View File

@@ -1,30 +0,0 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconName as _IconName, library, SizeProp } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { useTheme } from '@mui/material/styles'
library.add(fas)
const Icon = ({
color,
htmlColor,
icon,
size,
}: {
color?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
htmlColor?: string
icon: _IconName
size?: SizeProp
}): JSX.Element => {
const { palette } = useTheme()
return (
<FontAwesomeIcon
icon={icon}
size={size}
color={htmlColor ?? (color !== undefined ? palette[color][palette.mode] : undefined)}
/>
)
}
export default Icon
export type IconName = _IconName

View File

@@ -1,26 +0,0 @@
import React from 'react'
import { TextField, TextFieldProps } from '@mui/material'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
// An interface can only extend an object type or intersection
// of object types with statically known members.
type Props = _Props & TextFieldProps
interface _Props {}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const Input = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ effects, resetState, state, ...props }) => <TextField fullWidth {...props} />
)
export default Input

View File

@@ -1,21 +0,0 @@
import React, { ElementType, ReactElement, ReactNode } from 'react'
import { FormattedMessage, MessageDescriptor, useIntl } from 'react-intl'
import intlMessage from '../lang/en.json'
// Extends FormattedMessage not working: "FormattedMessage refers to a value, but is being used as a type here"
// https://stackoverflow.com/questions/62059408/reactjs-and-typescript-refers-to-a-value-but-is-being-used-as-a-type-here-ts
// InstanceType<typeof FormattedMessage> not working: "Type [...] does not satisfy the constraint abstract new (...args: any) => any."
// See https://formatjs.io/docs/react-intl/components/#formattedmessage
interface Props extends MessageDescriptor {
children?: (chunks: ReactElement) => ReactElement
id?: keyof typeof intlMessage
tagName?: ElementType
values?: Record<string, ReactNode>
}
const IntlMessage = (props: Props): JSX.Element => <FormattedMessage {...props} />
export function translate(message: MessageDescriptor){
return useIntl().formatMessage(message)
}
export default React.memo(IntlMessage)

View File

@@ -1,38 +0,0 @@
import MaterialLink from '@mui/material/Link'
import React from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { withState } from 'reaclette'
interface ParentState {}
interface State {}
interface Props {
children: React.ReactNode
decorated?: boolean
to?: string
}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const UNDECORATED_LINK = { textDecoration: 'none', color: 'inherit' }
const Link = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ to, decorated = true, children }) =>
to === undefined ? (
<>{children}</>
) : to.startsWith('http') ? (
<MaterialLink style={decorated ? undefined : UNDECORATED_LINK} target='_blank' rel='noopener noreferrer' href={to}>
{children}
</MaterialLink>
) : (
<RouterLink style={decorated ? undefined : UNDECORATED_LINK} component={MaterialLink} to={to}>
{children}
</RouterLink>
)
)
export default Link

View File

@@ -1,152 +0,0 @@
import React from 'react'
import { ButtonProps, Dialog, DialogContent, DialogContentText, DialogActions, DialogTitle } from '@mui/material'
import { withState } from 'reaclette'
import Button from './Button'
import Icon, { IconName } from './Icon'
import IntlMessage from './IntlMessage'
type ModalButton = {
color?: ButtonProps['color']
label: string | React.ReactNode
reason?: unknown
value?: unknown
}
interface GeneralParamsModal {
icon: IconName
message: string | React.ReactNode
title: string | React.ReactNode
}
interface ModalParams extends GeneralParamsModal {
buttonList: ModalButton[]
}
let instance: EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> | undefined
const modal = ({ buttonList, icon, message, title }: ModalParams) =>
new Promise((resolve, reject) => {
if (instance === undefined) {
throw new Error('No modal instance')
}
instance.state.buttonList = buttonList
instance.state.icon = icon
instance.state.message = message
instance.state.onReject = reject
instance.state.onSuccess = resolve
instance.state.showModal = true
instance.state.title = title
})
export const alert = (params: GeneralParamsModal): Promise<unknown> => {
const buttonList: ModalButton[] = [
{
label: <IntlMessage id='ok' />,
color: 'primary',
value: 'success',
},
]
return modal({ ...params, buttonList })
}
export const confirm = (params: GeneralParamsModal): Promise<unknown> => {
const buttonList: ModalButton[] = [
{
label: <IntlMessage id='confirm' />,
value: 'confirm',
color: 'success',
},
{
label: <IntlMessage id='cancel' />,
color: 'secondary',
reason: 'cancel',
},
]
return modal({ ...params, buttonList })
}
interface ParentState {}
interface State {
buttonList?: ModalButton[]
icon?: IconName
message?: string | React.ReactNode
onReject?: (reason: unknown) => void
onSuccess?: (value: unknown) => void
showModal: boolean
title?: string | React.ReactNode
}
interface Props {}
interface ParentEffects {}
interface Effects {
closeModal: () => void
reject: (reason: unknown) => void
}
interface Computed {}
const Modal = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: () => ({
buttonList: undefined,
icon: undefined,
message: undefined,
onReject: undefined,
onSuccess: undefined,
showModal: false,
title: undefined,
}),
effects: {
initialize: function () {
if (instance !== undefined) {
throw new Error('Modal is a singelton')
}
instance = this
},
closeModal: function () {
this.state.showModal = false
},
reject: function (reason) {
this.state.onReject?.(reason)
this.effects.closeModal()
},
},
},
({ effects, state }) => {
const { closeModal, reject } = effects
const { buttonList, icon, message, onReject, onSuccess, showModal, title } = state
return showModal ? (
<Dialog open={showModal} onClose={reject}>
<DialogTitle>
{icon !== undefined && <Icon icon={icon} />} {title}
</DialogTitle>
<DialogContent>
<DialogContentText>{message}</DialogContentText>
</DialogContent>
<DialogActions>
{buttonList?.map(({ label, reason, value, ...props }, index) => {
const onClick = () => {
if (value !== undefined) {
onSuccess?.(value)
} else {
onReject?.(reason)
}
closeModal()
}
return (
<Button key={index} onClick={onClick} {...props}>
{label}
</Button>
)
})}
</DialogActions>
</Dialog>
) : null
}
)
export default Modal

View File

@@ -1,63 +0,0 @@
import React from 'react'
import { withState } from 'reaclette'
import Icon, { IconName } from './Icon'
import Button, { ButtonProps } from '@mui/material/Button'
import ButtonGroup, { ButtonGroupClassKey } from '@mui/material/ButtonGroup'
import Stack from '@mui/material/Stack'
import Typography, { TypographyClassKey } from '@mui/material/Typography'
import { Theme } from '@mui/material/styles'
interface ParentState {}
interface State {}
interface Action extends ButtonProps {
icon: IconName
}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const DEFAULT_TITLE_STYLE = { marginLeft: '0.5em', flex: 1, fontSize: '250%' }
const DEFAULT_BUTTONGROUP_STYLE = { margin: '0.5em', flex: 0 }
const DEFAULT_STACK_STYLE = {
backgroundColor: (theme: Theme) => {
const { background, palette } = theme
return palette.mode === 'light' ? background.primary.light : background.primary.dark
},
paddingTop: '1em',
}
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
// Accepts an array of Actions. An action accepts all the props of a Button + an icon
actions?: Array<Action>
// the props passed to the title, accepts all the keys of Typography
titleProps?: TypographyClassKey
// the props passed to the button group, accepts all the keys of a ButtonGroup
buttonGroupProps?: ButtonGroupClassKey
}
const PanelHeader = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{},
({ actions = [], titleProps = {}, buttonGroupProps = {}, children = null }) => (
<Stack direction='row' justifyContent='space-between' alignItems='center' sx={DEFAULT_STACK_STYLE}>
<Typography variant='h2' sx={DEFAULT_TITLE_STYLE} {...titleProps}>
{children}
</Typography>
<ButtonGroup sx={DEFAULT_BUTTONGROUP_STYLE} {...buttonGroupProps}>
{(actions as Array<Action>)?.map(({ icon, ...actionProps }) => (
<Button {...actionProps} key={actionProps.key}>
<Icon icon={icon} />
</Button>
))}
</ButtonGroup>
</Stack>
)
)
export default PanelHeader

View File

@@ -1,87 +0,0 @@
import Box from '@mui/material/Box'
import React from 'react'
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress'
import { styled } from '@mui/material/styles'
import { Typography } from '@mui/material'
import { withState } from 'reaclette'
const BackgroundBox = styled(Box)({
position: 'absolute',
})
const BackgroundCircle = styled(CircularProgress)({
color: '#e3dede',
})
const Container = styled(Box)({
display: 'inline-flex',
position: 'relative',
})
const StyledLabel = styled(Typography)(({ color, theme: { palette } }) => ({
color: (palette[(color as string) ?? 'primary'] ?? palette.primary).main,
textAlign: 'center',
}))
const LabelBox = styled(Box)({
alignItems: 'center',
bottom: 0,
display: 'flex',
height: '80%',
justifyContent: 'center',
left: 0,
margin: 'auto',
overflow: 'hidden',
position: 'absolute',
right: 0,
top: 0,
width: '80%',
})
interface ParentState {}
interface State {}
interface Props {
color?: CircularProgressProps['color']
label?: string
max?: number
showLabel?: boolean
size?: number
value: number
}
interface ParentEffects {}
interface Effects {}
interface Computed {
label: string
progress: number
}
const ProgressCircle = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
label: ({ progress }, { label }) => label ?? `${progress}%`,
progress: (_, { max = 100, value }) => Math.round((value / max) * 100),
},
},
({ color = 'success', showLabel = true, size = 100, state: { label, progress } }) => (
<Container>
<BackgroundBox>
<BackgroundCircle variant='determinate' value={100} size={size} />
</BackgroundBox>
<CircularProgress aria-label={label} color={color} size={size} value={progress} variant='determinate' />
{showLabel && (
<LabelBox>
<StyledLabel variant='h5' color={color}>
{label}
</StyledLabel>
</LabelBox>
)}
</Container>
)
)
export default ProgressCircle

View File

@@ -1,7 +0,0 @@
import React from 'react'
type Props = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'>
const RangeInput = React.memo((props: Props) => <input {...props} type='range' />)
export default RangeInput

View File

@@ -1,97 +0,0 @@
import FormControl from '@mui/material/FormControl'
import MenuItem from '@mui/material/MenuItem'
import React from 'react'
import SelectMaterialUi, { SelectProps } from '@mui/material/Select'
import { iteratee } from 'lodash'
import { SelectChangeEvent } from '@mui/material'
import { withState } from 'reaclette'
import IntlMessage from './IntlMessage'
type AdditionalProps = Record<string, any>
interface ParentState {}
interface State {}
interface Props extends SelectProps {
additionalProps?: AdditionalProps
onChange: (e: SelectChangeEvent<unknown>) => void
optionRenderer?: string | { (item: any): number | string }
options: any[] | undefined
value: any
valueRenderer?: string | { (item: any): number | string }
}
interface ParentEffects {}
interface Effects {}
interface Computed {
renderOption: (item: any, additionalProps?: AdditionalProps) => React.ReactNode
renderValue: (item: any, additionalProps?: AdditionalProps) => number | string
options?: JSX.Element[]
}
const Select = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
computed: {
// @ts-ignore
renderOption: (_, { optionRenderer }) => iteratee(optionRenderer),
// @ts-ignore
renderValue: (_, { valueRenderer }) => iteratee(valueRenderer),
options: (state, { additionalProps, options, optionRenderer, valueRenderer }) =>
options?.map(item => {
const label =
optionRenderer === undefined
? item.name ?? item.label ?? item.name_label
: state.renderOption(item, additionalProps)
const value =
valueRenderer === undefined ? item.value ?? item.id ?? item.$id : state.renderValue(item, additionalProps)
if (value === undefined) {
console.error('Computed value is undefined')
}
return (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
)
}),
},
},
({
additionalProps,
displayEmpty = true,
effects,
multiple,
options,
required,
resetState,
state,
value,
...props
}) => (
<FormControl>
<SelectMaterialUi
multiple={multiple}
required={required}
displayEmpty={displayEmpty}
value={value ?? (multiple ? [] : '')}
{...props}
>
{!multiple && (
<MenuItem value=''>
<em>
<IntlMessage id='none' />
</em>
</MenuItem>
)}
{state.options}
</SelectMaterialUi>
</FormControl>
)
)
export default Select

View File

@@ -1,73 +0,0 @@
import React from 'react'
import styled from 'styled-components'
import { withState } from 'reaclette'
import IntlMessage from './IntlMessage'
export type Column<Type> = {
header: React.ReactNode
id?: string
render: { (item: Type): React.ReactNode }
}
type Item = {
id?: string
[key: string]: any
}
interface ParentState {}
interface State {}
interface Props {
collection: Item[] | undefined
columns: Column<any>[]
placeholder?: JSX.Element
}
interface ParentEffects {}
interface Effects {}
interface Computed {}
const StyledTable = styled.table`
border: 1px solid #333;
td {
border: 1px solid #333;
}
thead {
background-color: #333;
color: #fff;
}
`
const Table = withState<State, Props, Effects, Computed, ParentState, ParentEffects>({}, ({ collection, columns, placeholder }) =>
collection !== undefined ? (
collection.length !== 0 ? (
<StyledTable>
<thead>
<tr>
{columns.map((col, index) => (
<td key={col.id ?? index}>{col.header}</td>
))}
</tr>
</thead>
<tbody>
{collection.map((item, index) => (
<tr key={item.id ?? index}>
{columns.map((col, index) => (
<td key={col.id ?? index}>{col.render(item)}</td>
))}
</tr>
))}
</tbody>
</StyledTable>
) : (
placeholder ?? <IntlMessage id='noData' />
)
) : (
<IntlMessage id='loading' />
)
)
export default Table

View File

@@ -1,114 +0,0 @@
import Box from '@mui/material/Box'
import React from 'react'
import Tab from '@mui/material/Tab'
import TabContext from '@mui/lab/TabContext'
import TabList from '@mui/lab/TabList'
import TabPanel from '@mui/lab/TabPanel'
import Typography from '@mui/material/Typography'
import { RouteComponentProps } from 'react-router-dom'
import { withState } from 'reaclette'
import { withRouter } from 'react-router'
import IntlMessage from '../components/IntlMessage'
const BOX_STYLE = { borderBottom: 1, borderColor: 'divider', marginTop: '0.5em' }
interface ParentState {}
interface State {
value: string
}
interface Tab {
component?: React.ReactNode
disabled?: boolean
label: React.ReactNode
}
interface UrlTab extends Tab {
pathname: string
value?: any
}
interface NoUrlTab extends Tab {
value: any
}
// For compatibility with 'withRouter'
interface Props extends RouteComponentProps {
indicatorColor?: 'primary' | 'secondary'
textColor?: 'inherit' | 'primary' | 'secondary'
// tabs = [
// {
// component: <span>BAR</span>,
// pathname: '/path',
// label: (
// <span>
// <Icon icon='cloud' /> {labelA}
// </span>
// ),
// },
// ]
tabs: Array<NoUrlTab | UrlTab>
useUrl?: boolean
value?: any
}
interface ParentEffects {}
interface Effects {
onChange: (event: React.SyntheticEvent, value: string) => void
}
interface Computed {}
// TODO: improve view as done in the model(figma).
const pageUnderConstruction = (
<div style={{ color: '#0085FF', textAlign: 'center' }}>
<Typography variant='h2'>
<IntlMessage id='xoLiteUnderConstruction' />
</Typography>
<Typography variant='h3'>
<IntlMessage id='newFeaturesUnderConstruction' />
</Typography>
</div>
)
const Tabs = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: ({ location: { pathname }, tabs, useUrl = false, value }) => ({
value: (useUrl && pathname) || (value ?? tabs[0].value ?? tabs[0].pathname),
}),
effects: {
onChange: function (_, value) {
if (this.props.useUrl) {
const { history, tabs } = this.props
history.push(tabs.find(tab => (tab.value ?? tab.pathname) === value).pathname)
}
this.state.value = value
},
},
},
({ effects, state: { value }, indicatorColor, textColor, tabs }) => (
<TabContext value={value}>
<Box sx={BOX_STYLE}>
<TabList indicatorColor={indicatorColor} onChange={effects.onChange} textColor={textColor}>
{tabs.map((tab: UrlTab | NoUrlTab) => {
const value = tab.value ?? tab.pathname
return <Tab disabled={tab.disabled} key={value} label={tab.label} value={value} />
})}
</TabList>
</Box>
{tabs.map((tab: UrlTab | NoUrlTab) => {
const value = tab.value ?? tab.pathname
return (
<TabPanel key={value} value={value}>
{tab.component === undefined ? pageUnderConstruction : tab.component}
</TabPanel>
)
})}
</TabContext>
)
)
export default withRouter(Tabs)

View File

@@ -1,196 +0,0 @@
import classNames from 'classnames'
import React, { useEffect } from 'react'
import Tooltip from '@mui/material/Tooltip'
import TreeView from '@mui/lab/TreeView'
import TreeItem, { useTreeItem, TreeItemContentProps } from '@mui/lab/TreeItem'
import { withState } from 'reaclette'
import { useHistory } from 'react-router-dom'
import Icon from '../components/Icon'
interface ParentState {}
interface State {
expandedNodes?: Array<string>
selectedNodes?: Array<string>
}
export interface ItemType {
children?: Array<ItemType>
id: string
label: React.ReactElement
to?: string
tooltip?: React.ReactNode
}
interface Props {
// collection = [
// {
// id: 'idA',
// label: (
// <span>
// <Icon icon='warehouse' /> {labelA}
// </span>
// ),
// to: '/routeA',
// children: [
// {
// id: 'ida',
// label: label: (
// <span>
// <Icon icon='server' /> {labela}
// </span>
// ),
// },
// ],
// },
// {
// id: 'idB',
// label: (
// <span>
// <Icon icon='warehouse' /> {labelB}
// </span>
// ),
// to: '/routeB',
// tooltip: <IntlMessage id='tooltipB' />
// }
// ]
collection: Array<ItemType>
defaultSelectedNodes?: Array<string>
}
interface CustomContentProps extends TreeItemContentProps {
defaultSelectedNode?: string
to?: string
}
interface ParentEffects {}
interface Effects {
setExpandedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
setSelectedNodeIds: (event: React.SyntheticEvent, nodeIds: Array<string>) => void
}
interface Computed {
defaultSelectedNode?: string
}
// Inspired by https://mui.com/components/tree-view/#contentcomponent-prop.
const CustomContent = React.forwardRef(function CustomContent(props: CustomContentProps, ref) {
const { classes, className, defaultSelectedNode, expansionIcon, label, nodeId, to } = props
const { focused, handleExpansion, handleSelection, selected } = useTreeItem(nodeId)
const history = useHistory()
useEffect(() => {
// There can only be one node selected at once for now.
// Auto-revealing more than one node in the tree would require a different implementation.
if (defaultSelectedNode === nodeId) {
ref?.current?.scrollIntoView()
}
}, [])
useEffect(() => {
if (selected) {
to !== undefined && history.push(to)
}
}, [selected])
const handleExpansionClick = (event: React.SyntheticEvent) => {
event.stopPropagation()
handleExpansion(event)
}
return (
<span
className={classNames(className, { [classes.focused]: focused, [classes.selected]: selected })}
onClick={handleSelection}
ref={ref}
>
<span className={classes.iconContainer} onClick={handleExpansionClick}>
{expansionIcon}
</span>
<span className={classes.label}>{label}</span>
</span>
)
})
const renderItem = ({ children, id, label, to, tooltip }: ItemType, defaultSelectedNode?: string) => {
return (
<TreeItem
ContentComponent={CustomContent}
// FIXME: ContentProps should only be React.HTMLAttributes<HTMLElement> or undefined, it doesn't support other type.
// when https://github.com/mui-org/material-ui/issues/28668 is fixed, remove 'as CustomContentProps'.
ContentProps={{ defaultSelectedNode, to } as CustomContentProps}
label={tooltip ? <Tooltip title={tooltip}>{label}</Tooltip> : label}
key={id}
nodeId={id}
>
{Array.isArray(children) ? children.map(item => renderItem(item, defaultSelectedNode)) : null}
</TreeItem>
)
}
const Tree = withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
{
initialState: ({ collection, defaultSelectedNodes }) => {
if (defaultSelectedNodes === undefined) {
return {
expandedNodes: [collection[0].id],
selectedNodes: [],
}
}
// expandedNodes should contain all nodes up to the defaultSelectedNodes.
const expandedNodes = new Set<string>()
const pathToNode = new Set<string>()
const addExpandedNode = (collection: Array<ItemType> | undefined) => {
if (collection === undefined) {
return
}
for (const node of collection) {
if (defaultSelectedNodes.includes(node.id)) {
for (const nodeId of pathToNode) {
expandedNodes.add(nodeId)
}
}
pathToNode.add(node.id)
addExpandedNode(node.children)
pathToNode.delete(node.id)
}
}
addExpandedNode(collection)
return { expandedNodes: Array.from(expandedNodes), selectedNodes: defaultSelectedNodes }
},
effects: {
setExpandedNodeIds: function (_, nodeIds) {
this.state.expandedNodes = nodeIds
},
setSelectedNodeIds: function (_, nodeIds) {
this.state.selectedNodes = [nodeIds[0]]
},
},
computed: {
defaultSelectedNode: (_, { defaultSelectedNodes }) =>
defaultSelectedNodes !== undefined ? defaultSelectedNodes[0] : undefined,
},
},
({ effects, state: { defaultSelectedNode, expandedNodes, selectedNodes }, collection }) => (
<TreeView
defaultCollapseIcon={<Icon icon='chevron-up' />}
defaultExpanded={[collection[0].id]}
defaultExpandIcon={<Icon icon='chevron-down' />}
expanded={expandedNodes}
multiSelect
onNodeSelect={effects.setSelectedNodeIds}
onNodeToggle={effects.setExpandedNodeIds}
selected={selectedNodes}
>
{collection.map(item => renderItem(item, defaultSelectedNode))}
</TreeView>
)
)
export default Tree

View File

@@ -1,26 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Helmet } from 'react-helmet'
import { createGlobalStyle } from 'styled-components'
import App from './App/index'
const GlobalStyle = createGlobalStyle`
body {
margin: 0;
font-family: Arial, Verdana, Helvetica, Ubuntu, sans-serif;
box-sizing: border-box;
color: #212529;
}
`
ReactDOM.render(
<React.StrictMode>
<Helmet>
<link rel='shortcut icon' href='favicon.ico' />
</Helmet>
<GlobalStyle />
<App />
</React.StrictMode>,
document.getElementById('root')
)

View File

@@ -1,55 +0,0 @@
{
"about": "About",
"active": "Active",
"availableUpdates": "{nUpdates, number} available update{nUpdates, plural, one {} other {s}}",
"badCredentials": "Bad credentials",
"cancel": "Cancel",
"confirm": "Confirm",
"confirmCtrlAltDel": "Send Ctrl+Alt+Del to VM?",
"connect": "Connect",
"connectionError": "Connection error",
"consoleNotAvailable": "Console is only available for running VMs",
"ctrlAltDel": "Ctrl+Alt+Del",
"description": "Description",
"device": "Device",
"disconnect": "Disconnect",
"dns": "DNS",
"errorOccurred": "An error has occurred.",
"gateway": "Gateway",
"halted": "Halted",
"hosts": "Hosts",
"hostUnreachable": "Host unreachable",
"inactive": "Inactive",
"infrastructure": "Infrastructure",
"ip": "IP",
"loading": "Loading…",
"login": "Login",
"name": "Name",
"newFeaturesUnderConstruction": "New features are coming soon!",
"noHosts": "No hosts",
"noData": "No data",
"noImplemented": "Not implemented",
"noManagementPifs": "No management PIFs found",
"none": "None",
"noVms": "No VMs",
"notFound": "Not Found",
"pageNotFound": "This page doesn't exist.",
"xoLiteUnderConstruction": "XO Lite is under construction",
"noUpdatesAvailable": "No updates available",
"ok": "OK",
"password": "Password",
"paused": "Paused",
"reconnectionAttempt": "Trying to reconnect…",
"release": "Release",
"rememberMe": "Remember me",
"running": "Running",
"size": "Size",
"status": "Status",
"suspended": "Suspended",
"total": "Total",
"unreachableHost": "Click here to make sure your host ({name}) is reachable. You may have to allow self-signed SSL certificates in your browser.",
"vms": "VMs",
"version": "Version",
"versionValue": "Version {version}",
"vmStartLabel": "Start"
}

View File

@@ -1,4 +0,0 @@
{
"connect": "Connexion",
"vmStartLabel": "Démarrer"
}

View File

@@ -1,205 +0,0 @@
import Cookies from 'js-cookie'
import { EventEmitter } from 'events'
import { Map } from 'immutable'
import { Xapi } from 'xen-api'
export interface XapiObject {
$pool: Pool
$ref: string
$type: keyof types
$id: string
}
// Dictionary of XAPI types and their corresponding TypeScript types
interface types {
PIF: Pif
pool: Pool
VM: Vm
host: Host
}
// XAPI types ---
export interface Pif extends XapiObject {
device: string
DNS: string
gateway: string
IP: string
management: boolean
network: string
}
export interface Pool extends XapiObject {
name_label: string
}
export interface PoolUpdate {
changelog: {
author: string
date: Date
description: string
}
description: string
license: string
name: string
release: string
size: number
url: string
version: string
}
export interface Vm extends XapiObject {
$consoles: Array<{ protocol: string; location: string }>
is_a_snapshot: boolean
is_a_template: boolean
is_control_domain: boolean
name_description: string
name_label: string
power_state: string
resident_on: string
}
interface HostMetrics {
live: boolean
}
export interface Host extends XapiObject {
$metrics: HostMetrics
address: string
name_label: string
power_state: string
}
// --------
export interface ObjectsByType extends Map<string, Map<string, XapiObject>> {
get<NSV, T extends keyof types>(key: T, notSetValue: NSV): Map<string, types[T]> | NSV
get<T extends keyof types>(key: T): Map<string, types[T]> | undefined
}
export default class XapiConnection extends EventEmitter {
areObjectsFetched: Promise<void>
connected: boolean
objectsByType: ObjectsByType
sessionId?: string
_resolveObjectsFetched!: () => void
_xapi?: {
objects: EventEmitter & {
all: { [id: string]: XapiObject }
}
connect(): Promise<void>
disconnect(): Promise<void>
call: (method: string, ...args: unknown[]) => Promise<unknown>
_objectsFetched: Promise<void>
}
constructor() {
super()
this.objectsByType = Map() as ObjectsByType
this.connected = false
this.areObjectsFetched = new Promise(resolve => {
this._resolveObjectsFetched = resolve
})
}
async reattachSession(url: string): Promise<void> {
const sessionId = Cookies.get('sessionId')
if (sessionId === undefined) {
return
}
return this.connect({ url, sessionId })
}
async connect({
url,
user = 'root',
password,
sessionId,
rememberMe = Cookies.get('rememberMe') === 'true',
}: {
url: string
user?: string
password?: string
sessionId?: string
rememberMe?: boolean
}): Promise<void> {
const xapi = (this._xapi = new Xapi({
auth: { user, password, sessionId },
url,
watchEvents: true,
readonly: false,
}))
const updateObjects = (objects: { [id: string]: XapiObject }) => {
try {
this.objectsByType = this.objectsByType.withMutations(objectsByType => {
Object.entries(objects).forEach(([id, object]) => {
if (object === undefined) {
// Remove
objectsByType.forEach((objects, type) => {
objectsByType.set(type, objects.remove(id))
})
} else {
// Add or update
const { $type } = object
objectsByType.set($type, objectsByType.get($type, Map<string, XapiObject>()).set(id, object))
}
})
})
this.emit('objects', this.objectsByType)
} catch (err) {
console.error(err)
}
}
xapi.on('connected', () => {
this.sessionId = xapi.sessionId
this.connected = true
this.emit('connected')
})
xapi.on('disconnected', () => {
Cookies.remove('sessionId')
this.emit('disconnected')
})
xapi.on('sessionId', (sessionId: string) => {
if (rememberMe) {
Cookies.set('rememberMe', 'true', { expires: 7 })
}
Cookies.set('sessionId', sessionId, rememberMe ? { expires: 7 } : undefined)
})
await xapi.connect()
await xapi._objectsFetched
updateObjects(xapi.objects.all)
this._resolveObjectsFetched()
xapi.objects.on('add', updateObjects)
xapi.objects.on('update', updateObjects)
xapi.objects.on('remove', updateObjects)
}
disconnect(): Promise<void> | undefined {
Cookies.remove('rememberMe')
Cookies.remove('sessionId')
const { _xapi } = this
if (_xapi !== undefined) {
return _xapi.disconnect()
}
}
call(method: string, ...args: unknown[]): Promise<unknown> {
const { _xapi, connected } = this
if (!connected || _xapi === undefined) {
throw new Error('Not connected to XAPI')
}
return _xapi.call(method, ...args)
}
}

View File

@@ -1,63 +0,0 @@
{
"compilerOptions": {
/* Basic Options */
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "incremental": true, /* Enable incremental compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
"noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
"noUnusedLocals": true, /* Report errors on unused locals. */
"noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"resolveJsonModule": true
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
}
}

View File

@@ -1,6 +0,0 @@
declare module '@novnc/novnc/lib/rfb'
declare module 'human-format'
declare module 'iterable-backoff'
declare module 'json-rpc-protocol'
declare module 'promise-toolbox'
declare module 'xen-api'

View File

@@ -1,42 +0,0 @@
type RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects> = {
readonly effects: Effects & ParentEffects
readonly state: State & ParentState & Computed
readonly resetState: () => void
} & Props
interface EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects> {
readonly effects: Effects & ParentEffects
readonly state: State & ParentState & Computed
readonly props: Props
}
interface StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects> {
initialState?: State | ((props: Props) => State) // what about Reaclette's state inheritance?
effects?: {
initialize?: () => void | Promise<void>
finalize?: () => void | Promise<void>
} & Effects &
ThisType<EffectContext<State, Props, Effects, Computed, ParentState, ParentEffects>>
computed?: {
[ComputedName in keyof Computed]: (
state: State & ParentState & Computed,
props: Props
) => Computed[ComputedName] | Promise<Computed[ComputedName]>
}
}
declare module 'reaclette' {
function provideState<State, Props, Effects, Computed, ParentState, ParentEffects>(
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>
): (component: React.Component<Props>) => React.Component<Props>
function injectState<State, Props, Effects, Computed, ParentState, ParentEffects>(
// FIXME: also accept class components
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
): React.ElementType<Props>
function withState<State, Props, Effects, Computed, ParentState, ParentEffects>(
stateSpec: StateSpec<State, Props, Effects, Computed, ParentState, ParentEffects>,
component: React.FC<RenderParams<State, Props, Effects, Computed, ParentState, ParentEffects>>
): React.ElementType<Props>
}

View File

@@ -1,21 +0,0 @@
import { Theme as ThemeMui, ThemeOptions as ThemeOptionsMui } from '@mui/material/styles'
declare module '@mui/material/styles' {
// FIXME: when https://github.com/microsoft/TypeScript/issues/40315 is fixed.
// issue: Type 'Theme'/'ThemeOptions' recursively references itself as a base type.
interface Theme extends ThemeMui {
background: {
primary: {
dark: string
light: string
}
}
}
interface ThemeOptions extends ThemeOptionsMui {
background?: {
primary?: {
dark?: string
light?: string
}
}
}
}

View File

@@ -1,72 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path')
const webpack = require('webpack')
const resolveApp = relative => path.resolve(__dirname, relative)
const { NODE_ENV = 'production' } = process.env
const __PROD__ = NODE_ENV === 'production'
// https://webpack.js.org/configuration/
module.exports = {
mode: NODE_ENV,
target: 'web',
devServer: {
historyApiFallback: true,
},
entry: resolveApp('src/index.tsx'),
output: {
filename: __PROD__ ? '[name].[contenthash:8].js' : '[name].js',
path: resolveApp('dist'),
},
optimization: {
moduleIds: __PROD__ ? 'deterministic' : undefined,
runtimeChunk: true,
splitChunks: {
chunks: 'all',
},
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
},
{
test: /\.css$/i,
use: ['css-loader'],
},
],
},
resolve: {
alias: {
dns: false,
},
extensions: ['.tsx', '.ts', '.js'],
},
devtool: __PROD__ ? 'source-map' : 'eval-cheap-module-source-map',
plugins: [
new (require('clean-webpack-plugin').CleanWebpackPlugin)(),
new (require('copy-webpack-plugin'))({
patterns: [
{
from: resolveApp('public'),
to: resolveApp('dist'),
filter: file => file !== resolveApp('public/index.html'),
},
],
}),
new (require('html-webpack-plugin'))({
template: resolveApp('public/index.html'),
}),
new webpack.EnvironmentPlugin({ XAPI_HOST: '', NPM_VERSION: require('./package.json').version }),
new (require('node-polyfill-webpack-plugin'))(),
].filter(Boolean),
}

View File

@@ -48,6 +48,10 @@ configure([
// if filter is a string, then it is pattern
// (https://github.com/visionmedia/debug#wildcards) which is
// matched against the namespace of the logs
//
// If it's an array, it will be handled as an array of filters
// and the transport will be used if any one of them match the
// current log
filter: process.env.DEBUG,
transport: transportConsole(),

View File

@@ -24,7 +24,7 @@
},
"dependencies": {
"lodash": "^4.17.4",
"promise-toolbox": "^0.19.2"
"promise-toolbox": "^0.20.0"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -19,7 +19,7 @@
"node": ">=6"
},
"dependencies": {
"bind-property-descriptor": "^1.0.0",
"bind-property-descriptor": "^2.0.0",
"lodash": "^4.17.21"
},
"scripts": {

View File

@@ -13,7 +13,7 @@ module.exports = class Config {
const watchers = (this._watchers = new Set())
app.hooks.on('start', async () => {
app.hooks.on(
app.hooks.once(
'stop',
await watch({ appDir, appName, ignoreUnknownFormats: true }, (error, config) => {
if (error != null) {
@@ -32,7 +32,7 @@ module.exports = class Config {
get(path) {
const value = get(this._config, path)
if (value === undefined) {
throw new TypeError('missing config entry: ' + value)
throw new TypeError('missing config entry: ' + path)
}
return value
}

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "AGPL-3.0-or-later",
"version": "0.1.1",
"version": "0.1.2",
"engines": {
"node": ">=12"
},
@@ -22,7 +22,7 @@
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/emit-async": "^0.1.0",
"@xen-orchestra/log": "^0.3.0",
"app-conf": "^0.9.0",
"app-conf": "^1.0.0",
"lodash": "^4.17.21"
},
"scripts": {

View File

@@ -23,22 +23,22 @@
"xo-proxy-cli": "dist/index.js"
},
"engines": {
"node": ">=8.10"
"node": ">=12"
},
"dependencies": {
"@iarna/toml": "^2.2.0",
"@vates/read-chunk": "^0.1.2",
"ansi-colors": "^4.1.1",
"app-conf": "^0.9.0",
"app-conf": "^1.0.0",
"content-type": "^1.0.4",
"cson-parser": "^4.0.7",
"getopts": "^2.2.3",
"http-request-plus": "^0.12",
"http-request-plus": "^0.13.0",
"json-rpc-protocol": "^0.13.1",
"promise-toolbox": "^0.19.2",
"promise-toolbox": "^0.20.0",
"pump": "^3.0.0",
"pumpify": "^2.0.1",
"split2": "^3.1.1"
"split2": "^4.1.0"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -18,7 +18,9 @@ keepAliveInterval = 10e3
#
# https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation
dirMode = 0o700
disableMergeWorker = false
snapshotNameLabelTpl = '[XO Backup {job.name}] {vm.name_label}'
vhdDirectoryCompression = 'brotli'
[backups.defaultSettings]
reportWhen = 'failure'
@@ -59,6 +61,13 @@ cert = '/var/lib/xo-proxy/certificate.pem'
key = '/var/lib/xo-proxy/key.pem'
port = 443
[logs]
# Display all logs matching this filter, regardless of their level
#filter = 'xo:backups:*'
# Display all logs with level >=, regardless of their namespace
level = 'info'
[remoteOptions]
mountsDir = '/run/xo-proxy/mounts'
@@ -79,3 +88,20 @@ ignoreNobakVdis = false
maxUncoalescedVdis = 1
watchEvents = ['network', 'PIF', 'pool', 'SR', 'task', 'VBD', 'VDI', 'VIF', 'VM']
#compact mode
[reverseProxies]
# '/http/' = 'http://localhost:8081/'
#The target can have a path ( like `http://target/sub/directory/`),
# parameters (`?param=one`) and hash (`#jwt:32154`) that are automatically added to all queries transfered by the proxy.
# If a parameter is present in the configuration and in the query, only the config parameter is transferred.
# '/another' = http://hiddenServer:8765/path/
# And use the extended mode when required
# The additionnal options of a proxy's configuraiton's section are used to instantiate the `https` Agent(respectively the `http`).
# A notable option is `rejectUnauthorized` which allow to connect to a HTTPS backend with an invalid/ self signed certificate
#[reverseProxies.'/https/']
# target = 'https://localhost:8080/'
# rejectUnauthorized = false

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.14.7",
"version": "0.17.3",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -22,43 +22,44 @@
"xo-proxy": "dist/index.mjs"
},
"engines": {
"node": ">=14.13"
"node": ">=14.18"
},
"dependencies": {
"@iarna/toml": "^2.2.0",
"@koa/router": "^10.0.0",
"@vates/compose": "^2.0.0",
"@vates/decorate-with": "^0.1.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^1.0.0",
"@vates/disposable": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.13.0",
"@xen-orchestra/fs": "^0.18.0",
"@xen-orchestra/backups": "^0.18.3",
"@xen-orchestra/fs": "^0.19.3",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.1.1",
"@xen-orchestra/mixins": "^0.1.2",
"@xen-orchestra/self-signed": "^0.1.0",
"@xen-orchestra/xapi": "^0.7.0",
"@xen-orchestra/xapi": "^0.8.5",
"ajv": "^8.0.3",
"app-conf": "^0.9.0",
"app-conf": "^1.0.0",
"async-iterator-to-stream": "^1.1.0",
"fs-extra": "^10.0.0",
"get-stream": "^6.0.0",
"getopts": "^2.2.3",
"golike-defer": "^0.5.1",
"http-server-plus": "^0.11.0",
"http2-proxy": "^5.0.53",
"json-rpc-protocol": "^0.13.1",
"jsonrpc-websocket-client": "^0.6.0",
"jsonrpc-websocket-client": "^0.7.2",
"koa": "^2.5.1",
"koa-compress": "^5.0.1",
"koa-helmet": "^5.1.0",
"lodash": "^4.17.10",
"node-zone": "^0.4.0",
"parse-pairs": "^1.0.0",
"promise-toolbox": "^0.19.2",
"promise-toolbox": "^0.20.0",
"source-map-support": "^0.5.16",
"stoppable": "^1.0.6",
"xdg-basedir": "^4.0.0",
"xen-api": "^0.34.3",
"xdg-basedir": "^5.1.0",
"xen-api": "^0.35.1",
"xo-common": "^0.7.0"
},
"devDependencies": {

View File

@@ -15,24 +15,29 @@ import { createLogger } from '@xen-orchestra/log'
const { debug, warn } = createLogger('xo:proxy:api')
const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable) {
let headerSent = false
try {
for await (const data of iterable) {
if (!headerSent) {
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
headerSent = true
}
let cursor, iterator
try {
const getIterator = iterable[Symbol.iterator] ?? iterable[Symbol.asyncIterator]
iterator = getIterator.call(iterable)
cursor = await iterator.next()
yield format.response(responseId, { $responseType: 'ndjson' }) + '\n'
} catch (error) {
yield format.error(responseId, error)
throw error
}
while (!cursor.done) {
try {
yield JSON.stringify(data) + '\n'
yield JSON.stringify(cursor.value) + '\n'
} catch (error) {
warn('ndJsonStream, item error', { error })
}
cursor = await iterator.next()
}
} catch (error) {
warn('ndJsonStream, fatal error', { error })
if (!headerSent) {
yield format.error(responseId, error)
}
}
})
@@ -40,8 +45,8 @@ export default class Api {
constructor(app, { appVersion, httpServer }) {
this._ajv = new Ajv({ allErrors: true })
this._methods = { __proto__: null }
const router = new Router({ prefix: '/api/v1' }).post('/', async ctx => {
const PREFIX = '/api/v1'
const router = new Router({ prefix: PREFIX }).post('/', async ctx => {
// Before Node 13.0 there was an inactivity timeout of 2 mins, which may
// not be enough for the API.
ctx.req.setTimeout(0)
@@ -97,6 +102,7 @@ export default class Api {
// breaks, send some data every 10s to keep it opened.
const stopTimer = clearInterval.bind(
undefined,
// @to check : can this add space inside binary data ?
setInterval(() => stream.push(' '), keepAliveInterval)
)
stream.on('end', stopTimer).on('error', stopTimer)
@@ -113,7 +119,14 @@ export default class Api {
.use(router.routes())
.use(router.allowedMethods())
httpServer.on('request', koa.callback())
const callback = koa.callback()
httpServer.on('request', (req, res) => {
// only answers to query to the root url of this mixin
// do it before giving the request to Koa to ensure it's not modified
if (req.url.startsWith(PREFIX)) {
callback(req, res)
}
})
this.addMethods({
system: {

View File

@@ -1,5 +1,6 @@
import assert from 'assert'
import fse from 'fs-extra'
import xdg from 'xdg-basedir'
import { xdgConfig } from 'xdg-basedir'
import { createLogger } from '@xen-orchestra/log'
import { execFileSync } from 'child_process'
@@ -10,33 +11,48 @@ const { warn } = createLogger('xo:proxy:authentication')
const isValidToken = t => typeof t === 'string' && t.length !== 0
export default class Authentication {
constructor(_, { appName, config: { authenticationToken: token } }) {
if (!isValidToken(token)) {
token = JSON.parse(execFileSync('xenstore-read', ['vm-data/xo-proxy-authenticationToken']))
#token
if (!isValidToken(token)) {
throw new Error('missing authenticationToken in configuration')
}
constructor(app, { appName, config: { authenticationToken: token } }) {
const setToken = ({ token }) => {
assert(isValidToken(token), 'invalid authentication token: ' + token)
// save this token in the automatically handled conf file
fse.outputFileSync(
// this file must take precedence over normal user config
`${xdgConfig}/${appName}/config.z-auto.json`,
JSON.stringify({ authenticationToken: token }),
{ mode: 0o600 }
)
this.#token = token
}
if (isValidToken(token)) {
this.#token = token
} else {
setToken({ token: JSON.parse(execFileSync('xenstore-read', ['vm-data/xo-proxy-authenticationToken'])) })
try {
// save this token in the automatically handled conf file
fse.outputFileSync(
// this file must take precedence over normal user config
`${xdg.config}/${appName}/config.z-auto.json`,
JSON.stringify({ authenticationToken: token }),
{ mode: 0o600 }
)
execFileSync('xenstore-rm', ['vm-data/xo-proxy-authenticationToken'])
} catch (error) {
warn('failed to remove token from XenStore', { error })
}
}
this._token = token
app.api.addMethod('authentication.setToken', setToken, {
description: 'change the authentication token used by this XO Proxy',
params: {
token: {
type: 'string',
minLength: 1,
},
},
})
}
async findProfile(credentials) {
if (credentials?.authenticationToken === this._token) {
if (credentials?.authenticationToken === this.#token) {
return new Profile()
}
}

View File

@@ -164,6 +164,17 @@ export default class Backups {
},
},
],
deleteVmBackups: [
({ filenames, remote }) =>
Disposable.use(this.getAdapter(remote), adapter => adapter.deleteVmBackups(filenames)),
{
description: 'delete VM backups',
params: {
filenames: { type: 'array', items: { type: 'string' } },
remote: { type: 'object' },
},
},
],
fetchPartitionFiles: [
({ disk: diskId, remote, partition: partitionId, paths }) =>
Disposable.use(this.getAdapter(remote), adapter => adapter.fetchPartitionFiles(diskId, partitionId, paths)),
@@ -403,6 +414,7 @@ export default class Backups {
return new RemoteAdapter(yield app.remotes.getHandler(remote), {
debounceResource: app.debounceResource.bind(app),
dirMode: app.config.get('backups.dirMode'),
vhdDirectoryCompression: app.config.get('backups.vhdDirectoryCompression'),
})
}

View File

@@ -0,0 +1,17 @@
import transportConsole from '@xen-orchestra/log/transports/console.js'
import { configure } from '@xen-orchestra/log/configure.js'
export default class Logs {
constructor(app) {
const transport = transportConsole()
app.config.watch('logs', ({ filter, level }) => {
configure([
{
filter: [process.env.DEBUG, filter],
level,
transport,
},
])
})
}
}

View File

@@ -0,0 +1,120 @@
import { urlToHttpOptions } from 'url'
import proxy from 'http2-proxy'
function removeSlash(str) {
return str.replace(/^\/|\/$/g, '')
}
function mergeUrl(relative, base) {
const res = new URL(base)
const relativeUrl = new URL(relative, base)
res.pathname = relativeUrl.pathname
relativeUrl.searchParams.forEach((value, name) => {
// we do not allow to modify params already specified by config
if (!res.searchParams.has(name)) {
res.searchParams.append(name, value)
}
})
res.hash = relativeUrl.hash.length > 0 ? relativeUrl.hash : res.hash
return res
}
export function backendToLocalPath(basePath, target, backendUrl) {
// keep redirect url relative to local server
const localPath = `${basePath}/${backendUrl.pathname.substring(target.pathname.length)}${backendUrl.search}${
backendUrl.hash
}`
return localPath
}
export function localToBackendUrl(basePath, target, localPath) {
let localPathWithoutBase = removeSlash(localPath).substring(basePath.length)
localPathWithoutBase = './' + removeSlash(localPathWithoutBase)
const url = mergeUrl(localPathWithoutBase, target)
return url
}
export default class ReverseProxy {
constructor(app, { httpServer }) {
app.config.watch('reverseProxies', proxies => {
this._proxies = Object.keys(proxies)
.sort((a, b) => b.length - a.length)
.map(path => {
let config = proxies[path]
if (typeof config === 'string') {
config = { target: config }
}
config.path = '/proxy/v1/' + removeSlash(path) + '/'
return config
})
})
httpServer.on('request', (req, res) => this._proxy(req, res))
httpServer.on('upgrade', (req, socket, head) => this._upgrade(req, socket, head))
}
_getConfigFromRequest(req) {
return this._proxies.find(({ path }) => req.url.startsWith(path))
}
_proxy(req, res) {
const config = this._getConfigFromRequest(req)
if (config === undefined) {
res.writeHead(404)
res.end('404')
return
}
const url = new URL(config.target)
const targetUrl = localToBackendUrl(config.path, url, req.originalUrl || req.url)
proxy.web(req, res, {
...urlToHttpOptions(targetUrl),
...config.options,
onReq: (req, { headers }) => {
headers['x-forwarded-for'] = req.socket.remoteAddress
headers['x-forwarded-proto'] = req.socket.encrypted ? 'https' : 'http'
if (req.headers.host !== undefined) {
headers['x-forwarded-host'] = req.headers.host
}
},
onRes: (req, res, proxyRes) => {
// rewrite redirect to pass through this proxy
if (proxyRes.statusCode === 301 || proxyRes.statusCode === 302) {
// handle relative/ absolute location
const redirectTargetLocation = new URL(proxyRes.headers.location, url)
// this proxy should only allow communication between known hosts. Don't open it too much
if (redirectTargetLocation.hostname !== url.hostname || redirectTargetLocation.protocol !== url.protocol) {
throw new Error(`Can't redirect from ${url.hostname} to ${redirectTargetLocation.hostname} `)
}
res.writeHead(proxyRes.statusCode, {
...proxyRes.headers,
location: backendToLocalPath(config.path, url, redirectTargetLocation),
})
res.end()
return
}
// pass through the answer of the remote server
res.writeHead(proxyRes.statusCode, proxyRes.headers)
// pass through content
proxyRes.pipe(res)
},
})
}
_upgrade(req, socket, head) {
const config = this._getConfigFromRequest(req)
if (config === undefined) {
return
}
const { path, target, options } = config
const targetUrl = localToBackendUrl(path, target, req.originalUrl || req.url)
proxy.ws(req, socket, head, {
...urlToHttpOptions(targetUrl),
...options,
})
}
}

View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDETCCAfkCFHXO1U7YJHI61bPNhYDvyBNJYH4LMA0GCSqGSIb3DQEBCwUAMEUx
CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl
cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwMTEwMTI0MTU4WhcNNDkwNTI3MTI0
MTU4WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE
CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEA1jMLdHuZu2R1fETyB2iRect1alwv76clp/7A8tx4zNaVA9qB
BcHbI83mkozuyrXpsEUblTvvcWkheBPAvWD4gj0eWSDSiuf0edcIS6aky+Lr/n0T
W/vL5kVNrgPTlsO8OyQcXjDeuUOR1xDWIa8G71Ynd6wtATB7oXe7kaV/Z6b2fENr
4wlW0YEDnMHik59c9jXDshhQYDlErwZsSyHuLwkC7xuYO26SUW9fPcHJA3uOfxeG
BrCxMuSMOJtdmslRWhLCjbk0PT12OYCCRlvuTvPHa8N57GEQbi4xAu+XATgO1DUm
Dq/oCSj0TcWUXXOykN/PAC2cjIyqkU2e7orGaQIDAQABMA0GCSqGSIb3DQEBCwUA
A4IBAQCTshhF3V5WVhnpFGHd+tPfeHmUVrUnbC+xW7fSeWpamNmTjHb7XB6uDR0O
DGswhEitbbSOsCiwz4/zpfE3/3+X07O8NPbdHTVHCei6D0uyegEeWQ2HoocfZs3X
8CORe8TItuvQAevV17D0WkGRoJGVAOiKo+izpjI55QXQ+FjkJ0bfl1iksnUJk0+I
ZNmRRNjNyOxo7NAzomSBHfJ5rDE+E440F2uvXIE9OIwHRiq6FGvQmvGijPeeP5J0
LzcSK98jfINFSsA/Wn5vWE+gfH9ySD2G3r2cDTS904T77PNiYH+cNSP6ujtmNzvK
Bgoa3jXZPRBi82TUOb2jj5DB33bg
-----END CERTIFICATE-----

View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1jMLdHuZu2R1fETyB2iRect1alwv76clp/7A8tx4zNaVA9qB
BcHbI83mkozuyrXpsEUblTvvcWkheBPAvWD4gj0eWSDSiuf0edcIS6aky+Lr/n0T
W/vL5kVNrgPTlsO8OyQcXjDeuUOR1xDWIa8G71Ynd6wtATB7oXe7kaV/Z6b2fENr
4wlW0YEDnMHik59c9jXDshhQYDlErwZsSyHuLwkC7xuYO26SUW9fPcHJA3uOfxeG
BrCxMuSMOJtdmslRWhLCjbk0PT12OYCCRlvuTvPHa8N57GEQbi4xAu+XATgO1DUm
Dq/oCSj0TcWUXXOykN/PAC2cjIyqkU2e7orGaQIDAQABAoIBAQC65uVq6WLWGa1O
FtbdUggGL1svyGrngYChGvB/uZMKoX57U1DbljDCCCrV23WNmbfkYBjWWervmZ1j
qlC2roOJGQ1/Fd3A6O7w1YnegPUxFrt3XunijE55iiVi3uHknryDGlpKcfgVzfjW
oVFHKPMzKYjcqnbGn+hwlwoq5y7JYFTOa57/dZbyommbodRyy9Dpn0OES0grQqwR
VD1amrQ7XJhukcxQgYPuDc/jM3CuowoBsv9f+Q2zsPgr6CpHxxLLIs+kt8NQJT9v
neg/pm8ojcwOa9qoILdtu6ue7ee3VE9cFnB1vutxS1+MPeI5wgTJjaYrgPCMxXBM
2LdJJEmBAoGBAPA6LpuU1vv5R3x66hzenSk4LS1fj24K0WuBdTwFvzQmCr70oKdo
Yywxt+ZkBw5aEtzQlB8GewolHobDJrzxMorU+qEXX3jP2BIPDVQl2orfjr03Yyus
s5mYS/Qa6Zf1yObrjulTNm8oTn1WaG3TIvi8c5DyG2OK28N/9oMI1XGRAoGBAORD
YKyII/S66gZsJSf45qmrhq1hHuVt1xae5LUPP6lVD+MCCAmuoJnReV8fc9h7Dvgd
YPMINkWUTePFr3o4p1mh2ZC7ldczgDn6X4TldY2J3Zg47xJa5hL0L6JL4NiCGRIE
FV5rLJxkGh/DDBfmC9hQQ6Yg6cHvyewso5xVnBtZAoGAI+OdWPMIl0ZrrqYyWbPM
aP8SiMfRBtCo7tW9bQUyxpi0XEjxw3Dt+AlJfysMftFoJgMnTedK9H4NLHb1T579
PQ6KjwyN39+1WSVUiXDKUJsLmSswLrMzdcvx9PscUO6QYCdrB2K+LCcqasFBAr9b
ZyvIXCw/eUSihneUnYjxUnECgYAoPgCzKiU8ph9QFozOaUExNH4/3tl1lVHQOR8V
FKUik06DtP35xwGlXJrLPF5OEhPnhjZrYk0/IxBAUb/ICmjmknQq4gdes0Ot9QgW
A+Yfl+irR45ObBwXx1kGgd4YDYeh93pU9QweXj+Ezfw50mLQNgZXKYJMoJu2uX/2
tdkZsQKBgQCTfDcW8qBntI6V+3Gh+sIThz+fjdv5+qT54heO4EHadc98ykEZX0M1
sCWJiAQWM/zWXcsTndQDgDsvo23jpoulVPDitSEISp5gSe9FEN2njsVVID9h1OIM
f30s5kwcJoiV9kUCya/BFtuS7kbuQfAyPU0v3I+lUey6VCW6A83OTg==
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,60 @@
import { createServer as creatServerHttps } from 'https'
import { createServer as creatServerHttp } from 'http'
import { WebSocketServer } from 'ws'
import fs from 'fs'
const httpsServer = creatServerHttps({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
})
const httpServer = creatServerHttp()
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false })
function upgrade(request, socket, head) {
const { pathname } = new URL(request.url)
// web socket server only on /foo url
if (pathname === '/foo') {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request)
ws.on('message', function message(data) {
ws.send(data)
})
})
} else {
socket.destroy()
}
}
function httpHandler(req, res) {
switch (req.url) {
case '/index.html':
res.end('hi')
return
case '/redirect':
res.writeHead(301, {
Location: 'index.html',
})
res.end()
return
case '/chainRedirect':
res.writeHead(301, {
Location: '/redirect',
})
res.end()
return
default:
res.writeHead(404)
res.end()
}
}
httpsServer.on('upgrade', upgrade)
httpServer.on('upgrade', upgrade)
httpsServer.on('request', httpHandler)
httpServer.on('request', httpHandler)
httpsServer.listen(8080)
httpServer.listen(8081)

View File

@@ -0,0 +1,123 @@
import ReverseProxy, { backendToLocalPath, localToBackendUrl } from '../dist/app/mixins/reverseProxy.mjs'
import { deepEqual, strictEqual } from 'assert'
function makeApp(reverseProxies) {
return {
config: {
get: () => reverseProxies,
},
}
}
const app = makeApp({
https: {
target: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
oneOption: true,
},
http: 'http://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
})
// test localToBackendUrl
const expectedLocalToRemote = {
https: [
{
local: '/proxy/v1/https/',
remote: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
},
{
local: '/proxy/v1/https/sub',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1#one=2&another=3',
},
{
local: '/proxy/v1/https/sub/index.html',
remote: 'https://localhost:8080/remotePath/sub/index.html?baseParm=1#one=2&another=3',
},
{
local: '/proxy/v1/https/sub?param=1',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1&param=1#one=2&another=3',
},
{
local: '/proxy/v1/https/sub?baseParm=willbeoverwritten&param=willstay',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1&param=willstay#one=2&another=3',
},
{
local: '/proxy/v1/https/sub?param=1#another=willoverwrite',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1&param=1#another=willoverwrite',
},
],
}
const proxy = new ReverseProxy(app, { httpServer: { on: () => {} } })
for (const proxyId in expectedLocalToRemote) {
for (const { local, remote } of expectedLocalToRemote[proxyId]) {
const config = proxy._getConfigFromRequest({ url: local })
const url = new URL(config.target)
strictEqual(localToBackendUrl(config.path, url, local).href, remote, 'error converting to backend')
}
}
// test backendToLocalPath
const expectedRemoteToLocal = {
https: [
{
local: '/proxy/v1/https/',
remote: 'https://localhost:8080/remotePath/',
},
{
local: '/proxy/v1/https/sub/index.html',
remote: '/remotePath/sub/index.html',
},
{
local: '/proxy/v1/https/?baseParm=1#one=2&another=3',
remote: '?baseParm=1#one=2&another=3',
},
{
local: '/proxy/v1/https/sub?baseParm=1#one=2&another=3',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1#one=2&another=3',
},
],
}
for (const proxyId in expectedRemoteToLocal) {
for (const { local, remote } of expectedRemoteToLocal[proxyId]) {
const config = proxy._getConfigFromRequest({ url: local })
const targetUrl = new URL('https://localhost:8080/remotePath/?baseParm=1#one=2&another=3')
const remoteUrl = new URL(remote, targetUrl)
strictEqual(backendToLocalPath(config.path, targetUrl, remoteUrl), local, 'error converting to local')
}
}
// test _getConfigFromRequest
const expectedConfig = [
{
local: '/proxy/v1/http/other',
config: {
target: 'http://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
options: {},
path: '/proxy/v1/http',
},
},
{
local: '/proxy/v1/http',
config: undefined,
},
{
local: '/proxy/v1/other',
config: undefined,
},
{
local: '/proxy/v1/https/',
config: {
target: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3',
options: {
oneOption: true,
},
path: '/proxy/v1/https',
},
},
]
for (const { local, config } of expectedConfig) {
deepEqual(proxy._getConfigFromRequest({ url: local }), config)
}

View File

@@ -35,7 +35,7 @@
"form-data": "^4.0.0",
"fs-extra": "^10.0.0",
"get-stream": "^6.0.0",
"http-request-plus": "^0.12",
"http-request-plus": "^0.13.0",
"human-format": "^0.11.0",
"l33teral": "^3.0.3",
"lodash": "^4.17.4",
@@ -44,8 +44,8 @@
"pw": "^0.0.4",
"strip-indent": "^3.0.0",
"xdg-basedir": "^4.0.0",
"xo-lib": "^0.10.1",
"xo-vmdk-to-vhd": "^2.0.0"
"xo-lib": "^0.11.1",
"xo-vmdk-to-vhd": "^2.0.3"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

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