Compare commits

..

272 Commits

Author SHA1 Message Date
Pierre Brunet
88495d763d feat: aggregate errors in backup runners 2026-05-20 17:10:43 +02:00
OlivierFL
e3a58ed351 chore(xo-core/xo-6): introduce form validation (#9630) 2026-05-20 14:47:32 +02:00
Mathieu
a3d8ab8dbb feat(rest-api): add RBAC check for host endpoints (#9840)
RBAC for 
  - `/hosts/:id/actions/disable`  
  - `/hosts/:id/actions/enable` 
  - `/hosts/:id/actions/management_reconfigure`
2026-05-20 13:12:00 +02:00
Florent BEAUCHAMP
680b810886 feat(backups): bi directional replication (#9806)
following the symmetrical backup PR

reuse any snapshot in common between the source and destination if 
* using incremental replication
* only one target
 * a common snapshot exists between source and destination , even from another job, based on the CONTENT_KEY

The same rule of reuse the existing VM target / create a new one of symmetrical backups applies

if the destination don't have any data between the snapshot and the active disk 
    update an existing VM 
else create a new VM but chain disks
2026-05-20 12:33:34 +02:00
spacotte-vates
a0a302430d fix(encryption): fix orphaned entry issue (#9854)
When a Redis entry has an ID but no corresponding data key, undefined was returned which caused errors when new data was added.

Error case was the configuration of a plugin failing to save.
2026-05-20 11:16:47 +02:00
Grandalf
7c9c7efb77 feat(xo-server-ipmi-sensors) : Migrated the old ipmi feature to a new plugin and added user defined sensors (#9724)
Move ipmi sensor discovery to it's own plugin and removed old logic, ships with a default configuration with the same behaviour as the old system.
The plugin can be configured via the UI, accepts an exact match for the vendor and regexes for the ipmi matching.

Tested via unit tests.

Tested with R620-L1 host on xo-lab by adding and removing Dell from configured vendors
2026-05-20 10:58:53 +02:00
Pierre Brunet
60fbce0df9 fix(backup-archive): resolve xva path relative to backup directory (#9865)
Path needs to be relative, not absolute in metadata

introduced by #9765
2026-05-20 10:48:43 +02:00
Florent BEAUCHAMP
5e717b6e04 fix(qa-text): better logs, better env , less magic (#9833)
- Add `getRequiredEnv()`: replaces all `process.env.X || 'default'` fallbacks
  with a hard throw so a missing or typo'd .env key fails immediately with a
  diagnostic message instead of silently operating on wrong defaults
- Add `logSetup.js`: dual-transport logging — console filtered by $DEBUG,
  plus a full debug-level file in /tmp for post-mortem inspection
- Add `assertRepositoryMatchesConfig()`: fails fast when a named repository
  in XO points at a different path than the one declared in .env
- Make `getAllowedCleanupPaths()` lazy so env vars are read after .env is
  loaded, not at ESM module evaluation time
- Pass full error objects (not `.message`) to all structured log calls
- Add retry logic on flaky operations and surface more detail on failures
- Fix: only purge non-empty backup repositories
- Fix: backup jobs no longer skipped under some conditions
- Fix: missing `backuprepository.delete` function
- Add `.USAGE.md` and update `README.md`
2026-05-19 16:12:33 +02:00
Florent BEAUCHAMP
ed558e9854 fix(xo-server): ssh cli tool script name (#9861) 2026-05-19 15:49:37 +02:00
Florent BEAUCHAMP
8a51c621da feat(xo-server): ssh tool to connect to hosts (#9844)
add xo-server-ssh executable that ease a connection to the hypervisors

Without parameters it list the pool uuid/master ip
With a pool uuid parameter it opens a sshg connection to the pool master 
With a pool uuid and ip, it will reuse the pool credentials to connect to the slave host
2026-05-19 15:41:37 +02:00
Enée DI IORIO
f464803053 feat(lite): create robots.txt (#9858) 2026-05-19 14:12:05 +02:00
Mathieu
09c352244e feat(mcp): support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars (#9820)
* feat(mcp): support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars

* chore(changelog): mention MCP proxy env var support

* refactor(mcp): delegate proxy env handling to undici
2026-05-19 12:06:21 +02:00
Mathieu
ec365231dd fix(@vates/types): explicit XoUser permission type (#9829) 2026-05-19 11:51:13 +02:00
Laudin Alessandro Molina Troconis
e0ae3db341 fix(docs): formatting and minor grammar errors in CONTRIBUTING.md (#9827) 2026-05-19 11:45:01 +02:00
spacotte-vates
16d1c7ce9d feat(rest): add revert snapshot route (#9788) 2026-05-19 10:53:05 +02:00
MlssFrncJrg
64422066c2 feat(@xen-orchestra/rest-api): add delete SR (#9464) 2026-05-18 17:06:57 +02:00
Pierre Brunet
abbdfe6cb7 fix(xo-web): update sanitize-html (#9851)
dependabot alert : https://github.com/vatesfr/xen-orchestra/security/dependabot/484
2026-05-18 16:19:13 +02:00
Joris K
4e1c8b7188 fix(xo6/tasks): issue with task type and size when accessing backup runs (#9841) 2026-05-18 16:06:36 +02:00
Pierre Brunet
100f11308a refactor(backups): rewrite and test cleanVm (#9765)
the cleanVm script was a monolith in charge of cleaning the backup, 
detecting any corrupted data, deleting the irrecoverable one, removing
and merging older backups 

this PR do a complete rewrite, using the RemoteDisk abstraction and add
tests
2026-05-18 15:15:56 +02:00
spacotte-vates
d810ea0c46 feat(rest-api): refactor registerRestApi (#9553) 2026-05-13 17:53:15 +02:00
Mathieu
2b08359bcf fix(xo-server): fix read-only role by adding acl resources (#9839) 2026-05-13 15:00:21 +02:00
Thomas Moraine
0ede983c61 docs: add TOTP setup instructions to user documentation (#9843)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-05-13 14:56:27 +02:00
OlivierFL
8c00ccefd4 chore(xo-stack): update Vite, Typescript and Vue related libs (#9703) 2026-05-13 09:49:29 +02:00
spacotte-vates
6f68b04f7e feat(xo-server): encrypt/decrypt credentials in redis (#9735)
Implements encryption and decryption feature for Redis content with migration.
2026-05-12 16:29:34 +02:00
Pierre Brunet
ee53cd0723 fix(xo5/new-job): fix cpuMask options while using vm.set (#9823) 2026-05-12 15:25:10 +02:00
Grandalf
5985ac63f2 docs: added documentation for traffic rule REST endpoints (#9816) 2026-05-11 16:27:05 +02:00
Pierre Brunet
c89c184073 feat(fs): add custom user-agent in s3 client (#9815)
clientConfig:
  apiVersion: '2006-03-01',
  customUserAgent: [ [ 'xen-orchestra-fs-4.8.0' ] ],
2026-05-11 16:02:25 +02:00
Pierre Donias
407432b4e6 feat(xo-web/config): hide passphrase by default when exporting config (#9824)
* feat(xo-web/config): hide passphrase by default when exporting config

* PR
2026-05-11 11:05:35 +02:00
Pierre Donias
acdfcb4235 fix(xo-server/config): typo in comment of ignoreVmSnapshotResources field (#9822) 2026-05-11 11:03:41 +02:00
Enée DI IORIO
070a3da7e2 feat(web-core): Update UiChip component to V8 (#9491) 2026-05-11 10:47:35 +02:00
Enée DI IORIO
7a69b2c09a feat(web-core): Update UiTag to VtsTag and add tertiary (#9811) 2026-05-11 10:20:08 +02:00
Florent BEAUCHAMP
40eab4f4e7 feat(xo-web/xo-server): import qcow2 disk from form (#9817) 2026-05-11 08:19:01 +02:00
Julien Fontanet
c5b7e7d2cb fix(complex-matcher): avoid array allocation in isRawString (#9813) 2026-05-07 17:02:25 +02:00
Thomas Moraine
fd9e271139 docs(backup_reports.md) document the transport-nagios plugin (#9819)
The transport-nagios plugin lets users monitors backup jobs from the Nagios interface.
This was previously documented only in a Xen Orchestra blog post.
This commit completes the existing Backup reports page, by explaining how
to get backup reports in Nagios, using that plugin.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-05-07 16:57:47 +02:00
Grandalf
008ec6ed6a feat(rest-api): add support for txChecksumming and rateLimitting on V… (#9793) 2026-05-06 19:16:22 +02:00
Florent BEAUCHAMP
2791a8c42d feat(qa-test): console.log=>xen-orchestra/log (#9810) 2026-05-06 16:50:32 +02:00
Enée DI IORIO
d4f1798530 feat(web-core/UiInfo): upgrade UiInfo to v6 (#9773) 2026-05-06 14:43:09 +02:00
Enée DI IORIO
7ef3dd3bbe feat(web-core/UiLink): upgrade UiLink to v6 (#9784) 2026-05-06 14:21:31 +02:00
Bastien Nollet
7e144234b9 feat: release 6.4.1 (#9808) 2026-05-05 12:30:45 +02:00
Bastien Nollet
b4f0e5f64d feat: technical release (#9807) 2026-05-05 10:42:40 +02:00
Mathieu
93e38c0280 fix(rest-api): early return for admin user in acl check (#9798)
* fix(rest-api): early return for non admin user in acl check
2026-05-05 10:09:10 +02:00
Sebastien Marie
93aba2dcc7 xo-server-sdn-controller: small cleanup (#9794)
This PR contains several commits to do some code cleanup in xo-server-sdn-controller.
They doesn't belong to any specific tasks, but it was done while working in the code.
They will be better upstreamed than in my local worktree.

    unify createLogger inside xo-server-sdn-controller package : it simplifies filter usage in configuration to have debug output of the sdn-controller
    in openflow-plugin.js, avoid using log.error() for a not fatal error but use log.warn(). reorder a bit the throw code to make it more readable (the error is throw only if no plugin call successed)
2026-05-05 10:04:39 +02:00
Florent BEAUCHAMP
80ba88bb0e fix(immutable-backups): use date in filename nstead of modified time to lock/unlock + fixes
Fixes: 
* better support of localized systems
* types
* add a retry on chattr error, selinux and / or xfs seems to need a little more time before locking it
* force a complete check on startup to remove immutables files that should not be froma previous run 
* don't try to lock deleted files
2026-05-05 10:00:16 +02:00
OlivierFL
ef1fe13667 feat(xo-stack): update system pages layout (#9746) 2026-05-05 09:26:19 +02:00
Thomas Moraine
225d1699e8 docs(sdn_controller.md): openSSL 3 + sdn upgrade path: fix error in code sample (#9805)
docs(sdn_controller.md): fix error in code sample

As reported by Jon Sands, the previous version of the code sample showed the script executing line by line. It works as expected after removing the -xe

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-05-05 08:34:21 +02:00
Thomas Moraine
0338013220 docs(v2v migration guide): Highlight link to V2V troubleshooting doc (#9797) 2026-04-30 18:28:05 +02:00
Thomas Moraine
893b229265 docs(aclv2): fix broken links to and from the new ACLv2 page (#9796)
docs(aclv2): fix broken links

The ACLv2 page was created and merged at roughly the same time we split the documentation pages into two sections (XO6 and XO5), so the change in file structure broke some links to and from that ACLv2 page.

This commit fixes the links so that the Docusaurus build can complete without errors.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-04-30 10:34:47 +02:00
Thomas Moraine
598c71ca3c docs(xo): create a dedicated xo6 doc section (#9730)
XO6 has become the default interface for Xen Orchestra,
but not all features have been ported to XO5 yet
and they don't work the same way.
To make the documentation easier to maintain
and prevent confusion,
we're moving the current pages to a new section called 'XO 5'
and create a new section for XO6-specific documentation. The XO 6 section will be completed gradually.
On top of that, this commit updates internal links and redirections
to adapt to this new doc architecture.
Finally, most of the pages in the Support category
have been moved to a new section called General support,
since these pages apply the same way to XO 5 and XO 6.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-04-30 09:48:45 +02:00
Mathieu
a04c5d671f feat(acl-v2): ACL v2 documentation (#9795)
#9754 was merged into the wrong branch
2026-04-30 09:34:21 +02:00
Bastien Nollet
13f987ff3d feat(lite): 0.21.0 (#9792) 2026-04-29 17:41:36 +02:00
Bastien Nollet
ad85a881dd feat: release 6.4.0 (#9791) 2026-04-29 16:54:08 +02:00
Grandalf
b0c4b7c4cc chore(docs): update installation command to include libfuse2 (#9751) 2026-04-29 16:26:19 +02:00
Bastien Nollet
2f1155af78 chore(changelog): forgotten package (#9790) 2026-04-29 16:24:49 +02:00
Bastien Nollet
81f83d6f22 feat: technical release (#9789)
* feat(@xen-orchestra/web): 0.49.0

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

* chore(CHANGELOG): update next
2026-04-29 14:50:23 +02:00
Pierre Briffaux
c388d998d8 fix(vm/console): fix VNC reconnection causing focus loss (#9727) 2026-04-29 14:21:41 +02:00
Elise-F
b6a303e02a fix(xo6): remove dev routes from prod (#9787)
Introduced by: #9747
2026-04-29 14:07:53 +02:00
Joris K
a1f660d31b feat(xo6): add possibility to connect or disconnect a VBD (#9726) 2026-04-29 13:54:16 +02:00
Pierre Briffaux
c632fd2032 feat(xo6/vm/new): removed new VM form alert (#9757) 2026-04-29 13:46:45 +02:00
spacotte-vates
cfb91c8a1c fix(sdn-controller): fix invalidParameters import (#9786)
Introduced by 1f0a3d8
2026-04-29 11:30:22 +02:00
Bastien Nollet
33b9106da9 feat: technical release (#9785)
* feat(@vates/types): 1.24.0

* feat(@xen-orchestra/acl): 1.0.0

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

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

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

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

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

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

* feat(xo-server): 5.200.0

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

* chore(CHANGELOG): update next
2026-04-29 11:00:24 +02:00
Florent BEAUCHAMP
12fb701686 fix(backups): handle diskless vm snapshot retention (#9758)
* fix(backups): handle diskless vm snapshot retention
2026-04-29 10:25:23 +02:00
Joris K
b83a7f9758 feat(xo6): add possibility to connect or disconnect a VIF (#9756) 2026-04-29 10:20:42 +02:00
Grandalf
95b9d28e6a feat : Make "allow = false " accepted in the traffic rules Rest API (#9783) 2026-04-29 09:42:29 +02:00
Bastien Nollet
3d1df14373 fix(backups): missing logs (#9781)
Fix some types of backup and restore logs not being returned by getBackupNgLogs
Make log garbage collector treat restore logs as backup logs
2026-04-29 09:21:52 +02:00
Elise-F
0eb5c2fb65 feat(xo6): implement security tab on pool to display traffic rules (#9770) 2026-04-29 09:13:00 +02:00
Florent BEAUCHAMP
c998f99e9d fix(proxy): improve error to better differentiate between source error and import error (#9775) 2026-04-28 16:49:59 +02:00
MlssFrncJrg
ec1ebd1687 feat(xo-server): add DNS-01 challenge support (#9592) 2026-04-28 16:40:12 +02:00
Grandalf
c868b788fb feat(rest-api): add and remove vif rules routes (#9759) 2026-04-28 15:39:56 +02:00
Florent BEAUCHAMP
144d95aac7 fix(backups): failed cleanVm test (#9778)
- ancestor (blocks 1, 3) merged into child (blocks 1, 2) -> 3 blocks
 - cleanAncestor merged into clean -> 0 blocks
 grandchild is not merged so its size is not counted here
2026-04-28 14:40:41 +02:00
OlivierFL
781e8a357f feat(xo-6/network): implement network deletion (#9714) 2026-04-28 11:59:24 +02:00
Julien Porschen
a4382b7687 fix(xo-server): display of RHEL 10 icons in vm list (#9766)
os_version.distro changed from "rhel" to "red hat enterprise linux" since version 10
2026-04-28 11:32:55 +02:00
jhoustek
7378bde4a8 fix(backups): record actual incremental VHD size (#9637)
* fix(backups): record actual incremental VHD size
* fix(backups): record merged VHD size from merge result
* fix(backups): only rewrite metadata after actual merge
* fix(backups): record actual incremental VHD size
2026-04-28 11:28:12 +02:00
spacotte-vates
1f0a3d8115 feat(rest-api): add and remove network rules routes (#9448) 2026-04-28 11:27:22 +02:00
vates-bot
7f1b8949cb feat(web-stack/i18n): update Xen Orchestra 6 (#9729) 2026-04-28 11:23:59 +02:00
Mathieu
71b2e373ab feat(mcp) : openapi bootstrap (#9641)
* refactor(mcp): split monolithic index.mts into modular file structure

* feat(mcp): add markdown formatters for LLM-optimized responses

* feat(mcp): dynamic tool generation from OpenAPI spec at startup

* fix(mcp): remove orphan tests and fix mock client after rebase

* refactor(mcp): clean up dead code, consolidate formatters, use
human-format

* docs(mcp): reflect dynamic tool generation + actions flag

* refactor(mcp): generate tools directly from OpenAPI spec

* refactor(mcp): drop redundant tool annotation hints
2026-04-28 11:12:58 +02:00
Mathieu
63d4334837 feat(rest-api): ACL V2 integration (#9774) 2026-04-28 11:03:49 +02:00
Sebastien Marie
00560dd418 feat(xo-server-sdn-controller): add the openflow controller method used to pool:other-config
it will be used by xapi to know if the OpenFlow channel is used and so if port 6653 needs to be opened or not.
it might also be used later to know how to interprete of-rules in the migration path from channel to xapi-plugin, if needed.

XCPNG-2470
XO-2282
2026-04-28 09:42:57 +02:00
Sebastien Marie
d56b68d49a fix(xo-server-sdn-controller): allow pool.certificate_uninstall to fail with CERTIFICATE_DOES_NOT_EXIST
It could happens if xapi database is out-of-sync with filesystem.

As the purpose of this block is to decide if the certificate should be installed,
assume that on CERTIFICATE_DOES_NOT_EXIST you need to install the new certificate.
2026-04-28 09:42:03 +02:00
Sebastien Marie
954a611399 fix(xo-server-sdn-controller): generate psk password using cryptographically secure pseudorandom number generator
For strong passwords, you need a good source of entropy (true randomness).  
lodash.sample's randomness relies on the JavaScript runtime's PRNG, which might 
not have enough entropy.
2026-04-28 09:42:03 +02:00
Sebastien Marie
c5c3cc23a2 fix(xo-server-sdn-controller): fix VM events to propagate Network Rules on changes
on topology changes (VM started/stopped/migrated/...), network rules needs to be 
refreshed as they are installed on each port of virtual switch.

add the same logic than VIF rules to refresh them.
2026-04-28 09:42:03 +02:00
Sebastien Marie
657c639de2 fix(xo-server-sdn-controller): fix function name in error message (copy/paste error) 2026-04-28 09:42:03 +02:00
Sebastien Marie
b57765231f fix(xo-server-sdn-controller): manage network rules deletion with cookie
Enable the usage of `cookie` parameter exposed by sdn-controller xapi plugin
- https://github.com/xcp-ng/xcp-ng-xapi-plugins/pull/58

It fixes several cases where OF rules where leaked and left behind.

Upgrade notes (apply to Network Rules only, VIF Rules are not impacted):
- a new xapi-plugin with old XO is compatible (and no behaviour change)
- an old xapi-plugin with new XO will fail to communicate
  XO will try to use `cookie` but xapi-plugin will complain about invalid parameter
- a new xapi-plugin with new XO will use new behaviour
  and existing OF rules will migrate smoothly from old to new behaviour
2026-04-28 09:42:03 +02:00
Pierre Briffaux
20dfa98575 feat(xo6/host): show management IP first in hosts table IP column (#9747) 2026-04-27 16:38:42 +02:00
Elise-F
3bb0850626 fix(xo6): remove dev pages in prod (#9700) 2026-04-27 16:10:54 +02:00
Joris K
699fb196fb feat(xo6): add possibility to remove a snapshot (#9749) 2026-04-27 10:25:14 +02:00
Mathieu
00e0bee372 fix(xo-server): fix default-template still in xo after deletion (#9760)
When VM_destroy is called on a default template, it is first converted to a simple template (and then to a VM).

After converting a default template to a simple template, the object received after the xapiObjectToXo transformation has a different ID (the default template's ID = pool.uuid + template.uuid, and non default template ID = template.uuid).

Since this object does not already exist in the XO server cache(because the ID is new), XO treats it as a new object and not as an update, so the old object with ID pool.uuid + template.uuid stay in the cache
2026-04-27 08:39:51 +02:00
Florent BEAUCHAMP
da045e0de1 fix(backups): use a delta transfer even if the VM is modified (#9709)
* fix(backups): use a delta transfer even if the VM is modified
* test(backup): fix date test without timezone
* refactor(backups): extract method that check which snapshot can be reused for replica target
* reset before launching the replication
2026-04-24 19:30:51 +02:00
Bastien Nollet
fdb82ed68a feat: technical release (#9768) 2026-04-24 14:38:15 +02:00
Pierre Brunet
112c22e748 fix: add chainPhysicalUsage property in vdi object (#9708)
*add  chainPhysicalUsage property to vdi
2026-04-23 16:29:55 +02:00
Thomas Moraine
c540a49643 doc(users.md): recommend against using local users in prod environments (#9761) 2026-04-23 15:13:14 +02:00
Joris K
f6484a0695 feat(xo6): add possibility to delete a VBD (#9698) 2026-04-21 16:34:13 +02:00
Joris K
5593f27110 feat(xo6): add possibility to remove VDIs (#9689) 2026-04-21 15:41:15 +02:00
Joris K
81813c7e5b feat(xo6): add possibility to remove VIFs (#9601) 2026-04-21 15:19:06 +02:00
Mathieu
0bf0a468b5 fix(rest-api): fix other_config being ignored on VDI creation (#9695) 2026-04-21 12:35:33 +02:00
Pierre Brunet
e78275f046 docs: fix typo (#9739) 2026-04-21 11:39:49 +02:00
spacotte-vates
89c7551293 feat(rest-api): add connect/disconnect vif actions (#9643) 2026-04-21 10:25:24 +02:00
Mathieu
ed30291993 fix(rest-api): fix double slash in the href property (#9741)
Introduced by dd5f0cdaa9
2026-04-21 10:07:52 +02:00
Pierre Donias
9765435471 feat(xo-web/servers): remind users to only add pool masters (#9742) 2026-04-20 15:43:02 +02:00
spacotte-vates
e2a4bd2231 fix(changelog): add version 6.2.3 (#9743) 2026-04-20 15:10:02 +02:00
Florent BEAUCHAMP
c6ccd987e2 fix(xo-server): TLSSocket memory leak — stoppable + http-server-plus incompatibility (#9725)
xo-server uses two libraries for its web server:

    http-server-plus — a multi-protocol server facade that multiplexes HTTP and HTTPS listeners behind a single EventEmitter interface ( introduced here JsCommunity/http-server-plus@ec63f3f )
    stoppable — wraps the server to track open connections for graceful shutdown, using an internal reqsPerSocket Map (server._pendingSockets) (introduced here : 782505b )


Fix

@vates/http-server-plus (fork of JsCommunity/http-server-plus): add 'secureConnection' to the forwardedEvents list so that webServer.on('secureConnection', handler) is propagated to underlying https.Server instances at listen() time.

xo-server/src/index.mjs: register the missing handler that stoppable was supposed to add but never did:
2026-04-20 14:38:37 +02:00
Mathieu
45f810c044 feat(openmetrics): add VM status and uptime metrics (#9684)
* feat(openmetrics): add VM status and uptime metrics

* refactor(openmetrics): address review feedback on VM status/uptime
2026-04-20 14:09:28 +02:00
Bastien Nollet
fbba05bbde feat(backups): use XO Tasks (#9734)
Change the backups so they stop using their own task system, and use the same system as XO Tasks.

For the moment, it shouldn't affect the users, but when the previous backup logs will be old enough to be erased, this will allow us to have shorter loading times for backup logs.

XO-52
2026-04-17 17:49:18 +02:00
OlivierFL
9dc52f2e5b fix(xo-6/network-form): toRef causes TS error on build (#9736) 2026-04-17 17:33:06 +02:00
Mathieu
271d123395 fix(rest-api): SSE MiB limit instead percent (#9731) 2026-04-17 16:58:26 +02:00
Enée DI IORIO
88a89cc3a9 fix(changelog): fix PR link formatting in changelogs (#9732) 2026-04-17 11:46:47 +02:00
Florent BEAUCHAMP
5f7fab9d34 test(backups): replication and full mirror backups
* test(replication): test the new snapshot based replication

* fix(tests): use mergeBackupsSynchronously to ensure the backups don't fails with lock already heled error

* fix: ensure the repository that will be purged start empty

* test(backup): add test for full mirror backups
2026-04-17 11:25:38 +02:00
spacotte-vates
8df8ef0af8 feat(backups): log merge size (#9679)
XO-2050

Add the merged size in the cleanVm task log
2026-04-17 09:58:12 +02:00
Mathieu
6fbb961e7f fix(xo-server): fix 408 timeout when copying a VM with compression (#9699)
* fix compression not used on front whe copying a VM
* ensure the first bytes are ready before starting to import the VM 

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-04-17 09:56:39 +02:00
Mathieu
6b1a2afc9c docs(@vates/types): document vm.videoram (#9694) 2026-04-16 16:05:28 +02:00
Pierre Donias
3ed8c7b689 feat(netbox): introduce sub-platforms with major version number (#9644)
Fixes #7773
See Zammad#53857

On Netbox >= 4.4 (which introduced platform hierarchies):
- If the major version number is available: create a versioned child platform (e.g. "Debian 12") with the distro-only platform (e.g. "Debian") as its parent, and assign the child to the VM
- Otherwise: keep previous behaviour and assign the VM to the distro-only platform
2026-04-16 10:31:32 +02:00
Mathieu
db9c4815b1 feat(openmetrics): add 9 missing host RRD metrics (#9696) 2026-04-15 19:27:03 +02:00
Mathieu
3e21edad4e chore: upgrade dependencies with known security vulnerabilities (#9640)
* chore: upgrade dependencies with known security vulnerabilities

* Update Node.js version in CI workflow

* chore: upgrade additional security dependencies

* chore: stabilize yarn.lock path-to-regexp entry

* chore: bump minimum version ranges for security-patched dependencies

* chore: update CHANGELOG.unreleased.md with security upgrades

* chore: revert vite upgrade to 6.4.1 (6.4.2 breaks vue-tsc)

* chore: fix vite upgrade and pin vue to 3.5.28
2026-04-15 16:56:43 +02:00
vates-bot
1b12aebb47 feat(web-stack/i18n): update Xen Orchestra 6 (#9649) 2026-04-15 14:43:26 +02:00
p-bo
2e009535aa fix(i18n): shutdown -> reboot in force reboot dialog (as in French) (#9683)
99d19012ad/%40xen-orchestra/web-core/lib/locales/fr.json (L485)
2026-04-15 14:36:13 +02:00
Elise-F
5af32683ce feat(web-core): update default tab functionality (#9651) 2026-04-15 12:40:20 +02:00
Mathieu
0fef765ce6 fix(rest-api): fix memory-leak on subscription (#9707) 2026-04-15 12:18:06 +02:00
Pierre Donias
4508e01644 fix(CHANGELOG): markdown, URLs, labels, ... (#9723) 2026-04-15 11:56:25 +02:00
Pierre Briffaux
f60203a927 fix(web): fix lost focus on vdi name input (#9685) 2026-04-15 11:29:11 +02:00
Mathieu
5ebefb0af2 feat(openmetrics): add VDI disk size and physical usage metrics (#9680)
Fixes XO-1995
2026-04-15 09:52:26 +02:00
Thomas Moraine
fa8844037f docs(xen-orchestra): upgrade docusaurus to v3.10.0 (#9682) 2026-04-15 09:40:19 +02:00
Bastien Nollet
2098c02e7f chore(xo-web): remove unused function (#9691) 2026-04-15 09:37:11 +02:00
Mathieu
c4763d5eec feat(rest-api,mcp): add ?markdown=true output format to REST API (#9624) 2026-04-15 09:30:20 +02:00
Elise-F
0b78cab0b3 fix(web-core): match npm package version (#9721)
web-core 0.49.0 was accidentally published to npm.
This commit bumps the version to match what is already on npm.
2026-04-14 18:18:38 +02:00
Elise-F
bbf3e1e446 feat: release 6.3.3 (#9720) 2026-04-14 17:19:55 +02:00
Elise-F
d94de6090b feat: technical release (#9716) 2026-04-14 15:31:22 +02:00
Bastien Nollet
816b60f11b docs(backups): extra details (#9693) 2026-04-14 14:20:07 +02:00
Florent BEAUCHAMP
6b36b41d68 fix(xo-web): toggle distributed replication (#9715)
introduced by #9433
2026-04-14 11:44:26 +02:00
spacotte-vates
e9caeafd7a fix(bakups): fix merge resume issue when child is disk chain (#9668)
* fix(bakups): fix merge resume issue when child is disk chain
2026-04-14 11:04:23 +02:00
Pierre Brunet
06c3933756 test(backups): add unit test to check #9667 (#9670) 2026-04-13 18:30:17 +02:00
Mathieu
30ff04d88a docs(mcp): add multi-instance configuration (#9705) 2026-04-13 18:25:27 +02:00
Florent BEAUCHAMP
43d86038b1 fix(backups): Fix "Storage_error ([S(Illegal_transition);[[S(Activated);S(RO)];[S(Activated);S(RW)]]])" (#9702)
* don't connect NBD server if we only want the block list
* wait for the disk to be disconnected from dom0 before trying to write into it
2026-04-13 16:46:23 +02:00
Thomas Moraine
415207d1ba docs(intro_backup.md): fix broken youtube embed (#9701) 2026-04-13 10:41:57 +02:00
spacotte-vates
b21e5e8c53 fix(backups): fix vhd directory merge speed regression (#9692)
* fix(backups): fix vhd directory merge speed regression

* add changelog
2026-04-13 09:35:40 +02:00
OlivierFL
aa605639e4 feat(xo-6): network creation forms (#9629) 2026-04-10 17:35:21 +02:00
Elise-F
8ecaa6d817 fix(xo6): increase SSE ping timeout (#9681)
Introduced by #9375
2026-04-09 10:14:54 +02:00
Mathieu
76655a9e05 fix(@xen-orchestra/immutable-backups): fix missing prepublishOnly entry (#9687)
See zammad#51952
2026-04-08 16:12:20 +02:00
Elise-F
58b56a0749 fix(XO6): 'please wait' page not displayed on dev pages (#9686) 2026-04-08 14:41:50 +02:00
Thomas Moraine
99d19012ad docs(xo): replace some XO5 screenshots with the XO6 versions (#9678) 2026-04-07 09:24:38 +02:00
Elise-F
c1e5f2e8d7 feat: release XO 6.3.2 (#9676) 2026-04-03 15:21:41 +02:00
Elise-F
372a2be21d feat: technical release (#9673) 2026-04-03 10:47:59 +02:00
Florent BEAUCHAMP
3acd35142c fix(backups): don't fail incremental replication when the VM has VTPM (#9671) 2026-04-03 10:06:44 +02:00
Florent BEAUCHAMP
28c5e5f138 fix(backups): schedule reference is missing (#9667)
introduced by #9635
2026-04-02 14:47:00 +02:00
Elise-F
a6c508be46 feat(lite): 0.20.0 (#9666) 2026-04-01 16:27:01 +02:00
Elise-F
875ceb7cab feat: release XO 6.3.1 (#9665) 2026-04-01 15:08:02 +02:00
Thomas Moraine
d98dd78168 docs(manage-infrastructure.md): fix formatting errors in info panels (#9664)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-04-01 13:07:04 +02:00
Elise-F
df0c69c023 feat: technical release (#9663) 2026-04-01 10:37:53 +02:00
Mathieu
df7ffbb90c fix(web-core): fix objects store not updated (#9660)
* fix(web-core): fix objects store not updated
2026-04-01 10:10:23 +02:00
Mathieu
8553f1cd08 fix(rest-api): fix no such VDI for /vms/:id/(dashboard|vdis) (#9662)
* fix(rest-api): fix no such VDI for /vms/:id/(dashboard|vdis)
2026-04-01 10:07:10 +02:00
Florent BEAUCHAMP
41de7528bf fix(backups): full when going from 6.2 to 6.3 (#9661) 2026-04-01 09:48:55 +02:00
Elise-F
bfb0a052ef feat: release XO 6.3.0 (#9658) 2026-03-31 16:37:31 +02:00
Elise-F
5bd04b0736 feat: technical release (#9656) 2026-03-31 14:52:50 +02:00
Mathieu
4015987b03 fix(web-core): fix performance issue when lot of new events (#9627) 2026-03-31 12:05:00 +02:00
Elise-F
25db113349 feat: technical release (#9654) 2026-03-31 10:00:56 +02:00
spacotte-vates
b8637097c2 fix(backups): fix double close on merge (#9636)
* fix(backups): fix double close on merge

* set dispose to promise instead of empty func
2026-03-31 09:20:06 +02:00
Mathieu
9226c51df7 fix(rest-api): cannot read properties of undefined for vms/:id/dashboard (#9650)
Fix forum#12031
2026-03-30 20:07:45 +02:00
Joris K
14328ad62a feat(web-core): add stepper component (#9569) 2026-03-30 17:19:29 +02:00
Florent BEAUCHAMP
ac8dfeb7f5 feat(backups): improve incremental replication (#9635) 2026-03-30 17:04:16 +02:00
OlivierFL
bce3c88915 fix(xo-core): wrong translation key on PT locale (#9652)
Introduced by 658de40
2026-03-30 15:55:53 +02:00
DustyArmstrong
658de40f9e fix(web-core): fix some grammar issues on the XO 6 Dashboard (#9647) 2026-03-30 15:31:16 +02:00
Pierre Donias
b413db14e7 fix(xo-server-netbox): avoid mutating TYPES_WITH_UUID constant (#9607) 2026-03-30 15:24:58 +02:00
Enée DI IORIO
e6e5031964 feat(xo6/lite): add bug tools download button (#9419) 2026-03-30 14:35:45 +02:00
Florent BEAUCHAMP
484b9a02a3 docs(openmetrics): add documentation for xo metrics (#9631)
introduced by 9535
2026-03-30 14:34:32 +02:00
OlivierFL
a7829414b0 chore(web-core/query-builder): add query builder documentation (#9541) 2026-03-30 10:56:05 +02:00
vates-bot
39acc6b3a0 feat(web-stack/i18n): update Xen Orchestra 6 (#9554) 2026-03-30 10:55:00 +02:00
Mathieu
465c00b71d fix(xo-stack): return null instead of 0 for every NaN stats (#9634) 2026-03-27 17:26:55 +01:00
Mathieu
6814f6b2ef feat(qa-test): migrate xo-qatest into monorepo as @xen-orchestra/qa-test (#9626)
* feat(qa-test): migrate xo-qatest into monorepo as @xen-orchestra/qa-test
2026-03-27 14:35:03 +01:00
Elise-F
cc4cc1796b feat: technical release (#9646) 2026-03-27 14:22:03 +01:00
Florent BEAUCHAMP
1d81c0b26e fix(xapi): add missing package to changelog (#9645)
introduced by #9596
2026-03-27 13:46:38 +01:00
Elise-F
04a6fb3805 feat: technical release (#9642) 2026-03-27 10:23:50 +01:00
Florent BEAUCHAMP
1302618f90 fix(backup): compute the restore size for incremental restore (#9632)
introduced by #8432
2026-03-26 11:48:44 +01:00
Elise-F
7a39e45ffb feat(xo6): add please wait page for first connection in xo server (#9568) 2026-03-26 11:27:16 +01:00
Florent BEAUCHAMP
4bbfa77ec8 feat(xo5/sr): add preferred-image-format field (#9596) 2026-03-26 10:43:52 +01:00
Elise-F
57414dafce feat(xo6): implement vm snapshot tab page (#9521) 2026-03-26 10:03:19 +01:00
Elise-F
e28e768c72 Fix(xo6): remove scrollbar from console tab (#9609) 2026-03-26 09:14:24 +01:00
Joris K
8e5809a675 feat(xo6): add backup replication on vm dashboard (#9545) 2026-03-25 14:55:54 +01:00
Mathieu
56b17ce7f6 feat(rest-api): expose create internal and bonded network (#9611) 2026-03-25 11:25:06 +01:00
Mathieu
27a119f037 refactor(mcp): split monolithic index.mts into modular file structure (#9621)
XO-2221
2026-03-25 11:14:48 +01:00
Elise-F
60ba5070c9 fix(XO6): update watch to avoid reset template select in new VM (#9603) 2026-03-24 17:33:01 +01:00
Florent BEAUCHAMP
0efa078190 fix(backups): vhd directory have empty names (#9633) 2026-03-24 17:05:49 +01:00
Mathieu
c3dcb8fd50 feat(mcp): add list_srs and get_sr_details tools for SR (#9599)
* feat(mcp): add list_srs and get_sr_details tools for storage
repositories

* docs(mcp): add list_srs and get_sr_details to documentation

* fix(mcp): extract expected tool names into a constant in tests

* docs(mcp): add link to live filter syntax documentation for all filter
parameters
2026-03-24 16:39:58 +01:00
Florent BEAUCHAMP
0286f7786a fix(immutable-backups): decrease resource consumption, add tests and typing (#9548) 2026-03-24 16:00:01 +01:00
Florent BEAUCHAMP
f2b1931408 feat(disk-cli): implement disk cli tooling (#9551)
* feat(disk-cli): implement disk cli tooling

* changelog

* fix test path

* fix following review

* fix following rebase

* ci

* fix following rebase

* more tests
2026-03-24 14:35:05 +01:00
Florent BEAUCHAMP
f546849494 feat(xo-server): use index for getAllUnhealthyVdiChainsLength (#9622) 2026-03-24 10:37:18 +01:00
Florent BEAUCHAMP
6ae70a424a fix(backups): correctly rename replicated VMs (#9606) 2026-03-24 09:16:43 +01:00
Florent BEAUCHAMP
41751a8599 fix(backups): use the snapshot of the source (#9612)
* fix(backups): use the snapshot of the source

introduced by 9524

use only the vdi from the source VM as the source on backup
this bug could only occurs if the replication was intra pool, if
not the different xapi was filtering the results
2026-03-24 09:16:16 +01:00
Joris K
7ea8640ad0 fix(xo6/lite): fixed duplicated ip addresses in vm networks tab (#9547) 2026-03-23 16:29:41 +01:00
Enée DI IORIO
2994761178 fix(xo-lite/header): Fix menu button on large screen (#9558) 2026-03-23 10:12:56 +01:00
Mathieu
d1736e01f7 feat(mcp): add list_networks and get_network_details tools (#9595) 2026-03-20 09:38:59 +01:00
Mathieu
a2e3343b6e fix(rest-api/dashboard): exlude ISO SRs from dashboard (#9608) 2026-03-19 14:07:54 +01:00
Bastien Nollet
e325c6af51 docs(load-balancer): add info on migration cooldown (#9604) 2026-03-18 10:09:40 +01:00
Florent BEAUCHAMP
598ab85446 feat(vmware-explorer): make V2V import cancellable (#9552) 2026-03-17 18:17:08 +01:00
Pierre Brunet
1591749a23 fix(fs/s3): add check before using DeleteObjectsCommand (#9598)
fallback to delete object by object on multiple delete error  to handle backends that can't handle this command
2026-03-16 16:56:33 +01:00
Mathieu
800fd97996 feat(openmetrics): add missing VBD and DCMI metrics (#9563)
XO-1491
2026-03-16 11:37:19 +01:00
Pierre Brunet
45aee36ae7 typescript: Add types in several packages (#9583)
* feat: add types for fs (#9515)

* feat: add types for fs

* fix: update from review

* fix: update devDep

* feat: add types from @xen-orchestra/fs/path

* feat: export types based on async-each

* fix: remove ts-expect-error because async-each is now typed

* fix: export backups types and packages

* fix: update tsconfig async-each and fix types in MergeRemoteDisk

* fix: extractIdsFromSimplePattern from backups

* fix: package.json

* feat: export vhd-lib types

* feat(disks): export types

* fix(backups/disks): make params optional

* fix(backups/disks): small update in jsdoc

* bump: update yarn.lock

* fix: update vhd-lib exports

* fix(vhd-lib): update package exports

* fix: revert to previous import

* fix: new approach without exports

* fix: revert types except for asyncEach, openDisk

* fix(disks): import in vhd-lib

* fix(remoteVhdDisk): update typedef
2026-03-16 10:55:18 +01:00
Mathieu
2aff87d6d6 feat(mcp): add list_vdis tool to list virtual disks (#9559) 2026-03-13 15:54:40 +01:00
Mathieu
449e79de21 feat(rest-api): add disable/enable action endpoints for hosts (#9532) 2026-03-13 10:03:56 +01:00
MlssFrncJrg
9d657ac7cc feat(@xen-orchestra/rest-api): expose forget SR (#9505) 2026-03-12 16:12:43 +01:00
Enée DI IORIO
f4cc40233c fix(xo6/backups): backupLog transfer size calculation (#9496) 2026-03-12 15:09:19 +01:00
MlssFrncJrg
2cd5ba0319 feat(@xen-orchestra/rest-api): expose vms/:id/actions/clone (#9453) 2026-03-11 15:58:18 +01:00
Mathieu
2444861082 fix(rest-api/xo6): cpu at VM creation was not working (#9591)
* fix(rest-api/xo6): cpu at VM creation was not working

* changelog
2026-03-11 15:07:56 +01:00
Florent BEAUCHAMP
7ddff121de feat(fs): make part size configurable for s3 (#9561)
from https://xcp-ng.org/forum/topic/10587/s3-backup-maximum-number-of-parts/
2026-03-11 09:54:08 +01:00
Florent BEAUCHAMP
c1e9b83adc feat(backups): symetrical backups (#9524) 2026-03-11 09:34:09 +01:00
Mathieu
18d9b33c87 feat(mcp): support token authentication via XO_TOKEN (#9577) 2026-03-11 09:20:40 +01:00
Bastien Nollet
673e729afe fix(xo-server): incorrect import (#9589) 2026-03-10 19:25:55 +01:00
Florent BEAUCHAMP
4e0655dc3d fix(backups): better handling of generator cleanup (#9557) 2026-03-10 15:55:59 +01:00
bleader
a78870b473 docs(sdn-controller): mention xapi plugin is in BETA (#9556) 2026-03-10 15:42:16 +01:00
Thomas Moraine
0bf309a4ad docs(sdn_controller.md): clarify requirements for regenerating certificates (#9584) 2026-03-10 09:54:01 +01:00
spacotte-vates
5bdd76a955 feat: release 6.2.2 (#9579) 2026-03-09 14:41:09 +01:00
Pierre Brunet
e11530ea7c feat: technical release (#9576) 2026-03-09 10:10:36 +01:00
Florent BEAUCHAMP
c70f911b5e fix(changelog): remove package from release list (#9575)
introduced during rebase by #9572
2026-03-09 09:56:22 +01:00
Mathieu
82f047ef9d fix(xo-server): proxy issue if hostname defined (#9572)
fix #9500
2026-03-09 09:49:24 +01:00
Mathieu
57aec1ab2d fix(xo-server): race condition on XOA WebSocket during plugin loading (#9402) 2026-03-09 09:42:53 +01:00
Pierre Donias
385fcf3810 fix(xo-server/vm): allocate resource set quotas when not using DMC (#9566)
When editing VM RAM, the high-level `memory` field dispatches to `memoryMax` or
`_memory` depending on whether the VM uses DMC. Both paths need `limitName:
'memory'` on their spec so the resource set quota counter is correctly updated,
but `_memory` was missing it, causing memory resizes on non-DMC VMs to silently
create a stale `_memory` counter instead of updating the actual memory quota.
2026-03-09 09:37:24 +01:00
Florent BEAUCHAMP
6699bd1f5d fix(backups): remove snapshot of vm with cdrommounted (#9571) 2026-03-06 17:28:50 +01:00
Pierre Brunet
844c07e166 feat: technical release (#9570) 2026-03-06 14:49:29 +01:00
spacotte-vates
7676978c78 fix(backups): fix out of range error on merge (#9567)
Fixes out of range error during merge that was happening on delta backups when the child disk was bigger than the parent disk but no blocks where written in the "new" space.

Also added a test for this error.
2026-03-06 14:17:17 +01:00
Elise-F
7054fefdbd Style(xo6): update color for status paused VM icon (#9562) 2026-03-05 11:21:06 +01:00
Enée DI IORIO
acd84649ed feat(xo6): add backupRuns and BackupArchives card on vm dashboard (#9303) 2026-03-05 10:32:07 +01:00
Florent BEAUCHAMP
7460bf93d1 feat(openmetrics): add XO related metrics (#9535) 2026-03-04 10:30:20 +01:00
Mathieu
61e4a77cab fix(openmetrics): convert latency from µs to seconds instead of ms (#9550) 2026-03-03 10:01:56 +01:00
vates-bot
55130311f3 feat(web-stack/i18n): update Xen Orchestra 6 (#9531) 2026-03-03 09:18:35 +01:00
Florent BEAUCHAMP
f0cbd1bd72 docs(backup): document distributed backups (#9528)
* docs(backup): document distributed backups

* Apply suggestions from MPT code review

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>

---------

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>
2026-03-03 09:11:36 +01:00
Enée DI IORIO
561e2349e9 feat(xo6-lite/dashboard): add reactivity on resize (#9378) 2026-03-02 14:59:51 +01:00
Enée DI IORIO
7c83ed63f1 fix(web-core/treeItemLabel): fix treeview tooltip overlap (#9319) 2026-03-02 10:41:26 +01:00
Enée DI IORIO
9525abdee5 feat(xo6/Lite): add secureBoot support (#9423) 2026-02-27 15:09:49 +01:00
spacotte-vates
c6172b7b74 feat: release 6.2.1 (#9546) 2026-02-27 14:42:27 +01:00
Pierre Brunet
1b865bbf89 feat: technical release (#9544)
* feat(@xen-orchestra/backups): 0.69.2

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

* feat(xo-server): 5.197.3

* chore(CHANGELOG): update next

* fix: update changelog
2026-02-27 11:56:10 +01:00
spacotte-vates
815f4be76c fix(backups): changelog (#9543) 2026-02-27 11:44:13 +01:00
spacotte-vates
53f5c63fa2 fix(backups): race condition on merge (#9542) 2026-02-27 11:29:42 +01:00
spacotte-vates
4ffd1b4e13 feat(lite): 0.19.0 (#9540) 2026-02-26 18:29:55 +01:00
spacotte-vates
d25d15efd7 feat: release 6.2.0 (#9539) 2026-02-26 17:46:07 +01:00
Pierre Brunet
322205d203 feat: technical release (#9537) 2026-02-26 14:57:42 +01:00
Mathieu
89494457a1 fix(mcp): use bin wrapper to fix npx permission denied (#9536)
Fix npx @xen-orchestra/mcp failing with "Permission denied".

tsc does not preserve the executable bit on compiled files, so bin pointing to ./dist/index.mjs (mode 644) caused the error. This adds a bin/xo-mcp.mjs wrapper tracked by git with mode 755, following the same pattern as other packages (@xen-orchestra/xapi, @xen-orchestra/proxy, etc.).
2026-02-26 13:34:08 +01:00
Pierre Briffaux
6bc2844092 feat(xo6/new-vm): add custom user-config to new VM creation (#9427) 2026-02-26 13:15:30 +01:00
Mathieu
53c15bc9e4 docs(mcp) : documentation (#9529)
docs(mcp): add README and Docusaurus documentation page
2026-02-25 17:19:38 +01:00
Pierre Brunet
1dd8c1603f Release (#9534) 2026-02-25 16:19:54 +01:00
Mathieu
52513fc898 feat(xo-server): add transient option to host.disable (#9503)
Add optional transient parameter (boolean) to host.disable API endpoint.

When transient=true, passes auto_enable=false to XAPI, keeping the host disabled across reboots and toolstack restarts (requires XAPI >= 25.31.0). Default behavior unchanged (backward compatible).

XAPI 25.31.0 introduced a new auto_enable parameter to Host.disable (xen-api#6652). When set to false, the host stays disabled regardless of toolstack restarts, host reboots, or consider_enabling_host calls. Only an explicit Host.enable can re-activate it.
2026-02-25 15:54:26 +01:00
spacotte-vates
ed508ec04f feat: technical release (#9533)
* feat(@xen-orchestra/backups): 0.69.1

* feat(complex-matcher): 1.1.0

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

* feat(@xen-orchestra/mcp): 1.0.0

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

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

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

* feat(xo-server): 5.197.1

* feat(xo-web): 5.194.0

* chore(CHANGELOG): update next

---------

Co-authored-by: Pierre Brunet <pierre.brunet@vates.tech>
Co-authored-by: Pierre Donias <pierre.donias@gmail.com>
2026-02-25 15:39:25 +01:00
MathieuRA
21823bf430 feat: add rest-api debug 2026-02-25 14:13:30 +01:00
Florent BEAUCHAMP
e36e1012e2 fix(xo-web): reducing polling for patches and license 2026-02-25 14:13:30 +01:00
Elise-F
3fcf582087 feat(new VM): add ssh key to vm creation form (#9413) 2026-02-25 10:24:29 +01:00
vates-bot
f1389cff6e feat(web-stack/i18n): update Xen Orchestra 6 (#9462) 2026-02-25 09:39:32 +01:00
Mathieu
438a7cc746 feat(mcp): add @xen-orchestra/mcp package (#9519)
This PR adds the new @xen-orchestra/mcp package — an MCP (Model Context Protocol) server that enables AI assistants (Claude Desktop, Claude Code, or any MCP-compatible client) to query a Xen Orchestra infrastructure in read-only mode.
2026-02-24 17:10:32 +01:00
Florent BEAUCHAMP
31e2ff1b02 feat(backups): only check size for full backups (#9527)
The size is computed from the blocksize * nb allocated blocks during the backups
which is not aligned with the real size on disk
Checking the file on disk is too costly for vhd directory
This will be eventually reimplemented by the next cleanVm rework
2026-02-24 17:04:58 +01:00
Thomas Moraine
44a23fb33f docs(sdn_controller.md): document openssl 3 + sdn upgrade path (#9525)
This pull request documents the manual steps that will be needed for the SDN controller, with the incoming March 2026 update of XCP-ng that will include OpenSSL3.
2026-02-24 13:21:26 +01:00
Pierre Donias
f8501c1a1e feat(xo-web/patches): warn about updating XOSTOR before installing patches (#9517) 2026-02-24 12:07:44 +01:00
spacotte-vates
6cab502260 feat(rest-api): sr scan route (#9514) 2026-02-24 11:05:18 +01:00
Pierre Donias
157e7fe757 feat(xo-web/licenses): display bundle name next to license name (#9512) 2026-02-23 16:20:46 +01:00
OlivierFL
0866a5db81 feat(xo-stack): implement Query Builder (#9488) 2026-02-23 15:35:23 +01:00
Pierre Donias
1c245acca5 feat(xo-web/network): reconfigure management PIF (#9510) 2026-02-23 14:36:18 +01:00
spacotte-vates
58c2c518a3 feat: technical release (#9523)
* feat(@xen-orchestra/fs): 4.6.7

* feat(@vates/types): 1.20.0

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

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

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

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

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

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

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

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

* feat(xo-server): 5.197.0

* feat(xo-server-load-balancer): 0.12.0

* feat(xo-server-netbox): 1.11.0

* feat(xo-server-openmetrics): 1.2.0

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

* feat(xo-web): 5.193.0

* chore(CHANGELOG): update next

---------

Co-authored-by: Pierre Brunet <pierre.brunet@vates.tech>
2026-02-23 10:23:57 +01:00
Elise-F
755ac108f2 feat(xo6): adds a banner when access to the xo server fails (#9375) 2026-02-20 17:12:29 +01:00
Bastien Nollet
f88c0e8be2 feat(rest-api): expose POST vdis (#9492) 2026-02-20 16:24:18 +01:00
Philippe Coval
109e37695c fix(sdn-controller): Support openssl-3 for ca/cert generation (#9507)
OpenSSL-3 (on xcp-ng) requires CA set to true in basicConstraints.

"All CAs should have the CA flag set to true."

For context, here are additional resources:

- https://docs.openssl.org/3.0/man1/openssl-verification-options/#basic-constraints
- https://docs.openssl.org/1.0.2/man1/x509v3_config/#basic-constraints
- https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.9

To connect to OpenSSL 3, certificates should be re-generated if they
already exist (just remove xo-server/data/sdn-controller).

Renewed certs were tested on XCP-ng host on "OpenSSL 1.0.2k-fips"
without regression, so it is safe to assume those ext are designed to
be backward compatible.

Observed issue:

    openssl verify \
      -CAfile .../xo-server/data/sdn-controller/ca-cert.pem \
      .../xo-server/data/sdn-controller/client-cert.pem

    C=XX, L=Default City, O=Default Company LTD
    error 79 at 1 depth lookup: invalid CA certificate
    error .../xo-server/data/sdn-controller/client-cert.pem: \
      verification failed

The expected output is now:

    .../xo-server/data/sdn-controller/client-cert.pem: OK

The expected output is now:

    .../xo-server/data/sdn-controller/client-cert.pem: OK

It was tested along ovsdb-server (Open vSwitch) 2.17.7 :

    openssl s_client \
      -connect  10.1.75.62:6640 \
      -cert .../xo-server/data/sdn-controller/client-cert.pem \
      -key .../xo-server/data/sdn-controller/client-key.pem

    (...)
    CONNECTED(00000003)
    Can't use SSL_get_servername
    (...)
    verify error:num=18:self-signed certificate
    (...)
    SSL handshake has read 2713 bytes and written 3799 bytes
    Verification error: self-signed certificate
    (...)
    New, TLSv1.2, Cipher is AES128-GCM-SHA256
    (...)
    {"id":"echo","method":"echo","params":[]}

Extra info at:

- https://xapi-project.github.io/new-docs/design/pool-certificates/

Origin: https://github.com/vatesfr/xen-orchestra/pull/9507
Relate-to: XCPNG-2710
Relate-to: XO-1986
Signed-off-by: Philippe Coval <philippe.coval@vates.tech>
2026-02-20 09:34:13 +01:00
spacotte-vates
a4b480de4a feat(rest-api): add relaim_space action to sr (#9486) 2026-02-19 17:26:12 +01:00
spacotte-vates
db8addbc45 fix(backups): fix RemoteVhdDisk & DiskChain parent and directory issue (#9508)
* fix(backups): fix RemoteVhdDisk & DiskChain parent and directory issue
2026-02-18 14:06:05 +01:00
Sebastien Marie
9dbc090116 fix(xo-server-sdn-controller): communicate with enabled hosts only (#9442)
permit to have working traffic rules when a pool is in a 'degraded' mode (for
example, one host temporary down or unreachable).

currently, exceptions occurs and nothing happens (no rules added or deleted).
with it, unreachable hosts are ignored and reachable hosts properly updated.
2026-02-17 13:31:44 +01:00
OlivierFL
353678e754 feat(xo-6/internal-links): normalize internal links (#9276) 2026-02-17 08:20:01 +01:00
Florent BEAUCHAMP
a313986694 feat(backups): implements distributed backup and replications (#9433)
The deletion of old backups is done globally
For Full backups/replication:
 * retention is computed on the total of the target
 * choose the storage with the most free size ( if possible, since some backup repositories don't have any size used/ free )
 * one vm is written on the same target ( xva, index, checksum )
 * healthcheck ( if enabled ) will run only on the target that received the data
 * the cleanup of old backups/replication is done on the right target ( but considering the full lenght across all the targets )

For incremental: 
 * delta chain are continued on the same target 
 
For mirror : 
if a vm has multiple backups to transfer during the same run, then
they will all go to teh same remote
 
UX : 
* show a toggle on remote/sr selectors
* toggle is disabled if 1 or less targets are selected
* for backup and mirror backup forms
* show the details in the job list
  

More details on the PR page
2026-02-16 18:17:03 +01:00
Florent BEAUCHAMP
983ac2d2c9 docs(config): explain the config files priorities (#9463) 2026-02-16 18:11:50 +01:00
spacotte-vates
4e50858526 feat(@xen-orchestra/backups): refactor backup merge code (#9300)
this merge code leverage the new classes introduced to handle qcow2, improving our flexibility, and future uses.

this also improve the performance on "block mode" backups (s3/ azure and other storage with the option), since it reliably updates the block allocation table only at the end of the merge instead of each time a new block is detected
2026-02-16 18:04:12 +01:00
Mathieu
c497ae3d38 fix(web): use the store's object type and not type from vates/types (#9421) 2026-02-16 17:01:26 +01:00
spacotte-vates
9d9b5d5e33 feat(rest-api): add plug and unplug pbd routes to rest api (#9477) 2026-02-16 16:52:04 +01:00
OlivierFL
5834070e77 fix(xo-6/xo-lite): missing required prop for UiRadioButtonGroup in new VM creation form (#9504) 2026-02-16 16:50:40 +01:00
Enée DI IORIO
5c5f27f92b fix(xo-stack): fix VM creation on small screen (#9344) 2026-02-16 16:16:11 +01:00
Cyrille G.
8fc04bc03b fix(hub/recipe): remove DHCP option for the Kubernetes recipe (#9454) 2026-02-16 10:20:11 +01:00
Pierre Brunet
87135c7469 feat: add qcow2 support in vdi creation (#9493)
Add qcow2 image-format when size is higher than 2TB. It will still be a VHD if PBD.preferred-image-formats do not list qcow2
2026-02-16 09:56:08 +01:00
Mathieu
fb030a590e docs(openmetrics): update documentation for host uptime, host status and is_control_domain (#9497)
docs(openmetrics): update documentation for host uptime, host status and
is_control_domain
2026-02-16 09:55:02 +01:00
Florent BEAUCHAMP
0be2392519 feat(xo-server/V2V): automatically take a snapshot if needed (#9471) 2026-02-12 17:07:56 +01:00
Florent BEAUCHAMP
546d42427c feat(xo-web): remember V2V connections settings (#9490) 2026-02-12 15:42:47 +01:00
Mathieu
fc86fda13d fix(rest-api): exclude removable storage from top 5 SRs usage (#9495) 2026-02-12 14:04:12 +01:00
Elise-F
b44bed6d10 feat(web-core): new ui rich radio button component created (#9198) 2026-02-12 11:53:35 +01:00
1209 changed files with 64211 additions and 11677 deletions

View File

@@ -251,6 +251,9 @@ module.exports = {
'ja',
'fi',
'pl',
'zh-Hans',
'sk',
'pt',
],
},
],
@@ -278,7 +281,7 @@ module.exports = {
},
},
{
files: ['@xen-orchestra/{web-core,lite,web}/typed-router.d.ts'],
files: ['@xen-orchestra/{web-core,lite,web}/src/route-map.d.ts'],
parserOptions: {
sourceType: 'module',
},
@@ -287,6 +290,17 @@ module.exports = {
'eslint-comments/no-unlimited-disable': 'off',
},
},
{
files: ['*.d.ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
},
rules: {
'n/no-unsupported-features/es-syntax': 'off',
'no-unused-vars': 'off',
},
},
],
ignorePatterns: ['@vates/fatfs/'],

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node environment
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '>=22.12'
cache: 'yarn'
- name: Install project dependencies

5
@vates/async-each/index.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export function asyncEach<Item>(
iterable: Iterable<Item> | AsyncIterable<Item>,
iteratee: (item: Item, index: number, iterable: Iterable<Item> | AsyncIterable<Item>) => Promise<void>,
opts?: { concurrency?: number; signal?: AbortSignal; stopOnError?: boolean }
): Promise<void>

View File

@@ -67,20 +67,17 @@ describe('asyncEach', () => {
})
it('stops on first error when stopOnError is true', async () => {
const tracker = new assert.CallTracker()
const error = new Error()
const iteratee = tracker.calls((_, i) => {
const iteratee = spy((_, i) => {
if (i === 1) {
throw error
}
}, 2)
})
assert.deepStrictEqual(
await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true })),
error
)
tracker.verify()
assert.deepStrictEqual(iteratee.callCount, 2)
})
it('rejects AggregateError when stopOnError is false', async () => {
@@ -100,19 +97,16 @@ describe('asyncEach', () => {
})
it('can be interrupted with an AbortSignal', async () => {
const tracker = new assert.CallTracker()
const ac = new AbortController()
const iteratee = tracker.calls((_, i) => {
const iteratee = spy((_, i) => {
if (i === 1) {
ac.abort()
}
}, 2)
})
await assert.rejects(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal }), {
message: 'asyncEach aborted',
})
tracker.verify()
assert.deepStrictEqual(iteratee.callCount, 2)
})
it('handle error on iterable', async () => {

View File

@@ -24,7 +24,8 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.0.1",
"types": "index.d.ts",
"version": "1.0.3",
"engines": {
"node": ">=8.10"
},

View File

@@ -24,8 +24,8 @@
},
"dependencies": {
"@vates/multi-key-map": "^0.2.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/async-map": "^0.1.3",
"@xen-orchestra/log": "^0.7.2",
"ensure-array": "^1.0.0"
},
"devDependencies": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/generator-toolbox",
"version": "1.1.0",
"version": "1.1.2",
"main": "dist/index.mjs",
"license": "MIT",
"private": false,

View File

@@ -144,14 +144,7 @@ class Forked<T, TReturn, TNext> implements AsyncGenerator<T, TReturn, TNext> {
return this.#parent.remove(this.#uid, e)
}
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
while (true) {
const res = await this.next()
if (res.done) {
break
}
yield res.value
}
[Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext> {
return this
}
}

View File

@@ -2,8 +2,7 @@ import assert from 'node:assert'
import { suite, test } from 'node:test'
import { Synchronized } from './synchronized.mjs'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (val: unknown) => {}) {
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (_val: unknown) => {}) {
for (let i = 0; i < end; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
@@ -17,7 +16,7 @@ async function consume(
iterable: AsyncGenerator,
delay = 2500,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onConsumed = (val: unknown, iterable: AsyncGenerator) => Promise.resolve(false)
onConsumed = (_val: unknown, _iterable: AsyncGenerator) => Promise.resolve(false)
) {
for await (const val of iterable) {
await new Promise(resolve => setTimeout(resolve, delay))
@@ -28,6 +27,30 @@ async function consume(
}
suite('success', () => {
test('for-await-of break stops the source generator', async () => {
let sourceClosed = false
const source = (async function* () {
try {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
}
} finally {
sourceClosed = true
}
})()
const forker = new Synchronized(source)
const fork = forker.fork('first')
// for-await-of must call fork.return() on break, which propagates to the source
for await (const val of fork) {
if (val === 2) break
}
assert.strictEqual(sourceClosed, true)
})
test('if works with multiple consumer', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(3, progress)

View File

@@ -1,4 +1,7 @@
import assert from 'node:assert'
const TIMEOUT = Symbol('timeout')
export class Timeout<T> implements AsyncGenerator {
#source: AsyncGenerator<T>
#timeout: number
@@ -8,21 +11,30 @@ export class Timeout<T> implements AsyncGenerator {
this.#timeout = timeout
}
async next(): Promise<IteratorResult<T>> {
let timeout: ReturnType<typeof setTimeout>
const promiseTimeout = new Promise((_, reject) => {
timeout = setTimeout(() => {
reject(new Error('Timeout reached '))
}, this.#timeout)
})
const promiseNext = new Promise((resolve, reject) => {
this.#source.next().then(res => {
// ensure timetout won't fire later
clearTimeout(timeout)
resolve(res)
}, reject)
})
// promiseTimeout will never resolve
return Promise.race([promiseNext, promiseTimeout]) as Promise<IteratorResult<T>>
let timeoutHandle: ReturnType<typeof setTimeout>
const sourceNext = this.#source.next()
const result = await Promise.race([
sourceNext.then(res => {
clearTimeout(timeoutHandle)
return res
}),
new Promise<typeof TIMEOUT>(resolve => {
timeoutHandle = setTimeout(() => resolve(TIMEOUT), this.#timeout)
}),
])
if (result === TIMEOUT) {
// stop the source once the in-flight next settles to avoid data loss on the next call
// we don't want to await for the end of timeouted code
sourceNext.then(
() => this.#source.return(undefined).catch(() => {}),
() => {} // source already errored, nothing to clean up
)
throw new Error('Timeout reached')
}
return result
}
return(): Promise<IteratorResult<T>> {
return this.#source.return(undefined)

View File

@@ -64,4 +64,24 @@ describe('Timeout class', () => {
await assert.rejects(timeout.throw(new Error('Test error')), 'Expected error was not thrown')
})
it('should close the source generator after a timeout', async () => {
let sourceClosed = false
const slowSource = (async function* () {
try {
// resolves after 100ms so the in-flight next() eventually settles
await new Promise(resolve => setTimeout(resolve, 100))
yield 1
} finally {
sourceClosed = true
}
})()
const timeout = new Timeout(slowSource, 10) // fires before source yields
await assert.rejects(() => timeout.next(), /Timeout reached/)
// wait for the in-flight next() to settle so source.return() can be called
await new Promise(resolve => setTimeout(resolve, 200))
assert.strictEqual(sourceClosed, true)
})
})

View File

@@ -0,0 +1,65 @@
### `create([opts], [requestListener])`
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
```js
import { create } from '@vates/http-server-plus'
const server = create(function onRequest(req, res) {
res.end('Hello')
})
```
### `server.listen(opts)`
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
```js
// HTTP on a specific port
await server.listen({ port: 8080 })
// HTTPS — detected automatically when cert+key are provided
await server.listen({
port: 443,
cert: fs.readFileSync('certificate.pem'),
key: fs.readFileSync('private_key.pem'),
})
// Unix socket
await server.listen({ socket: '/var/run/http.sock' })
// File descriptor
await server.listen({ fd: 3 })
// Systemd socket activation
await server.listen({ systemdSocket: 0 })
```
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
Multiple endpoints can be started on the same instance:
```js
const [httpAddr, httpsAddr] = await Promise.all([server.listen({ port: 80 }), server.listen({ port: 443, cert, key })])
console.log('listening on', httpAddr, httpsAddr)
```
### HTTP/2
Pass a custom `createSecureServer` factory to enable HTTP/2:
```js
import { createSecureServer } from 'node:http2'
import { create } from '@vates/http-server-plus'
const server = create({ createSecureServer }, app)
await server.listen({ port: 443, cert, key })
```
### `server.close()`
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
```js
await server.close()
```

View File

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

View File

@@ -0,0 +1,91 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/http-server-plus
> Augmented &#x60;http.Server&#x60;, HTTP/HTTPS/HTTP2 and multiple ports on the same instance
## Usage
### `create([opts], [requestListener])`
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
```js
import { create } from '@vates/http-server-plus'
const server = create(function onRequest(req, res) {
res.end('Hello')
})
```
### `server.listen(opts)`
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
```js
// HTTP on a specific port
await server.listen({ port: 8080 })
// HTTPS — detected automatically when cert+key are provided
await server.listen({
port: 443,
cert: fs.readFileSync('certificate.pem'),
key: fs.readFileSync('private_key.pem'),
})
// Unix socket
await server.listen({ socket: '/var/run/http.sock' })
// File descriptor
await server.listen({ fd: 3 })
// Systemd socket activation
await server.listen({ systemdSocket: 0 })
```
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
Multiple endpoints can be started on the same instance:
```js
const [httpAddr, httpsAddr] = await Promise.all([
server.listen({ port: 80 }),
server.listen({ port: 443, cert, key }),
])
console.log('listening on', httpAddr, httpsAddr)
```
### HTTP/2
Pass a custom `createSecureServer` factory to enable HTTP/2:
```js
import { createSecureServer } from 'node:http2'
import { create } from '@vates/http-server-plus'
const server = create({ createSecureServer }, app)
await server.listen({ port: 443, cert, key })
```
### `server.close()`
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
```js
await server.close()
```
## 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,268 @@
'use strict'
// ===================================================================
const EventEmitter = require('events').EventEmitter
const formatUrl = require('url').format
const fromEvent = require('promise-toolbox/fromEvent')
const inherits = require('util').inherits
const resolvePath = require('path').resolve
// ===================================================================
const forwardedEvents = [
'checkContinue',
'checkExpectation',
'clientError',
'connect',
'connection',
'listening',
'request',
'secureConnection',
'upgrade',
]
function extractProperty(obj, prop) {
const value = obj[prop]
if (value !== undefined) {
delete obj[prop]
return value
}
}
function forOwn(obj, cb) {
const keys = Object.keys(obj)
for (let i = 0, n = keys.length; i < n; ++i) {
cb(obj[keys[i]])
}
}
function getSystemdFd(index) {
if (process.pid !== +process.env.LISTEN_PID) {
throw new Error('systemd sockets are not meant for us (LISTEN_PID)')
}
const listenFds = process.env.LISTEN_FDS
if (listenFds == null) {
throw new Error('no systemd sockets found (LISTEN_FDS)')
}
const max = listenFds - 1
if (index > max) {
throw new Error('no such systemd socket found')
}
return index + 3 // 3 = SD_LISTEN_FDS_START
}
const isEmpty = obj => Object.keys(obj).length === 0
// ===================================================================
function onNewListener(event, listener) {
if (forwardedEvents.includes(event)) {
forOwn(this._servers, server => {
server.on(event, listener)
})
}
}
function onRemoveListener(event, listener) {
if (forwardedEvents.includes(event)) {
forOwn(this._servers, server => {
server.off(event, listener)
})
}
}
function Server(opts) {
EventEmitter.call(this)
this._createServer = (opts != null && opts.createServer) || require('http').createServer
this._createSecureServer = (opts != null && opts.createSecureServer) || require('https').createServer
this._servers = Object.create(null)
this._niceAddresses = Object.create(null)
this.on('removeListener', onRemoveListener).on('newListener', onNewListener)
}
inherits(Server, EventEmitter)
const proto = Server.prototype
function getAddress(serverId) {
return this[serverId].address()
}
proto.addresses = function Server$addresses() {
const servers = this._servers
return Object.keys(servers).map(getAddress, servers)
}
function getNiceAddress(serverId) {
return this[serverId]
}
proto.niceAddresses = function Server$niceAddresses() {
const niceAddresses = this._niceAddresses
return Object.keys(niceAddresses).map(getNiceAddress, niceAddresses)
}
function close(server) {
server.close()
}
proto.close = function Server$close(callback) {
if (callback) {
this.once('close', callback)
}
// Emit the close event even if there are no registered servers.
if (isEmpty(this._servers)) {
const self = this
setImmediate(function () {
self.emit('close')
})
return Promise.resolve()
}
// Closes each servers.
forOwn(this._servers, close)
return fromEvent(this, 'close')
}
let nextId = 0
proto.listen = function Server$listen(opts) {
opts = Object.assign({}, opts)
if (opts.hostname === 'localhost') {
return Promise.all([
this.listen(Object.assign(opts, { hostname: '::1' })),
this.listen(Object.assign(opts, { hostname: '127.0.0.1' })),
]).then(addresses => addresses[0])
}
const hostname = extractProperty(opts, 'hostname')
let port = extractProperty(opts, 'port')
const systemdSocket = extractProperty(opts, 'systemdSocket')
let fd = extractProperty(opts, 'fd')
let socket = extractProperty(opts, 'socket')
const servers = this._servers
const niceAddresses = this._niceAddresses
let server, protocol
if (opts.pfx || opts.SNICallback || (opts.cert && opts.key)) {
server = this._createSecureServer(opts)
protocol = 'https'
} else {
server = this._createServer(opts)
protocol = 'http'
}
const id = nextId++
servers[id] = server
if (systemdSocket != null) {
fd = getSystemdFd(systemdSocket)
}
// Compute a temporary nice address to display in case of error.
let niceAddress
if (fd != null) {
server.listen({ fd })
niceAddress = protocol + '://<fd:' + fd + '>'
} else if (socket != null) {
socket = resolvePath(socket)
server.listen(socket)
niceAddress = protocol + '://' + socket
} else {
if (port == null) {
port = protocol === 'https' ? 443 : 80
}
server.listen(port, hostname)
niceAddress = formatUrl({
protocol,
// Hostname default to localhost.
hostname: hostname || 'localhost',
// No port means random, unknown for now.
port: port || '<unknown>',
})
}
const emit = this.emit.bind(this)
server.once('close', function onClose() {
delete servers[id]
delete niceAddresses[id]
if (isEmpty(servers)) {
emit('close')
}
})
server.on('error', function onError() {
delete servers[id]
// FIXME: Should it be forwarded and be fatal if there are no
// listeners?
})
forwardedEvents.forEach(event => {
this.listeners(event).forEach(listener => {
server.on(event, listener)
})
})
return fromEvent(server, 'listening').then(
function () {
const address = server.address()
const niceAddress =
typeof address === 'string'
? protocol + '://' + address
: formatUrl({
protocol,
hostname: address.address,
port: address.port,
})
niceAddresses[id] = niceAddress
return niceAddress
},
function (error) {
error.niceAddress = niceAddress
throw error
}
)
}
function ref(server) {
server.ref()
}
proto.ref = function Server$ref() {
forOwn(this._servers, ref)
}
function unref(server) {
server.unref()
}
proto.unref = function Server$unref() {
forOwn(this._servers, unref)
}
// ===================================================================
module.exports = exports = Server
exports.create = function create(opts, requestListener) {
if (typeof opts === 'function') {
requestListener = opts
opts = undefined
}
const server = new Server(opts)
if (requestListener !== undefined) {
server.on('request', requestListener)
}
return server
}

View File

@@ -0,0 +1,303 @@
'use strict'
// ===================================================================
const assert = require('assert/strict')
const { EventEmitter } = require('events')
const resolvePath = require('path').resolve
const { describe, it } = require('node:test')
const HttpServerPlus = require('./')
// ===================================================================
// Creates a minimal mock server that can be used as a drop-in for http/https.Server.
// `address` is the value returned by mock.address() — use an object for TCP, a string for sockets.
function createMock(address) {
const mock = new EventEmitter()
mock.listen = function () {
setImmediate(() => this.emit('listening'))
}
mock.address = () => address
mock.close = function () {
setImmediate(() => this.emit('close'))
}
return mock
}
// ===================================================================
describe('HttpServerPlus', function () {
describe('#create()', function () {
it('creates a new instance', function () {
assert(HttpServerPlus.create() instanceof HttpServerPlus)
})
it('can register a `request` listener', function () {
const listener = function () {}
const server = HttpServerPlus.create(listener)
assert.deepEqual(server.listeners('request'), [listener])
})
})
describe('.addresses()', function () {
it('returns the list of addresses the server is listening at', function () {
const addr = { address: '127.0.0.1', port: 8080, family: 'IPv4' }
const mock = createMock(addr)
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(function () {
assert.deepEqual(server.addresses(), [addr])
return server.close()
})
})
})
describe('.niceAddresses()', function () {
it('returns the list of human readable addresses the server is listening at', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function (address) {
assert.deepEqual(server.niceAddresses(), [address])
return server.close()
})
})
})
describe('secureConnection event forwarding', function () {
function createMockHttpsServer() {
const server = new EventEmitter()
server.listen = function () {
setImmediate(() => this.emit('listening'))
}
server.address = () => ({ address: '127.0.0.1', port: 8443 })
server.close = function () {
setImmediate(() => this.emit('close'))
}
return server
}
function createServerWithMock() {
let mockServer
const server = HttpServerPlus.create({
createSecureServer: () => {
mockServer = createMockHttpsServer()
return mockServer
},
})
return {
server,
getMock: () => mockServer,
}
}
it('forwards secureConnection to listeners registered before listen()', function () {
const { server, getMock } = createServerWithMock()
const received = []
server.on('secureConnection', socket => received.push(socket))
const fakeSocket = {}
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
getMock().emit('secureConnection', fakeSocket)
assert.deepEqual(received, [fakeSocket])
return server.close()
})
})
it('forwards secureConnection to listeners registered after listen()', function () {
const { server, getMock } = createServerWithMock()
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
const received = []
server.on('secureConnection', socket => received.push(socket))
const fakeSocket = {}
getMock().emit('secureConnection', fakeSocket)
assert.deepEqual(received, [fakeSocket])
return server.close()
})
})
it('stops forwarding secureConnection after listener is removed', function () {
const { server, getMock } = createServerWithMock()
const received = []
const listener = socket => received.push(socket)
server.on('secureConnection', listener)
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
server.off('secureConnection', listener)
getMock().emit('secureConnection', {})
assert.deepEqual(received, [])
return server.close()
})
})
})
describe('.listen()', function () {
it('can use a host:port', function () {
let listenArgs
const mock = createMock({ address: '127.0.0.1', port: 8080 })
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function () {
assert.deepEqual(listenArgs, [8080, '127.0.0.1'])
return server.close()
})
})
it('can use a systemd socket', function () {
const SD_LISTEN_FDS_START = 3
const SOCKET_INDEX = 0
let listenArgs
const mock = createMock('http://<fd:3>')
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
const env = process.env
env.LISTEN_FDS = String(SOCKET_INDEX + 1)
env.LISTEN_PID = String(process.pid)
return server.listen({ systemdSocket: SOCKET_INDEX }).then(function () {
delete env.LISTEN_FDS
delete env.LISTEN_PID
assert.deepEqual(listenArgs, [{ fd: SOCKET_INDEX + SD_LISTEN_FDS_START }])
return server.close()
})
})
it('can use a socket', function () {
const socketPath = '/tmp/http-server-plus-test.sock'
let listenArgs
const mock = createMock(socketPath)
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ socket: socketPath }).then(function () {
assert.deepEqual(listenArgs, [resolvePath(socketPath)])
return server.close()
})
})
it('can use a HTTPS certificate', function () {
let capturedOpts
const mock = createMock({ address: '127.0.0.1', port: 8443 })
const server = HttpServerPlus.create({
createSecureServer: opts => {
capturedOpts = opts
return mock
},
})
return server.listen({ cert: 'my-cert', key: 'my-key', port: 8443 }).then(function (address) {
assert.equal(capturedOpts.cert, 'my-cert')
assert.equal(capturedOpts.key, 'my-key')
assert.ok(address.startsWith('https://'), `expected https URL, got: ${address}`)
return server.close()
})
})
it('returns a promise which will resolve once listening', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(function (address) {
assert.equal(typeof address, 'string')
assert.ok(address.startsWith('http://'), `expected http URL, got: ${address}`)
return server.close()
})
})
it('returns a promise which will reject if failure', function () {
const listenError = new Error('EADDRINUSE')
const mock = createMock(null)
mock.listen = function () {
setImmediate(() => this.emit('error', listenError))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(
function () {
assert.fail('should have rejected')
},
function (err) {
assert.equal(err, listenError)
assert.equal(typeof err.niceAddress, 'string')
}
)
})
})
describe('.close()', function () {
it('closes all servers', function () {
const closed = []
let callCount = 0
const server = HttpServerPlus.create({
createServer: () => {
const id = callCount++
const mock = createMock({ address: '127.0.0.1', port: id })
mock.close = function () {
closed.push(id)
setImmediate(() => this.emit('close'))
}
return mock
},
})
return Promise.all([server.listen({ port: 0 }), server.listen({ port: 0 })])
.then(function () {
return server.close()
})
.then(function () {
assert.equal(closed.length, 2)
})
})
it('emit the `close` event when all servers are closed', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
let closeEmitted = false
server.on('close', () => {
closeEmitted = true
})
return server
.listen({ port: 8080 })
.then(function () {
return server.close()
})
.then(function () {
assert.ok(closeEmitted)
})
})
it('emit the `close` event even if there are no servers', function () {
const server = HttpServerPlus.create()
// close() with no servers returns Promise.resolve() immediately (before emitting
// 'close' via setImmediate), so we must wait for the event itself, not the promise.
return new Promise(function (resolve) {
server.close(resolve)
})
})
})
})

View File

@@ -0,0 +1,37 @@
{
"name": "@vates/http-server-plus",
"version": "2.0.0",
"license": "ISC",
"description": "Augmented `http.Server`, HTTP/HTTPS/HTTP2 and multiple ports on the same instance",
"keywords": [
"http",
"https",
"server",
"ports"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/http-server-plus",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/http-server-plus",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"promise-toolbox": "^0.21.0"
},
"devDependencies": {
"eslint-plugin-n": "^15.1.0"
},
"scripts": {
"pretest": "eslint .",
"test": "node --test index.spec.js"
},
"private": true,
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
}
}

View File

@@ -353,6 +353,7 @@ export default class NbdClient {
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
return pRetry(() => this.#readBlock(index, size), {
tries: this.#readBlockRetries,
when: error => error.code !== 'ERR_ABORTED',
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
@@ -370,7 +371,7 @@ export default class NbdClient {
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type ( 0 means data).
*/
/* async */ getMap() {
/* async */ getMap(signal) {
return new Promise((resolve, reject) => {
const process = spawn('nbdinfo', [
'--json',
@@ -381,8 +382,17 @@ export default class NbdClient {
let errText = ''
process.stdout.on('data', data => (text += data))
process.stderr.on('data', data => (errText += data))
process.on('error', reject)
const onAbort = () => {
process.kill()
reject(signal.reason)
}
signal?.addEventListener('abort', onAbort, { once: true })
process.on('error', err => {
signal?.removeEventListener('abort', onAbort)
reject(err)
})
process.on('close', code => {
signal?.removeEventListener('abort', onAbort)
if (code !== 0) {
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
}

View File

@@ -138,8 +138,8 @@ export default class MultiNbdClient {
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type (0 means no data).
*/
async getMap() {
async getMap(signal) {
// ask the map from one of the connected client
return this.#clients[Math.floor(this.#clients.length * Math.random())].getMap()
return this.#clients[Math.floor(this.#clients.length * Math.random())].getMap(signal)
}
}

View File

@@ -13,19 +13,19 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "3.2.3",
"version": "3.4.0",
"engines": {
"node": ">=14.0"
},
"main": "./index.mjs",
"dependencies": {
"@vates/async-each": "^1.0.1",
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/disk-transform": "^1.2.1",
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/async-map": "^0.1.3",
"@xen-orchestra/disk-transform": "^1.2.3",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0",
"xen-api": "^4.7.6"
"xen-api": "^4.7.7"
},
"devDependencies": {
"tap": "^18.7.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/node-vsphere-soap",
"version": "2.1.2",
"version": "2.1.3",
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
"main": "lib/client.mjs",
"author": "reedog117",
@@ -11,8 +11,8 @@
},
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/log": "^0.7.1",
"axios": "^1.8.0",
"@xen-orchestra/log": "^0.7.2",
"axios": "^1.15.0",
"soap": "^1.0.0",
"soap-cookie": "^0.10.1"
},

View File

@@ -23,7 +23,7 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
Object.defineProperty(taskLog, '$root', { value: taskLog })
// start of a root task
onRootTaskStart(taskLog)
onRootTaskStart(taskLog, event)
} else {
// start of a subtask
const parent = taskLogs.get(parentId)
@@ -54,10 +54,10 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
}
if (type === 'end' && taskLog.$root === taskLog) {
onRootTaskEnd(taskLog)
onRootTaskEnd(taskLog, event)
}
}
onTaskUpdate(taskLog)
onTaskUpdate(taskLog, event)
}
}

View File

@@ -7,6 +7,27 @@ function alreadyEnded() {
throw new Error('task has already ended')
}
const serializeErrors = errors => (Array.isArray(errors) ? errors.map(serializeError) : errors)
// Create a serializable object from an error.
//
// Otherwise some fields might be non-enumerable and missing from logs.
const serializeError = error => {
return error instanceof Error
? {
...error, // Copy enumerable properties.
cause: error.cause,
code: error.code,
errors: serializeErrors(error.errors), // supports AggregateError
message: error.message,
name: error.name,
stack: error.stack,
}
: error
}
exports.serializeError = serializeError
// define a read-only, non-enumerable, non-configurable property
function define(object, property, value) {
Object.defineProperty(object, property, { value })
@@ -72,7 +93,11 @@ class Task {
_onProgress
get id() {
return (this.id = Math.random().toString(36).slice(2))
// Use a compact, sortable, string representation of the creation date,
// and add a random part to differentiate tasks created at the same time
//
// Due to the padding, dates are sortable up to 5188-04-22T11:04:28.415Z
return (this.id = Date.now().toString(36).padStart(9, '0') + '-' + Math.random().toString(36).slice(2))
}
set id(value) {
define(this, 'id', value)
@@ -136,6 +161,10 @@ class Task {
assert.equal(this.#status, PENDING)
assert.equal(this.#running, false)
if (result instanceof Error && result.toJSON === undefined) {
result = serializeError(result)
}
this.#emit('end', { status, result })
this._onProgress = alreadyEnded
this.#status = status

View File

@@ -4,7 +4,7 @@ const assert = require('node:assert').strict
const { describe, it } = require('node:test')
const { makeOnProgress } = require('./combineEvents.js')
const { Task } = require('./index.js')
const { Task, serializeError } = require('./index.js')
const noop = Function.prototype
@@ -28,6 +28,36 @@ function createTask(opts) {
return task
}
describe('serializeError', function () {
it('serializes simple error', function () {
const err = new Error('test Error')
err.code = 404
const serialized = serializeError(err)
assert.equal(serialized.message, 'test Error')
assert.equal(serialized.name, 'Error')
assert.equal(serialized.code, 404)
assert.notEqual(serialized.stack, undefined)
})
it('serializes aggregate error', function () {
const err = new AggregateError([new Error('test error 1'), new Error('test error 2')], 'Error message', {
cause: 'broken',
})
const serialized = serializeError(err)
assert.equal(serialized.message, 'Error message')
assert.equal(serialized.name, 'AggregateError')
assert.equal(serialized.cause, 'broken')
assert.notEqual(serialized.stack, undefined)
assert.equal(serialized.errors.length, 2)
assert.equal(serialized.errors[0].name, 'Error')
assert.equal(serialized.errors[1].name, 'Error')
assert.equal(serialized.errors[0].message, 'test error 1')
assert.equal(serialized.errors[1].message, 'test error 2')
assert.notEqual(serialized.errors[0].stack, undefined)
assert.notEqual(serialized.errors[1].stack, undefined)
})
})
describe('Task', function () {
describe('constructor', function () {
it('data properties are passed to the start event', async function () {
@@ -106,7 +136,7 @@ describe('Task', function () {
assert.equal(task.$events.length, 3)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'abortionRequested', reason }, 1)
assertEvent(task, { type: 'end', status: 'failure', result }, 2)
assertEvent(task, { type: 'end', status: 'failure', result: serializeError(result) }, 2)
})
it('does not abort if the task succeed', async function () {
@@ -176,7 +206,7 @@ describe('Task', function () {
assertEvent(task, {
status: 'failure',
result: error,
result: serializeError(error),
type: 'end',
})
})
@@ -475,7 +505,7 @@ describe('Task', function () {
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
result: serializeError(e),
type: 'end',
})
})
@@ -511,7 +541,7 @@ describe('Task', function () {
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
result: serializeError(e),
type: 'end',
})
})

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.6.2",
"version": "0.7.0",
"engines": {
"node": ">=14"
},

View File

@@ -1,7 +1,7 @@
{
"name": "@vates/types",
"private": false,
"version": "1.19.0",
"version": "1.24.0",
"main": "./dist/index.mjs",
"exports": {
".": {

View File

@@ -3,4 +3,5 @@ export * from './xen-api.mjs'
export * from './xo.mjs'
export * from './xo-app.mjs'
export * from './lib/xen-orchestra-xapi.mjs'
export * from './lib/xen-orchestra-acl.mjs'
export * from './lib/complex-matcher.mjs'

View File

@@ -0,0 +1,78 @@
import { Branded } from '../common.mjs'
import type { XoGroup, XoUser } from '../xo.mjs'
export type XoAclRole =
| {
id: Branded<'acl-v2-role'>
name: string
description?: string
}
| {
id: Branded<'acl-v2-role'>
name: string
description?: string
isTemplate: true
roleTemplateId: number
}
export type XoAclSupportedActionsByResource = {
[resource: string]: Record<string, unknown>
}
/**
* E.g
* vms {
* shutdown: {
* clean: true,
* hard: true,
* }
* }
*
* `shutdown | shutdown:clean | shutdown:hard`
*/
export type GetKeysRecursively<T, Prefix extends string = ''> = {
[K in keyof T]: T[K] extends object
? K extends string
? `${Prefix}${K}` | GetKeysRecursively<T[K], `${Prefix}${K}:`>
: never
: K extends string
? `${Prefix}${K}`
: never
}[keyof T]
export type XoAclSupportedResource<TActionsByResource extends XoAclSupportedActionsByResource> =
keyof TActionsByResource
export type XoAclSupportedActions<
TActionsByResource extends XoAclSupportedActionsByResource,
TResource extends XoAclSupportedResource<TActionsByResource>,
> = (GetKeysRecursively<TActionsByResource[TResource]> & string) | '*'
export type XoAclBasePrivilege = {
id: Branded<'acl-v2-privilege'>
resource: string
action: string
selector?: string
effect: 'allow' | 'deny'
roleId: XoAclRole['id']
}
export type XoAclPrivilege<
TActionsByResource extends XoAclSupportedActionsByResource,
TResource extends XoAclSupportedResource<TActionsByResource>,
> = XoAclBasePrivilege & {
resource: TResource
action: XoAclSupportedActions<TActionsByResource, TResource>
}
export type XoUserRole = {
id: string
roleId: XoAclRole['id']
userId: XoUser['id']
}
export type XoGroupRole = {
id: string
roleId: XoAclRole['id']
groupId: XoGroup['id']
}

View File

@@ -1,6 +1,7 @@
import {
WrappedXenApiRecord,
XenApiHost,
XenApiHostWrapped,
XenApiNetwork,
XenApiNetworkWrapped,
XenApiRecord,
@@ -13,9 +14,18 @@ import {
XenApiVmWrapped,
XenApiVtpm,
} from '../xen-api.mjs'
import type { OPAQUE_REF_NULL, SUPPORTED_VDI_FORMAT, VBD_MODE, VBD_TYPE, VIF_LOCKING_MODE } from '../common.mjs'
import type {
BOND_MODE,
OPAQUE_REF_NULL,
SUPPORTED_VDI_FORMAT,
VBD_MODE,
VBD_TYPE,
VDI_TYPE,
VIF_LOCKING_MODE,
} from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
XapiXoRecord,
XoGpuGroup,
XoHost,
XoNetwork,
@@ -27,6 +37,7 @@ import type {
XoVm,
XoVmTemplate,
XoVif,
XoVmSnapshot,
} from '../xo.mjs'
export type XcpPatches = {
@@ -63,11 +74,19 @@ export interface Xapi {
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
barrier<T extends XenApiRecord>(ref: T['$ref']): Promise<Extract<WrappedXenApiRecord, { $ref: T['$ref'] }>>
connectVif(vifId: XoVif['id']): Promise<void>
getField<T extends XenApiRecord, K extends keyof T>(
type: Extract<WrappedXenApiRecord, T>['$type'],
ref: T['$ref'],
field: K
): Promise<T[K]>
getObject: <
XoRecord extends XapiXoRecord,
WrappedRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: XoRecord['type'] }>,
>(
idOrUuidOrRef: XoRecord['id'] | WrappedRecord['$ref'] | WrappedRecord['uuid'],
defaultValue?: WrappedRecord
) => WrappedRecord
createNetwork(
params:
| {
@@ -84,8 +103,16 @@ export interface Xapi {
vlan: number
}
): Promise<XenApiNetworkWrapped>
createBondedNetwork(params: {
bondMode: BOND_MODE
pifIds: XoPif['id'][]
name: string
description?: string
mtu?: number
}): Promise<XenApiNetworkWrapped>
deleteNetwork(id: XoNetwork['id']): Promise<void>
deleteVif(vifId: XoVif['id']): Promise<void>
disconnectVif(vifId: XoVif['id']): Promise<void>
exportVmOva(vmRef: XenApiVm['$ref']): Promise<PassThrough>
migrateVm(
vmId: XoVm['id'],
@@ -103,7 +130,32 @@ export interface Xapi {
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
resumeVm(id: XoVm['id']): Promise<void>
revertVm(snapshotId: XoVmSnapshot['id']): Promise<void>
unpauseVm(id: XoVm['id']): Promise<void>
cloneVm(
vmId: XoVm['id'],
opts?: {
nameLabel?: string
fast?: boolean
}
): Promise<XenApiVmWrapped>
copyVm(
vmId: XoVm['id'],
opts?: {
nameLabel?: string
srOrSrId?: XoSr['id'] | XoSr
}
): Promise<XenApiVmWrapped>
remoteCopyVm(
vmId: XoVm['id'],
targetXapi: Xapi,
targetSrId: XoSr['id'],
opts?: {
compress?: 'zstd' | 'gzip' | boolean
nameLabel?: string
}
): Promise<{ vm: XenApiVmWrapped }>
forgetSr(id: XoSr['id']): Promise<void>
SR_importVdi(
ref: XenApiSr['$ref'],
stream: Readable,
@@ -121,6 +173,7 @@ export interface Xapi {
xenstore_data?: Record<string, string>
}
): Promise<XenApiVdi['$ref']>
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
startVm(
id: XoVm['id'],
opts?: {
@@ -150,6 +203,7 @@ export interface Xapi {
metadataVm: {
affinityHost?: XoHost['id']
autoPoweron?: boolean
cpus?: number
memory?: number
name_description?: string
name_label: string
@@ -218,6 +272,23 @@ export interface Xapi {
}): Promise<XenApiVbd['$ref']>
VBD_destroy(vbdRef: XenApiVbd['$ref']): Promise<void>
VBD_unplug(vbdRef: XenApiVbd['$ref']): Promise<void>
VDI_create(
options: {
name_description?: XoVdi['name_description']
name_label?: XoVdi['name_label']
other_config?: XoVdi['other_config']
read_only?: boolean
sharable?: boolean
SR?: XenApiSr['$ref']
tags?: XoVdi['tags']
type?: VDI_TYPE
virtual_size: XoVdi['size']
xenstore_data?: Record<string, string>
},
extraOptions?: {
sm_config?: Record<string, string>
}
): Promise<XenApiVdi['$ref']>
VDI_destroy(vdiRef: XenApiVdi['$ref']): Promise<void>
VDI_destroyCloudInitConfig(vdiRef: XenApiVdi['$ref'], opts?: { timeLimit?: number }): Promise<void>
VDI_exportContent(
@@ -265,6 +336,25 @@ export interface Xapi {
pathname: string,
params?: { host?: XenApiHost; query?: Record<string, unknown>; task?: boolean | XenApiTask['$ref'] }
): Promise<{ body: Readable }>
clearHost(host: Pick<XenApiHostWrapped, '$ref' | '$pool'>, force?: boolean): Promise<void>
disableHost(hostId: XoHost['id']): Promise<void>
enableHost(hostId: XoHost['id']): Promise<void>
getRecordByUuid<
Type extends WrappedXenApiRecord['$type'],
XenApiRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: Type }>,
>(
type: Type,
uuid: XenApiRecord['uuid']
): Promise<XenApiRecord>
isHyperThreadingEnabled(hostId: XoHost['id']): Promise<boolean | null>
VTPM_create(params: { VM: XenApiVm['$ref']; is_unique?: boolean; contents?: string }): Promise<XenApiVtpm['$ref']>
destroySr(id: XoSr['id']): Promise<void>
xostor_delete(ref: XenApiSr['$ref']): Promise<void>
objects: {
indexes: {
type: {
[XenApiRecord in WrappedXenApiRecord as XenApiRecord['$type']]: Record<XenApiRecord['uuid'], XenApiRecord>
}
}
}
}

View File

@@ -82,6 +82,7 @@ type TagCallMethods = {
type WrapperXenApi<T, Type extends string, Fn = { (): void }> = T & {
$call: Fn
$callAsync: Fn
$pool: XenApiPool
$type: Type
$snapshot(params: {
cancelToken?: unknown
@@ -490,7 +491,9 @@ export interface XenApiDrTask {
uuid: string
}
type XenApiHostCallMethods = TagCallMethods & {}
type XenApiHostCallMethods = TagCallMethods & {
<T>(method: 'call_plugin',plugin:string, fn: string, args: Record<string, string>): Promise<T>
}
export interface XenApiHost {
$ref: Branded<'host'>
address: string
@@ -622,7 +625,9 @@ export interface XenApiHostCpu {
vendor: string
}
type XenApiNetworkCallMethods = TagCallMethods & {}
type XenApiNetworkCallMethods = TagCallMethods & {
(method: 'add_purpose', purpose: NETWORK_PURPOSE): Promise<void>
}
export interface XenApiNetwork {
$ref: Branded<'network'>

View File

@@ -44,6 +44,10 @@ import {
XenApiVifWrapped,
XenApiVmWrapped,
XenApiVtpmWrapped,
XoAclBasePrivilege,
XoAclRole,
XoGroupRole,
XoUserRole,
} from './index.mjs'
export type XapiConnection = Xapi & {
@@ -52,7 +56,6 @@ export type XapiConnection = Xapi & {
sessionId: string
_url?: { protocol: string; hostname: string; port?: string }
}
type XapiRecordByXapiXoRecord = {
gpuGroup: XenApiGpuGroupWrapped
host: XenApiHostWrapped
@@ -78,7 +81,42 @@ type XapiRecordByXapiXoRecord = {
'VM-template': XenApiVmWrapped
VTPM: XenApiVtpmWrapped
}
type LicenseProductId =
| 'premium'
| 'xcpng-enterprise'
| 'xcpng-standard'
| 'xo-proxy'
| 'xosan.trial'
| 'xostor'
| 'xostor.trial'
type LicenseProductType = 'xo' | 'xoproxy' | 'xcpng' | 'xosan' | 'xostor'
type License = {
id: string
type: 'license'
created: number
licenseType: 'unit'
productId: LicenseProductId
tags: string[]
expires?: number
boundObjectId?: string
bindDate?: number
buyer?: { token: string; email: string }
history?: { date: number; boundObjectId: string }[]
rebound?: number
productTypes?: LicenseProductType[]
bundleInfo?: { name: string; id: string }
}
export type XoApp = {
hooks: EventEmitter
_redis: {
get(key: string): Promise<string | null>
mSet(args: string[]): Promise<unknown>
sMembers(key: string): Promise<string[]>
sIsMember(key: string, member: string): Promise<boolean>
}
config: {
get<T = string>(path: string): T
getOptional(path: string): Record<string, string> | undefined
@@ -128,7 +166,18 @@ export type XoApp = {
}
// methods ------------
addAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<XoGroupRole>
addAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<XoUserRole>
addUserToGroup: (userId: XoUser['id'], groupId: XoGroup['id']) => Promise<void>
addApiMethod: <A extends unknown[], R>(
name: string,
method: (...args: A) => Promise<R>,
//we use any since it is a legacy call, might be better typed in another pr
info: {
resolve?: any
params?: any
}
) => () => void // eslint-disable-line @typescript-eslint/no-explicit-any
authenticateUser: (
credentials: { token?: string; username?: string; password?: string },
userData?: { ip?: string },
@@ -138,6 +187,18 @@ export type XoApp = {
checkFeatureAuthorization(featureCode: string): Promise<void>
/* connect a server (XCP-ng/XenServer) */
connectXenServer(id: XoServer['id']): Promise<void>
// TODO: replace all XoAclBasePrivilege with a more strict type. (discriminate union)
createAclV2Privilege(
privilege: Omit<XoAclBasePrivilege, 'id'>,
options?: {
force?: boolean
}
): Promise<XoAclBasePrivilege>
copyAclV2Role(
id: XoAclRole['id'],
params?: { name?: XoAclRole['name']; description?: XoAclRole['description'] }
): Promise<XoAclRole['id']>
createAclV2Role(role: { name: XoAclRole['name']; description?: XoAclRole['description'] }): Promise<XoAclRole>
createAuthenticationToken(opts: {
client?: {
id?: string
@@ -148,11 +209,21 @@ export type XoApp = {
userId: XoUser['id']
}): Promise<XoAuthenticationToken>
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
deleteAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<boolean>
deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean>
deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean>
deleteGroup(id: XoGroup['id']): Promise<void>
deleteUser(id: XoUser['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
/* disconnect a server (XCP-ng/XenServer) */
disconnectXenServer(id: XoServer['id']): Promise<void>
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
getAclV2RolePrivileges(roleId: XoAclRole['id']): Promise<XoAclBasePrivilege[]>
getAclV2Role(id: XoAclRole['id']): Promise<XoAclRole>
deleteAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<boolean>
getAclV2Roles(): Promise<XoAclRole[]>
getAclV2UserPrivileges(userId: XoUser['id']): Promise<XoAclBasePrivilege[]>
getAllGroups(): Promise<XoGroup[]>
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoBackupJob['type']>(type: T): Promise<Extract<AnyXoBackupJob, { type: T }>[]>
@@ -246,6 +317,20 @@ export type XoApp = {
preferences?: Record<string, string>
}
): Promise<void>
updateAclV2Privilege(
privilegeId: XoAclBasePrivilege['id'],
privilege: XoAclBasePrivilege
): Promise<XoAclBasePrivilege>
updateAclV2Role(
roleId: XoAclRole['id'],
role: {
name?: XoAclRole['name']
description?: XoAclRole['description'] | null
},
options?: {
force?: boolean
}
): Promise<XoAclRole>
updateGroup(
id: XoGroup['id'],
updates: {
@@ -254,4 +339,12 @@ export type XoApp = {
): void
getAllXapis(): Record<string, XapiConnection>
getObjects(opts?: { filter?: Record<string, unknown>; limit?: number }): Record<string, XapiXoRecord>
getLicenses(params?: { productType?: LicenseProductType }): Promise<License[]>
bindLicense(params: { licenseId: string; boundObjectId: string }): Promise<License>
unbindLicense(params: {
productId: LicenseProductId
boundObjectId: string
licenseId: string
data?: Record<string, string>
}): Promise<License>
}

View File

@@ -19,6 +19,7 @@ import type {
VM_POWER_STATE,
} from './common.mjs'
import type * as CMType from './lib/complex-matcher.mjs'
import { XoAclPrivilege, XoAclRole, XoAclSupportedActionsByResource } from './lib/xen-orchestra-acl.mjs'
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
type BaseXapiXo = {
@@ -93,7 +94,7 @@ type BaseXoVm = BaseXapiXo & {
notes?: string
os_version: Record<string, string> | null
other: Record<string, string>
parent?: XoVm['id']
parent?: XoVmSnapshot['id']
power_state: VM_POWER_STATE
pvDriversDetected?: boolean
pvDriversUpToDate?: boolean
@@ -106,6 +107,10 @@ type BaseXoVm = BaseXapiXo & {
suspendSr?: XoSr['id']
tags: string[]
vga?: string
/**
* Value in MiB.
* See: https://wiki.xenproject.org/wiki/XCP_PV_templates_start
*/
videoram?: number
viridian: boolean
virtualizationMode: DOMAIN_TYPE
@@ -330,6 +335,7 @@ export type XoNetwork = BaseXapiXo & {
defaultIsLocked: boolean
id: Branded<'network'>
insecureNbd?: boolean
isBonded: boolean
name_description: string
name_label: string
nbd?: boolean
@@ -652,13 +658,15 @@ export type XoTask = {
| 'server'
| 'task'
| 'user'
| 'acl-privilege'
| 'acl-role'
params?: Record<string, unknown>
progress?: number
type?: string
userId?: string
[key: string]: unknown | undefined
}
result: Record<string, unknown>
result?: Record<string, unknown>
start: number
status: 'failure' | 'interrupted' | 'pending' | 'success'
tasks?: XoTask[]
@@ -672,7 +680,7 @@ export type XoUser = {
groups: XoGroup['id'][]
id: Branded<'user'>
name?: string
permission: string
permission: 'none' | 'admin'
pw_hash?: string
preferences: Record<string, string>
}
@@ -725,6 +733,7 @@ type BaseXoVdi = BaseXapiXo & {
export type XoVdi = BaseXoVdi & {
id: Branded<'VDI'>
chainPhysicalUsage: number
type: 'VDI'
}
@@ -845,7 +854,10 @@ export type XapiXoRecord =
| XoVtpm
| XoSm
export type NonXapiXoRecord =
export type NonXapiXoRecord<
TActionsByResource extends XoAclSupportedActionsByResource = never,
TResource extends string = never,
> =
| AnyXoBackupArchive
| AnyXoJob
| AnyXoLog
@@ -859,8 +871,13 @@ export type NonXapiXoRecord =
| XoServer
| XoTask
| XoUser
| XoAclRole
| XoAclPrivilege<TActionsByResource, TResource>
export type XoRecord = XapiXoRecord | NonXapiXoRecord
export type XoRecord<
TActionsByResource extends XoAclSupportedActionsByResource = never,
TResource extends string = never,
> = XapiXoRecord | NonXapiXoRecord<TActionsByResource, TResource>
export type AnyXoVm = XoVm | XoVmSnapshot | XoVmTemplate | XoVmController

View File

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

View File

@@ -0,0 +1,16 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended, {
languageOptions: {
parserOptions: {
tsconfigRootDir: __dirname,
},
}
})

View File

@@ -0,0 +1,47 @@
{
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"main": "dist/index.mjs",
"name": "@xen-orchestra/acl",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/acl",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"private": false,
"type": "module",
"engines": {
"node": ">= 20.18"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsc && node scripts/generate-types.mjs",
"clean": "rimraf dist/",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"test": "npm run build && node --test ./dist/**/*.test.mjs",
"prebuild": "npm run clean",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@vates/types": "^1.24.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.23.0"
},
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@xen-orchestra/acl",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"dependencies": {
"complex-matcher": "^1.0.0"
}
}

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
// AUTO-GENERATES dist/generated/privilege-types.d.mts
// Run via `npm run build` (after tsc).
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { SUPPORTED_ACTIONS_BY_RESOURCE } from '../dist/actions/index.mjs'
import { getActionStrings } from '../dist/index.mjs'
function toPascalCase(str) {
return str
.split(/[-_]/)
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
.join('')
}
const resources = Object.keys(SUPPORTED_ACTIONS_BY_RESOURCE)
const typeBlocks = resources.map(resource => {
const actionUnion = getActionStrings(resource)
.map(action => `'${action}'`)
.join(' | ')
return `export type ${toPascalCase(resource)}Privilege = {
id: XoAclBasePrivilege['id']
resource: '${resource}'
action: ${actionUnion}
selector?: XoAclBasePrivilege['selector']
effect: XoAclBasePrivilege['effect']
roleId: XoAclBasePrivilege['roleId']
}`
})
const anyUnion = resources.map(resource => ` | ${toPascalCase(resource)}Privilege`).join('\n')
const types = `\
// -----------------------------------------------------------------------
// AUTO-GENERATED — do not edit manually.
// Regenerated on every \`npm run build\`.
// -----------------------------------------------------------------------
import type { XoAclBasePrivilege } from '@vates/types'
${typeBlocks.join('\n\n')}
export type AnyPrivilege =
${anyUnion}
`
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const outDir = join(root, 'dist/generated')
mkdirSync(outDir, { recursive: true })
writeFileSync(join(outDir, 'privilege-types.d.mts'), types, 'utf-8')

View File

@@ -0,0 +1,11 @@
export default {
create: true,
delete: true,
read: true,
update: {
action: true,
effect: true,
resource: true,
selector: true,
},
}

View File

@@ -0,0 +1,11 @@
export default {
create: true,
delete: true,
read: true,
update: {
description: true,
groups: true,
name: true,
users: true,
},
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,9 @@
export default {
create: true,
delete: true,
read: true,
update: {
name: true,
users: true,
},
}

View File

@@ -0,0 +1,13 @@
export default {
'allow-vm': true,
disable: true,
enable: true,
evacuate: true,
export: {
logs: true,
},
read: true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,80 @@
import { default as aclPrivilege } from './acl-privilege.mjs'
import { default as aclRole } from './acl-role.mjs'
import { default as alarm } from './alarm.mjs'
import { default as backupArchive } from './backup-archive.mjs'
import { default as backupJob } from './backup-job.mjs'
import { default as backupLog } from './backup-log.mjs'
import { default as backupRepository } from './backup-repository.mjs'
import { default as gpuGroup } from './gpuGroup.mjs'
import { default as group } from './group.mjs'
import { default as host } from './host.mjs'
import { default as message } from './message.mjs'
import { default as network } from './network.mjs'
import { default as pbd } from './pbd.mjs'
import { default as pci } from './pci.mjs'
import { default as pgpu } from './pgpu.mjs'
import { default as pif } from './pif.mjs'
import { default as pool } from './pool.mjs'
import { default as proxy } from './proxy.mjs'
import { default as restoreLog } from './restore-log.mjs'
import { default as schedule } from './schedule.mjs'
import { default as server } from './server.mjs'
import { default as sm } from './sm.mjs'
import { default as sr } from './sr.mjs'
import { default as task } from './task.mjs'
import { default as user } from './user.mjs'
import { default as vbd } from './vbd.mjs'
import { default as vdiSnapshot } from './vdi-snapshot.mjs'
import { default as vdiUnmanaged } from './vdi-unmanaged.mjs'
import { default as vdi } from './vdi.mjs'
import { default as vgpu } from './vgpu.mjs'
import { default as vgpuType } from './vgpuType.mjs'
import { default as vif } from './vif.mjs'
import { default as vmController } from './vm-controller.mjs'
import { default as vmSnapshot } from './vm-snapshot.mjs'
import { default as vmTemplate } from './vm-template.mjs'
import { default as vm } from './vm.mjs'
import { default as vtpm } from './vtpm.mjs'
/**
* value without sub action like 'shutdown' mean: 'shutdown:*' (so shutdown mean you can shutdown:clean and shutdown:hard)
*/
export const SUPPORTED_ACTIONS_BY_RESOURCE = {
'acl-privilege': aclPrivilege,
'acl-role': aclRole,
alarm,
'backup-archive': backupArchive,
'backup-job': backupJob,
'backup-log': backupLog,
'backup-repository': backupRepository,
group,
gpuGroup,
host,
message,
network,
pbd,
pci,
pgpu,
pif,
pool,
proxy,
'restore-log': restoreLog,
schedule,
server,
sm,
sr,
task,
user,
vbd,
'vdi-snapshot': vdiSnapshot,
'vdi-unmanaged': vdiUnmanaged,
vdi,
vgpu,
vgpuType,
vif,
'vm-controller': vmController,
'vm-snapshot': vmSnapshot,
'vm-template': vmTemplate,
vm,
vtpm,
} as const

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,8 @@
export default {
create: true,
delete: true,
read: true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,6 @@
export default {
read: true,
update: {
management: true,
},
}

View File

@@ -0,0 +1,13 @@
export default {
create: {
network: true,
vm: true,
},
'emergency-shutdown': true,
read: true,
'rolling-reboot': true,
'rolling-update': true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,4 @@
export default {
read: true,
run: true,
}

View File

@@ -0,0 +1,7 @@
export default {
connect: true,
create: true,
delete: true,
disconnect: true,
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,11 @@
export default {
delete: true,
import: {
vdi: true,
vm: true,
},
read: true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,5 @@
export default {
abort: true,
delete: true,
read: true,
}

View File

@@ -0,0 +1,11 @@
export default {
create: true,
delete: true,
read: true,
update: {
name: true,
password: true,
permission: true,
preferences: true,
},
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,11 @@
export default {
boot: true,
create: true,
delete: true,
'export-content': true,
'import-content': true,
read: true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,4 @@
export default {
read: true,
create: true,
}

View File

@@ -0,0 +1,6 @@
export default {
read: true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,8 @@
export default {
delete: true,
export: true,
read: true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,9 @@
export default {
delete: true,
export: true,
instantiate: true,
read: true,
update: {
tags: true,
},
}

View File

@@ -0,0 +1,24 @@
export default {
delete: true,
export: true,
pause: true,
read: true,
reboot: {
clean: true,
hard: true,
},
resume: true,
'revert-snapshot': true,
shutdown: {
clean: true,
hard: true,
},
snapshot: true,
start: true,
suspend: true,
unpause: true,
update: {
datasources: true,
tags: true,
},
}

View File

@@ -0,0 +1,3 @@
export default {
read: true,
}

View File

@@ -0,0 +1,119 @@
import * as CM from 'complex-matcher'
import type { XoAclPrivilege, XoAclSupportedActions, XoAclSupportedResource } from '@vates/types/lib/xen-orchestra/acl'
import { SUPPORTED_ACTIONS_BY_RESOURCE } from '../actions/index.mjs'
export type SupportedActionsByResource = typeof SUPPORTED_ACTIONS_BY_RESOURCE
export type SupportedResource = XoAclSupportedResource<SupportedActionsByResource>
export type SupportedActions<T extends SupportedResource> = XoAclSupportedActions<SupportedActionsByResource, T>
export type TPrivilege<T extends SupportedResource> = XoAclPrivilege<SupportedActionsByResource, T>
export class Privilege<T extends SupportedResource> {
#action: TPrivilege<T>['action']
#selector?: (object: object) => boolean
#resource: TPrivilege<T>['resource']
#effect: TPrivilege<T>['effect']
constructor({
action,
selector,
resource,
effect,
}: {
action: TPrivilege<T>['action']
selector?: TPrivilege<T>['selector']
resource: TPrivilege<T>['resource']
effect: TPrivilege<T>['effect']
}) {
Privilege.checkActionIsValid(resource, action)
this.#action = action
this.#selector = selector !== undefined ? CM.parse(selector).createPredicate() : undefined
this.#resource = resource
this.#effect = effect
}
get effect() {
return this.#effect
}
#matchAction<Resource extends SupportedResource = T>(action: SupportedActions<Resource>) {
// read:name_label - read:name_label
if (this.#action === action) {
return true
}
const [namespaceToMatch, actionToMatch] = action.split(':')
const [thisNamespace, thisAction] = this.#action.split(':')
if (thisNamespace === '*') {
return true
}
// update:vm - read:name_label
if (thisNamespace !== namespaceToMatch) {
return false
}
// read - read:name_label
if (thisAction === undefined) {
return true
}
// read:name_decription - read:name_label
if (thisAction !== actionToMatch) {
return false
}
throw new Error(`Unable to verify if ${this.#action} match ${action} `)
}
#matchSelector(object: object) {
if (this.#selector === undefined) {
return true
}
return this.#selector(object)
}
#matchResource(resource: string): resource is SupportedResource {
return resource === this.#resource
}
match<Resource extends SupportedResource = T>(constraint: {
action: SupportedActions<Resource>
resource: Resource
object: object
}): boolean {
return (
this.#matchResource(constraint.resource) &&
this.#matchAction(constraint.action) &&
this.#matchSelector(constraint.object)
)
}
/**
* Throw if action not supported
*/
static checkActionIsValid<T extends SupportedResource>(resource: T, action: SupportedActions<T>) {
const supportedActions = SUPPORTED_ACTIONS_BY_RESOURCE[resource]
if (supportedActions === undefined) {
throw new Error(`${resource} resource not supported`)
}
if (action === '*') {
return
}
const segments = action.split(':')
let _action: object | undefined = undefined
segments.forEach(segment => {
_action = (_action ?? supportedActions)[segment]
if (_action === undefined) {
throw new Error(
`${action} action not supported for the resource: ${resource}. See ${JSON.stringify(supportedActions)}`
)
}
})
}
}

View File

@@ -0,0 +1,10 @@
// DO NOT EDIT
// Overwritten by scripts/generate-types.mjs after running npm run build
// scripts/generate-types.mjs will replace this file to automatically generate and export all privileged types.
// This makes external use easier because it's not necessary to resolve the type every time.
import type { Privilege, SupportedResource } from '../index.mjs'
export type AnyPrivilege = {
[Resource in SupportedResource]: Privilege<Resource>
}[SupportedResource]

View File

@@ -0,0 +1,167 @@
import assert from 'node:assert'
import type { XoUser } from '@vates/types/xo'
import { SUPPORTED_ACTIONS_BY_RESOURCE } from './actions/index.mjs'
import {
Privilege as CPrivilege,
TPrivilege as Privilege,
SupportedActions,
SupportedActionsByResource,
SupportedResource,
} from './class/privilege.mjs'
// Export all types constructed using SUPPORTED_ACTIONS_BY_RESOURCE
export type { Privilege, SupportedActions, SupportedActionsByResource, SupportedResource }
export { SUPPORTED_ACTIONS_BY_RESOURCE }
export type * from './generated/privilege-types.mjs'
import { AnyPrivilege } from './generated/privilege-types.mjs'
export type AnyPrivilegeOnParam = {
[Resource in SupportedResource]: {
user: XoUser
resource: Resource
action: SupportedActions<Resource>
objects: object | object[]
}
}[SupportedResource]
export function hasPrivilegeOn<T extends SupportedResource>({
user,
action,
resource,
objects,
userPrivileges,
}: {
user: XoUser
resource: T
action: SupportedActions<T>
objects: object | object[]
userPrivileges: AnyPrivilege[]
}) {
// Function that will be called outside of the module
// We cannot be sure types are respected
assert.strictEqual(typeof user?.permission, 'string')
assert.strictEqual(typeof action, 'string')
assert.strictEqual(typeof resource, 'string')
assert.strictEqual(objects === undefined, false)
CPrivilege.checkActionIsValid(resource, action as SupportedActions<typeof resource>)
if (user.permission === 'admin') {
return true
}
const effectsByObject = new Map<object, Privilege<T>['effect'][]>()
if (Array.isArray(objects)) {
objects.forEach(obj => effectsByObject.set(obj, []))
} else {
effectsByObject.set(objects, [])
}
for (const userPrivilege of userPrivileges) {
const privilege = new CPrivilege(userPrivilege as Privilege<typeof userPrivilege.resource>)
for (const [object, effects] of effectsByObject.entries()) {
if (!privilege.match({ action, resource, object })) {
continue
}
effects.push(privilege.effect)
}
}
if (
Array.from(effectsByObject.values()).some(
effects => effects.length === 0 || effects.some(effect => effect === 'deny')
)
) {
return false
}
return true
}
export function getMissingPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
return params
.filter(
param =>
!hasPrivilegeOn({
user: param.user,
resource: param.resource,
action: param.action as SupportedActions<typeof param.resource>,
objects: param.objects,
userPrivileges,
})
)
.map(({ action, objects, resource }) => {
let objectIds: unknown[] | undefined
let objectId: unknown | undefined
if (Array.isArray(objects)) {
if (objects.length > 1) {
objectIds = []
objects.forEach(obj => {
if ('id' in obj) {
objectIds!.push(obj.id)
}
})
} else {
objects = objects?.[0] ?? {}
}
}
if (!Array.isArray(objects)) {
objectId = 'id' in objects ? objects.id : undefined
}
return {
objectId,
objectIds,
action,
resource,
}
})
}
export function hasPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
return getMissingPrivileges(params, userPrivileges).length === 0
}
export function filterObjectsWithPrivilege<Resource extends SupportedResource, Object extends object>(param: {
user: XoUser
resource: Resource
action: SupportedActions<Resource>
objects: Object[]
userPrivileges: AnyPrivilege[]
}) {
return param.objects.filter(obj => hasPrivilegeOn({ ...param, objects: obj }))
}
// Utils
/**
* Mirror GetKeysRecursively from @vates/types
*/
export function getActionStrings<Resource extends SupportedResource, Actions = SupportedActions<Resource>>(
resource: Resource
): Actions[] {
type NestedActions = { [key: string]: boolean | NestedActions }
function collectActions(obj: NestedActions, prefix?: Actions): Actions[] {
const actions: Actions[] = ['*' as Actions]
for (const [key, value] of Object.entries(obj)) {
const action = (prefix !== undefined ? `${prefix}:${key}` : key) as Actions
actions.push(action)
if (typeof value === 'boolean') {
continue
}
actions.push(...collectActions(value, action))
}
return actions
}
return collectActions(SUPPORTED_ACTIONS_BY_RESOURCE[resource])
}

View File

@@ -0,0 +1,378 @@
import { test, suite } from 'node:test'
import assert from 'node:assert'
import type { Branded } from '@vates/types/common'
import type { XoUser } from '@vates/types/xo'
import { Privilege } from '../class/privilege.mjs'
import type { Privilege as TPrivilege } from '../index.mjs'
import {
filterObjectsWithPrivilege,
getActionStrings,
getMissingPrivileges,
hasPrivilegeOn,
hasPrivileges,
} from '../index.mjs'
suite('Privilege.checkActionIsValid behavior', () => {
test('throw on invalid resource', () => {
// @ts-expect-error test invalid action
assert.throws(() => Privilege.checkActionIsValid('foo', 'read'))
})
test('throw on invalid action', () => {
// @ts-expect-error test invalid action
assert.throws(() => Privilege.checkActionIsValid('vm', 'foo:vm'))
})
test('should work', () => {
assert.doesNotThrow(() => Privilege.checkActionIsValid('vm', 'read'))
})
})
suite('Privilege.match behavior', () => {
const privilege = new Privilege({ action: 'read', resource: 'vm', effect: 'allow' })
const object = { id: 1, name_label: 'foo' }
test('Should not match because of the action', () => {
assert.strictEqual(privilege.match({ resource: 'vm', action: 'start', object }), false)
})
test('Should not match because of the resource', () => {
assert.strictEqual(privilege.match({ resource: 'host', action: 'read', object }), false)
})
test('Should match', () => {
assert.strictEqual(privilege.match({ resource: 'vm', action: 'read', object }), true)
})
test('Should match even if the action is not equal to the action privilege', () => {
const shutdownPrivilege = new Privilege({ action: 'shutdown', resource: 'vm', effect: 'allow' })
assert.strictEqual(shutdownPrivilege.match({ resource: 'vm', action: 'shutdown:clean', object }), true)
})
test('Should match because the action is *', () => {
const vmPrivilege = new Privilege({ action: '*', resource: 'vm', effect: 'allow' })
assert.strictEqual(vmPrivilege.match({ action: 'reboot:hard', resource: 'vm', object: object }), true)
})
test('Should not match due to insufficient privilege', () => {
const cleanShutdownPrivilege = new Privilege({ action: 'shutdown:clean', resource: 'vm', effect: 'allow' })
assert.strictEqual(cleanShutdownPrivilege.match({ resource: 'vm', action: 'shutdown', object }), false)
})
})
suite('ACL V2 behavior', async () => {
// === Setup ===
const admin = {
id: '1' as XoUser['id'],
groups: [],
permission: 'admin',
email: 'admin@admin.net',
preferences: {},
} satisfies XoUser
const user = {
id: '2' as XoUser['id'],
groups: [],
permission: 'none',
email: 'user@user.net',
preferences: {},
} satisfies XoUser
const privilegeId = 'fake-priv-test-id' as Branded<'acl-v2-privilege'>
const roleId = 'fake-role-test-id' as Branded<'acl-v2-role'>
// === Objects
const poolWithoutPrivilege = { id: 'pool-id' }
const alpineVm = { id: 'alpine', creation: { creator: admin.id } }
const almaVm = { id: 'alma', creation: { creator: user.id } }
const xoaVm = { id: 'xoa' }
const template = { id: 'template-id', $pool: '1' }
const templateDeny = { id: 'template-deny-id', $pool: '2' }
const vdis = [{ $SR: '2' }, { $SR: '2' }]
const vdisMultipleSrs = [{ $SR: '1' }, { $SR: '2' }]
const vifs = [{ $network: '1' }]
const iso = { id: 'iso-vdi-id', $SR: '3' }
const hostAffinity = { id: 'affinity-host-id', $pool: '1' }
// === DB Privileges
const vmRead = { id: privilegeId, action: 'read', resource: 'vm', effect: 'allow', roleId } satisfies TPrivilege<'vm'>
const vmCleanShutdown = {
id: privilegeId,
action: 'shutdown:clean',
resource: 'vm',
selector: `creation:creator:${user.id}`,
effect: 'allow',
roleId,
} satisfies TPrivilege<'vm'>
const xoaVmDenyRead = {
id: privilegeId,
action: 'read',
resource: 'vm',
effect: 'deny',
selector: `id: ${xoaVm.id}`,
roleId,
} satisfies TPrivilege<'vm'>
const vdiCreate = {
id: privilegeId,
action: 'create',
resource: 'vdi',
selector: '$SR:2',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vdi'>
const vifCreate = {
id: privilegeId,
action: 'create',
resource: 'vif',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vif'>
const templateInstantiate = {
id: privilegeId,
action: 'instantiate',
resource: 'vm-template',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vm-template'>
const templateInstantiateDeny = {
id: privilegeId,
action: 'instantiate',
resource: 'vm-template',
selector: '$pool:2',
effect: 'deny',
roleId,
} satisfies TPrivilege<'vm-template'>
const isoVdiUse = {
id: privilegeId,
action: 'boot',
resource: 'vdi',
selector: '$SR:3',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vdi'>
const hostVm = {
id: privilegeId,
action: 'allow-vm',
resource: 'host',
effect: 'allow',
roleId,
} satisfies TPrivilege<'host'>
const allPrivileges = [
vmRead,
vmCleanShutdown,
xoaVmDenyRead,
vdiCreate,
vifCreate,
templateInstantiate,
templateInstantiateDeny,
isoVdiUse,
hostVm,
]
// === Test ===
suite('hasPrivilegeOn behavior', () => {
test('Should always return true for admin', () => {
assert.strictEqual(
hasPrivilegeOn<'pool'>({
user: admin,
action: 'read',
resource: 'pool',
objects: poolWithoutPrivilege,
userPrivileges: allPrivileges,
}),
true
)
})
test('Should return false by default for non admin', () => {
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'read',
resource: 'pool',
objects: poolWithoutPrivilege,
userPrivileges: allPrivileges,
}),
false
)
})
test('Should return true if user has privilege', () => {
assert.strictEqual(
hasPrivilegeOn({ user, action: 'read', resource: 'vm', objects: alpineVm, userPrivileges: allPrivileges }),
true
)
})
test('Should return false if user have a both deny and allow privileges', () => {
assert.strictEqual(
hasPrivilegeOn({ user, action: 'read', resource: 'vm', objects: xoaVm, userPrivileges: allPrivileges }),
false
)
})
test('Should handle multiple and single object', () => {
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'read',
resource: 'vm',
objects: [alpineVm, almaVm],
userPrivileges: allPrivileges,
}),
true
)
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'read',
resource: 'vm',
objects: [alpineVm, almaVm, xoaVm],
userPrivileges: allPrivileges,
}),
false
)
})
test('Should allow clean shutdown only own VMs', () => {
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'shutdown:clean',
resource: 'vm',
objects: almaVm,
userPrivileges: allPrivileges,
}),
true
)
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'shutdown:clean',
resource: 'vm',
objects: [almaVm, alpineVm],
userPrivileges: allPrivileges,
}),
false
)
})
})
suite('getMissingPrivileges behavior', () => {
test('Should return empty array if no missing privileges', () => {
const missingPrivileges = getMissingPrivileges(
[
{ user, action: 'shutdown:clean', resource: 'vm', objects: almaVm },
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
{ user, action: 'create', resource: 'vdi', objects: vdis },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 0)
})
test('Should return only missing privileges', () => {
const missingPrivileges = getMissingPrivileges(
[
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
{ user, action: 'create', resource: 'vdi', objects: vdis },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
{ user, action: 'read', resource: 'pool', objects: poolWithoutPrivilege },
{ user, action: '*', resource: 'pool', objects: poolWithoutPrivilege },
],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 2)
})
test('Should return objectId if only one object in the missing privilege', () => {
const missingPrivileges = getMissingPrivileges(
[{ user, action: 'read', resource: 'pool', objects: poolWithoutPrivilege }],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 1)
assert.notStrictEqual(missingPrivileges[0]!.objectId, undefined)
})
test('Should return objectIds if multiple objects in the missing privilege', () => {
const missingPrivileges = getMissingPrivileges(
[{ user, action: 'read', resource: 'pool', objects: [poolWithoutPrivilege, poolWithoutPrivilege] }],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 1)
assert.notStrictEqual(missingPrivileges[0]!.objectIds, undefined)
})
})
suite('hasPrivileges behavior', () => {
test('Should return false if at least one privilege deny', () => {
const result = hasPrivileges(
[
{ user, action: 'instantiate', resource: 'vm-template', objects: templateDeny },
{ user, action: 'create', resource: 'vdi', objects: vdis },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
],
allPrivileges
)
assert.strictEqual(result, false)
})
test("Should return false if at least one privilege didn't match", () => {
const result = hasPrivileges(
[
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
{ user, action: 'create', resource: 'vdi', objects: vdisMultipleSrs },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
],
allPrivileges
)
assert.strictEqual(result, false)
})
})
suite('filterObjectsWithPrivilege behavior', () => {
test('Should return only objects that match privileges', () => {
let vms = [almaVm, alpineVm, xoaVm, xoaVm]
assert.strictEqual(vms.length, 4)
vms = filterObjectsWithPrivilege({
user,
action: 'read',
resource: 'vm',
objects: vms,
userPrivileges: allPrivileges,
})
assert.strictEqual(vms.length, 2)
})
})
})
suite('getActionStrings', () => {
test('returns flat actions for a resource without nesting', () => {
const actions = getActionStrings('host')
assert.ok(actions.includes('read'))
// @ts-ignore A TypeScript error is thrown because `foo` is not of type `SupportedActions<'vm'>`.
// To test for a negative value, the error is ignored.
assert.strictEqual(actions.includes('foo'), false)
})
test('parent action is included alongside its children', () => {
const actions = getActionStrings('vm')
assert.ok(actions.includes('shutdown'))
assert.ok(actions.includes('shutdown:clean'))
assert.ok(actions.includes('shutdown:hard'))
})
})

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"module": "nodenext",
"lib": ["ESNext"],
"moduleResolution": "nodenext",
"target": "ES2022",
"skipLibCheck": true,
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
"noImplicitAny": false, // otherwise it will throw an error when importing packages without type definition
"noUncheckedIndexedAccess": true,
"declaration": true
},
"include": ["./src/**/*"],
"exclude": ["./node_modules", "./dist"]
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/async-map",
"version": "0.1.2",
"version": "0.1.3",
"license": "ISC",
"description": "Promise.all + map for all iterables",
"keywords": [
@@ -28,7 +28,7 @@
"node": ">=6"
},
"dependencies": {
"lodash": "^4.17.4"
"lodash": "^4.18.0"
},
"scripts": {
"postversion": "npm publish",

View File

@@ -17,7 +17,7 @@
},
"dependencies": {
"@vates/decorate-with": "^2.1.0",
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/log": "^0.7.2",
"golike-defer": "^0.5.1",
"object-hash": "^2.0.1"
},

View File

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

View File

@@ -0,0 +1,21 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended, {
languageOptions: {
parserOptions: {
tsconfigRootDir: __dirname,
},
},
ignores: ['dist'],
rules: {
// Allow empty interface for recursive generic typing (E.g. interface XenApiNetworkWrapped)
'@typescript-eslint/no-empty-object-type': 'off',
},
})

View File

@@ -0,0 +1,55 @@
{
"name": "@xen-orchestra/backup-archive",
"version": "1.0.0",
"description": "Helper to handle generic backup archives",
"license": "ISC",
"author": "",
"type": "module",
"main": "./dist/index.mjs",
"exports": {
".": "./dist/index.mjs",
"./disks": "./dist/disks/index.mjs"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"rimraf": "^6.0.1",
"@types/node": "^22.3.0",
"typescript": "~5.6",
"typescript-eslint": "^8.23.0",
"fs-extra": "^11.1.0",
"sinon": "^18.0.0",
"tar-stream": "^3.1.8",
"@types/tar-stream": "^3.1.4",
"tmp": "^0.2.1",
"@vates/types": "^1.21.0"
},
"dependencies": {
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/fs": "^4.6.7",
"@xen-orchestra/disk-transform": "^1.2.3",
"@vates/task": "0.7.0",
"golike-defer": "0.5.1",
"uuid": "^9.0.0",
"promise-toolbox": "^0.21.0",
"@vates/async-each": "^1.0.1",
"vhd-lib": "^4.14.7"
},
"scripts": {
"build": "yarn run clean && tsc",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"test-integration": "yarn run clean && tsc && node --test **/*.integ.mjs",
"clean": "rimraf dist/"
},
"private": true,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/backup-archive",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@xen-orchestra/backup-archive",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"engines": {
"node": ">=20.17"
}
}

View File

@@ -0,0 +1,409 @@
import { RemoteHandlerAbstract } from '@xen-orchestra/fs'
import { basename, dirname, normalize } from '@xen-orchestra/fs/path'
import {
MergeRemoteDisk,
openDisk,
openDisposableDisk,
RemoteDisk,
isDisk,
openDiskChain,
} from '@xen-orchestra/backup-archive/disks'
import Disposable from 'promise-toolbox/Disposable'
import {
CleanResult,
DEFAULT_MERGE_CONCURRENCY,
DEFAULT_REMOVE_CONCURRENCY,
ResolvedBackupCleanOptions,
} from './VmBackup.types.mjs'
import { asyncEach } from '@vates/async-each'
import { Task } from '@vates/task'
import { limitConcurrency } from 'limit-concurrency-decorator'
import assert from 'node:assert/strict'
const defaultMergeLimiter = limitConcurrency(1)
/**
* Tracks the disk chain for a single VDI across all backup snapshots.
* Owns merge and deletion decisions for its chain given which disks are still
* referenced by active backups (accumulated via addActiveDiskPath).
*/
export class RemoteDiskLineage {
#handler: RemoteHandlerAbstract
#vdiDir: string
#opts: ResolvedBackupCleanOptions
// Concrete disk paths discovered in this VDI directory (normalized)
#diskPaths: Set<string> = new Set()
// Disk paths that exist on disk but failed to open (corrupted)
#brokenDiskPaths: Set<string> = new Set()
// child path: parent path (from disk header)
#parentOf: Map<string, string> = new Map()
// parent path: child path
#childOf: Map<string, string> = new Map()
// Disk paths declared active by their owning archives (accumulated across all referencing archives)
#activeDiskPaths: Set<string> = new Set()
// Interrupted merges: normalized parent path { stateFilePath, chain }
#interruptedMerges: Map<string, { stateFilePath: string; chain?: string[] }> = new Map()
constructor(handler: RemoteHandlerAbstract, vdiDir: string, opts: ResolvedBackupCleanOptions) {
this.#handler = handler
this.#vdiDir = vdiDir
this.#opts = opts
}
#unregisterDisk(diskPath: string): void {
this.#diskPaths.delete(diskPath)
const parent = this.#parentOf.get(diskPath)
if (parent !== undefined) {
this.#parentOf.delete(diskPath)
this.#childOf.delete(parent)
}
const child = this.#childOf.get(diskPath)
if (child !== undefined) {
this.#childOf.delete(diskPath)
this.#parentOf.delete(child)
}
}
/**
* Lists disk files and reads their headers to build the parent-child chain.
* Also detects interrupted merge state files.
*/
async init(): Promise<void> {
const files = await this.#handler.list(this.#vdiDir, { prependDir: true })
for (const filePath of files) {
if (isDisk(filePath)) {
this.#diskPaths.add(normalize(filePath))
}
}
const uuidToPath = new Map<string, string>()
for (const diskPath of this.#diskPaths) {
let disk: RemoteDisk | undefined
try {
disk = await openDisk({ handler: this.#handler, path: diskPath })
} catch (error) {
if (error?.code === 'NOT_SUPPORTED' && this.#opts.merge) {
throw error
}
this.#opts.logWarn('failed to open disk', { path: diskPath, error })
// Broken disks will be removed by the clean phase
// because they are marked as NOT active
this.#brokenDiskPaths.add(diskPath)
continue
}
try {
const uuid = disk.getUuid()
// Detect Disks with the same UUIDs
// Due to a bug introduced in a1bcd35e2
const existingPath = uuidToPath.get(uuid)
if (existingPath !== undefined) {
this.#opts.logWarn('duplicate disk UUID detected', { uuid, path1: existingPath, path2: diskPath })
let existingDisk: RemoteDisk | undefined
try {
existingDisk = await openDisk({ handler: this.#handler, path: existingPath })
if (existingDisk.containsAllDataOf(disk)) {
this.#opts.logWarn('should drop duplicate disk, existing is superset', { dropped: diskPath })
continue
} else if (disk.containsAllDataOf(existingDisk)) {
this.#opts.logWarn('should drop old duplicate disk, new is superset', { dropped: existingPath })
} else {
this.#opts.logWarn('duplicate disks have different content, should first-seen', { dropped: diskPath })
continue
}
} catch (error) {
this.#opts.logWarn('failed to open existing duplicate disk, dropping it', { path: existingPath, error })
this.#unregisterDisk(existingPath)
} finally {
await existingDisk?.close()
}
}
uuidToPath.set(uuid, diskPath)
if (disk.isDifferencing()) {
const parentPath = disk.getParentPath()
this.#parentOf.set(diskPath, parentPath)
if (this.#childOf.has(parentPath)) {
const error = new Error('this script does not support multiple children', {
cause: {
parent: parentPath,
child1: this.#childOf.get(parentPath),
child2: diskPath,
},
})
throw error
}
this.#childOf.set(parentPath, diskPath)
}
} catch (error) {
this.#opts.logWarn('failed to read disk parent info', { path: diskPath, error })
} finally {
await disk.close()
}
}
this.#interruptedMerges = await MergeRemoteDisk.findInterruptedMerges(
this.#handler,
this.#vdiDir,
files,
uuidToPath,
this.#childOf
)
const propagateBroken = (diskPath: string) => {
if (!this.#brokenDiskPaths.has(diskPath)) {
this.#brokenDiskPaths.add(diskPath)
const child = this.#childOf.get(diskPath)
if (child !== undefined) {
propagateBroken(child)
}
}
}
for (const [child, parent] of this.#parentOf) {
if (!this.#diskPaths.has(parent) || this.#brokenDiskPaths.has(parent)) {
this.#opts.logWarn('disk broken: parent missing or broken', { child, parent })
propagateBroken(child)
}
}
}
/**
* Returns true if the disk path exists on disk and opened without errors during init.
* Used by VmIncrementalBackupArchive.check() to detect corrupted disks without extra I/O.
*/
isPathAccessible(diskPath: string): boolean {
return this.#diskPaths.has(diskPath) && !this.#brokenDiskPaths.has(diskPath)
}
/** Returns true if the disk exists but failed to open during init. */
isBroken(diskPath: string): boolean {
return this.#brokenDiskPaths.has(diskPath)
}
/** Verifies that every parent referenced in disk headers exists in the directory. */
async check(): Promise<void> {
for (const [child, parent] of this.#parentOf) {
if (!this.#diskPaths.has(parent)) {
this.#opts.logWarn('parent disk is missing', { child, parent })
}
}
}
/**
* Declares disk paths as active for this lineage.
* Called by each archive that references this lineage after it determines it is complete.
* Accumulated across all referencing archives before clean() is called.
*/
addActiveDiskPath(diskPath: string): void {
this.#activeDiskPaths.add(normalize(diskPath))
}
/**
* For each orphan disk:
* - If it has an active descendant in the chain → build a merge chain and merge (if opts.merge).
* - If it has no active descendant → delete it (if opts.remove).
*
* Also resumes interrupted merges (detected via .merge.json files) when opts.merge is true.
* Deletes orphan merge state files (for missing disks) when opts.remove is true.
*
* Requires activeDiskPaths to be completed to have been called by all referencing archives before this.
* @returns Set of deleted disk paths.
*/
async clean({
remove = this.#opts.remove ?? false,
merge = this.#opts.merge ?? false,
}: { remove?: boolean; merge?: boolean } = {}): Promise<CleanResult> {
const orphanDisks = new Set([...this.#diskPaths].filter(p => !this.#activeDiskPaths.has(p)))
const toDelete = new Set<string>()
const toMerge: { chain: string[]; isResuming: boolean }[] = []
const visited = new Set<string>()
// Walk from an orphan toward its descendants.
// Returns a merge chain [orphan, ..., activeAnchor] if an active child exists,
// or undefined if the subtree is entirely orphaned (mark for deletion).
const getUsedChildChainOrDelete = (diskPath: string): string[] | undefined => {
if (visited.has(diskPath)) {
return undefined
}
visited.add(diskPath)
if (!orphanDisks.has(diskPath)) {
// Active disk, anchor of the merge chain
return [diskPath]
}
const child = this.#childOf.get(diskPath)
if (child !== undefined) {
const chain = getUsedChildChainOrDelete(child)
if (chain !== undefined) {
chain.unshift(diskPath)
return chain
}
}
toDelete.add(diskPath)
return undefined
}
// Process interrupted merges first so their disks are protected from the orphan loop
for (const [parentPath, { stateFilePath, chain: stateChain }] of this.#interruptedMerges) {
if (!this.#diskPaths.has(parentPath)) {
this.#opts.logWarn('orphan merge state', { stateFilePath, missingDisk: parentPath })
if (remove) {
this.#opts.logInfo('deleting orphan merge state', { stateFilePath })
await this.#handler.unlink(stateFilePath)
}
continue
}
let chain: string[] | undefined
if (stateChain !== undefined) {
const existing = stateChain.filter(p => this.#diskPaths.has(p))
if (existing.length >= 2) chain = existing
}
if (chain !== undefined) {
chain.forEach(p => visited.add(p))
toMerge.push({ chain, isResuming: true })
}
}
for (const orphan of orphanDisks) {
if (!visited.has(orphan)) {
const parent = this.#parentOf.get(orphan)
if (parent === undefined || !orphanDisks.has(parent)) {
const chain = getUsedChildChainOrDelete(orphan)
if (chain !== undefined && chain.length > 1) {
toMerge.push({ chain, isResuming: false })
}
}
}
}
if (remove) {
await asyncEach(
Array.from(toDelete),
async path => {
this.#opts.logInfo('deleting unused disk', { path })
try {
const disk = await openDisk({ handler: this.#handler, path, ignoreBlockIndexes: true })
await disk.unlink({ force: true })
} catch (error) {
this.#opts.logWarn('failed to delete unused disk', { path, error })
}
},
{ concurrency: DEFAULT_REMOVE_CONCURRENCY }
)
}
if (toMerge.length > 0) {
this.#opts.logInfo('Disk chain needs merging', { count: toMerge.length })
}
// mergeTargetPath: final size of the disk everything was merged into
const mergedSizes = new Map<string, number>()
if (merge) {
const limitedMergeChain = defaultMergeLimiter(this.#mergeChain.bind(this))
const doMerge = async () => {
await asyncEach(
toMerge,
async ({ chain, isResuming }) => {
const { finalDiskSize, mergeTargetPath } = await limitedMergeChain(chain, isResuming)
mergedSizes.set(mergeTargetPath, (mergedSizes.get(mergeTargetPath) ?? 0) + finalDiskSize)
},
{ concurrency: this.#opts.mergeConcurrency ?? DEFAULT_MERGE_CONCURRENCY }
)
}
if (toMerge.length !== 0) {
await Task.run({ properties: { name: 'merge' } }, doMerge)
}
}
await this.#cleanOrphanDataFiles(remove)
return {
removedFiles: Array.from(toDelete),
merge: toMerge.length > 0,
size: [...mergedSizes.values()].reduce((total, subSize) => total + subSize, 0),
mergedSizes,
}
}
/**
* Merges ancestors into the active child at the end of the chain.
* Returns the final size of the merge target and its path.
*/
async #mergeChain(chain: string[], isResuming: boolean): Promise<{ finalDiskSize: number; mergeTargetPath: string }> {
assert.ok(chain.length >= 2, `look to merge a chain shorter than 2 ${JSON.stringify(chain)}`)
const parentPath = chain[0]
this.#opts.logInfo('merging disk chain', { chain: [...chain] }) // need a copy here to log full chain
// The last disk in the chain is the active one that everything gets merged into
const mergeTargetPath = chain.pop()!
const merger = new MergeRemoteDisk(this.#handler, {
logInfo: this.#opts.logInfo,
removeUnused: this.#opts.remove,
mergeBlockConcurrency: this.#opts.mergeBlockConcurrency,
onProgress: this.#opts.onProgress,
})
let parentDisk: RemoteDisk | undefined
let childDisk: RemoteDisk | undefined
try {
parentDisk = await openDisk({ handler: this.#handler, path: parentPath, force: isResuming })
childDisk = await openDiskChain({
handler: this.#handler,
path: mergeTargetPath,
until: parentPath,
force: isResuming,
})
const { finalDiskSize } = (await merger.merge(parentDisk, childDisk!))!
return { finalDiskSize, mergeTargetPath }
} finally {
await parentDisk?.close().catch(error => {
this.#opts.logWarn('Error closing parent Disk', error)
})
await childDisk?.close().catch(error => {
this.#opts.logWarn('Error closing child Disk', error)
})
}
}
/**
* Validates each disk integrity, then scans discovered data subdirs
* and deletes any files not associated to a known disk.
*/
async #cleanOrphanDataFiles(remove: boolean): Promise<void> {
const claimedFiles = new Set<string>()
for (const diskPath of this.#diskPaths) {
const claimed = await Disposable.use(
openDisposableDisk({ handler: this.#handler, path: diskPath, ignoreBlockIndexes: true }),
async disk => {
await disk.clean({ remove, logWarn: this.#opts.logWarn, logInfo: this.#opts.logInfo })
return disk.listAssociatedFiles(this.#vdiDir)
}
)
for (const f of claimed) claimedFiles.add(normalize(f))
}
const dataDirs = new Set([...claimedFiles].map(p => dirname(normalize(p))).filter(p => p !== this.#vdiDir))
for (const dataDir of dataDirs) {
const items = await this.#handler.list(dataDir, { prependDir: true }).catch(() => [] as string[])
for (const item of items) {
if (!claimedFiles.has(normalize(item))) {
this.#opts.logWarn('orphaned data file', { path: item })
if (remove) {
await this.#handler.unlink(item).catch(async err => {
if (err?.code === 'EISDIR') {
await this.#handler.rmtree(item)
} else {
this.#opts.logWarn('failed to delete orphaned data file', { path: item, error: err })
}
})
}
}
}
}
}
}

View File

@@ -0,0 +1,69 @@
import { RemoteHandlerAbstract } from '@xen-orchestra/fs'
export interface PartialBackupMetadata {
mode: 'full' | 'delta'
xva?: string
vhds?: Record<string, string>
vdis?: Record<string, unknown>
isVhdDifferencing?: Record<string, boolean>
size?: number
jobId: string
scheduleId: string
timestamp: number
}
export const DEFAULT_MERGE_CONCURRENCY = 1
export const DEFAULT_REMOVE_CONCURRENCY = 4
export interface BackupCleanOptions {
fix?: boolean
merge?: boolean
remove?: boolean
mergeBlockConcurrency?: number
mergeConcurrency?: number
onProgress?: (progress: { total: number; done: number }) => void
logInfo?: (message: any, opts?: object) => void
logWarn?: (message: any, opts?: object) => void
}
export type ResolvedBackupCleanOptions = BackupCleanOptions & {
logInfo: (message: any, opts?: object) => void
logWarn: (message: any, opts?: object) => void
}
export interface ArchiveCleanOptions {
remove?: boolean
merge?: boolean
mergedSizes?: Map<string, number>
}
export interface CheckResult {
isValid: boolean
// Files referenced by metadata but absent on disk
missingDisks?: string[]
// Disk files that exist but failed to open (corrupted)
brokenDisks?: string[]
// Files present on disk but not referenced by metadata
orphans?: string[]
// Files referenced by at least one valid backup
linked?: string[]
}
export type CleanResult = {
removedFiles: string[]
merge?: boolean
size?: number
mergedSizes?: Map<string, number>
}
export interface VmBackupInterface {
handler: RemoteHandlerAbstract
// metadataPath: string
// metadata: PartialBackupMetadata
rootPath: string
opts: BackupCleanOptions
init(): Promise<void>
check(): Promise<CheckResult>
clean(opts?: ArchiveCleanOptions): Promise<CleanResult>
getAssociatedFiles(opts: object): Array<string>
}

View File

@@ -0,0 +1,284 @@
import { RemoteHandlerAbstract } from '@xen-orchestra/fs'
import { basename, normalize } from '@xen-orchestra/fs/path'
import { resolve } from 'node:path'
import { VmFullBackupArchive } from './VmFullBackupArchive.mjs'
import { VmIncrementalBackupArchive } from './VmIncrementalBackupArchive.mjs'
import { RemoteDiskLineage } from './RemoteDiskLineage.mjs'
import {
ArchiveCleanOptions,
BackupCleanOptions,
CheckResult,
CleanResult,
VmBackupInterface,
PartialBackupMetadata,
ResolvedBackupCleanOptions,
DEFAULT_MERGE_CONCURRENCY,
} from './VmBackup.types.mjs'
import { cleanOrphanDiskDirs } from '@xen-orchestra/backup-archive/disks'
import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
import { promisify } from 'node:util'
import zlib from 'node:zlib'
const gzip = promisify(zlib.gzip)
const gunzip = promisify(zlib.gunzip)
const { info: logInfo, warn: logWarn } = createLogger('xo:backup-archive')
const FILES_TO_KEEP = ['cache.json.gz', 'vdis']
export class VmBackupDirectory implements VmBackupInterface {
handler: RemoteHandlerAbstract
rootPath: string
files: Array<string> = new Array()
orphans: Set<string> = new Set()
backupArchives: Map<string, VmBackupInterface> = new Map()
opts: ResolvedBackupCleanOptions
// Cached result of the last check() call; invalidated by init()
#checkResult: (CheckResult & { orphans: string[]; linked: string[] }) | undefined = undefined
#uniqueLineages: Map<string, RemoteDiskLineage> | undefined = undefined
constructor(
handler: RemoteHandlerAbstract,
vmBackupPath: string,
opts: BackupCleanOptions = {
fix: true,
merge: false,
remove: false,
logInfo,
logWarn,
}
) {
this.handler = handler
this.rootPath = vmBackupPath
this.opts = {
...opts,
fix: opts.fix ?? true,
merge: opts.merge ?? false,
remove: opts.remove ?? false,
logInfo: opts.logInfo ?? logInfo,
logWarn: opts.logWarn ?? logWarn,
}
}
async #readCache(path: string): Promise<Record<string, unknown> | undefined> {
try {
return JSON.parse((await gunzip(await this.handler.readFile(path))).toString())
} catch (error) {
if (error?.code !== 'ENOENT') {
logWarn('failed to read cache', { error, path })
}
}
}
async #writeCache(path: string, data: Record<string, unknown>): Promise<void> {
try {
await this.handler.writeFile(path, await gzip(JSON.stringify(data)), { flags: 'w' })
} catch (error) {
logWarn('failed to write cache', { error, path })
}
}
async init() {
this.files = (await this.handler.list(this.rootPath, { prependDir: true })).map(file => normalize(file))
this.#checkResult = undefined
this.#uniqueLineages = new Map()
for (const fullPath of this.files.filter(path => path.endsWith('.json'))) {
let metadata: PartialBackupMetadata | undefined = undefined
try {
metadata = JSON.parse(await this.handler.readFile(fullPath)) satisfies PartialBackupMetadata
} catch (error) {
this.opts.logWarn(`Issue loading ${basename(fullPath)}`)
}
if (metadata !== undefined) {
try {
const backupArchive = await this.instantiateBackupArchive(fullPath, metadata)
this.backupArchives.set(fullPath, backupArchive)
} catch (error) {
if (error?.code === 'NOT_SUPPORTED') throw error
this.opts.logWarn(`Issue loading ${metadata.xva ?? metadata.vhds}`, { json: fullPath, backup: metadata })
}
}
}
}
getAssociatedFiles({ prefix = false }) {
const files = this.files.filter(file => FILES_TO_KEEP.some(pattern => file.endsWith(pattern)))
return prefix ? files : files.map(file => basename(file))
}
async check(): Promise<CheckResult & { orphans: string[]; linked: string[] }> {
await this.#checkCacheCount()
for (const backupArchive of this.backupArchives.values()) {
await backupArchive.check()
}
// allUsedFiles is used for root-level orphan detection only.
// Active disk paths are accumulated per-lineage by each archive during check() above.
const allUsedFiles = new Set<string>([
...Array.from(this.backupArchives.values()).flatMap(archive => archive.getAssociatedFiles({ prefix: true })),
...this.getAssociatedFiles({ prefix: true }),
])
for (const lineage of this.#uniqueLineages!.values()) {
await lineage.check()
}
const orphans = this.files.filter(file => !allUsedFiles.has(file))
const linked = Array.from(allUsedFiles)
this.#checkResult = { isValid: orphans.length === 0, orphans, linked }
return this.#checkResult
}
async clean({
remove = this.opts.remove ?? false,
merge = this.opts.merge ?? false,
}: ArchiveCleanOptions = {}): Promise<CleanResult> {
// Use cached check result if available, otherwise run check now
const { orphans } = this.#checkResult ?? (await this.check())
let cacheNeedsRegen = false
let someLineageMergedOrShouldBe = false
// Merge/delete orphan disks in VDI directories covered by archives; collect merged sizes per disk path
const allMergedSizes = new Map<string, number>()
await asyncEach(
Array.from(this.#uniqueLineages!.entries()),
async ([_vdiDir, lineage]) => {
const { mergedSizes, removedFiles, merge: hasPendingMerge } = await lineage.clean({ remove, merge })
if (removedFiles.length > 0) {
cacheNeedsRegen = true
}
if (hasPendingMerge) {
someLineageMergedOrShouldBe = true
}
for (const [diskPath, size] of mergedSizes || []) {
allMergedSizes.set(diskPath, (allMergedSizes.get(diskPath) ?? 0) + size)
}
},
{ concurrency: DEFAULT_MERGE_CONCURRENCY }
)
// Delete VDI directories not referenced by any archive
const coveredDirs = new Set(this.#uniqueLineages!.keys())
await cleanOrphanDiskDirs(this.handler, this.rootPath, coveredDirs, {
remove,
logWarn: this.opts.logWarn,
logInfo: this.opts.logInfo,
})
// Let each archive clean its own files (e.g. remove metadata for incomplete backups)
// and update metadata with merged sizes if applicable
await asyncEach(
Array.from(this.backupArchives.values()),
async (archive: VmBackupInterface) => {
const { removedFiles } = await archive.clean({ remove, mergedSizes: allMergedSizes })
if (removedFiles.length > 0) {
cacheNeedsRegen = true
}
},
{ concurrency: 2 }
)
if (allMergedSizes.size > 0 || cacheNeedsRegen) {
await this.#regenerateCache()
}
// Delete root-level orphan files (stray xva, checksum, json, etc.), skip folders
if (remove) {
await asyncEach(
orphans,
async (orphan: string) => {
try {
await this.handler.unlink(orphan)
} catch (error) {
if (error?.code === 'EISDIR') {
this.opts.logWarn('orphan is a directory, skipping deletion', { path: orphan })
} else if (error?.code !== 'ENOENT') {
throw error
}
}
},
{ concurrency: 2 }
)
}
const size = [...allMergedSizes.values()].reduce((total, merged) => total + merged, 0)
return { removedFiles: orphans, merge: someLineageMergedOrShouldBe, size: size }
}
async #checkCacheCount(): Promise<void> {
const cachePath = `${this.rootPath}/cache.json.gz`
const existingCache = await this.#readCache(cachePath)
const actual = existingCache === undefined ? 0 : Object.keys(existingCache).length
const expected = this.backupArchives.size
if (actual !== expected) {
this.opts.logWarn('unexpected number of entries in backup cache', { path: cachePath, actual, expected })
}
}
async #regenerateCache(): Promise<void> {
const cachePath = `${this.rootPath}/cache.json.gz`
const cache: Record<string, object> = {}
for (const [path, archive] of this.backupArchives.entries()) {
const a = archive as VmFullBackupArchive | VmIncrementalBackupArchive
cache[path] = { _filename: path, id: path, ...a.metadata }
}
await this.#writeCache(cachePath, cache)
}
async instantiateBackupArchive(metadataPath: string, metadata: PartialBackupMetadata) {
let backupArchive: VmBackupInterface
try {
if (metadata.mode === 'full') {
backupArchive = new VmFullBackupArchive(
this.handler,
this.rootPath,
metadataPath,
metadata,
resolve('/', this.rootPath, metadata.xva!),
this.opts
)
} else if (metadata.mode === 'delta') {
const rawDiskPaths = metadata.vhds
const diskPaths =
rawDiskPaths !== undefined ? Object.values(rawDiskPaths).map(p => resolve('/', this.rootPath, p)) : []
backupArchive = new VmIncrementalBackupArchive(
this.handler,
this.rootPath,
metadataPath,
metadata,
diskPaths,
this.opts,
this.#uniqueLineages
)
} else {
throw new Error(`Mode ${metadata.mode} not supported`)
}
} catch (error) {
this.opts.logWarn(`Error trying to create backupArchive from ${metadataPath}`, { metadata })
throw new Error(`Error trying to create backupArchive from ${metadataPath}`, { cause: error })
}
await backupArchive.init()
return backupArchive
}
/**
* Creates a fresh instance with the given handler/path/opts, then runs init/check/clean.
* The `lock` option is accepted but ignored (locking is the caller's responsibility).
*/
static async cleanVm(
handler: RemoteHandlerAbstract,
vmBackupPath: string,
opts: BackupCleanOptions & { lock?: boolean } = {}
) {
const { lock: _lock, ...cleanOpts } = opts
const dir = new VmBackupDirectory(handler, vmBackupPath, cleanOpts)
await dir.init()
await dir.check()
const { merge, size } = await dir.clean({ remove: cleanOpts.remove, merge: cleanOpts.merge })
return { merge, size }
}
}

View File

@@ -0,0 +1,147 @@
import test from 'node:test'
import { strict as assert } from 'node:assert'
import tmp from 'tmp'
import fs from 'fs-extra'
import * as uuid from 'uuid'
import { getHandler } from '@xen-orchestra/fs'
import { pFromCallback } from 'promise-toolbox'
import { rimraf } from 'rimraf'
// eslint-disable-next-line n/no-missing-import
import { VmBackupDirectory } from '../dist/VmBackupDirectory.mjs'
import tar from 'tar-stream'
const { beforeEach, afterEach, describe } = test
let tempDir, handler, vmBackupDir
const vmUuid = 'test-vm-uuid'
const rootPath = `xo-vm-backups/${vmUuid}`
async function createMinimalXva() {
const pack = tar.pack()
pack.entry({ name: 'ova.xml' }, '<value><struct/></value>')
pack.finalize()
const chunks = []
for await (const chunk of pack) {
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
beforeEach(async () => {
tempDir = await pFromCallback(cb => tmp.dir(cb))
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
await fs.mkdirp(`${tempDir}/${rootPath}`)
vmBackupDir = new VmBackupDirectory(handler, rootPath)
})
afterEach(async () => {
await rimraf(tempDir)
await handler.forget()
})
const uniqueId = () => uuid.v1()
async function createFullBackupMetadata(name, xvaName) {
await handler.writeFile(`${rootPath}/${xvaName}`, await createMinimalXva())
const size = await handler.getSize(`${rootPath}/${xvaName}`)
const metadata = {
mode: 'full',
xva: `./${xvaName}`,
vm: { uuid: vmUuid },
jobId: uniqueId(),
timestamp: Date.now(),
size,
}
await handler.writeFile(`${rootPath}/${name}`, JSON.stringify(metadata))
return metadata
}
describe('VmBackupDirectory with full backups', { concurrency: 1 }, () => {
test('init() loads full backup archives from metadata files', async () => {
await createFullBackupMetadata('backup1.json', 'backup1.xva')
await createFullBackupMetadata('backup2.json', 'backup2.xva')
await vmBackupDir.init()
assert.equal(vmBackupDir.backupArchives.size, 2)
})
test('getAssociatedFiles() returns cache files', async () => {
// Create a cache file
await handler.writeFile(`${rootPath}/cache.json.gz`, 'cache-content')
await vmBackupDir.init()
const filesWithPrefix = vmBackupDir.getAssociatedFiles({ prefix: true })
const filesWithoutPrefix = vmBackupDir.getAssociatedFiles({ prefix: false })
assert.equal(filesWithPrefix.length, 1)
assert.ok(filesWithPrefix[0].endsWith('cache.json.gz'))
assert.deepEqual(filesWithoutPrefix, ['cache.json.gz'])
})
test('check() identifies orphan files', async () => {
// Create a valid backup
await createFullBackupMetadata('backup1.json', 'backup1.xva')
// Create orphan files (not referenced by any metadata)
await handler.writeFile(`${rootPath}/orphan.xva`, 'orphan-content')
await handler.writeFile(`${rootPath}/random-file.txt`, 'random')
await vmBackupDir.init()
const { orphans, linked } = await vmBackupDir.check()
assert.ok(orphans.some(f => f.includes('orphan.xva')))
assert.ok(orphans.some(f => f.includes('random-file.txt')))
assert.ok(linked.some(f => f.includes('backup1.json')))
assert.ok(linked.some(f => f.includes('backup1.xva')))
})
test('clean() removes orphan files', async () => {
await createFullBackupMetadata('backup1.json', 'backup1.xva')
await handler.writeFile(`${rootPath}/orphan.xva`, 'orphan-content')
await vmBackupDir.init()
await vmBackupDir.clean({ remove: true })
const remainingFiles = await handler.list(rootPath)
assert.ok(remainingFiles.includes('backup1.json'))
assert.ok(remainingFiles.includes('backup1.xva'))
assert.ok(!remainingFiles.includes('orphan.xva'))
})
test('clean() preserves cache.json.gz', async () => {
await createFullBackupMetadata('backup1.json', 'backup1.xva')
await handler.writeFile(`${rootPath}/cache.json.gz`, 'cache')
await vmBackupDir.init()
await vmBackupDir.clean()
const remainingFiles = await handler.list(rootPath)
assert.equal(remainingFiles.length, 3)
assert.ok(remainingFiles.includes('cache.json.gz'))
})
test('clean() removes an orphan XVA checksum file (no matching metadata or XVA)', async () => {
await handler.writeFile(`${rootPath}/stray.xva.checksum`, 'sha256:deadbeef')
await vmBackupDir.init()
await vmBackupDir.clean({ remove: true })
const remainingFiles = await handler.list(rootPath)
assert.ok(!remainingFiles.includes('stray.xva.checksum'), 'orphan XVA checksum should be deleted')
})
test('clean() keeps an XVA checksum file alongside its valid full backup', async () => {
await createFullBackupMetadata('backup1.json', 'backup1.xva')
await handler.writeFile(`${rootPath}/backup1.xva.checksum`, 'sha256:abc123')
await vmBackupDir.init()
await vmBackupDir.clean({ remove: true })
const remainingFiles = await handler.list(rootPath)
assert.ok(remainingFiles.includes('backup1.xva'), 'XVA should be kept')
assert.ok(vmBackupDir.backupArchives.get(`/${rootPath}/backup1.json`).isValid)
assert.ok(remainingFiles.includes('backup1.xva.checksum'), 'checksum should be kept')
})
})

View File

@@ -0,0 +1,168 @@
import { basename, normalize } from '@xen-orchestra/fs/path'
import assert from 'node:assert'
import { FileDescriptor } from '@xen-orchestra/fs'
import {
ArchiveCleanOptions,
CheckResult,
CleanResult,
VmBackupInterface,
PartialBackupMetadata,
ResolvedBackupCleanOptions,
} from './VmBackup.types.mjs'
import { RemoteHandlerAbstract } from '@xen-orchestra/fs'
const COMPRESSED_MAGIC_NUMBERS: Buffer[] = [
// https://tools.ietf.org/html/rfc1952.html#page-5
Buffer.from('1F8B', 'hex'),
// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#zstandard-frames
Buffer.from('28B52FFD', 'hex'),
]
const MAGIC_NUMBER_MAX_LENGTH: number = Math.max(...COMPRESSED_MAGIC_NUMBERS.map(buf => buf.length))
const isCompressedFile = async (handler: RemoteHandlerAbstract, fd: FileDescriptor): Promise<boolean> => {
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
}
const isValidTar = async (handler: RemoteHandlerAbstract, size: number, fd: FileDescriptor): Promise<boolean> => {
if (size <= 1024 || size % 512 !== 0) {
return false
}
const buf = Buffer.allocUnsafe(1024)
assert.strictEqual((await handler.read(fd, buf, size - buf.length)).bytesRead, buf.length)
return buf.every(byte => byte === 0)
}
export async function isValidXva(handler: RemoteHandlerAbstract, filePath: string): Promise<boolean> {
// size is longer when encrypted + reading part of an encrypted file is not implemented
if (handler.isEncrypted) {
return true
}
const fd = await handler.openFile(filePath, 'r')
try {
const size = await handler.getSize(filePath)
if (size < 20) {
// neither a valid gzip nor tar
return false
}
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)
}
}
const noop = (): void => {}
export class VmFullBackupArchive implements VmBackupInterface {
handler: RemoteHandlerAbstract
rootPath: string
xvaPath: string
isValid?: boolean
missingDisk = false
metadataPath: string
metadata: PartialBackupMetadata
opts: ResolvedBackupCleanOptions
constructor(
handler: RemoteHandlerAbstract,
rootPath: string, // xo-vm-backups/<vmUuid>/
metadataPath: string, // xo-vm-backups/<vmUuid>/<timestamp>_<scheduleId>.json
metadata: PartialBackupMetadata,
xvaPath: string, // xo-vm-backups/<vmUuid>/<timestamp>_<scheduleId>.xva
opts: ResolvedBackupCleanOptions
) {
this.handler = handler
this.rootPath = normalize(rootPath)
this.metadataPath = normalize(metadataPath)
this.metadata = metadata
this.xvaPath = normalize(xvaPath)
this.opts = opts
}
async init() {}
/**
* As we are not 100% sure if XVA is valid,
* we prefer to warn if it is the case but not remove it.
*/
async check(): Promise<CheckResult> {
if (this.isValid === undefined) {
let fileSize = 0
let validDisk = false
let validSize = true
try {
fileSize = await this.handler.getSize(this.xvaPath)
validDisk = await isValidXva(this.handler, this.xvaPath)
} catch (error) {
validDisk = false
if (error?.code === 'ENOENT') {
this.missingDisk = true
}
this.opts.logWarn('Issue while checking XVA', { error })
}
try {
await this.handler.getSize(`${this.xvaPath}.checksum`)
} catch (error) {
this.opts.logWarn('Checksum file not valid, not blocking', { error })
}
if (this.metadata.size !== undefined) {
validSize = this.metadata.size === fileSize
}
this.isValid = fileSize > 0 && validSize && validDisk
}
if (!this.isValid) {
this.opts.logWarn('XVA might be broken', { path: this.xvaPath })
}
return { isValid: this.isValid }
}
async clean({ remove = this.opts.remove ?? false }: ArchiveCleanOptions = {}): Promise<CleanResult> {
if (this.isValid === undefined) {
await this.check()
}
const removedFiles: string[] = []
if (!this.isValid && this.missingDisk) {
removedFiles.push(this.metadataPath, `${this.xvaPath}.checksum`)
}
if (remove) {
if (!this.isValid) {
if (!this.missingDisk) {
this.opts.logWarn(`This files may be corrupted but not yet to be removed`, {
files: this.getAssociatedFiles({ prefix: false }),
})
} else {
for (const file of removedFiles) {
try {
await this.handler.unlink(file)
} catch (error) {
this.opts.logWarn(`Issue removing ${file}`, { error })
}
}
}
}
}
return { removedFiles, merge: false }
}
getAssociatedFiles({ prefix = false }): Array<string> {
let validFiles = [this.metadataPath, this.xvaPath, `${this.xvaPath}.checksum`]
return prefix ? validFiles : validFiles.map(file => basename(file))
}
}

View File

@@ -0,0 +1,688 @@
import test from 'node:test'
import { strict as assert } from 'node:assert'
import tmp from 'tmp'
import fs from 'fs-extra'
import * as uuid from 'uuid'
import { getHandler } from '@xen-orchestra/fs'
import { pFromCallback } from 'promise-toolbox'
// eslint-disable-next-line n/no-missing-import
import { VmBackupDirectory } from '../dist/VmBackupDirectory.mjs'
import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
import { VhdFile, Constants, VhdDirectory, VhdAbstract } from 'vhd-lib'
import { dirname, basename } from 'node:path'
import { rimraf } from 'rimraf'
const { beforeEach, afterEach, describe } = test
let tempDir, handler, jobId, vdiId, basePath, relativePath, vdiId2, basePath2, relativePath2
const rootPath = 'xo-vm-backups/VMUUID/'
beforeEach(async () => {
tempDir = await pFromCallback(cb => tmp.dir(cb))
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
jobId = uniqueId()
vdiId = uniqueId()
relativePath = `vdis/${jobId}/${vdiId}`
basePath = `${rootPath}/${relativePath}`
vdiId2 = uniqueId()
relativePath2 = `vdis/${jobId}/${vdiId2}`
basePath2 = `${rootPath}/${relativePath2}`
await fs.mkdirp(`${tempDir}/${basePath}`)
await fs.mkdirp(`${tempDir}/${basePath2}`)
})
afterEach(async () => {
await rimraf(tempDir)
await handler.forget()
})
const uniqueId = () => uuid.v1()
const uniqueIdBuffer = () => uuid.v1({}, Buffer.alloc(16))
async function generateVhd(path, opts = {}) {
let vhd
let dataPath = path
if (opts.useAlias) {
await handler.mkdir(dirname(path) + '/data/')
dataPath = dirname(path) + '/data/' + basename(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, uuid: uniqueIdBuffer() }
if (vhd.header.parentUuid) {
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)
}
if (opts.blocks) {
for (const blockId of opts.blocks) {
await vhd.writeEntireBlock({ id: blockId, buffer: Buffer.alloc(2 * 1024 * 1024 + 512, blockId) })
}
}
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')
// metadata references a non-existent disk so the lineage is created for this vdiDir
// and notReallyAVhd.vhd is treated as an unreferenced orphan
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/nonexistent.vhd`] })
)
assert.equal((await handler.list(basePath)).length, 1)
let logged = ''
const logInfo = message => {
logged += message
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: false, logInfo, logWarn: logInfo })
assert.ok(logged.includes('failed to open disk'))
// not removed
assert.deepEqual(await handler.list(basePath), ['notReallyAVhd.vhd'])
// really remove it
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logInfo, logWarn: () => {} })
assert.deepEqual(await handler.list(basePath), [])
})
test('it remove vhd with missing or multiple ancestors', async () => {
// one with a broken parent, should be deleted
await generateVhd(`${basePath}/abandoned.vhd`, {
header: {
parentUnicodeName: 'gone.vhd',
parentUuid: uniqueIdBuffer(),
},
})
// one orphan, which is a full vhd, no parent : should stay
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
// a child to the orphan in the metadata : should stay
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'orphan.vhd',
parentUuid: orphan.footer.uuid,
},
})
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [`${relativePath}/child.vhd`, `${relativePath}/abandoned.vhd`],
})
)
// clean
let logged = ''
const logInfo = message => {
logged += message + '\n'
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logInfo, logWarn: logInfo })
// abandoned has a missing parent, orphan and child have no active backup reference
const detectedMissingParent = logged.match(/parent disk is missing/g) || []
assert.equal(detectedMissingParent.length, 1)
// we don't test the file on disk, since they will all be marked as unused and deleted without a metadata.json file
})
test('it remove backup metadata referencing a missing vhd in delta backup', async () => {
// create a metadata file marking child and orphan as ok
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${relativePath}/orphan.vhd`,
`${relativePath}/child.vhd`,
// abandoned.json is not here
],
})
)
await generateVhd(`${basePath}/abandoned.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',
parentUuid: orphan.footer.uuid,
},
})
let logged = ''
const logInfo = message => {
logged += message + '\n'
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logInfo, logWarn: logInfo })
let matched = logged.match(/deleting unused disk/g) || []
assert.equal(matched.length, 1) // only one vhd should have been deleted
// a missing vhd cause clean to remove all vhds
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${relativePath}/deleted.vhd`, // in metadata but missing from disk
`${relativePath}/orphan.vhd`,
`${relativePath}/child.vhd`,
// abandoned.vhd is not here anymore
],
}),
{ flags: 'w' }
)
logged = ''
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logInfo, logWarn: () => {} })
matched = logged.match(/deleting unused disk/g) || []
assert.equal(matched.length, 2) // all vhds (orphan and child ) should have been deleted
assert.ok(
(await handler.list(rootPath)).includes('cache.json.gz'),
'cache.json.gz should be regenerated after metadata deletion'
)
})
test('it removes metadata and broken vhd when an active backup references a corrupt disk', async () => {
// A VHD that exists on disk but is corrupted, openDisk will fail during lineage init
await handler.writeFile(`${basePath}/broken.vhd`, 'I AM NOT A VHD')
// Metadata references the corrupt VHD as if it were a valid active disk
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [`${relativePath}/broken.vhd`],
})
)
let logged = ''
const logInfo = message => {
logged += message
}
await VmBackupDirectory.cleanVm(handler, rootPath, {
remove: true,
logInfo: () => {},
logWarn: logInfo,
})
assert.ok(logged.includes('disk check error'), 'should warn that the disk is broken')
assert.ok(logged.includes('incremental backup is incomplete'), 'backup should be flagged as incomplete')
// metadata removed because backup is incomplete
assert.ok(!(await handler.list(rootPath)).includes('metadata.json'), 'metadata should be deleted')
// broken VHD removed via the orphan path in lineage.clean()
assert.deepEqual(await handler.list(basePath), [], 'broken VHD should be deleted')
})
test('it preserves all disks when multiple backups reference the same VDI directory', async () => {
// Three delta backups share the same VDI directory: snapshot1 ← snapshot2 ← snapshot3.
// Each snapshot is the active disk for exactly one backup metadata file.
//
// Regression guard for a shared-lineage bug: each VmIncrementalBackupArchive creates its
// own RemoteDiskLineage for the same vdiDir; only the first archive's lineage reaches
// RemoteDiskLineage.clean(). The second and third archives mark their disks as active on
// their own (dead) lineage instances, so snapshot2 and snapshot3 appear orphaned and are
// deleted — destroying two live backups.
const snapshot1 = await generateVhd(`${basePath}/snapshot1.vhd`)
const snapshot2 = await generateVhd(`${basePath}/snapshot2.vhd`, {
header: { parentUnicodeName: 'snapshot1.vhd', parentUuid: snapshot1.footer.uuid },
})
await generateVhd(`${basePath}/snapshot3.vhd`, {
header: { parentUnicodeName: 'snapshot2.vhd', parentUuid: snapshot2.footer.uuid },
})
await handler.writeFile(
`${rootPath}/metadata1.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/snapshot1.vhd`] })
)
await handler.writeFile(
`${rootPath}/metadata2.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/snapshot2.vhd`] })
)
await handler.writeFile(
`${rootPath}/metadata3.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/snapshot3.vhd`] })
)
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logWarn: () => {} })
const remaining = await handler.list(basePath)
assert.ok(remaining.includes('snapshot1.vhd'), 'snapshot1.vhd must survive (referenced by metadata1)')
assert.ok(remaining.includes('snapshot2.vhd'), 'snapshot2.vhd must survive (referenced by metadata2)')
assert.ok(remaining.includes('snapshot3.vhd'), 'snapshot3.vhd must survive (referenced by metadata3)')
assert.equal(remaining.length, 3, 'no extra files should remain')
// All three metadata files must also survive (all backups are complete)
const rootFiles = await handler.list(rootPath)
assert.ok(rootFiles.includes('metadata1.json'), 'metadata1.json must survive')
assert.ok(rootFiles.includes('metadata2.json'), 'metadata2.json must survive')
assert.ok(rootFiles.includes('metadata3.json'), 'metadata3.json must survive')
})
test('it handles multiple VDI directories independently', async () => {
// vdiDir1: diskA1 (base) <- diskA2, plus an orphan
const diskA1 = await generateVhd(`${basePath}/diskA1.vhd`)
await generateVhd(`${basePath}/diskA2.vhd`, {
header: { parentUnicodeName: 'diskA1.vhd', parentUuid: diskA1.footer.uuid },
})
await generateVhd(`${basePath}/orphanA.vhd`)
// vdiDir2: diskB1 (base) <- diskB2, plus an orphan
const diskB1 = await generateVhd(`${basePath2}/diskB1.vhd`)
await generateVhd(`${basePath2}/diskB2.vhd`, {
header: { parentUnicodeName: 'diskB1.vhd', parentUuid: diskB1.footer.uuid },
})
await generateVhd(`${basePath2}/orphanB.vhd`)
// archive1 covers both base disks, archive2 covers both child disks
await handler.writeFile(
`${rootPath}/metadata1.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/diskA1.vhd`, `${relativePath2}/diskB1.vhd`] })
)
await handler.writeFile(
`${rootPath}/metadata2.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/diskA2.vhd`, `${relativePath2}/diskB2.vhd`] })
)
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logWarn: () => {} })
const remaining1 = await handler.list(basePath)
assert.ok(remaining1.includes('diskA1.vhd'), 'diskA1.vhd must survive (metadata1)')
assert.ok(remaining1.includes('diskA2.vhd'), 'diskA2.vhd must survive (metadata2)')
assert.ok(!remaining1.includes('orphanA.vhd'), 'orphanA.vhd must be deleted')
const remaining2 = await handler.list(basePath2)
assert.ok(remaining2.includes('diskB1.vhd'), 'diskB1.vhd must survive (metadata1)')
assert.ok(remaining2.includes('diskB2.vhd'), 'diskB2.vhd must survive (metadata2)')
assert.ok(!remaining2.includes('orphanB.vhd'), 'orphanB.vhd must be deleted')
})
test('it merges delta of non destroyed chain', async () => {
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
size: 12000, // a size too small
vhds: [
`${relativePath}/grandchild.vhd`, // grand child should not be merged
`${relativePath}/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',
parentUuid: orphan.footer.uuid,
},
blocks: [0, 1],
})
// a grand child
await generateVhd(`${basePath}/grandchild.vhd`, {
header: {
parentUnicodeName: 'child.vhd',
parentUuid: child.footer.uuid,
},
})
let logged = []
const logInfo = message => {
logged.push(message)
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logInfo, logWarn: logInfo })
assert.equal(logged[0], `unexpected number of entries in backup cache`)
logged = []
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, merge: true, logInfo, logWarn: () => {} })
assert.equal(logged.includes(`merging disk chain`), 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)
assert.equal(remainingVhds.length, 2)
assert.equal(remainingVhds.includes('child.vhd'), true)
assert.equal(remainingVhds.includes('grandchild.vhd'), true)
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
assert.equal(metadata.size, 4299776, 'metadata.size should be updated after merge')
})
test('it merges a chain of multiple consecutive orphan ancestors in one pass', async () => {
// ancestor -> child -> grandchild
// only grandchild is referenced in metadata: both ancestor and child are orphans
// they must be merged as a single 3-disk chain, not processed separately
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
size: 12000,
vhds: [`${relativePath}/grandchild.vhd`],
})
)
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`)
const child = await generateVhd(`${basePath}/child.vhd`, {
header: { parentUnicodeName: 'ancestor.vhd', parentUuid: ancestor.footer.uuid },
})
await generateVhd(`${basePath}/grandchild.vhd`, {
header: { parentUnicodeName: 'child.vhd', parentUuid: child.footer.uuid },
})
const mergeChains = []
const logInfo = (message, opts) => {
if (message === 'merging disk chain') mergeChains.push(opts.chain)
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, merge: true, logInfo, logWarn: () => {} })
// all three disks must be handled in exactly one merge, not two separate operations
assert.equal(mergeChains.length, 1, 'exactly one merge should occur for the full chain')
assert.equal(mergeChains[0].length, 3, 'merge chain must include ancestor, child and grandchild')
const remainingVhds = await handler.list(basePath)
assert.equal(remainingVhds.includes('grandchild.vhd'), true)
assert.equal(remainingVhds.includes('ancestor.vhd'), false, 'ancestor should have been merged, not deleted directly')
assert.equal(remainingVhds.includes('child.vhd'), false)
})
test('it resumes an interrupted merge with chain field', async () => {
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/child.vhd`] })
)
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1, 2, 3, 4] })
const intermediate = await generateVhd(`${basePath}/intermediate.vhd`, {
header: { parentUnicodeName: 'ancestor.vhd', parentUuid: ancestor.footer.uuid },
blocks: [5, 6],
})
await generateVhd(`${basePath}/child.vhd`, {
header: { parentUnicodeName: 'intermediate.vhd', parentUuid: intermediate.footer.uuid },
blocks: [7, 8],
})
await handler.writeFile(
`${basePath}/.ancestor.vhd.merge.json`,
JSON.stringify({
parent: { uuid: '0' },
child: { uuid: '0' },
chain: ['ancestor.vhd', 'intermediate.vhd', 'child.vhd'],
currentBlock: 3,
mergedDataSize: 3 * 2 * 1024 * 1024,
step: 'mergeBlocks',
diskSize: 0,
})
)
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, merge: true, logWarn: () => {} })
const files = await handler.list(basePath)
assert.ok(!files.some(f => f.endsWith('.merge.json')), 'merge state file should be deleted after successful resume')
assert.ok(!files.includes('ancestor.vhd'), 'ancestor should be removed after merge')
assert.ok(!files.includes('intermediate.vhd'), 'intermediate should be removed after merge')
assert.ok(files.includes('child.vhd'), 'child (merge target) should survive')
})
test('it resumes an interrupted merge without chain field', async () => {
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
size: 209920,
vhds: [`${relativePath}/orphan.vhd`, `${relativePath}/child.vhd`],
})
)
// 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',
parentUuid: 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,
},
})
)
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, merge: true, logWarn: () => {} })
// 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)
assert.equal(remainingVhds.length, 1)
assert.equal(remainingVhds.includes('child.vhd'), true)
})
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
// the message and resulting files should be identical to the output with vhd files which is tested independently
describe('tests multiple combination ', { concurrency: 1 }, () => {
for (const useAlias of [true, false]) {
for (const vhdMode of ['file', 'directory']) {
test(`alias : ${useAlias}, mode: ${vhdMode}`, async () => {
// a broken VHD
if (useAlias) {
await handler.mkdir(basePath + '/data')
}
const brokenVhdDataPath = basePath + (useAlias ? '/data/broken.vhd' : '/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 metadata
await generateVhd(`${basePath}/nonreference.vhd`, { useAlias, mode: vhdMode })
// an abandoned delta vhd without its parent
await generateVhd(`${basePath}/abandoned.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'gone.vhd',
parentUuid: uniqueIdBuffer(),
},
})
// an ancestor of a vhd present in metadata
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, {
useAlias,
mode: vhdMode,
blocks: [1, 3],
})
const child = await generateVhd(`${basePath}/child.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'ancestor.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUuid: ancestor.footer.uuid,
},
blocks: [1, 2],
})
// a grand child vhd in metadata
await generateVhd(`${basePath}/grandchild.vhd`, {
useAlias,
mode: vhdMode,
header: {
parentUnicodeName: 'child.vhd' + (useAlias ? '.alias.vhd' : ''),
parentUuid: child.footer.uuid,
},
blocks: [2, 3],
})
// 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' : ''),
parentUuid: 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(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${relativePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
`${relativePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
`${relativePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
],
})
)
if (!useAlias && vhdMode === 'directory') {
try {
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, merge: true, logWarn: () => {} })
} catch (err) {
assert.strictEqual(
err.code,
'NOT_SUPPORTED',
'Merging directory without alias should raise a not supported error'
)
return
}
assert.strictEqual(true, false, 'Merging directory without alias should raise an error')
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, merge: true, logWarn: () => {} })
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
// size should be the size of children + grand children + clean after the merge
assert.deepEqual(metadata.size, 6501888)
// cache.json.gz must be regenerated when metadata changes after a merge
assert.ok(
(await handler.list(rootPath)).includes('cache.json.gz'),
'cache.json.gz should be regenerated after a merge'
)
// broken vhd, non referenced, abandoned 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)
if (useAlias) {
const dataSurvivors = await handler.list(basePath + '/data')
// the goal of the alias : do not move a full folder
assert.equal(dataSurvivors.includes('ancestor.vhd'), true)
assert.equal(dataSurvivors.includes('grandchild.vhd'), true)
assert.equal(dataSurvivors.includes('cleanAncestor.vhd'), true)
assert.equal(survivors.includes('clean.vhd.alias.vhd'), true)
assert.equal(survivors.includes('child.vhd.alias.vhd'), true)
assert.equal(survivors.includes('grandchild.vhd.alias.vhd'), true)
assert.equal(survivors.length, 4) // the 3 ok + data
assert.equal(dataSurvivors.length, 3)
} else {
assert.equal(survivors.includes('clean.vhd'), true)
assert.equal(survivors.includes('child.vhd'), true)
assert.equal(survivors.includes('grandchild.vhd'), true)
assert.equal(survivors.length, 3)
}
})
}
}
})
test('it cleans orphan merge states ', async () => {
await handler.writeFile(`${basePath}/.orphan.vhd.merge.json`, '')
// metadata is needed so the lineage is created for basePath (otherwise cleanOrphanDiskDirs deletes the whole dir)
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/nonexistent.vhd`] })
)
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logWarn: () => {} })
assert.deepEqual(await handler.list(basePath), [])
})
test('check all types of aliases, corrupted, missing or not', async () => {
// valid alias pointing to an existing data file
await handler.mkdir(`${basePath}/data`)
await generateVhd(`${basePath}/data/ok.vhd`)
await VhdAbstract.createAlias(handler, `${basePath}/ok.alias.vhd`, `${basePath}/data/ok.vhd`)
// broken alias pointing to a missing data file
await VhdAbstract.createAlias(handler, `${basePath}/missingData.alias.vhd`, `${basePath}/data/nonexistent.vhd`)
// alias pointing to an existing but corrupt data file
await handler.writeFile(`${basePath}/data/corrupt.vhd`, 'I AM NOT A VHD')
await VhdAbstract.createAlias(handler, `${basePath}/corrupt.alias.vhd`, `${basePath}/data/corrupt.vhd`)
// data file without alias
await generateVhd(`${basePath}/data/missingalias.vhd`)
// only the valid alias is referenced by a complete backup
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/ok.alias.vhd`] })
)
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logWarn: () => {}, logInfo: () => {} })
// only ok.alias.vhd has survived
const aliases = (await handler.list(basePath)).filter(f => f.endsWith('.vhd'))
assert.equal(aliases.length, 1)
assert.equal(aliases[0], 'ok.alias.vhd')
// corrupt.vhd and missingalias.vhd must be gone
const data = await handler.list(`${basePath}/data`)
assert.equal(data.length, 1)
assert.equal(data[0], 'ok.vhd')
})

View File

@@ -0,0 +1,174 @@
import {
ArchiveCleanOptions,
CheckResult,
CleanResult,
ResolvedBackupCleanOptions,
VmBackupInterface,
PartialBackupMetadata,
} from './VmBackup.types.mjs'
import { RemoteHandlerAbstract } from '@xen-orchestra/fs'
import { basename, dirname, normalize } from '@xen-orchestra/fs/path'
import { RemoteDiskLineage } from './RemoteDiskLineage.mjs'
export class VmIncrementalBackupArchive implements VmBackupInterface {
handler: RemoteHandlerAbstract
metadataPath: string
metadata: PartialBackupMetadata
readonly diskPaths: Array<string>
rootPath: string
opts: ResolvedBackupCleanOptions
diskLineages: Map<string, RemoteDiskLineage> = new Map()
#isChecked = false
#isComplete = false
get isChecked(): boolean {
return this.#isChecked
}
get isComplete(): boolean {
return this.#isComplete
}
constructor(
handler: RemoteHandlerAbstract,
rootPath: string, // xo-vm-backups/<vmUuid>/
metadataPath: string, // xo-vm-backups/<vmUuid>/<timestamp>_<scheduleId>.json
metadata: PartialBackupMetadata,
diskPaths: Array<string>, // xo-vm-backups/<vmUuid>/vdis/<jobId>/<vdiUuid>/<snapshotUuid>.alias.vhd (one per VDI)
opts: ResolvedBackupCleanOptions,
lineageRegistry?: Map<string, RemoteDiskLineage>
) {
this.handler = handler
this.rootPath = normalize(rootPath)
this.metadataPath = normalize(metadataPath)
this.metadata = metadata
this.diskPaths = diskPaths.map(path => normalize(path))
this.opts = opts
if (lineageRegistry !== undefined) {
this.diskLineages = lineageRegistry
}
}
async init(): Promise<void> {
// Build one RemoteDiskLineage per VDI directory derived from diskPaths
try {
for (const diskPath of this.diskPaths) {
const vdiDir = dirname(diskPath)
if (!this.diskLineages.has(vdiDir)) {
const lineage = new RemoteDiskLineage(this.handler, vdiDir, this.opts)
await lineage.init()
this.diskLineages.set(vdiDir, lineage)
}
}
} catch (error) {
if (error?.code === 'NOT_SUPPORTED') throw error
this.opts.logWarn('failed to scan VDI directories', { error })
}
}
async check(): Promise<CheckResult> {
const missingDisks: string[] = []
const brokenDisks: string[] = []
if (this.diskPaths.length === 0) {
this.opts.logWarn('incremental backup has no disk paths', { metadataPath: this.metadataPath })
this.#isComplete = false
} else {
for (const diskPath of this.diskPaths) {
const lineage = this.diskLineages.get(dirname(diskPath))
if (lineage === undefined || !lineage.isPathAccessible(diskPath)) {
if (lineage?.isBroken(diskPath)) {
this.opts.logWarn('disk check error', { path: diskPath })
brokenDisks.push(diskPath)
} else {
this.opts.logWarn('disk file missing', { path: diskPath })
missingDisks.push(diskPath)
}
}
}
this.#isComplete = missingDisks.length === 0 && brokenDisks.length === 0
if (!this.#isComplete) {
this.opts.logWarn('incremental backup is incomplete', {
metadataPath: this.metadataPath,
missingDisks,
brokenDisks,
})
}
}
if (this.#isComplete) {
for (const diskPath of this.diskPaths) {
this.diskLineages.get(dirname(diskPath))?.addActiveDiskPath(diskPath)
}
}
this.#isChecked = true
return { isValid: this.#isComplete, missingDisks, brokenDisks }
}
/**
* Removes the metadata file if the backup is incomplete (missing disks).
* If mergedSizes is provided, updates metadata with the total merged size for this archive's disks.
* Actual disk merge/deletion is handled by RemoteDiskLineage.
*/
async clean({ remove = this.opts.remove ?? false, mergedSizes }: ArchiveCleanOptions = {}): Promise<CleanResult> {
if (!this.#isChecked) {
await this.check()
}
const removedFiles: string[] = []
if (!this.#isComplete) {
removedFiles.push(this.metadataPath)
}
if (remove) {
for (const file of removedFiles) {
try {
await this.handler.unlink(file)
} catch (error) {
this.opts.logWarn(`Issue removing ${file}`, { error })
}
}
}
let mergedSize = 0
if (mergedSizes !== undefined) {
for (const diskPath of this.diskPaths) {
mergedSize += mergedSizes.get(diskPath) ?? 0
}
if (mergedSize > 0) {
await this.updateMetadata(mergedSize)
}
}
return { removedFiles }
}
/**
* Updates metadata.size after a merge and writes the updated metadata file to disk.
* Also resets isVhdDifferencing so all VDIs are marked as full (non-differencing).
*/
async updateMetadata(mergedSize: number): Promise<void> {
this.metadata.size = mergedSize
if (this.metadata.vdis !== undefined) {
this.metadata.isVhdDifferencing = {}
for (const id of Object.keys(this.metadata.vdis)) {
this.metadata.isVhdDifferencing[id] = false
}
}
await this.handler.writeFile(this.metadataPath, JSON.stringify(this.metadata), { flags: 'w' })
}
/**
* Returns the metadata file path and active disk paths if the backup is complete, empty otherwise.
* Must be called after check().
*/
getAssociatedFiles({ prefix = false }): Array<string> {
if (!this.#isComplete) {
return []
}
const files = [this.metadataPath, ...this.diskPaths]
return prefix ? files : files.map(file => basename(file))
}
}

View File

@@ -0,0 +1,364 @@
// @ts-check
/**
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
*/
import assert from 'assert'
import { createLogger } from '@xen-orchestra/log'
import { basename, dirname } from 'path'
import { asyncEach } from '@vates/async-each'
import { relativeFromFile, normalize, resolveFromFile } from '@xen-orchestra/fs/path'
// @ts-ignore
const { warn } = createLogger('remote-disk:merge')
/**
* @typedef {Object} MergeState
* @property {{ uuid?: string }} child
* @property {{ uuid?: string }} parent
* @property { string[] | undefined} chain
* @property {number} currentBlock
* @property {number} mergedDataSize
* @property {'mergeBlocks' | 'cleanup'} step
* @property {number} diskSize
* @typedef {(message: string, data?: Record<string, unknown>) => void} Logger
*/
export class MergeRemoteDisk {
/**
* @type {MergeState}
*/
#state = {
child: { uuid: '0' },
parent: { uuid: '0' },
chain: undefined,
currentBlock: 0,
mergedDataSize: 0,
step: 'mergeBlocks',
diskSize: 0,
}
/**
* @type {string}
*/
#statePath = ''
/**
* @type {boolean}
*/
#isResuming
/**
* @type {Logger | Function}
*/
#logInfo
/**
* @type {number}
*/
#mergeBlockConcurrency
/**
* @type {Function}
*/
#onProgress
/**
* @type {boolean}
*/
#removeUnused
/**
* @type {number}
*/
#writeStateDelay
/**
* @type {number}
*/
#lastStateWrittenAt
/**
* @type {RemoteHandlerAbstract}
*/
#handler
/**
* @param {RemoteHandlerAbstract} handler
* @param {Object} params
* @param {Function} [params.onProgress]
* @param {Logger | Function} [params.logInfo]
* @param {boolean} [params.removeUnused]
* @param {number} [params.mergeBlockConcurrency]
* @param {number} [params.writeStateDelay]
*/
constructor(
handler,
{
onProgress = () => {},
logInfo = () => {},
removeUnused = false,
mergeBlockConcurrency = 2,
writeStateDelay = 10e3,
}
) {
this.#handler = handler
this.#logInfo = logInfo
this.#onProgress = onProgress
this.#removeUnused = removeUnused
this.#mergeBlockConcurrency = mergeBlockConcurrency
this.#writeStateDelay = writeStateDelay
this.#isResuming = false
this.#lastStateWrittenAt = 0
}
/**
* @param {RemoteDisk} parentDisk
* @returns {Promise<boolean>} isResuming
*/
async isResuming(parentDisk) {
try {
await this.#handler.readFile(
dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
)
return true
} catch (error) {
return false
}
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async merge(parentDisk, childDisk) {
this.#statePath = dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
try {
const mergeStateContent = await this.#handler.readFile(this.#statePath)
this.#state = JSON.parse(mergeStateContent)
// work-around a bug introduce in 97d94b795
//
// currentBlock could be `null` due to the JSON.stringify of a `NaN` value
if (this.#state?.currentBlock === null) this.#state.currentBlock = 0
this.#isResuming = true
} catch (error) {
// @ts-ignore
if (error.code !== 'ENOENT') {
warn('problem while checking the merge state', { error })
}
}
try {
/* eslint-disable no-fallthrough */
switch (this.#state?.step ?? 'mergeBlocks') {
case 'mergeBlocks':
await this.#step_mergeBlocks(parentDisk, childDisk)
case 'cleanup':
await this.#step_cleanup(parentDisk, childDisk)
return this.#cleanup(parentDisk, childDisk, true)
default:
warn(`Step ${this.#state?.step} is unknown`, { state: this.#state })
}
/* eslint-enable no-fallthrough */
} catch (error) {
await this.#cleanup(parentDisk, childDisk, false)
throw error
}
}
async #writeState() {
try {
await this.#handler.writeFile(this.#statePath, JSON.stringify(this.#state), { flags: 'w' })
} catch (err) {
warn('failed to write merge state', { error: err })
}
}
async #writeStateThrottled() {
const now = Date.now()
if (now - this.#lastStateWrittenAt > this.#writeStateDelay) {
this.#lastStateWrittenAt = now
await this.#writeState()
}
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async #step_mergeBlocks(parentDisk, childDisk) {
const getMaxBlockCount = childDisk.getMaxBlockCount()
await parentDisk.resize(getMaxBlockCount)
if (this.#isResuming) {
const alreadyMergedBlocks = []
for (let blockId = 0; blockId < this.#state.currentBlock; blockId++) {
if (childDisk.hasBlock(blockId)) {
alreadyMergedBlocks.push(blockId)
}
}
parentDisk.setAllocatedBlocks(alreadyMergedBlocks)
} else {
this.#state.child = { uuid: childDisk.getUuid() ?? undefined }
this.#state.parent = { uuid: parentDisk.getUuid() ?? undefined }
this.#state.chain = [parentDisk.getPath(), ...childDisk.getPaths()].map(path =>
relativeFromFile(this.#statePath, path)
)
// Finds first allocated block for the 2 following loops
while (this.#state.currentBlock < getMaxBlockCount && !childDisk.hasBlock(this.#state.currentBlock)) {
++this.#state.currentBlock
}
await this.#writeState()
}
await this.#mergeBlocks(parentDisk, childDisk)
await parentDisk.flushMetadata(childDisk)
await parentDisk.mergeMetadata(childDisk)
this.#state.diskSize = parentDisk.getSizeOnDisk()
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async #mergeBlocks(parentDisk, childDisk) {
this.#mergeBlockConcurrency =
(await parentDisk.canMergeConcurently()) && (await childDisk.canMergeConcurently())
? this.#mergeBlockConcurrency
: 1
const toMerge = []
for (let block = this.#state.currentBlock; block < childDisk.getMaxBlockCount(); block++) {
if (childDisk.hasBlock(block)) {
toMerge.push(block)
}
}
const nBlocks = toMerge.length
this.#onProgress({ total: nBlocks, done: 0 })
const merging = new Set()
let counter = 0
await asyncEach(
toMerge,
async blockId => {
merging.add(blockId)
const blockSize = await parentDisk.mergeBlock(childDisk, blockId, this.#isResuming)
this.#state.mergedDataSize += blockSize
this.#state.currentBlock = Math.min(...merging) - 1
merging.delete(blockId)
this.#onProgress({ total: nBlocks, done: counter + 1 })
counter++
await this.#writeStateThrottled()
},
{ concurrency: this.#mergeBlockConcurrency }
)
await this.#writeState()
this.#onProgress({ total: nBlocks, done: nBlocks })
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async #step_cleanup(parentDisk, childDisk) {
assert.notEqual(this.#state, undefined)
this.#state.step = 'cleanup'
await this.#writeState()
const mergeTargetPath = childDisk.getPath()
// delete intermediate children if needed
if (this.#removeUnused) {
await childDisk.unlink()
}
try {
await parentDisk.rename(mergeTargetPath)
} catch (error) {
// @ts-ignore
if (error.code === 'ENOENT' && this.#isResuming) {
// @ts-ignore
this.#logInfo(`the parent disk was already renamed`, {
parent: parentDisk.getPath(),
mergeTarget: mergeTargetPath,
})
} else {
throw error
}
}
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
* @param {boolean} cleanStateFile
*
* @returns {Promise<{mergedDataSize: number, finalDiskSize: number}>} result
*/
async #cleanup(parentDisk, childDisk, cleanStateFile) {
const finalDiskSize = this.#state?.diskSize ?? 0
const mergedDataSize = this.#state?.mergedDataSize ?? 0
await parentDisk.close().catch(warn)
await childDisk.close().catch(warn)
if (cleanStateFile) {
await this.#handler.unlink(this.#statePath).catch(warn)
}
return { mergedDataSize, finalDiskSize }
}
/**
* Scans vdiDir files for interrupted merge state files and resolves their chains
*
* @param {RemoteHandlerAbstract} handler
* @param {string} vdiDir
* @param {string[]} files - all file paths in vdiDir (prependDir: true)
* @param {Map<string, string>} uuidToPath - diskUuid: normalized disk path
* @param {Map<string, string>} childOf - parentPath: child path (from disk headers)
* @returns {Promise<Map<string, { stateFilePath: string, chain?: string[] }>>}
*/
static async findInterruptedMerges(handler, vdiDir, files, uuidToPath, childOf) {
const STATE_FILE_RE = /^\.(.+)\.merge\.json$/
const result = new Map()
for (const filePath of files) {
const match = STATE_FILE_RE.exec(basename(filePath))
if (match === null) continue
const parentPath = normalize(vdiDir + '/' + match[1])
let chain
try {
const state = JSON.parse(await handler.readFile(filePath))
if (Array.isArray(state?.chain)) {
chain = state.chain.map((/** @type {string} */ relPath) => normalize(resolveFromFile(filePath, relPath)))
} else {
// old state file without chain: fall back to UUID lookup then header-based map
const childUuid = state?.child?.uuid
const childPath = childUuid !== undefined ? uuidToPath.get(childUuid) : childOf.get(parentPath)
if (childPath !== undefined) chain = [parentPath, childPath]
}
} catch (error) {
warn("Merge state unreadable, can't restart merging.", error)
}
result.set(parentPath, { stateFilePath: filePath, chain })
}
return result
}
}

View File

@@ -0,0 +1,282 @@
// @ts-check
/**
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
*/
import { RandomAccessDisk } from '@xen-orchestra/disk-transform'
export class RemoteDisk extends RandomAccessDisk {
/**
* Abstract
* @param {Object} options
* @param {boolean} options.force
* @returns {Promise<void>}
*/
async init(options = { force: false }) {
throw new Error(`init must be implemented`)
}
/**
* Abstract
* @returns {Promise<void>}
*/
async close() {
throw new Error(`close must be implemented`)
}
/**
* Abstract
* @returns {number}
*/
getVirtualSize() {
throw new Error(`getVirtualSize must be implemented`)
}
/**
* Abstract
* @returns {number} size
*/
getSizeOnDisk() {
throw new Error(`getSizeOnDisk must be implemented`)
}
/**
* @returns {number}
*/
getBlockSize() {
throw new Error(`getBlockSize must be implemented`)
}
/**
* Abstract
* @returns {string}
*/
getPath() {
throw new Error(`getPath must be implemented`)
}
/**
* Abstract
* Returns an array of disk paths.
*
* @returns {string[]}
*/
getPaths() {
throw new Error(`getPaths must be implemented`)
}
/**
* Abstract
* @returns {string}
*/
getUuid() {
throw new Error(`getUuid must be implemented`)
}
/**
* Abstract
* @returns {string}
*/
getParentUuid() {
throw new Error(`getParentUuid must be implemented`)
}
/**
* Abstract
* @returns {string}
*/
getParentPath() {
throw new Error(`getParentPath must be implemented`)
}
/**
* Abstract
* @returns {object}
*/
getMetadata() {
throw new Error(`getMetadata must be implemented`)
}
/**
* Abstract
* @returns {Promise<boolean>} canMergeConcurently
*/
async canMergeConcurently() {
throw new Error(`canMergeConcurently must be implemented`)
}
/**
* @returns {number} getMaxBlockCount
*/
getMaxBlockCount() {
return Math.ceil(this.getVirtualSize() / this.getBlockSize())
}
/**
* Returns true if every block present in `other` is also present in this disk.
* @param {RemoteDisk} other
* @returns {boolean}
*/
containsAllDataOf(other) {
for (const blockIndex of other.getBlockIndexes()) {
if (!this.hasBlock(blockIndex)) return false
}
return true
}
/**
* Checks if the VHD contains a specific block.
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
throw new Error(`hasBlock must be implemented`)
}
/**
* Abstract
* Gets the indexes of all blocks in the VHD.
* @returns {Array<number>}
*/
getBlockIndexes() {
throw new Error(`getBlockIndexes must be implemented`)
}
/**
* Abstract
* Returns the parent non inizialized instance
* @returns {RemoteDisk}
*/
instantiateParent() {
throw new Error(`instantiateParent must be implemented`)
}
/**
* Abstract
* Writes a full block.
* @param {DiskBlock} diskBlock
* @return {Promise<number>} blockSize
*/
async writeBlock(diskBlock) {
throw new Error(`writeBlock must be implemented`)
}
/**
* Abstract
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>} diskBlock
*/
async readBlock(index) {
throw new Error(`readBlock must be implemented`)
}
/**
* Abstract
* Reads a specific block from the child disk to copy/move it to this disk.
* @param {RemoteDisk} childDisk
* @param {number} index
* @param {boolean} isResumingMerge
* @returns {Promise<number>} blockSize
*/
async mergeBlock(childDisk, index, isResumingMerge) {
throw new Error(`mergeBlock must be implemented`)
}
/**
* Abstract
* Manually set the disk allocated blocks.
* @param {Array<number>} blockIds
* @returns {Promise<void>}
*/
async setAllocatedBlocks(blockIds) {
throw new Error(`setAllocatedBlocks must be implemented`)
}
/**
* Abstract
* @param {number} blockCount
* @returns {Promise<void>}
*/
async resize(blockCount) {
throw new Error(`resize must be implemented`)
}
/**
* Abstract
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
async flushMetadata(childDisk) {
throw new Error(`flushMetadata must be implemented`)
}
/**
* Abstract
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
mergeMetadata(childDisk) {
throw new Error(`mergeMetadata must be implemented`)
}
/**
* Abstract
* Checks if the VHD is a differencing disk.
* @returns {boolean}
*/
isDifferencing() {
throw new Error(`isDifferencing must be implemented`)
}
/**
* Abstract
* Rename alias/disk
* @param {string} newPath
*/
async rename(newPath) {
throw new Error(`rename must be implemented`)
}
/**
* Abstract
* Deletes alias/disk/disks
* @param {Object} options
*/
async unlink({ force = false } = {}) {
throw new Error(`unlink must be implemented`)
}
/**
* Checks the integrity of the disk's external reference (e.g. alias target).
* @param {Object} [opts]
* @param {boolean} [opts.remove]
* @param {Function} [opts.logWarn]
* @param {Function} [opts.logInfo]
* @returns {Promise<string | undefined>}
*/
async clean(opts = {}) {
return undefined
}
/**
* Returns all file paths within dir that this disk claims.
*
* @param {string} dir
* @returns {Promise<string[]>}
*/
async listAssociatedFiles(dir) {
throw new Error(`listAssociatedFiles must be implemented`)
}
/**
* @returns {Promise<RemoteDisk>}
*/
async openParent() {
const parent = await super.openParent()
if (!(parent instanceof RemoteDisk)) {
throw new Error('parent of a RemoteDisk must be also a RemoteDisk')
}
return parent
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,646 @@
// @ts-check
/**
* @typedef {import('vhd-lib/Vhd/VhdDirectory.js').VhdDirectory} VhdDirectory
* @typedef {import('vhd-lib/Vhd/VhdFile.js').VhdFile} VhdFile
* @typedef {import('vhd-lib/_createFooterHeader.js').VhdFooter} VhdFooter
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
*
*/
import { openVhd, VhdAbstract, VhdDirectory } from 'vhd-lib'
import { RemoteDisk } from './RemoteDisk.mjs'
import { DISK_TYPES } from 'vhd-lib/_constants.js'
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
import { stringify } from 'uuid'
import { dirname, join } from 'node:path'
import { RemoteVhdDiskChain } from './RemoteVhdDiskChain.mjs'
import { normalize } from '@xen-orchestra/fs/path'
export class RemoteVhdDisk extends RemoteDisk {
/**
* @type {string}
*/
#path
/**
* @type {RemoteHandlerAbstract}
*/
#handler
/**
* @type {VhdFile | VhdDirectory | undefined}
*/
#vhd
/**
* @type {boolean | undefined}
*/
#isDifferencing
/**
* @type {number}
*/
#blockSize = 2 * 1024 * 1024
/**
* @type {number}
*/
#bitmapSize = 512
/**
* @type {() => any}
*/
#dispose = () => {}
/**
* @param {Object} params
* @param {RemoteHandlerAbstract} params.handler
* @param {string} params.path
*/
constructor({ handler, path }) {
super()
// @todo : ensure this is the full path from the root of the remote
this.#path = path
this.#handler = handler
}
/**
* @param {Object} [options]
* @param {boolean} [options.force=false]
* @param {boolean} [options.ignoreBlockIndexes=false]
* @returns {Promise<void>}
*/
async init(options = {}) {
if (this.#vhd === undefined) {
try {
const { value, dispose } = await openVhd(this.#handler, await resolveVhdAlias(this.#handler, this.#path), {
checkSecondFooter: !options.force,
})
this.#vhd = value
if ((await this.isDirectory()) && !isVhdAlias(this.#path)) {
this.#vhd = undefined
throw Object.assign(new Error("Can't init vhd directory without using alias"), { code: 'NOT_SUPPORTED' })
}
this.#dispose = dispose
if (!options.ignoreBlockIndexes) {
await this.#vhd.readBlockAllocationTable()
}
this.#isDifferencing = value.footer.diskType === DISK_TYPES.DIFFERENCING
} catch (error) {
await this.close()
throw error
}
}
}
/**
* Closes the VHD.
* We replace the dispose function call so the disk can only be closed once.
*
* @returns {Promise<void>}
*/
async close() {
const dispose = this.#dispose
this.#dispose = () => Promise.resolve()
await dispose()
}
/**
* @returns {number}
*/
getVirtualSize() {
if (this.#vhd === undefined) {
throw new Error(`can't call getVirtualSize of a RemoteVhdDisk before init`)
}
return this.#vhd.footer.currentSize
}
/**
* @returns {number} size
*/
getSizeOnDisk() {
if (this.#vhd === undefined) {
throw new Error(`can't call getVirtualSize of a RemoteVhdDisk before init`)
}
return this.#vhd.streamSize()
}
/**
* @returns {number}
*/
getBlockSize() {
return this.#blockSize
}
/**
* @returns {string}
*/
getPath() {
return this.#path
}
/**
* Returns the disk path in an array.
*
* @returns {string[]}
*/
getPaths() {
return [this.getPath()]
}
/**
* @returns {string}
*/
getUuid() {
if (this.#vhd === undefined) {
throw new Error(`can't call getUid of a RemoteVhdDisk before init`)
}
return stringify(this.#vhd.footer.uuid)
}
/**
* @returns {string}
*/
getParentUuid() {
if (this.#vhd === undefined) {
throw new Error(`can't call getParentUid of a RemoteVhdDisk before init`)
}
return stringify(this.#vhd.header.parentUuid)
}
/**
* Returns the parent path
*
* @returns {string}
*/
getParentPath() {
if (this.#vhd === undefined) {
throw new Error(`can't call getParentPath of a RemoteVhdDisk before init`)
}
const parentPath = this.#vhd.header.parentUnicodeName
return normalize(join(dirname(this.#path), parentPath))
}
/**
* @returns {Promise<boolean>} canMergeConcurently
*/
async canMergeConcurently() {
return await this.isDirectory()
}
/**
* Checks if the VHD contains a specific block.
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call hasblock of a RemoteVhdDisk before init`)
}
return this.#vhd.containsBlock(index)
}
/**
* Gets the indexes of all blocks in the VHD.
* @returns {Array<number>}
*/
getBlockIndexes() {
if (this.#vhd === undefined) {
throw new Error(`can't call getBlockIndexes of a RemoteVhdDisk before init`)
}
const indexes = []
for (let blockIndex = 0; blockIndex < this.#vhd.header.maxTableEntries; blockIndex++) {
if (this.hasBlock(blockIndex)) {
indexes.push(blockIndex)
}
}
return indexes
}
/**
* Returns the parent non inizialized instance
* @returns {RemoteDisk}
*/
instantiateParent() {
if (this.#vhd === undefined) {
throw new Error(`can't call instantiateParent of a RemoteVhdDisk before init`)
}
const parentPath = this.#vhd.header.parentUnicodeName
const fullParentPath = normalize(join(dirname(this.#path), parentPath))
if (!parentPath) {
throw new Error(`disk ${this.#path} doesn't have parents`)
}
const parent = new RemoteVhdDisk({ handler: this.#handler, path: fullParentPath })
return parent
}
/**
* Parent of a RemoteVhdDisk is a RemoteVhdDisk
* @returns {Promise<RemoteVhdDisk>}
*/
async openParent() {
return /** @type {Promise<RemoteVhdDisk>} */ (super.openParent())
}
/**
* Writes a full block into this VHD.
* @param {DiskBlock} diskBlock
* @return {Promise<number>} blockSize
*/
async writeBlock(diskBlock) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
await this.#vhd.writeEntireBlock({
id: diskBlock.index,
buffer: Buffer.concat([Buffer.alloc(this.#bitmapSize, 255), diskBlock.data]),
})
return this.getBlockSize()
}
/**
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>} diskBlock
*/
async readBlock(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
const { data } = await this.#vhd.readBlock(index)
return {
index,
data,
}
}
/**
* Reads a specific block from the child disk to copy/move it to this disk.
* @param {RemoteDisk} childDisk
* @param {number} index
* @param {boolean} isResumingMerge
* @returns {Promise<number>} blockSize
*/
async mergeBlock(childDisk, index, isResumingMerge) {
if (
(childDisk instanceof RemoteVhdDisk || childDisk instanceof RemoteVhdDiskChain) &&
(await this.isDirectory()) &&
(await childDisk.isDirectory())
) {
try {
await this.#handler.rename(childDisk.getBlockPath(index), this.getBlockPath(index))
this.setAllocatedBlocks([index])
} catch (error) {
// @ts-ignore
if (error.code === 'ENOENT' && isResumingMerge === true) {
// when resuming, the blocks moved since the last merge state write are
// not in the child anymore but it should be ok
// it will throw an error if block is missing in parent
// won't detect if the block was already in parent and is broken/missing in child
// since we can't know the initial size, this will create a discrepancy
// on the size
const { data } = await this.readBlock(index)
if (data.length !== this.getBlockSize()) {
throw error
} else {
this.setAllocatedBlocks([index])
}
} else {
throw error
}
}
return this.getBlockSize()
} else {
return this.writeBlock(await childDisk.readBlock(index))
}
}
/**
* Gets a specific block path from the VHD directory.
* @param {number} index
* @returns {string} blockPath
*/
getBlockPath(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
if (this.#vhd instanceof VhdDirectory) {
return this.#vhd.getFullBlockPath(index)
} else {
throw new Error(`can't call getBlockPath of non directory VHD`)
}
}
/**
* Manually set the disk allocated blocks.
* @param {Array<number>} blockIds
* @returns {Promise<void>}
*/
async setAllocatedBlocks(blockIds) {
if (this.#vhd instanceof VhdDirectory) {
this.#vhd.setAllocatedBlocks(blockIds)
}
}
/**
* Ensure that the disk can handle at least the new block count.
* @param {number} blockCount
* @returns {Promise<void>}
*/
async resize(blockCount) {
if (this.#vhd === undefined) {
throw new Error(`can't call resize of a RemoteVhdDisk before init`)
}
// Checks that the BAT is at least as big as the provided block count, if not, increases it and shift the blocks position
await this.#vhd.ensureBatSize(blockCount)
}
/**
* Writes Block Allocation Table
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
async flushMetadata(childDisk) {
if (this.#vhd === undefined) {
throw new Error(`can't call flushMetadata of a RemoteVhdDisk before init`)
}
await this.#vhd.writeBlockAllocationTable()
}
/**
* @returns {VhdFooter}
*/
getMetadata() {
if (this.#vhd === undefined) {
throw new Error(`can't call getMetadata of a RemoteVhdDisk before init`)
}
return this.#vhd.footer
}
/**
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
async mergeMetadata(childDisk) {
const childDiskMetadata = childDisk.getMetadata()
if (this.#vhd === undefined) {
throw new Error(`can't call mergeMetadata of a RemoteVhdDisk before init`)
}
// @ts-ignore
this.#vhd.footer.currentSize = childDiskMetadata.currentSize
// @ts-ignore
this.#vhd.footer.diskGeometry = { ...childDiskMetadata.diskGeometry }
// @ts-ignore
this.#vhd.footer.originalSize = childDiskMetadata.originalSize
// @ts-ignore
this.#vhd.footer.timestamp = childDiskMetadata.timestamp
// @ts-ignore
this.#vhd.footer.uuid = childDiskMetadata.uuid
await this.#vhd.writeFooter()
}
/**
* Checks if the VHD is a differencing disk.
* @returns {boolean}
*/
isDifferencing() {
if (this.#isDifferencing === undefined) {
throw new Error(`can't call isDifferencing of a RemoteVhdDisk before init`)
}
return this.#isDifferencing
}
/**
* Rename alias/disk
* @param {string} newPath
*/
async rename(newPath) {
if (isVhdAlias(newPath)) {
const dataPath = await resolveVhdAlias(this.#handler, this.#path)
await this.#handler.unlink(newPath)
await VhdAbstract.createAlias(this.#handler, newPath, dataPath)
await this.#handler.unlink(this.#path)
this.#path = newPath
} else {
try {
await this.#handler.unlink(newPath)
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
await this.#handler.rmtree(newPath).catch(() => {})
}
}
await this.#handler.rename(this.#path, newPath)
this.#path = newPath
}
}
/**
* Deletes disk
* @param {Object} options
*/
async unlink({ force = false } = {}) {
if (this.#vhd === undefined) {
if (force) {
let resolved = this.#path
try {
resolved = await resolveVhdAlias(this.#handler, this.#path)
} catch (err) {
// broken vhd directory must be unlinkable
if (err.code !== 'EISDIR') {
throw err
}
// warn('Deleting directly a VhdDirectory', { this.#path, err })
}
try {
await this.#handler.unlink(resolved)
} catch (err) {
if (err.code === 'EISDIR') {
await this.#handler.rmtree(resolved)
} else {
throw err
}
}
// also delete the alias file
if (this.#path !== resolved) {
await this.#handler.unlink(this.#path)
}
} else {
throw new Error(`can't call unlink of a RemoteVhdDisk before init`)
}
} else {
await this.close()
if (isVhdAlias(this.#path)) {
try {
await this.#handler.unlink(await resolveVhdAlias(this.#handler, this.#path))
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
await this.#handler.rmtree(await resolveVhdAlias(this.#handler, this.#path)).catch(() => {})
}
}
}
try {
await this.#handler.unlink(this.#path)
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
await this.#handler.rmtree(this.#path).catch(() => {})
}
}
}
}
/**
* Check if the disk is a VHD directory.
* @returns {Promise<boolean>}
*/
async isDirectory() {
if (this.#vhd === undefined) {
throw new Error(`can't call isDirectory of a RemoteVhdDisk before init`)
}
return this.#vhd instanceof VhdDirectory
}
/**
* Returns all file paths within dir that this disk claims.
* For a plain VHD or VHD directory: [path]
* For a VHD alias: [aliasPath, resolvedDataPath]
* If the alias target cannot be resolved, only the alias path is returned.
*
* @param {string} dir
* @returns {Promise<string[]>}
*/
async listAssociatedFiles(dir) {
const prefix = normalize(dir.endsWith('/') ? dir : dir + '/')
const isInDir = /** @param {string} p */ p => p === dir || p.startsWith(prefix)
const files = []
if (isInDir(this.#path)) {
files.push(this.#path)
}
if (isVhdAlias(this.#path)) {
try {
const resolved = await resolveVhdAlias(this.#handler, this.#path)
if (isInDir(resolved)) {
files.push(resolved)
}
} catch {
// broken alias, no data file to claim
}
}
return files
}
/**
* Checks the integrity of this disk's alias reference.
* Only meaningful for alias files (.alias.vhd); no-op for plain VHDs.
* Returns the resolved target path when the alias is valid, undefined otherwise.
*
* @param {Object} [opts]
* @param {boolean} [opts.remove]
* @param {Function} [opts.logWarn]
* @param {Function} [opts.logInfo]
* @returns {Promise<string | undefined>}
*/
async clean({ remove = false, logWarn = () => {}, logInfo = () => {} } = {}) {
if (!isVhdAlias(this.#path)) {
return undefined
}
let target
try {
target = await resolveVhdAlias(this.#handler, this.#path)
} catch (err) {
if (err.code === 'ENOENT') {
logWarn('missing target of alias', { alias: this.#path })
if (remove) {
logInfo('removing alias with missing target', { alias: this.#path })
await this.#handler.unlink(this.#path)
}
return undefined
}
if (err.code === 'EISDIR') {
logWarn('alias is a vhd directory', { alias: this.#path })
if (remove) {
logInfo('removing vhd directory named as alias', { alias: this.#path })
await VhdAbstract.unlink(this.#handler, this.#path)
}
return undefined
}
logWarn('unhandled error while checking alias', { alias: this.#path, err })
return undefined
}
if (target === '') {
logWarn('empty target for alias', { alias: this.#path })
if (remove) {
logInfo('removing alias with empty target', { alias: this.#path })
await this.#handler.unlink(this.#path)
}
return undefined
}
if (!isVhdAlias(target) && !target.endsWith('.vhd')) {
logWarn('alias references non VHD target', { alias: this.#path, target })
if (remove) {
logInfo('removing alias and non VHD target', { alias: this.#path, target })
await this.#handler.unlink(target)
await this.#handler.unlink(this.#path)
}
return undefined
}
try {
const { dispose } = await openVhd(this.#handler, target)
try {
await dispose()
} catch (_) {
// errors during dispose should not trigger deletion
}
return target
} catch (error) {
logWarn('missing or broken alias target', { alias: this.#path, target, error })
if (remove) {
try {
await VhdAbstract.unlink(this.#handler, this.#path)
} catch (/** @type {any} */ err) {
if (err.code !== 'ENOENT') {
logWarn('error deleting broken alias', { alias: this.#path, target, err })
}
}
}
return undefined
}
}
}

View File

@@ -0,0 +1,315 @@
// @ts-check
/**
* @typedef {import('./RemoteVhdDisk.mjs').VhdFooter} VhdFooter
* @typedef {import('./RemoteVhdDisk.mjs').RemoteVhdDisk} RemoteVhdDisk
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
*/
import { RemoteDisk } from './RemoteDisk.mjs'
export class RemoteVhdDiskChain extends RemoteDisk {
/**
* @type {RemoteVhdDisk[]}
*/
#disks
/**
* @type {number}
*/
#blockSize = 2 * 1024 * 1024
/**
* @type {number}
*/
#headerSize = 1024
/**
* @type {number}
*/
#footerSize = 512
/**
* @type {number}
*/
#bitmapSize = 512
/**
* @param {Object} params
* @param {RemoteVhdDisk[]} params.disks
*/
constructor({ disks }) {
super()
this.#disks = disks
}
/**
* Initializes the VHD chain
* @param {Object} options
* @param {boolean} options.force
* @returns {Promise<void>}
*/
async init(options) {
try {
await Promise.all(this.#disks.map(disk => disk.init(options)))
let parentUuid = ''
for (const [index, disk] of this.#disks.entries()) {
if (index !== 0) {
if (!disk.isDifferencing()) {
throw Object.assign(new Error("Can't init vhd directory with non differencing child disks"), {
code: 'NOT_SUPPORTED',
})
}
if (disk.getParentUuid() !== parentUuid) {
throw Object.assign(new Error("Can't init vhd directory with incorrect parentage"), {
code: 'NOT_SUPPORTED',
})
}
}
parentUuid = disk.getUuid()
}
} catch (error) {
await this.close()
throw error
}
}
/**
* Closes the VHD.
* @returns {Promise<void>}
*/
async close() {
await Promise.all(this.#disks.map(disk => disk.close()))
}
/**
* @returns {number}
*/
getVirtualSize() {
return this.#disks[this.#disks.length - 1].getVirtualSize()
}
/**
* @returns {number} size
*/
getSizeOnDisk() {
const batEntrySize = 4
const sectorSize = 512
const batSize = Math.ceil((this.getMaxBlockCount() * batEntrySize) / sectorSize) * sectorSize
return (
this.#footerSize +
this.#headerSize +
batSize +
this.getBlockIndexes().length * (this.#blockSize + this.#bitmapSize) +
this.#footerSize
)
}
/**
* @returns {number}
*/
getBlockSize() {
return this.#disks[this.#disks.length - 1].getBlockSize()
}
/**
* @returns {string}
*/
getPath() {
return this.#disks[this.#disks.length - 1].getPath()
}
/**
* Disk chains return an array of disk paths.
*
* @returns {string[]}
*/
getPaths() {
return this.#disks.map(disk => disk.getPath())
}
/**
* @returns {string}
*/
getUuid() {
return this.#disks[this.#disks.length - 1].getUuid()
}
/**
* @returns {Promise<boolean>} canMergeConcurently
*/
async canMergeConcurently() {
return this.isDirectory()
}
/**
* @returns {number} getMaxBlockCount
*/
getMaxBlockCount() {
return this.#disks[this.#disks.length - 1].getMaxBlockCount()
}
/**
* Checks if the VHD contains a specific block.
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
for (const disk of this.#disks) {
if (disk.hasBlock(index)) {
return true
}
}
return false
}
/**
* Gets the indexes of all blocks in the VHD.
* @returns {Array<number>}
*/
getBlockIndexes() {
const indexes = new Set()
for (const disk of this.#disks) {
for (const index of disk.getBlockIndexes()) {
indexes.add(index)
}
}
return [...indexes]
}
/**
* Returns the parent non inizialized instance
* @returns {RemoteDisk}
*/
instantiateParent() {
return this.#disks[0].instantiateParent()
}
/**
* Writes a full block into this VHD.
* @param {DiskBlock} diskBlock
* @return {Promise<number>} blockSize
*/
async writeBlock(diskBlock) {
throw new Error(`Can't write blocks into a disk chain`)
}
/**
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>} diskBlock
*/
async readBlock(index) {
const reversedDisks = this.#disks.slice().reverse()
for (const disk of reversedDisks) {
if (disk.hasBlock(index)) {
return disk.readBlock(index)
}
}
throw new Error(`Block ${index} not found in chain `)
}
/**
* Reads a specific block from the child disk to copy/move it to this disk.
* @param {RemoteDisk} childDisk
* @param {number} index
* @param {boolean} isResumingMerge
* @returns {Promise<number>} blockSize
*/
async mergeBlock(childDisk, index, isResumingMerge) {
throw new Error(`Can't merge block into a disk chain`)
}
/**
* Gets a specific block path from the VHD directory disk.
* @param {number} index
* @returns {string} blockPath
*/
getBlockPath(index) {
for (const disk of [...this.#disks].reverse()) {
if (disk.hasBlock(index)) {
return disk.getBlockPath(index)
}
}
throw new Error(`Block ${index} not found in chain`)
}
/**
* @returns {VhdFooter}
*/
getMetadata() {
return this.#disks[this.#disks.length - 1].getMetadata()
}
/**
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
async flushMetadata(childDisk) {
throw new Error(`Can't flush metadata on a disk chain`)
}
/**
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
mergeMetadata(childDisk) {
throw new Error(`Can't merge metadata on a disk chain`)
}
/**
* Checks if the VHD is a differencing disk.
* @returns {boolean}
*/
isDifferencing() {
return this.#disks[0].isDifferencing()
}
/**
* Abstract
* Rename alias/disk
* @param {string} newPath
*/
async rename(newPath) {
throw new Error(`Can't rename a disk chain`)
}
/**
* Deletes all the disks
* @param {Object} options
*/
async unlink({ force = false } = {}) {
for (const disk of this.#disks) {
await disk.unlink({ force })
}
}
/**
* Check if all the disks in the chain are VHD directories.
* @returns {Promise<boolean>}
*/
async isDirectory() {
for (const disk of this.#disks) {
if (!(await disk.isDirectory())) {
return false
}
}
return true
}
/**
* Checks the integrity of the disk's external reference (e.g. alias target).
* @param {Object} [opts]
* @param {boolean} [opts.remove]
* @param {Function} [opts.logWarn]
* @param {Function} [opts.logInfo]
* @returns {Promise<string | undefined>}
*/
async clean(opts = {}) {
throw new Error(`Can't clean a disk chain`)
}
}

View File

@@ -0,0 +1,108 @@
import { RemoteVhdDisk } from './RemoteVhdDisk.mjs'
export { RemoteDisk } from './RemoteDisk.mjs'
export { RemoteVhdDisk } from './RemoteVhdDisk.mjs'
export { openDiskChain } from './openDiskChain.mjs'
export { MergeRemoteDisk } from './MergeRemoteDisk.mjs'
const DISK_EXTENSIONS = ['.vhd']
/**
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
* @typedef {import('./MergeRemoteDisk.mjs').MergeState} MergeState
*/
export function isDisk(path) {
return DISK_EXTENSIONS.some(ext => path.endsWith(ext))
}
/**
*
* @param {Object} params
* @param {RemoteHandlerAbstract} params.handler
* @param {string} params.path
* @param {boolean} [params.force]
* @param {boolean} [params.ignoreBlockIndexes]
* @returns {Promise<Disposable<RemoteDisk>>}
*/
export async function openDisposableDisk({ handler, path, force = false, ignoreBlockIndexes = false }) {
const disk = new RemoteVhdDisk({ handler, path })
if (ignoreBlockIndexes) {
// Best-effort init: return the disk even if init fails so that callers
// can still use operations that work without a fully-opened VHD (e.g. unlink, checkAlias).
await disk.init({ force, ignoreBlockIndexes }).catch(() => {})
} else {
await disk.init({ force })
}
return {
value: disk,
dispose: () => disk.close(),
}
}
/**
* @param {Object} params
* @param {RemoteHandlerAbstract} params.handler
* @param {string} params.path
* @param {boolean} [params.force]
* @param {boolean} [params.ignoreBlockIndexes]
* @returns {Promise<RemoteDisk>}
*/
export async function openDisk({ handler, path, force = false, ignoreBlockIndexes = false }) {
const disk = new RemoteVhdDisk({ handler, path })
if (ignoreBlockIndexes) {
// Best-effort init: return the disk even if init fails so that callers
// can still use operations that work without a fully-opened VHD (e.g. unlink, checkAlias).
await disk.init({ force, ignoreBlockIndexes }).catch(() => {})
} else {
await disk.init({ force })
}
return disk
}
/**
* Deletes VDI directories under `vmDir/vdis/<jobId>/<vdiId>/` that are not in coveredDirs.
* Used to clean up disk directories that no backup metadata references at all.
*
* @param {RemoteHandlerAbstract} handler
* @param {string} vmDir - root of the VM backup directory (xo-vm-backups/<vmUuid>)
* @param {Set<string>} coveredDirs - normalized vdiDir paths already managed by a lineage
* @param {{ remove?: boolean, logWarn?: Function, logInfo?: Function }} opts
*/
export async function cleanOrphanDiskDirs(
handler,
vmDir,
coveredDirs,
{ remove = false, logWarn = () => {}, logInfo = () => {} } = {}
) {
const vdisPath = `${vmDir}/vdis`
let jobDirs
try {
jobDirs = await handler.list(vdisPath, { prependDir: true })
} catch {
return
}
for (const jobDir of jobDirs) {
let vdiDirs
try {
vdiDirs = await handler.list(jobDir, { prependDir: true })
} catch {
continue
}
for (const vdiDir of vdiDirs) {
if (!coveredDirs.has(vdiDir)) {
logWarn('orphan VDI directory, not referenced by any backup', { path: vdiDir })
if (remove) {
logInfo('deleting orphan VDI directory', { path: vdiDir })
try {
await handler.rmtree(vdiDir)
} catch (error) {
logWarn('failed to delete orphan VDI directory', { path: vdiDir, error })
}
}
}
}
}
}

View File

@@ -0,0 +1,54 @@
// @ts-check
/**
*
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
*/
import { RemoteVhdDisk } from './RemoteVhdDisk.mjs'
import { defer } from 'golike-defer'
import { RemoteVhdDiskChain } from './RemoteVhdDiskChain.mjs'
/**
* @param {any} $defer
* @param {Object} params
* @param {RemoteHandlerAbstract} params.handler
* @param {string} params.path
* @param {string | undefined} params.until
* @param {boolean} [params.force]
* @returns {Promise<RemoteDisk>}
*/
async function _openDiskChain($defer, { handler, path, until, force = false }) {
/**
* @type {RemoteVhdDisk}
*/
let disk
/**
* @type {Array<RemoteVhdDisk>}
*/
const disks = []
$defer.onFailure(() => Promise.all(disks.map(disk => disk.close())))
disk = new RemoteVhdDisk({ handler, path })
await disk.init({ force })
disks.push(disk)
let foundRootDisk = until === undefined
while (disk.isDifferencing()) {
disk = await disk.openParent()
if (disk.getPath() === until) {
foundRootDisk = true
await disk.close()
break
}
disks.unshift(disk)
}
if (!foundRootDisk) {
throw new Error(`trying to open chain from ${path} to ${until}, root disk not found`)
}
// the root disk
return new RemoteVhdDiskChain({ disks })
}
/**
* @type {(params: { handler: RemoteHandlerAbstract, path: string, until?: string, force?: boolean }) => Promise<RemoteDisk>}
*/
export const openDiskChain = defer(_openDiskChain)

View File

@@ -0,0 +1 @@
export { VmBackupDirectory } from './VmBackupDirectory.mjs'

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