Compare commits

..

569 Commits

Author SHA1 Message Date
Pierre Brunet
9829eb58f3 fix: add debug to better understand timeout error 2026-08-07 15:53:52 +02:00
Mathieu
2e5ec8c9e2 fix(xo-server): auth provider did not wait for token update (#10233) 2026-08-06 15:24:21 +02:00
Mathieu
8054d88a88 fix(xo-server): handle 'no-bundle' as legacy licence (#10241) 2026-08-06 15:22:22 +02:00
Thierry Goettelmann
20bedca539 refactor(web-core,lite,web): migrate from modal/drawer packages to the unified overlay system (#10096) 2026-08-05 17:13:15 +02:00
Grandalf
f61019b960 fix(rest-api): coerces strings into numbers in external-router when type="number" (#10213) 2026-08-05 16:22:09 +02:00
Thomas Moraine
be960b36b9 docs(advanced.md) fix broken link to kubernetes page (#10230)
The docs on devops tools and kubernetes was moved
from the Vates VMS doc to the Xen Orchestra doc.
As a result, one link to the kubernetes page broke in the XO doc.
This commit fixes that broken link.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-08-05 09:40:12 +02:00
Mathieu
40dede9e11 feat: release 6.7.1 (#10229) 2026-08-04 15:42:40 +02:00
Mathieu
e9548959c6 feat: technical release (#10228) 2026-08-04 12:05:18 +02:00
Florent BEAUCHAMP
be94d0d083 fix(xo-server/v2v): incorrect uuid for running VM without snapshot (#10194)
* add more details in task log
* fix an issue with VM with more than 10 snapshot, add unit test for this parsing
* fix the real bug: vmware do not update the vmdk description file before the next VM reboot. It means that the uid of the snapshot and vm are the same, thus finishing the transfer with " nothing to transfer" . We are now using the path on source as key + add unit test + add compatibiliy with legacy VM migrated with the uid.
* add e2e test for v2v
2026-08-04 10:41:07 +02:00
Julien PIALET
9c6d10327f doc(RBAC) : added details on POST privilege example (#10168) 2026-08-03 15:20:20 +02:00
spacotte-vates
a94eba4dd1 fix(nbd-client): fix long datamap read nbddisk (#10157)
Customer imported large VM from VMWare (> 3 To), import switched to Qcow2 automatically but the process was stuck while building the qcow2 L1/L2 address tables, where the NbdDisk's hasBlock is called for each l2 table entry which iterated over the disk dataMap which contained ~113,528 ranges. This froze the process for more than 18 minutes after which it was either killed or died due to OOM error.

This fix improves the NbdDisk by:

    Updating processDatamap to throw on overlapping ranges and merge touching extents.
    Override default getBlockIndexesCount with optimized Nbd one.
    Improve hasBlock with a built in cursor to improve performance.

Test file added to cover the new function and updated code.
2026-08-03 11:24:44 +02:00
Florent BEAUCHAMP
a6970b0150 fix(backups/filerestore): restore files from proxy as tgz (#10208)
from ticket 59505

node-tar >= 7 returns a Minipass stream: it has `pipe()` but is not a `node:stream`
instance. xo-proxy serves this stream through Koa, which pipes only bodies passing
instanceof Stream` and JSON-serializes anything else — so a file restore through a
proxy answered a JSON dump of the tar object instead of the archive. Wrapping it in a
real node stream also propagates tar errors to the consumer and aborts the tar job
when the client disconnects.
2026-08-02 14:57:48 +02:00
Bastien Nollet
26cd23179d fix(load-balancer): don't get disabled hosts (#10209)
The load balancer did not take into account that hosts may be in disabled state, which means it's impossible to migrate a VM to it.
This PR fixes that by ignoring these hosts instead.

Fixes ticket 62077
2026-08-02 14:56:58 +02:00
Joris K
03d2af39ad feat(xo6): add host scan PIFs button and job (#10191)
* changelog

* feat(xo6): add host scan PIFs button and job

* add changelog

* missing translation

* review

* review

---------

Co-authored-by: MathieuRA <contact@mathieu-raisin.fr>
2026-07-31 16:10:46 +02:00
Mathieu
be868a307b feat(rest-api): add scan-pifs endpoint (#10187)
* feat(rest-api): add scan-pifs endpoint

* changelog

* add pifs tag to the route

* fix typo

* fix  typo in changelog

* fix

* missing response
2026-07-31 11:50:05 +02:00
Florent BEAUCHAMP
01c00b110e test(immutable-backups): integration test of the new process 2026-07-31 11:28:25 +02:00
Florent BEAUCHAMP
65c32014d6 feat(immutable-backups): better cleanup on start
feat: changelog
2026-07-31 11:28:25 +02:00
Florent BEAUCHAMP
69dfeeaaf6 fix(immutable-backups): ensure shell is in english 2026-07-31 11:28:25 +02:00
Florent BEAUCHAMP
4a1269b898 feat(immutable-backups): run lift on proces start 2026-07-31 11:28:25 +02:00
Marie Garde
498cc7509f feat(xo6/host): host action button start shutdown (#10088)
* feat(xo6): add disable and enable host button

* Add button enable disable

* remove route map file

* Refacto for PR

* Add modal

* refacto for PR

* change key i18n

* fix i18n

* fix route map

* change modale to VtsActionModale

* refacto for MR

* fix type VtsActionModal

* refacto for MR

* add enable disable un treeview

* refacto treeview

* wip

* add shutdown and start button and job

* Update changelog and remove route map

* add check on disable button

* fix job error type

* update button for menuitem

* wip i18n

* add vtsaction modal

* tests ok

* fix build

* refacto for mr

* fix fr.json

* refacto for PR

* add check pending action

* add check host not halted

* fix enable disable button while host shutdown

* returns from MR

* fix changestate host header

* wip

* wip

* add check pending operations

* last fix for PR

* fix menuItem in mobile vue

* remove route map

* returns from MR

* fix start button

* Add changelog
2026-07-31 10:12:52 +02:00
Florent BEAUCHAMP
f98b4c9e4a docs(s3): add ovh as a tier 1 provider (#10206) 2026-07-31 09:40:37 +02:00
Mathieu
cc7ba53b29 feat(lite): 0.24.0 (#10204) 2026-07-30 17:46:20 +02:00
Mathieu
1a795970f9 feat: release 6.7.0 (#10203) 2026-07-30 17:26:51 +02:00
Mathieu
59306dce8f feat: technical release (#10198) 2026-07-30 11:08:08 +02:00
Florent BEAUCHAMP
6ba465be04 fix(xapi): don't try to compute progress on Buffer (#10195)
* fix(xapi): don't try to compute progress on Buffer

introduced by 76b45a9b3b

* port type to vates/types
2026-07-30 10:19:37 +02:00
Mathieu
068c4cdc86 feat: technical release (#10192) 2026-07-29 15:30:14 +02:00
Mathieu
93a7c3b509 fix(docs): fix yarn.lock and broken anchor (#10190)
Introduced by ada3cf745
2026-07-29 14:52:45 +02:00
Florent BEAUCHAMP
07c42bbd1e fix(fs/s3): improve delete multiple performances (#10189) 2026-07-29 11:29:55 +02:00
Florent BEAUCHAMP
ada3cf745c feat(xo-server): serve doc from xo + add backup calculator (#10154)
* feat(xo-server): serve doc locally
* the doc is already distributed from the mono repo
* add a build phase to the global yanr build that build a doc with the
right prefix
* add a http mount doc => embed build

* doc(backups): add a space calculator
* estimate the space needed for backups
* compute the duration protected by immutability
2026-07-29 09:48:41 +02:00
Mathieu
6a0332a784 fix(xapi/host): fix async use in a sync block for smart reboot (#10180)
* fix(xapi/host): fix async use in a sync block for smart reboot
2026-07-29 09:38:38 +02:00
Mathieu
09bd5c3425 feat(openmetrics): expose SR content and UUID (#10149)
Refs XO-2503
2026-07-29 09:24:34 +02:00
Florent BEAUCHAMP
26c36d485e fix(backups): don't hide errors during backup transfers (#10183) 2026-07-28 17:08:19 +02:00
Thierry Goettelmann
087fd5d397 feat(overlay): allow manually aborting overlays (#10167) 2026-07-28 16:47:45 +02:00
Grandalf
4636fcf820 feat(types): type-register-rest-routes (#10171) 2026-07-28 14:51:36 +02:00
Marie Garde
dd58fed217 feat(xo6/xolite): add copyall button xo and lite (#10081) 2026-07-28 13:38:57 +02:00
vates-bot
4aa8a31c25 feat(web-stack/i18n): update Xen Orchestra 6 (#10033) 2026-07-28 11:00:31 +02:00
Olivier Lambert
4ca263c10a feat(xo-server,xo-web/rpu): shut down and restart pinned VMs instead of aborting (#10125)
* feat(xo-server/rpu): opt-in shutdown/restart of pinned VMs instead of aborting
2026-07-28 10:05:43 +02:00
Alina D
8d58147d66 feat(xo6): align vertically all hosts in Treeview (#10153) 2026-07-28 09:53:07 +02:00
Joris K
5849844fca fix(lite): change defer type import on vm creation (#10176) 2026-07-28 09:22:52 +02:00
Mathieu
a5648aede8 fix(web-core): fix several store issues (#10156) 2026-07-28 09:22:45 +02:00
Florent BEAUCHAMP
84ce89d199 fix(disk-transform): implement a guardrail to ensure it does not read block only in parent 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
9140cd273b fix(disk-transform): fix getBlockIndexesCount
introduced by #10058

the current method assumes that source block are full but they can
be sparse. In the worst case getBlockIndexesCount can be the same as
the source
2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
e710135eda fix(backups): ensure replication never transmit a delta without a base
differentiate when it's expected to transmit a full , and throw an
error on any discrepency
2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
5509f62ccd fix(backups): fix aggregated backup failing instead of falling back to a full
map.foreach((value,key)=> , not map.foreach(key=>
2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
6c4f7336e1 fix(backup-archive): really run tests 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
65cd607a49 fix(backups): force a full on continuous replication if the base could have been empty 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
453ffc98d2 fix(backups): fall back to a full backup if it's possible that the base is broken 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
f8df78c03d feat(backup-archive): add method to get the root of a chain 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
3613b30aa1 fix(backups): stamp backup after the fix 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
58332e6b16 doc(backups): more clarification on qcow2 + nbd 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
9388fa4bb6 fix(disk-transform): erronous offset in DiskLargerBlock when copying data
the offset must be relative to the start of THIS large block, not to the
start of the disk — otherwise every large block after index 0 lands past
the end of destinationBlock and copy() silently writes nothing (zeroed block).

affect qcow2 incremental backup with NBD disable

NBD + qcow2 and Vhd backups don't go through this code path
2026-07-27 19:45:25 +02:00
Olivier Lambert
f683817dec feat(docs): working cookieless Matomo tracking on a dedicated site
The previous static/js/matomo.js snippet never loaded the tracker
script, so nothing was ever recorded; it also pointed at site 5, the
XO website. Replace it with the client module from the Vates VMS docs
on the new dedicated docs site (23): cookieless page-view tracking that
follows SPA navigations, and instrumentation of the lunr search box
reporting each settled query with its suggestion count, so zero-result
searches surface missing content. The feedback widget mirrors votes and
thumbs-down reasons as doc_feedback events.

Also fills in the Formbricks IDs for the "XO Docs page feedback"
survey, enabling the feedback widget.
2026-07-27 19:25:51 +02:00
Olivier Lambert
51fe6b8920 feat(docs): page feedback widget wired to Formbricks
Swizzles DocItem/Footer to show a 'Was this page helpful?' widget under
every doc article, ported from the Vates VMS docs. Votes post to the
self-hosted Formbricks instance via its public client API; a thumbs-down
asks why (outdated, unclear, missing info, inaccurate) plus optional
free text. The vote is recorded on click so it survives the visitor
leaving before the follow-up. Page path rides along as a hidden field
for per-page filtering.
2026-07-27 19:25:51 +02:00
Mathieu
74b952be2b feat: technical release (#10174) 2026-07-27 15:05:16 +02:00
Florent BEAUCHAMP
efbfb664f7 fix(backup-archives): regenerate cache on discrepencies (#10132)
* ensure cache is generated completely at the end of the backup job
* ensure a broken cache is regenerated even without merge during the cleanup phase
* ensure a broken cache is regenerated on request
2026-07-27 14:19:17 +02:00
Bastien Nollet
1616ec6f41 feat(backups): make fallback cause appear in the task (#10148) 2026-07-27 14:10:03 +02:00
Alina D
246e2e4eba feat(xo6/pool): add connect/disconnect actions on page site (#10075) 2026-07-27 13:26:20 +02:00
Mathieu
bcb91e8b4f feat(rest-api): add POST /srs route to create a storage repository (#9990) 2026-07-27 10:37:55 +02:00
Florent BEAUCHAMP
76b45a9b3b fix(xo-server): ensure import task progress is correctly updated (#10133) 2026-07-27 10:23:29 +02:00
Sylvère
5f42e437c7 feat(lite): add SR connect/disconnect/delete (#10006) 2026-07-27 09:44:23 +02:00
Sylvère
b76294a1b1 feat(web-core): refactor the sidebar system (#10120) 2026-07-27 09:43:16 +02:00
Grandalf
b7bce8e7e7 fix(csp): update Content-Security-Policy to allow connections to GitH… (#10162) 2026-07-27 09:03:17 +02:00
Pierre Brunet
1445f986cf feat: add azure section (#10166)
* feat: linter

* feat: add Azure doc
2026-07-24 17:59:06 +02:00
MlssFrncJrg
83b43a44f6 feat(xo5/hub): open Hub to all plans and gate recipes individually (#10117) 2026-07-24 17:30:04 +02:00
Olivier Lambert
235ff3c455 feat(xo6/new-vm): set HA restart priority at VM creation (#10072) 2026-07-24 14:15:22 +02:00
Alina D
b0194b2c5f feat(xo6): update input version (#10009) 2026-07-24 11:47:22 +02:00
Marie Garde
316122a683 feat(xo6/users): Add Users Table (#10029) 2026-07-24 10:34:04 +02:00
Sandrine Barrucand
954c0aaa9e fix(xo6): add separator between label and hint in table actions (#10143) 2026-07-23 16:19:37 +02:00
Sandrine Barrucand
377e315e02 fix(xo6/host): wrap Network name in PIF side panel (#10155) 2026-07-23 14:54:56 +02:00
Joris K
9b6f882424 fix(xo6/lite): fix some design inconsistency between pages on xo6 and lite (#10109) 2026-07-23 14:23:37 +02:00
Marie Garde
874da48125 feat(xo6): add enable/disable host button (#10074) 2026-07-23 12:02:46 +02:00
Pierre Brunet
e22ee2547b Fix: avoid default value for "private" in normalize-packages script (#10121) 2026-07-23 11:43:21 +02:00
Mathieu
43e45667ab fix(xo-server/rpu): LINSTOR packages update progress no longer exceeds 100% (#10122) 2026-07-23 11:24:23 +02:00
Florent BEAUCHAMP
c05f9bb357 feat(backup): update footer and header at once (#10123) 2026-07-23 11:16:56 +02:00
amouillard-vates
92f8ed4077 feat(lite): add copy info into JSON action to pool networks table (#10083) 2026-07-23 11:10:52 +02:00
MathieuRA
fc6415b4ec fix(xo-server): fix group deletion checking RBAC authorization 2026-07-23 10:52:29 +02:00
MathieuRA
53014d7d18 feat(rest-api): expose acl-roles/:id/groups 2026-07-23 10:52:29 +02:00
MathieuRA
5bb4ac62bf fix(xo-server): delete user don't remove the link between user and acl-role 2026-07-23 10:52:29 +02:00
MathieuRA
b4433e4ecc feat(rest-api): expose acl-roles/:id/users 2026-07-23 10:52:29 +02:00
MathieuRA
fee9bf8334 feat(xo-server): add groupIds and userIds to acl-role object 2026-07-23 10:52:29 +02:00
MathieuRA
1455801282 feat(xo-server): add aclRoleIds property to groups 2026-07-23 10:52:29 +02:00
MathieuRA
bd5937366a feat(rest-api): expose groups/:id/acl-roles 2026-07-23 10:52:29 +02:00
Joris K
d2667ac64b feat(xo6/lite): updated the modal so it can be closed using the X or by clicking outside it (#10137) 2026-07-23 09:06:19 +02:00
Pierre Brunet
de9687e2c0 fix: issue on file overwriting during vhdDirectory creation (#10139) 2026-07-22 15:04:00 +02:00
Florent BEAUCHAMP
c12bd807ed feat(qa-test): add a script to do a load test (#10119)
add new option and script to launch a load test :

    number of vm is configurable
    concurrency is configurable
    number of backup run is configurable

will do multiple run, then a restore
2026-07-22 14:36:50 +02:00
amouillard-vates
7b21690721 feat(xo6/pool): add new VM button and disconnect to pool tree actions (#10046) 2026-07-22 14:19:26 +02:00
Sandrine Barrucand
b719c0a018 refactor(xo6/vif): merge connect/disconnect buttons into a single component (#10112) 2026-07-22 13:25:45 +02:00
Grandalf
8e8afeb1b5 feat(xo-server): add content security policy (#10101) 2026-07-22 12:15:16 +02:00
Elise-F
0a5693345f feat(traffic rule type by VIF): display only the traffic rules for VMs and disable VMs without a VIF. (#9977)
- Show only traffic rules of VMs. Don't include `vm-snapshot` or `vm-template`
- For VM selection, display all VMs and disable those without a VIF, with a
message displayed
2026-07-22 11:01:17 +02:00
Bruno Verachten
ebce0e508f docs(xo): document VBD attach and note ISO mount is JSON-RPC only (#9981) 2026-07-21 16:35:13 +02:00
Olivier Lambert
64f1327f46 feat(rest-api): allow setting HA restart priority on VM creation (#10070)
See #10069
2026-07-21 16:02:08 +02:00
bleader
d2c28450d4 docs(sdn_controller.md): clarify openssl3 update instructions (#9821) 2026-07-21 11:45:09 +02:00
MlssFrncJrg
4eb43fbc36 feat(xo6): resolve objects in tasks names (#9830) 2026-07-21 10:37:36 +02:00
Bastien Nollet
e44034285b fix(backups): fix failed status on successful retry (#10129)
A VM backup log status is calculated depending on its subtask status. Previously, we made that it would be a failure if at least one of its subtask is a failure.

However, in case of successful retries, the intended behaviour is to have failed subtasks, which should not affect the parent task status. The first commit of this PR fixes that.

The second commit simplifies the getStatus function and its usage.
---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-07-21 09:50:07 +02:00
Thierry Goettelmann
9c1aa104f5 chore(deps): hoist pinia, vite and vue-router to root devDependencies (#10138) 2026-07-21 09:30:21 +02:00
Thierry Goettelmann
4ff8af838f feat(web-core): add hide-permanently package (#10108) 2026-07-21 09:28:07 +02:00
amouillard-vates
101fb46ad1 fix(xo6/site): remove site tree view new vm action (#10135) 2026-07-21 09:04:44 +02:00
Thierry Goettelmann
687dd5f456 feat(web-core): add promise-based overlay system (#10095) 2026-07-20 17:34:09 +02:00
Mathieu
ee6c6b3bbc feat(xo-server): delay load balancer re-enable after RPU (#10111)
XO-2706
2026-07-20 16:51:45 +02:00
Mathieu
54260268ba fix(xo-server): demote successful fallback logs (#10131)
XO-2734
2026-07-20 16:00:13 +02:00
Elise-F
0d53d7621a feat(lite): add the “connect/disconnect” action for a VIF on a VM (#10080) 2026-07-20 15:12:46 +02:00
Florent BEAUCHAMP
c3a2e89c1f feat(disk-cli): add option to show chain info, size, and improve peformances (#10063) 2026-07-20 14:37:20 +02:00
Enée DI IORIO
27fe714e17 feat(web-core): update UiTextArea to V5 (#9855) 2026-07-20 11:10:33 +02:00
Mathieu
5e6c4ad96d fix(xo-server): automatically reconnect to XenServer after connection (#10086)
A server left enabled but without a Xapi instance was never retried by anything in
xo-server. Three paths lead to that state:

    a failed explicit connect: xo-server boots while a pool is down, a user clicks
    "connect" during an outage, or the host-eject path exhausts its 5 attempts;
    a SESSION_INVALID teardown after a pool master restart (_sessionCall calls
    disconnect(), and nothing listens to server:disconnected);
    fixing a server's credentials/address while it is disconnected (server.set never
    triggers a connection).

Once there, the server stays disconnected forever, showing an error that can be
minutes or hours old. Support ends up chasing false "pool is dead" reports, typically
right after a rolling pool update, which is exactly when hosts reboot by design.
2026-07-20 09:42:12 +02:00
Florent BEAUCHAMP
926eb348d3 feat(xo-server): log more data to track memory leaks (#10041)
* feat(xo-server): log more data to track memory leaks

* changelog
2026-07-17 16:35:06 +02:00
Mathieu
bfb6b15cfa fix(xo-server/rpu): retry check_update while updater plugin is busy (#10115) 2026-07-17 16:17:09 +02:00
Grandalf
d05be427fb fix(xo-server): replace hasPermissions (#10093) 2026-07-17 11:48:37 +02:00
Pierre Brunet
76fa4bba51 fix(CI): do not log "ENOENT" when unlink fails after check (#10102)
* fix(CI): do not log "ENOENT" when unlink fails after check

* fix: await for mkdirp before continuing
2026-07-17 10:16:07 +02:00
Grandalf
d02098a397 fix(xo-server): don't log the admin password on server creation (#10094)
XO-2647
Introduced by #3579
2026-07-17 09:43:51 +02:00
Sandrine Barrucand
20c9ed8a41 feat(xo6/vm): add New VM button on Host view (#10048) 2026-07-16 16:18:31 +02:00
Mathieu
2f19fda3a5 feat(xo-server/rpu): assert each host can be evacuated right before its evacuation (#10097) 2026-07-16 15:16:17 +02:00
Mathieu
798bdeb973 fix(xo-server): disable auto power on during rolling pool update/reboot (#10104) 2026-07-16 14:34:50 +02:00
MlssFrncJrg
ac52f8b75e fix(perf-alert): update URL generation for alarms to support v6 routing (#10054)
* fix(perf-alert): update URL generation for alarms to support v6 routing

* changelog entry

* fix changelog

* Apply suggestions from code review

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

---------

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>
2026-07-16 10:25:24 +02:00
Enée DI IORIO
691af588d3 feat(Lite): remove all coming soon (#10068) 2026-07-15 17:26:22 +02:00
Grandalf
cda3c3a724 perf(xo-server): don't wait for errored br to send data (#10025) 2026-07-15 15:32:21 +02:00
John P. Cooper
f2d6b7f0e4 Update XCP-ng version label in supported_hosts.md to bring into sync with supported status (#10092) 2026-07-15 15:20:56 +02:00
Sandrine Barrucand
1afffb8f75 feat(xo6): add possibility to export VM (#9989) 2026-07-15 14:42:36 +02:00
Mathieu
2b7b6baf02 feat(xo-server-openmetrics): estimate per-VM power consumption from CPU (#10031)
* feat(xo-server-openmetrics): estimate per-VM power consumption from CPU
usage

* refactor(xo-server-openmetrics): move hostPowerWatts undefined guard to
caller

* fix(xo-server-openmetrics): log IPMI power read failure as info
2026-07-15 14:06:49 +02:00
Florent BEAUCHAMP
0cfcb998a2 feat(disk-transform): port getBlockIndexesCount to the relevant classes (#10058) 2026-07-15 14:04:53 +02:00
Sandrine Barrucand
3ffd9b1f56 feat(xo6/vm): in the VM actions, add a separator to Delete, placeholder actions and submenu (#10036) 2026-07-15 13:18:34 +02:00
Mathieu
184d65cef1 feat(xo-server): add crash-resistant on-disk observability for RPU/RPR (#10078)
* feat(xo-server): add crash-resistant on-disk observability for RPU/RPR
(XO-2640)

* fix(xo-server): reconcile RPU heartbeats left pending at boot (XO-2640)

* chore(CHANGELOG): add RPU observability entry (XO-2640)

* docs(rpu): document on-disk traces and heartbeat for troubleshooting

* refactor(xo-server): address RPU observability review feedback
2026-07-15 12:23:36 +02:00
Elise-F
a7ba7c6a89 feat(xo6): add VM duplicate action (#9580) 2026-07-15 12:16:34 +02:00
Sandrine Barrucand
25da6d4c4b fix(xo6/xolite): switch CPU and RAM panels (#10059) 2026-07-15 11:47:20 +02:00
amouillard-vates
8b78ab985d feat(xo6/site): add new VM button and connect pool to site tree actions (#10047) 2026-07-15 11:45:19 +02:00
Joris K
2bee8c279d feat(xo6): add possibility to create VIF (#9677) 2026-07-15 11:26:42 +02:00
Bastien Nollet
5de65620f1 fix(xapi-explore-sr): fix crash on unreferenced VDI (#10113)
Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-07-15 11:20:33 +02:00
Olivier Lambert
b078d3d42d feat(xen-api): reconnect to a surviving pool member when the master is unreachable (#10016)
* feat(xen-api): reconnect to a surviving pool member when the master is unreachable

XO follows a pool master change only through HOST_IS_SLAVE redirection, which
needs the contacted host to be up and answering. That covers a graceful master
hand-over (the old master stays up as a slave and redirects us), but not the
case where the master host itself becomes unreachable while another host takes
over as master: HA automatic failover, or a manual emergency transition. The
stored URL then points at a host that no longer responds, login fails with a
transport error instead of HOST_IS_SLAVE, the existing retry never redirects,
and the connection stays stuck on the dead master.

Maintain a set of pool-member addresses (seeded from the configured target and
optional opts.candidateHostnames, refreshed from host.get_all_records on every
session open) and, when the current target is unreachable at the transport
level, probe the other members in parallel. The first to answer wins; if it is
a slave, its login redirects us to the current master through the existing
HOST_IS_SLAVE retry. XapiError responses (including auth failures) are not
failed over, so wrong credentials still fail fast.

* feat(xo-server): persist pool-member addresses so master failover survives a restart

The xen-api failover relies on an in-memory set of pool-member addresses, which
is lost when xo-server restarts: it then only knows the configured host. That is
exactly the address that is dead when XO runs on the very pool whose master just
failed and was HA-restarted onto a survivor, leaving XO unable to reconnect.

Persist the member addresses on the server record (Redis, JSON-encoded like the
existing error field), refresh them whenever the pool object is seen via the
xen-api candidateHostnames getter, and feed them back as opts.candidateHostnames
when (re)building the connection. After a restart the connection can then probe
the surviving members and reconnect to the new master.

* chore(changelog): reconnect to a surviving pool member on master failure

Add the user-facing changelog entry and list xen-api in the packages to
release for the master-failover reconnect feature.

* fix(xen-api): prune ejected hosts from pool-member failover candidates

host.get_all_records is authoritative current membership, so replace the
candidate set on refresh instead of unioning into it. An ejected host is
typically still alive as its own standalone master with the same
credentials, so leaving it in the set could make a failover probe
silently connect to the wrong pool. The currently-connected target is
preserved since host.address may differ from the address XO used to
reach it.

* fix(xen-api): harden surviving-member failover per review

- reject failover probes on sessionId-based connections (no reusable
  credentials to open a session on another host)
- bracket bare IPv6 addresses (host.address / HOST_IS_SLAVE) before using
  them as URL hostnames
- log the probe session out even when the host answers after the probe
  timeout, to avoid leaking a session
- abort the mid-session failover loop when the connection was torn down or
  replaced while waiting (e.g. user disconnected the server)
- xo-server: drop persisted pool-member addresses when the connection
  identity (host/credentials) changes, so a stale slave list can't send a
  failover to the wrong pool

See #10016
2026-07-15 11:04:16 +02:00
Bastien Nollet
913148ec2f chore(changelog): add previously unreleased package (#10106) 2026-07-13 10:48:01 +02:00
Mathieu
44e5ba4cf5 feat(rest-api): add PATCH /rest/v0/vdis/{id} (#9945) 2026-07-10 15:40:05 +02:00
Mathieu
db795ccfe2 feat(rest-api): ability to set transient for host.disable (#10040) 2026-07-10 15:33:08 +02:00
MlssFrncJrg
519b2bd7e1 feat(rest-api): add RBAC check for pool endpoints (#9891) 2026-07-10 15:27:26 +02:00
Grandalf
b5f5e39ddc feat(rest-api): add array type to FieldDefinition (#10055) 2026-07-10 14:33:00 +02:00
Alina D
f3f8519b7a fix(xo6): repair negative other value in backup repository dashboard (#10044) 2026-07-10 09:38:42 +02:00
Bastien Nollet
1d82c373da feat: release 6.6.2 (#10105) 2026-07-09 16:50:11 +02:00
Bastien Nollet
12b380e806 feat: technical release (#10103)
* feat(@xen-orchestra/fs): 4.9.2

* feat(@xen-orchestra/backup-archive): 1.0.4

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

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

* feat(xo-server): 5.205.2

* chore(CHANGELOG): update next
2026-07-09 14:31:09 +02:00
Pierre Brunet
f8c9c66c40 fix(backups): remove warning for encrypted xva (size cannot be checked for encrypted data) 2026-07-09 12:00:48 +02:00
Pierre Brunet
1afa1e52ed fix(backups): transfer length and maxStreamLength in pipeline() 2026-07-09 12:00:48 +02:00
Florent BEAUCHAMP
b2a48ffacc fix(fs): compute the output stream precisely when encrypted (#10061)
* fix(fs): compute the output stream precisely when encrypted

the current encryption add 12 byte of initialization vector at
the start + 16 bytes of authentication tag at the end

thus, the destination stream can be bigger than the source stream,
and this length is used to compute the optimal chunk size for s3/
azure

from https://xcp-ng.org/forum/topic/12328/error-mirroring-full-backups-to-backblaze-b2

---------

Co-authored-by: Pierre Brunet <pierre.brunet@vates.tech>
2026-07-09 10:35:40 +02:00
Bastien Nollet
a3077cdb62 feat: release 6.6.1 (#10087) 2026-07-08 09:56:44 +02:00
Bastien Nollet
3ba9523dfe feat: technical release (#10084) 2026-07-07 14:37:05 +02:00
spacotte-vates
377581b44a fix(backups): add warn with debug for snapshot pileup issue (#10076) 2026-07-06 14:56:03 +02:00
Thomas Moraine
090ce7e721 docs(xo): refactor navbar navigation to create a unified documentation ecosystem (#10067)
* docs(xo): overhaul navbar to create a unified doc ecosystem

- Clean up the navbar by dropping the redundant GitHub link (retained in footer).
- Relocate "Blog" to the "What's new in XO 6" sidebar.
- Introduce direct navbar links for XCP-ng and Vates VMS.
- Rename "Documentation" to "Xen Orchestra".

This aligns the navigation layout across our three documentation sites, allowing readers to smoothly transition between ecosystems with a fully unified experience.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): move home link from header to footer

To make the navbar cleaner and make space
for the links to the other documentation websites
We're moving the Home link from the header to the footer
and renaming the link to 'About Xen Orchestra'.
Also, we're displaying the 'What's new in XO 6' section again
which now contains a link to the XO blog

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-07-03 10:30:40 +02:00
Thomas Moraine
d53dd3e990 docs(xo): import devops tools documentation from vates vms (#10062) 2026-07-03 09:24:44 +02:00
Thomas Moraine
1b0dbc7d3b docs(xo6): hide categories that dont contain any actual pages (#10066)
Recently we've introduced new sections for the xo6 category of the XO doc.
These categories were meant to be filled later with new pages.
The pages were created as placeholders, but didn't actually contain any text, just titles.
These made readers confused, so this commit
Hides the sections from the sidebar until we update them
With actual content.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-07-03 08:44:14 +02:00
Florent BEAUCHAMP
bb4daac07a fix(xo-server): store rrd as Float32 instead of string (#10039) 2026-07-02 15:57:43 +02:00
spacotte-vates
63f8d624fd fix(backups): improve resume of backup merge failure (#10053)
Introduced by #9765

When a backup merge fails on a VHD file, the footer might be missing. This caused a footer1 !== footer2 error and triggered a full backup while not deleting the previous chain and resulted in all further backup jobs to be displayed as failures even though the new backup chain was working properly.

The fix allows the cleanVm to resume the merge where it stops even if the VHD file footer is missing.
2026-07-01 17:01:38 +02:00
Alina D
373a4c8c6d fix(web-core): make VDI used space in progress bar appear correctly (#10027)
Used space progress bar now shows correctly filled at 100% instead of appearing half-filled

References issue :  XO-1696
2026-07-01 10:43:37 +02:00
Warren Postdam
1ec57392fc docs: update users to add passkeys auth (#9975)
Add configuration elements to allow FIDO2 (passkeys) administrators to configure
with XO by using `auth-oidc` plugin.
2026-07-01 10:24:55 +02:00
Joris K
376bb3603f fix(xo6): fixed an issue where the selected item in the treeview would scroll to the top (#10052) 2026-07-01 09:02:12 +02:00
Bastien Nollet
3bc70f657d feat(lite): 0.23.0 (#10050) 2026-06-30 16:50:07 +02:00
Bastien Nollet
f3b2ac5983 feat: release 6.6.0 (#10049) 2026-06-30 16:20:58 +02:00
Bastien Nollet
eb4c55d82c feat: technical release (#10045)
* feat(@vates/types): 1.28.0

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

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

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

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

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

* feat(xo-server): 5.205.0

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

* chore(CHANGELOG): update next

* clear changelog
2026-06-30 12:01:18 +02:00
spacotte-vates
d43ca09b8b feat(rest-api): add 7 rest actions on hosts (#9887) 2026-06-30 11:37:17 +02:00
Grandalf
3ad3ecb244 feat(sdn-controller): add patch routes for vif and network (#9936) 2026-06-30 11:29:29 +02:00
Bastien Nollet
7bbe139b36 fix(backups): fix memory error during delta replication (#10034) 2026-06-29 17:25:17 +02:00
spacotte-vates
235fc07404 feat(rest-api): backup-repository health/bench routes (#9847) 2026-06-29 17:12:51 +02:00
Bastien Nollet
26038fdaec feat: technical release (#10037)
* feat(@xen-orchestra/backups): 0.73.4

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

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

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

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

* feat(xo-server): 5.204.0

* feat(xo-server-netbox): 1.13.1

* feat(xo-web): 5.199.0

* chore(CHANGELOG): update next
2026-06-29 14:38:39 +02:00
Mathieu
4fe505b229 fix(web-core): required disappear after clicking on VtsSelect (#10032) 2026-06-29 14:04:20 +02:00
Sylvère
276c4e5f7e feat(lite): add storage tabs and read-only SR views (#10005) 2026-06-29 13:31:52 +02:00
Florent BEAUCHAMP
91badc593c fix(@xen-orchestra/proxy): invalid parameter in listVmBackups (#10035)
introduced by #9143
2026-06-29 13:01:45 +02:00
Pierre Brunet
130b1612e6 fix(xo-server): add safeguards to xo-server shutdown and update changelog 2026-06-29 10:55:29 +02:00
Pierre Brunet
e8c43dafce fix(s3): stop s3 agents properly after xo-server shutdown 2026-06-29 10:55:29 +02:00
Pierre Brunet
8e0c0e05c0 feat(xo-server-auth-ldap): support LDAP multidomain (#10015) 2026-06-29 10:49:09 +02:00
Enée DI IORIO
71d71b9742 feat(lite): use uiLoader instead of uiSpinner (#10023) 2026-06-29 10:35:45 +02:00
spacotte-vates
34051927a0 fix(encryption): handle missing xen-tools (#9987)
Introduced by 6f68b04

Improve missing xen-tools handling of encryption with better logic and logs.
2026-06-29 09:41:35 +02:00
Sandrine Barrucand
9e6ad45a79 feat(xo6): add possibility to migrate VDI on SR (#9973) 2026-06-29 08:07:21 +02:00
Sandrine Barrucand
52591ec65b feat(xo6/vm): add actions available on VMs tab (#10030) 2026-06-26 17:32:23 +02:00
Mathieu
bf1e992161 feat(rbac): add Storage administrator built-in role template (#9963) 2026-06-26 16:40:52 +02:00
vates-bot
29b9bc46e4 feat(web-stack/i18n): update Xen Orchestra 6 (#9998) 2026-06-26 14:49:43 +02:00
Florent BEAUCHAMP
9d470997e8 fix(xo-server,netbox): properly close hrp stream on error (#10026)
when hrp get a 404, the stream stay ready to be consumed, for example to get
more information on the error

but most of the usage won't handle this case, and will keep the stream in memory
(for example a failing XC patches check) leaking memory

This fixes the calls from xo, and also identifies and fixes the point where we
need body content on 4xx code

relevant code in hrp is here:
https://github.com/JsCommunity/http-request-plus/blob/master/index.js#L149
2026-06-26 14:09:49 +02:00
OlivierFL
836e06843d feat(xo-core): introduce new VtsCardObjectTitle component (#9755) 2026-06-26 12:12:56 +02:00
Joris K
5160ec4c7e fix(web-core/xo6): stream infra tree collections and flatten into a virtualized list (#9986) 2026-06-26 11:33:44 +02:00
Marie Garde
cc88cc395c feat(xo6/vdi): add export VDI action (#9983) 2026-06-26 11:33:14 +02:00
Enée DI IORIO
6ea64e9413 feat(lite): Use web-core's UiLogEntryViewer instead of xo-lite UiRaw (#9995) 2026-06-26 10:28:27 +02:00
Florent BEAUCHAMP
b440f41cd5 fix(xo-server): ignore template memory_static_min is it's less than actual vm memory 2026-06-25 16:29:46 +02:00
Florent BEAUCHAMP
7ea6a5c541 feat(xo-web): show status of previous transfer to help the 2 part transfer 2026-06-25 16:29:46 +02:00
Florent BEAUCHAMP
1987008db1 fix(xo-server): v2v should create a DVDRom is source has one 2026-06-25 16:29:46 +02:00
Florent BEAUCHAMP
4e1e1510e8 fix(xo-server): v2V not updating correctly other config
setting undefined is a no ops, we need to use null to clean a value

this don't have any visible or functionnal impact, but complexify
the build of a filter to look for any non completely migrated VM
2026-06-25 16:29:46 +02:00
Grandalf
e9a8ffe315 feat(xo-server): add esxi.importDisk (#10024)
* feat(xo-server): add esxi.importDisk
2026-06-25 16:17:43 +02:00
OlivierFL
8639e86227 fix: changelog (#10028) 2026-06-25 15:43:26 +02:00
Pierre Brunet
89e1b051cb feat: add failover URIs in ldap domains (#9961) 2026-06-25 14:09:03 +02:00
Florent BEAUCHAMP
c162839d4e docs(immutable-backups): document how to lift the immutability for one VM (#9974) 2026-06-25 11:57:31 +02:00
Enée DI IORIO
d73e9d5e94 chore(lite): remove unused component (#10011) 2026-06-25 10:55:20 +02:00
Sylvère
ab472af351 feat(web-core/web/lite): updated UiPanel to v5 (#9836) 2026-06-25 10:52:35 +02:00
Sylvère
01d8e21538 feat(xo6): warn and link to doc when traffic rules are not in the expected format (#10020)
Co-authored-by: Sylvere <sylvere.armange@vates.tech>
2026-06-25 10:06:35 +02:00
Bastien Nollet
bf2954edb0 fix(backups): uncaught VM error (#10021) 2026-06-25 10:06:19 +02:00
Enée DI IORIO
0fca9b2cde feat(lite): use vtsDivider instead of UiSeparator (#10017) 2026-06-25 10:05:46 +02:00
Alina D
8b9b99b68f feat(xo6): change redirect target (#9984) 2026-06-25 09:22:05 +02:00
Mathieu
c74a83ed06 fix(web): no active job instead no job (#10013) 2026-06-24 16:39:33 +02:00
Bastien Nollet
b96a6dc1e9 feat: technical release (#10022)
* feat(@xen-orchestra/fs): 4.9.1

* feat(@vates/generator-toolbox): 1.1.3

* feat(@xen-orchestra/disk-transform): 1.3.1

* feat(@vates/fuse-vhd): 2.1.3

* feat(xo-common): 0.10.0

* feat(xen-api): 4.7.8

* feat(@vates/types): 1.27.0

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

* feat(@xen-orchestra/backup-archive): 1.0.2

* feat(@xen-orchestra/qcow2): 1.3.1

* feat(@xen-orchestra/xapi): 8.9.0

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

* feat(@xen-orchestra/disk-cli): 2.1.2

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

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

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

* feat(@xen-orchestra/openflow): 0.1.4

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

* feat(@xen-orchestra/qa-test): 1.1.0

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

* feat(@xen-orchestra/upload-ova): 0.1.8

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

* feat(xo-acl-resolver): 0.5.3

* feat(xo-server): 5.203.0

* feat(xo-server-audit): 0.15.1

* feat(xo-server-ipmi-sensors): 2.0.1

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

* feat(xo-server-netbox): 1.13.0

* feat(xo-server-openmetrics): 1.7.0

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

* feat(xo-web): 5.198.0

* chore(CHANGELOG): update next
2026-06-24 10:18:29 +02:00
Sebastien Marie
a6f2a25aec docs: explain traffic rules migration path (from OpenFlow Protocol to XAPI Plugin) (#9999)
* fix(configuration.md): remove trailing spaces

* feat(sdn-controller.md): explain traffic rules migration path: from OpenFlow Protocol to XAPI Plugin

- add sdn-migration.py python script
- in configuration.md, refer to sdn_controller.md for changing `useDirectChannel`
- in sdn_controller.md, provide documentation on migration path
2026-06-24 08:53:34 +02:00
Sebastien Marie
8f31ff7481 feat(xo-server-sdn-controller): traffic-rules: check the configuration matches the rules format (#10000)
XCPNG-3260

report an error if we detect that the runtime configuration doesn't match the of-rules format stored in xapi database.

the admin is expected to ran migration script by hand to fix it.
2026-06-24 08:52:48 +02:00
Enée DI IORIO
d7bfb171fd feat(lite): Use web-core's UiCardTitle instead of xo-lite version (#9982) 2026-06-23 17:10:25 +02:00
Sylvère
fc45f3e59e feat(web-stack): extract SR types/utils to web-core (#10004) 2026-06-23 17:09:22 +02:00
Florent BEAUCHAMP
cecf1cb6bc feat(xoa): add an option to take a snapshot before upgrade (#9920) 2026-06-23 16:10:29 +02:00
Thomas Moraine
bf0e40977c docs(sdn_controller.md): mention that network-wide rules can now be created in the UI with XO6 (#10019) 2026-06-23 15:33:40 +02:00
Marie Garde
caa174c69c feat(xo6): vdi dedicated page (#9949) 2026-06-23 11:37:52 +02:00
Mathieu
304e219441 fix(rest-api/dashboard): count job sequence schedules for backup computation (#9997) 2026-06-23 10:24:57 +02:00
Enée DI IORIO
9c2739b6dd feat(lite): Use web-core's UiCard instead of xo-lite version (#9980) 2026-06-23 10:16:31 +02:00
Pierre Brunet
7a24dba9c1 fix(backup/cleanVM): avoid useless warning (#9996) 2026-06-23 10:08:32 +02:00
Mathieu
a5d410e0aa feat(xo-server-openmetrics): include XO tags as tags label (#9971)
Fixes #9628
Plane : XO-2244
2026-06-23 10:07:15 +02:00
Thomas Moraine
64401c4040 docs: make a more logical doc structure for xo6 (#9972)
This commit creates a new, refreshed tree structure
for the XO 6 documentation, which we intend
to be more helpful for readers and XO 6 users.
New sections are created, to be completed later.
Additionnally, the ACLv2/RBAC page has been
moved from the Support section to the Security section.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-06-23 09:41:13 +02:00
Pierre Briffaux
93909d9851 feat(xo6/rbac): add admin tab with user management menu (#9947) 2026-06-22 17:25:42 +02:00
Grandalf
5604289f3b test(sdn-controller): add tests (#9957)
Adds rest api endpoints testing for the sdn controller.
Don't forget to set the required SDN_CONTROLLER_VM_ID to a vm that has both a vif and a network.
Run @xen-orchestra/qa-test yarn qa:sdn-controller for testing.
2026-06-22 17:09:39 +02:00
Mathieu
cf569efff2 feat(rest-api): add PATCH /rest/v0/vifs/{id} (#9935) 2026-06-22 15:54:05 +02:00
Sandrine Barrucand
4bde431167 feat(xo6/traffic-rules): add delete button (#9956) 2026-06-18 12:14:56 +02:00
Mathieu
37868e3a74 fix(rest-api): fix vm dashboard replica detection (#9991)
introduced by 680b810886

See https://github.com/vatesfr/xen-orchestra/pull/9806/changes#diff-6f2227ada6bc2da3fd324691bb30b604b7c61a2cdbeebcad7969a8233cd4f0c4R394
2026-06-18 10:40:13 +02:00
Florent BEAUCHAMP
443de97c57 fix(qa-test): metadata test can run so fast there is a datetime collision (#9954) 2026-06-17 17:57:02 +02:00
vates-bot
9e39d0d534 feat(web-stack/i18n): update Xen Orchestra 6 (#9914) 2026-06-17 17:01:24 +02:00
Mathieu
f166037b4d docs(rest-api): update .USAGE.md (#9967)
* docs(rest-api): update .USAGE.md

* Mathieu.P's feedback
2026-06-17 15:19:00 +02:00
Alina D
60c97d3678 feat(xo6): add policy tag colors (#9979) 2026-06-17 15:08:53 +02:00
Joris K
62ba0a5a30 feat(web-core): update table header to remove icons (#9943) 2026-06-17 11:36:50 +02:00
Pierre Brunet
2a5c350a15 fix(disk-cli): update isDisk call parameters (#9988) 2026-06-17 10:55:34 +02:00
Pierre Brunet
4a8b29530b chore: fix changelog (#9993) 2026-06-17 10:15:02 +02:00
Pierre Brunet
9a710e5bf3 feat: update packages (#9969) 2026-06-17 10:02:00 +02:00
Sylvère
8eb6543905 feat(xo6): add the SR connect feature (#9877) 2026-06-16 11:52:29 +02:00
Alina D
b99f686d0c feat(xo6): update CPU labels to vCPU in VM system resource card (#9950) 2026-06-16 10:59:19 +02:00
Stephen Boyd
07b45e157d feat(xo-server-netbox): add option to ignore private IPs (#6306)
Optionally ignore RFC 1918 VM IPs during synchronization
2026-06-16 09:33:39 +02:00
Enée DI IORIO
3e7f003060 feat(lite/xoa-deploy): add confirmation before leaving deployment (#9893)
Add a message if you try to leave the page during XOA deployment.

Unfortunately, it is not possible to have a custom info box for the
`beforeUnload` event. So for homogeneity, `onBeforeRouteLeave` is
`window.confirm` too.
2026-06-16 09:27:00 +02:00
MlssFrncJrg
cf26d3f5b2 feat(rbac): create Administrator template role (#9885) 2026-06-15 15:50:09 +02:00
Florent BEAUCHAMP
60f59509a9 fix(backups): remove inspector (#9966) 2026-06-15 14:31:31 +02:00
Bastien Nollet
801a1f70fe feat(load-balancer): wrap migrations in XO Tasks for readability (#9909)
Use XO Tasks in the load balancer so users can see on the task page what the load balancer does. Now, it creates a task (with no progress) for each VM migration.

The task has a description explaining the reason of the migration, which can be accessed by opening the task log.

XO-992
2026-06-15 13:59:48 +02:00
Enée DI IORIO
b06779655e fix(web-core/VtsStateHero): behavior on mobile (#9363) 2026-06-15 13:46:08 +02:00
Grandalf
d1b99b9e86 feat(rest-api): add forgetRepository endpoint (#9846) 2026-06-15 10:59:09 +02:00
Mathieu
04deb9e328 feat(acl): add Network administrator built-in role template (#9952) 2026-06-12 15:47:22 +02:00
Nathanaël HANNEBERT
8c51de5043 fix(docs): typo preventing building of docs 2026-06-12 11:48:50 +02:00
Florent BEAUCHAMP
de5ea8a888 fix(backups): file level restore : omnibus fixes (#9776) 2026-06-12 11:40:16 +02:00
Marie Garde
e9b66fe607 feat(xo6): add column vif to table Vm > Network (#9959) 2026-06-11 17:03:46 +02:00
Sylvère
d5d9c00c70 feat(xo6): add the SR disconnect feature (#9856) 2026-06-11 16:04:53 +02:00
Joris K
ae2ef7a7d9 feat(xo6): update actions name to be more consistent (#9968) 2026-06-11 14:25:57 +02:00
Sylvère
899d3e27bb feat(xo6): add the SR delete feature (#9853) 2026-06-11 14:11:46 +02:00
Sylvère
632532f88a docs: update glossary (#9857)
Updated glossary with more acronyms and definitions.
2026-06-11 11:42:10 +02:00
Sylvère
cbf7da417f feat(web-core): updated UiModal to v3 (#9825) 2026-06-11 11:40:11 +02:00
Florent BEAUCHAMP
1f826f7d5d feat(qa-test): add minimal runner and mail report (#9866) 2026-06-11 11:07:37 +02:00
MlssFrncJrg
ae79d51d87 feat(rest-api): add RBAC check for VM endpoints (#9910)
* feat(rest-api): add RBAC check for VM endpoints
2026-06-11 10:33:12 +02:00
MlssFrncJrg
21f191b829 feat(rest-api): add RBAC check for VDIs endpoints (#9908)
* feat(rest-api): add RBAC check for VDIs endpoints
2026-06-11 10:27:00 +02:00
Elise-F
ba5b9ba7f9 fix(xo-lite): vue-router@5 breaking changes (#9951) 2026-06-11 09:49:39 +02:00
Alina D
88045806f0 feat(web-core/xo6): update connect/disconnect icons (#9942) 2026-06-11 09:02:12 +02:00
Pierre Brunet
1ba82a9304 fix(backups): remove warning for old vdis not removed yet (#9958)
* fix(backups): remove warning for old vdis not removed yet
2026-06-10 16:31:58 +02:00
Mathieu
d6d530d616 feat(xo6/lite): add ha_reboot_vm_on_internal_shutdown in pool's system (#9962) 2026-06-10 11:55:09 +02:00
Mathieu
c61083e59e fix(rest-api): vm export correctly handle zstd and gzip compression (#9960) 2026-06-10 10:55:13 +02:00
Pierre Donias
e376a0e102 feat(netbox): support Netbox v4.6.x (#9939)
Fixes #9818
See https://github.com/netbox-community/netbox/releases
2026-06-09 17:15:02 +02:00
Bastien Nollet
13e0cd6b79 fix(xo-server): guards against reading property of undefined (#9944) 2026-06-09 16:59:35 +02:00
Mathieu
3f9c8ecce0 fix(vates/types): remove duplicate entries (#9913)
Introduced by 544590a860
2026-06-09 14:04:10 +02:00
Pierre Brunet
7b40af7e86 feat(qcow2): fix invalid range error (#9948)
Reduce memory consumption up to 700MB / TB of data ( 300-350Mb per call of getBlockIndexes().length , called twice).

Ticket: 59102
Tested in client environment. Works for a 7TB import
2026-06-09 11:54:04 +02:00
Mathieu
d04421ed54 fix(xo-server): fix authorization unit test at startup (#9965) 2026-06-09 11:52:07 +02:00
Florent BEAUCHAMP
3500d6c6c1 fix(rest-api): properly handle unauthorized error (#9938) 2026-06-08 16:04:06 +02:00
MlssFrncJrg
442e63f716 feat(rest-api): add RBAC check for VDI-snapshots endpoints (#9906) 2026-06-08 15:14:22 +02:00
Grandalf
ed670fbe36 feat(ipmi-plugin): Add a default value for outlet temp to the Dell preset (#9884)
feat(ipmi-plugin): Add a default value for outlet temp to the Dell preset
2026-06-08 15:01:36 +02:00
Florent BEAUCHAMP
59780ef834 feat(qa-test): user url instead of fs (#9898)
this enable the test to run using from and to shared storage ( nfs/samba/s3/azure)

the local path is only used for export tests

tested with a remote garage (object storage) provider, full suite is passing
2026-06-08 14:55:59 +02:00
spacotte-vates
53be744746 feat(rest-api): add add_host route to pools (#9876) 2026-06-08 11:29:31 +02:00
Mathieu
7adcf9afd3 feat(xo-server): support bundle in featureCheck (#9769) 2026-06-08 10:16:03 +02:00
MlssFrncJrg
7f693de5a5 feat(rest-api): add RBAC check for PBD endpoints (#9888)
* feat(rest-api): add RBAC check for PBD endpoints
2026-06-08 09:51:46 +02:00
MlssFrncJrg
fc67f88fcb feat(rest-api): add RBAC check for SRs endpoints (#9896)
* feat(rest-api): add RBAC check for SRs endpoints
2026-06-08 09:44:21 +02:00
Pierre Donias
81b14af429 fix: filter syntax doc link (#9929) 2026-06-08 09:30:35 +02:00
Florent BEAUCHAMP
8939a2deeb feat(backups): add progress to incremental mirror (#9901) 2026-06-05 16:00:02 +02:00
Florent BEAUCHAMP
d8094a71e2 fix: normalize packages (#9917)
* fix: normalize packages

no changelog since it does not need any release

* put back lost chanegs
2026-06-05 14:12:57 +02:00
Florent BEAUCHAMP
c070df74ed test(backups): add e2e test for metadata and pool backups (#9864)
add end to end test for pool metadata and xo config backup
2026-06-05 14:08:40 +02:00
Sebastien Marie
2b9524e8d5 fix(xo-server-sdn-controller): apply/clean network rules on VIF update (#9933)
when a VIF is unplugged, network rules applied to the VIF aren't properly removed.
2026-06-05 14:07:38 +02:00
Pierre Briffaux
199651984b feat(xo-core): create drawer component (#9555) 2026-06-05 11:32:11 +02:00
Grandalf
aa7e1bb44b perf(sdn-controller): replace xapi.objects.all with specified object types (#9886) 2026-06-05 10:54:47 +02:00
spacotte-vates
ebdc6713c0 fix(qcow2): fix init map error with large Qcow2 disks (#9940) 2026-06-05 10:54:12 +02:00
Bastien Nollet
3ba207e1f0 fix(xo-web): fix array handling in job UI (#9928) 2026-06-04 16:06:55 +02:00
Grandalf
ebb337b786 feat(rest-api): add RBAC check for vif endpoints (#9889) 2026-06-04 15:36:33 +02:00
MlssFrncJrg
f4a7b708c1 feat(rest-api): add RBAC check for VBDs endpoints (#9904) 2026-06-04 15:19:27 +02:00
spacotte-vates
5a88040318 feat(rest-api): add POST and PATCH backup repository endpoints (#9852) 2026-06-04 15:10:20 +02:00
Enée DI IORIO
f0fe294628 feat(lite): Use web-core's UiTitle instead of xo-lite version (#9869) 2026-06-04 12:59:21 +02:00
Mathieu
e6443edb6e fix(qa-test): fix NodeJS version in README (#9930) 2026-06-02 09:32:40 +02:00
Pierre Brunet
daefcab0f2 chore: move latest tag (#9932) 2026-06-01 13:48:18 +02:00
Pierre Brunet
c39b9e8760 feat: release 6.5.1 (#9931) 2026-06-01 13:37:33 +02:00
Pierre Brunet
e822276d62 feat: technical release (#9927) 2026-06-01 11:31:40 +02:00
Pierre Brunet
c792d1e1dc chore: update packages to release (#9926) 2026-06-01 11:16:51 +02:00
Florent BEAUCHAMP
4552fb9edf fix(backups): disable orphan dir cleanup
this feature can lead to more cleanup than needed, we will enable
if again when all the side effects are handled, after an extented
real world usage
2026-06-01 10:56:35 +02:00
Florent BEAUCHAMP
ecceb7c0b8 fix(backups): backup cleanup removed to many files when listing fails 2026-06-01 10:56:35 +02:00
Mathieu
f875ed5264 docs(mcp): require Xen Orchestra 6.5 or later (#9922)
* docs(mcp): require Xen Orchestra 6.5 or later

* docs(mcp): match warning/note admonition style
2026-06-01 09:29:21 +02:00
Florent BEAUCHAMP
79b87855d9 fix(xo-server): fix xo xcp ssh path and help (#9919) 2026-05-29 16:40:40 +02:00
Florent BEAUCHAMP
014fcb94b3 fix(backups): transfer size (#9900)
this is an async bug leading to the full size not really sumed, thus
depending on the async and concurrency leading to a size 0 even if another
disk has data
2026-05-29 16:04:08 +02:00
laszlobortel
16bf4400ba fix: correct 2 typos (#9873)
* Update esxi.mjs

* Update importVdi.mjs

Correct typo

* Update importDisksfromDatastore.mjs

Correct typo

* Update CHANGELOG.unreleased.md

Updated list of changed packages
2026-05-29 15:57:51 +02:00
Pierre Brunet
dec0cf7943 fix: bump lite version (#9916) 2026-05-28 16:14:36 +02:00
Pierre Brunet
315686678a feat(lite): 0.21.0 (#9915) 2026-05-28 16:02:11 +02:00
Pierre Brunet
2b887158ba feat: release 6.5.0 (#9912) 2026-05-28 15:31:56 +02:00
Florent BEAUCHAMP
51546d475f docs(immutable-backups): fix and improve the documentation (#9890)
better reflect the new implementation of immutable backup settings and troubleshooting in the doc
2026-05-28 14:53:41 +02:00
Pierre Brunet
0f29421627 feat: technical release (#9907) 2026-05-28 11:11:08 +02:00
Grandalf
781c08016f feat(acls-v2): add VMS_ADMINISTRATOR template (#9845) 2026-05-28 10:54:58 +02:00
Grandalf
5c157c1367 feat(xo-server): add date and time to exported config filename (#9859) 2026-05-28 10:32:19 +02:00
Mathieu
5089be9998 feat(rest-api/vm): add PATCH /vms/{id} to partially update a VM (#9835) 2026-05-28 10:17:15 +02:00
vates-bot
5c555170f7 feat(web-stack/i18n): update Xen Orchestra 6 (#9780) 2026-05-28 08:53:35 +02:00
Mathieu
40e944c901 fix(docs): wrap {master, satellite} in backticks on xo5/advanced page (#9902) 2026-05-28 08:42:07 +02:00
Elise-F
75b61b84c5 feat(xo6): add general tab vif page (#9831) 2026-05-27 17:10:16 +02:00
Joris K
f0aa3df923 feat(xo6): add possibility to create a VDI (#9848) 2026-05-27 16:38:06 +02:00
Elise-F
00844c66d9 feat(xo6): creating vif page with traffic rules table and form to add new traffic rule from VIF (#9837) 2026-05-27 16:31:19 +02:00
Pierre Brunet
1f243bf90e feat: update http-server-plus (#9899) 2026-05-27 16:05:43 +02:00
Pierre Brunet
b3d9dc6d0c feat: technical release (#9897) 2026-05-27 15:16:53 +02:00
Florent BEAUCHAMP
405724eacb fix(qa-test): remove rebase issue and cleanup (#9894)
introduced by #9863
also use rest api for stop
2026-05-27 14:38:12 +02:00
spacotte-vates
20222f7051 fix(encryption): fix 401 issue caused by key deletion before set (#9892) 2026-05-27 14:37:33 +02:00
Florent BEAUCHAMP
dae64be80b fix(backups): omnibus and e2e test improvement (#9863)
* fix(backups): tolerate ENOENT when deleting missing backups

* fix(xo-server): propagate content-length in VM import

* fix(qa-test): retry REST API calls on transient 401

* fix(qa-test): harden cleanup against stale cache files

* fix(qa-test): fail fast on misconfigured test environment

Assert that source and mirror repositories are distinct before running
any backup steps, giving an actionable error when both env vars resolve
to the same remote ID. Also improve the mkdir failure message in the
export test to include the exact path and the remediation command.

* feat(qa-test): make source vm runs to ensure there is content to transfer

* fix: harden job deletion

* fix(qa-test) don't use a deprecated route for authent

* fixup! feat(qa-test): make source vm runs to ensure there is content to transfer

* fixup! fix(backups): tolerate ENOENT when deleting missing backups

* fixup! fix(backups): tolerate ENOENT when deleting missing backups
2026-05-27 09:27:12 +02:00
Mathieu
fc4d34b354 feat(openmetrics): expose XOSTOR metrics for Grafana dashboards (#9849)
Implements XO-2155. The OpenMetrics plugin now ships XOSTOR-specific metrics so operators can build a Grafana dashboard for XCP-ng Hyperconverged Infrastructure.

All metrics use the xcp_xostor_* prefix:

    Cluster status: xcp_xostor_up, xcp_xostor_node_status (master / satellite roles + raw state from linstor-manager.healthCheck), xcp_xostor_resource_total.
    Storage pool health: xcp_xostor_resource_state_count — replica counts per disk-state (UpToDate, Inconsistent, Outdated, Diskless, Unknown), aggregated per cluster.
    Performance filter: a new sr_type label on every SR-tagged metric (xcp_host_disk_iops_*, xcp_host_disk_throughput_*, latency, xcp_sr_*, xcp_vdi_*, xcp_vm_disk_*). Dashboards can filter sr_type="linstor" without changing existing queries.
    Active alarms: xcp_xostor_alarms_count aggregated by alarm_name (ALARM, BOND_STATUS_CHANGED, MULTIPATH_PERIODIC_ALERT) and target_type (sr / host).
    Disk SMART: xcp_xostor_disk_smart_status per (host, device) with the overall-health string in a status label, sourced from smartctl.py.
    Pending updates: xcp_xostor_package_update_available for xcp-ng-linstor, linstor-satellite, linstor-controller, xcp-ng-release-linstor, and xcp-ng-xapi-plugins.

Each collector caches at its own TTL (60 s for cluster and alarms, 5 min for SMART, 1 h for updates) and exposes an xcp_xostor_<feature>_up gauge so silent collection failures still show up on dashboards. A TtlCache<T> helper encapsulates the cache-plus-coalescing pattern shared by the three caching collectors.
2026-05-26 17:57:40 +02:00
Mathieu
5195018361 feat(mcp): whitelist REST endpoints by x-mcp-exposure extension (#9875)
Replace the hard-coded CONFIRM_PATTERNS list in the MCP server with a per-endpoint exposure decision carried by an x-mcp-exposure OpenAPI vendor extension. Each REST endpoint now declares its MCP exposure (allow / confirm / deny) with @Extension('x-mcp-exposure', ...) next to its HTTP method decorator. A new local ESLint rule mcp/require-mcp-expose makes the decision mandatory: any new @Get/@Post/@Put/@Patch/@Delete method without an exposure tag fails lint.

The MCP parseSwagger now reads the extension and applies a default-deny policy:

    'allow' → exposed as a query/action tool
    'confirm' → exposed only when XO_MCP_ENABLE_ACTIONS=1, with confirm-token round-trip
    'deny' / absent → never exposed

Defense in depth is preserved: route-filter regex (binaries, streams, stats) is applied before the exposure check, so a binary endpoint mistakenly tagged 'allow' still stays out of the MCP surface. The confirm_token UUID + TTL machinery in risk-config.mts is untouched.

All 248 endpoints across 34 controllers have been annotated with conservative defaults (GET → allow, destructive POST/DELETE/PUT/PATCH → confirm, binary exports → deny).
2026-05-26 17:50:09 +02:00
Joris K
a9f8561cdd feat(xo6): add possibility to revert a snapshot (#9862) 2026-05-26 16:38:05 +02:00
Elise-F
8d318e8eb2 feat(xo6): add possibility to add new traffic rule from pool (#9809) 2026-05-26 16:24:44 +02:00
Mathieu
406a4aa00a feat(mcp): add global kill-switch (#9860)
Adds a server-side kill-switch for MCP. Setting [mcp] enabled = false in xo-server's config blocks every MCP connection globally
2026-05-26 16:21:22 +02:00
Mathieu
9abb66b594 fix(rest-api): post vdi other_config optional (#9883)
* fix(rest-api): post vdi other_config optional

introduced by 0bf0a46
2026-05-26 14:48:36 +02:00
Florent BEAUCHAMP
b17f43d676 fix(xo-web): ignore the number of snapshot of replicated VM in the health dashboard (#9868)
the number of snapshots will be as high as the retention selected, but this is not really
an issue since those VM will be run only when a disaster occurs. In this case, degraded
performance is an accepted tradeoff
2026-05-26 10:55:49 +02:00
Florent BEAUCHAMP
59a04d71a6 fix(xo-server): really use @vates/http-request-plus (#9842)
introduced by c6ccd987e2
2026-05-26 10:38:09 +02:00
spacotte-vates
fdcc999f66 fix(backups): fix out_of_range -4 merge error (#9782)
Introduced by #9300

Ticket #57310

Fix OUT_OF_RANGE error occurring when resuming failed merge.
2026-05-26 09:56:54 +02:00
spacotte-vates
1d9a6ac17f fix(xo-server): fix wrong boot order when hvm template has vdis (#9867)
Introduced by 7a8ca

When creating an HVM VM from a template that already has a disk attached, the boot order was being incorrectly modified to put the network first, resulting in the VM attempting a PXE boot before eventually falling through to the disk.
The boot order was modified whenever vdis and existingVdis were both empty, since no new disk needs to be provisioned. This caused network to be put first in the boot order regardless of whether a network install was actually requested.

Boot order modification now depends exclusively on installMethod:

    Network: network (n) moved to the front of the string, this is unchanged.
    CD: DVD-drive (d) moved to the front of the string, this is a fix to another issue where HVM + CD installs did not update the boot order string at all.
    Everything else: String is unchanged, preserving the template's original order

Also, previously creating a diskless HVM VM with no install repository would get the network moved to the front of the boot order, that implicit behavior is gone, now diskless VMs now inherit the template's boot order.
This means that if in the template boot order, the network is last, the BIOS will try to find the disk and the cd first before trying the network. This is acceptable as these steps are fairly short.
2026-05-26 09:44:26 +02:00
Pierre Brunet
a467f12526 fix(backup): add warning if xva is missing and update tests (#9870) 2026-05-26 09:20:37 +02:00
Joris K
5811d12ed4 feat(xo6): add possibility to attach a VDI (#9772) 2026-05-22 17:51:40 +02:00
OlivierFL
94bc7a6e03 fix(xo6): route-map incorrectly formatted by Prettier on commit (#9881) 2026-05-22 17:37:45 +02:00
Mathieu
18556901d9 fix(rest-api): rename acl tag into rbac (#9874) 2026-05-22 17:23:38 +02:00
Pierre Brunet
598bb79edb fix: bump dependants to backup-archive (#9882) 2026-05-22 15:10:16 +02:00
Pierre Brunet
bb215d1ecc chore: revert backup-archive version (#9880) 2026-05-22 11:54:20 +02:00
Pierre Brunet
082ab9cf41 feat: technical release (#9878) 2026-05-22 10:34:38 +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
1702 changed files with 105295 additions and 19449 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

View File

@@ -3,4 +3,5 @@
**/*.hbs
# Generated files
/@xen-orchestra/web/typed-router.d.ts
/@xen-orchestra/web/src/route-map.d.ts
/@xen-orchestra/lite/src/route-map.d.ts

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

@@ -1,12 +1,12 @@
import LRU from 'lru-cache'
import Fuse from 'fuse-native'
import LRU from 'lru-cache'
import { createLogger } from '@xen-orchestra/log'
import { VhdSynthetic } from 'vhd-lib'
import { Disposable, fromCallback } from 'promise-toolbox'
const { warn } = createLogger('vates:fuse-vhd')
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
// build a stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
mtime: st.mtime || new Date(),
atime: st.atime || new Date(),
@@ -20,42 +20,109 @@ const stat = st => ({
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
const cache = new LRU({
max: 16, // each cached block is 2MB in size
})
await vhd.readBlockAllocationTable()
const { blockSize } = vhd.header
// A zeroed buffer returned for VHD blocks that are not present in the chain.
// Never mutated — safe to share across all reads.
const EMPTY_BLOCK = Buffer.alloc(blockSize, 0)
// --- coalescing block read queue ---
//
// FUSE read requests from NTFS-3g arrive in small chunks (typically 4 KiB)
// while VHD blocks are 2 MiB. Reading blocks one at a time from the backing
// store (CIFS/NFS/…) bounds libuv threadpool usage to 1 concurrent I/O call.
//
// Coalescing: all pending requests for the same blockId share a single
// readBlock() call. When the block resolves, every waiter is fulfilled at once.
//
// Together these two properties prevent the FUSE-on-FUSE threadpool deadlock:
// concurrent NTFS-3g reads no longer starve the VHD block reads they depend on.
// In-memory block cache. Each VHD block is 2 MiB; 16 blocks = 32 MiB.
// This is the primary tool against the 300× CIFS amplification: NTFS-3g
// reads a 2 MiB block in ~512 sequential 4 KiB FUSE requests. Without a
// cache, each request causes a full 2 MiB CIFS read. With the cache, only
// the first request reads from CIFS; the rest are served from RAM.
const blockCache = new LRU({ max: 64 })
const pendingBlocks = new Map() // blockId → Promise<Buffer>
const readQueue = [] // { blockId, resolve, reject }[]
let queueRunning = false
// log stats every 10 s while there is activity
function enqueueBlock(blockId) {
// 1. serve from cache if available
if (blockCache.has(blockId)) {
return Promise.resolve(blockCache.get(blockId))
}
// 2. coalesce: join an existing in-flight read for this block
if (pendingBlocks.has(blockId)) {
return pendingBlocks.get(blockId)
}
const p = new Promise((resolve, reject) => {
readQueue.push({ blockId, resolve, reject })
})
pendingBlocks.set(blockId, p)
p.then(
data => {
pendingBlocks.delete(blockId)
blockCache.set(blockId, data)
},
() => pendingBlocks.delete(blockId)
)
drainQueue()
return p
}
function drainQueue() {
if (queueRunning) return
queueRunning = true
// async IIFE — errors are forwarded to individual reject() callbacks,
// so the outer floating promise is always fulfilled
;(async () => {
while (readQueue.length > 0) {
const { blockId, resolve, reject } = readQueue.shift()
try {
resolve((await vhd.readBlock(blockId)).data)
} catch (err) {
reject(err)
}
}
queueRunning = false
})()
}
async function readData(buf, len, pos) {
if (len === 0) return 0
const startBlockId = Math.floor(pos / blockSize)
// use (pos + len - 1) so an exact block boundary doesn't include the next block
const endBlockId = Math.floor((pos + len - 1) / blockSize)
let copied = 0
for (let blockId = startBlockId; blockId <= endBlockId; blockId++) {
const data = vhd.containsBlock(blockId) ? await enqueueBlock(blockId) : EMPTY_BLOCK
const offsetStart = blockId === startBlockId ? pos % blockSize : 0
const offsetEnd = blockId === endBlockId ? ((pos + len - 1) % blockSize) + 1 : blockSize
data.copy(buf, copied, offsetStart, offsetEnd)
copied += offsetEnd - offsetStart
}
return copied
}
const fuse = new Fuse(mountDir, {
async readdir(path, cb) {
if (path === '/') {
return cb(null, ['vhd0'])
}
if (path === '/') return cb(null, ['vhd0'])
cb(Fuse.ENOENT)
},
async getattr(path, cb) {
if (path === '/') {
return cb(
null,
stat({
mode: 'dir',
size: 4096,
})
)
}
if (path === '/vhd0') {
return cb(
null,
stat({
mode: 'file',
size: vhd.footer.currentSize,
})
)
}
if (path === '/') return cb(null, stat({ mode: 'dir', size: 4096 }))
if (path === '/vhd0') return cb(null, stat({ mode: 'file', size: vhd.footer.currentSize }))
cb(Fuse.ENOENT)
},
read(path, fd, buf, len, pos, cb) {
if (path === '/vhd0') {
return vhd.readRawData(pos, len, cache, buf).then(cb, error => {
return readData(buf, len, pos).then(cb, error => {
warn('read error', { path, len, pos, error })
cb(Fuse.EIO)
})
@@ -64,7 +131,9 @@ export const mount = Disposable.factory(async function* mount(handler, diskPath,
},
})
return new Disposable(
() => fromCallback(cb => fuse.unmount(cb)),
() => {
return fromCallback(cb => fuse.unmount(cb))
},
fromCallback(cb => fuse.mount(cb))
)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/fuse-vhd",
"version": "2.1.2",
"version": "2.1.3",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
@@ -19,12 +19,12 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.6.6",
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/fs": "^4.9.3",
"@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.1"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/generator-toolbox",
"version": "1.1.0",
"version": "1.1.3",
"main": "dist/index.mjs",
"license": "MIT",
"private": false,
@@ -10,7 +10,7 @@
"rimraf": "^6.0.1",
"@types/node": "^20.6",
"typescript": "~5.6",
"typescript-eslint": "^8.23.0"
"typescript-eslint": "^8.61.0"
},
"scripts": {
"build": "tsc",

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,98 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/http-server-plus
[![Package Version](https://badgen.net/npm/v/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus) ![License](https://badgen.net/npm/license/@vates/http-server-plus) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/http-server-plus)](https://bundlephobia.com/result?p=@vates/http-server-plus) [![Node compatibility](https://badgen.net/npm/node/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus)
> Augmented &#x60;http.Server&#x60;, HTTP/HTTPS/HTTP2 and multiple ports on the same instance
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/http-server-plus):
```sh
npm install --save @vates/http-server-plus
```
## 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,38 @@
{
"name": "@vates/http-server-plus",
"version": "2.0.2",
"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",
"postversion": "npm publish --access public"
},
"private": false,
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
}
}

View File

@@ -19,6 +19,12 @@ export class NbdDisk extends RandomAccessDisk {
/** @type {number} */
#blockSize
/** @type {number | undefined} */
#hasBlockCursor
/** @type {number | undefined} */
#hasBlockPreviousIndex
constructor(nbdInfos, blockSize, { dataMap } = {}) {
super()
this.#blockSize = blockSize
@@ -27,11 +33,38 @@ export class NbdDisk extends RandomAccessDisk {
}
#processDatamap(rawDataMap) {
return rawDataMap
.filter(({ type }) => type === 0)
const ranges = rawDataMap
.filter(({ type, length }) => type === 0 && length > 0)
.map(({ offset, length }) => ({ offset, length }))
.sort(({ offset: offset1 }, { offset: offset2 }) => offset1 - offset2)
// hasBlock()'s forward-only cursor is only correct if the extents are
// sorted AND disjoint: an earlier extent must never reach into a block
// after a later one, otherwise the cursor could skip it and wrongly report
// a block as empty, dropping data and corrupting the output.
// Touching extents (cur.offset === prevEnd) are merged
const merged = []
for (const range of ranges) {
const last = merged[merged.length - 1]
if (last !== undefined) {
const lastEnd = last.offset + last.length
if (range.offset < lastEnd) {
throw new Error(
`overlapping ranges in data map: [${last.offset}, ${lastEnd}) and [${range.offset}, ${range.offset + range.length})`
)
}
if (range.offset === lastEnd) {
// touching: extend the previous extent
last.length += range.length
continue
}
}
// gap (or first range): new extent
merged.push(range)
}
return merged
}
/**
* @param {number} index
* @returns {Promise<DiskBlock>}
@@ -117,6 +150,34 @@ export class NbdDisk extends RandomAccessDisk {
return [...indexes].sort((a, b) => a - b)
}
/**
* Counts the allocated blocks without materializing the index list.
*
* @returns {number}
*/
getBlockIndexesCount() {
if (!this.#dataMap) {
throw new Error("can't getBlockIndexesCount before init")
}
const blockSize = this.getBlockSize()
let count = 0
let lastCountedBlock = -1
for (const { offset, length } of this.#dataMap) {
const firstBlockIndex = Math.floor(offset / blockSize)
const lastBlockIndex = Math.floor((offset + length - 1) / blockSize)
const from = Math.max(firstBlockIndex, lastCountedBlock + 1)
if (lastBlockIndex >= from) {
count += lastBlockIndex - from + 1
lastCountedBlock = lastBlockIndex
}
}
return count
}
/**
* @param {number} index
* @returns {boolean}
@@ -125,8 +186,32 @@ export class NbdDisk extends RandomAccessDisk {
if (!this.#dataMap) {
throw new Error("can't hasBlock before init")
}
const blockStart = index * this.getBlockSize()
const blockEnd = (index + 1) * this.getBlockSize()
return this.#dataMap.some(({ offset, length }) => offset + length > blockStart && offset < blockEnd)
const blockSize = this.getBlockSize()
const blockStart = index * blockSize
const blockEnd = blockStart + blockSize
let startExtentIndex = 0
if (this.#hasBlockCursor !== undefined && index >= this.#hasBlockPreviousIndex) {
startExtentIndex = this.#hasBlockCursor
} else {
this.#hasBlockCursor = undefined
}
this.#hasBlockPreviousIndex = index
const dataMap = this.#dataMap
const l = dataMap.length
for (let i = startExtentIndex; i < l; i++) {
const { offset, length } = dataMap[i]
if (offset >= blockEnd) {
// extents are sorted: nothing from here on can overlap this block
break
}
if (offset + length > blockStart) {
this.#hasBlockCursor = i
return true
}
}
return false
}
}

View File

@@ -0,0 +1,127 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { NbdDisk } from './NbdDisk.mjs'
const KiB = 1024
const MiB = 1024 * KiB
const TiB = 1024 * 1024 * MiB
const SOURCE_BLOCK_SIZE = 2 * MiB // VHD_BLOCK_SIZE, the block size used when importing from VMware
const CLUSTER_SIZE = 64 * KiB // qcow2 cluster size
// hasBlock() only needs the dataMap (passed through the constructor) and the
// block size, so we can exercise it without a live NBD connection.
describe('NbdDisk.hasBlock', () => {
it('reports allocated and unallocated blocks correctly', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(1), false)
assert.strictEqual(disk.hasBlock(4), false)
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(6), false)
})
it('handles repeated and out-of-order queries with the resume cursor', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
// repeated indexes (the qcow2 path queries each 2MB block ~32 times as it
// walks 64KB clusters) must return a stable answer
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(6), false)
assert.strictEqual(disk.hasBlock(6), false)
// querying backwards must still be correct (falls back to a full scan)
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(5), true)
})
it('invalidates the cursor on a backward miss (no stale skip)', () => {
const dataMap = [
{ type: 0, offset: 3 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 3
{ type: 0, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 10
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(10), true) // forward hit: cursor parks on the high extent
assert.strictEqual(disk.hasBlock(1), false) // backward miss: must invalidate the cursor
assert.strictEqual(disk.hasBlock(3), true) // lower extent must not be skipped
})
it('throws on overlapping ranges (would break the resume cursor)', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE },
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE }, // overlaps the previous range
]
assert.throws(() => new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap }), /overlapping ranges/)
})
it('merges touching ranges and drops zero-length / non type-0 entries', () => {
const dataMap = [
{ type: 0, offset: 2 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [2, 3)
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5)
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [1, 2)
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 }, // [.5, 1), touches [0, .5)
{ type: 1, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // wrong type: ignored
{ type: 0, offset: 7 * SOURCE_BLOCK_SIZE, length: 0 }, // zero length: ignored
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(1), true)
assert.strictEqual(disk.hasBlock(2), true)
assert.strictEqual(disk.hasBlock(3), false)
assert.strictEqual(disk.hasBlock(7), false) // zero-length range dropped
assert.strictEqual(disk.hasBlock(10), false) // non type-0 range dropped
})
})
describe('NbdDisk.getBlockIndexesCount', () => {
const cases = {
'aligned, disjoint blocks': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
],
'a range spanning several blocks': [
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: 3 * SOURCE_BLOCK_SIZE }, // blocks 0,1,2,3
],
'two disjoint extents sharing block 0 (needs dedup)': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5) => block 0
{ type: 0, offset: 3 * (SOURCE_BLOCK_SIZE / 4), length: SOURCE_BLOCK_SIZE }, // [.75, 1.75) => blocks 0,1
],
'touching sub-block fragments': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 },
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 },
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE },
],
empty: [],
}
for (const [name, dataMap] of Object.entries(cases)) {
it(`matches getBlockIndexes().length: ${name}`, () => {
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
}
it('matches getBlockIndexes().length on the large sparse map', () => {
const virtualSize = Math.floor(3.5 * TiB)
const NB_RANGES = 113528
const dataMap = []
for (let i = 0; i < NB_RANGES; i++) {
dataMap.push({ type: 0, offset: Math.floor((i / NB_RANGES) * virtualSize), length: CLUSTER_SIZE })
}
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
})

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.5.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.3.3",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0",
"xen-api": "^4.7.6"
"xen-api": "^4.8.0"
},
"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.30.1",
"main": "./dist/index.mjs",
"exports": {
".": {
@@ -32,7 +32,8 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"typescript": "~5.6.3",
"typescript-eslint": "^8.23.0"
"typescript-eslint": "^8.61.0",
"@types/express": "^5.0.0"
},
"scripts": {
"build": "tsc",

View File

@@ -1,4 +1,4 @@
import type { XoHost } from './xo.mjs'
import type { XoHost, XoNetwork, XoVif } from './xo.mjs'
declare const __brand: unique symbol
@@ -740,3 +740,32 @@ export type XapiVmStatsRaw = {
export type XapiVmStats = XapiStatsResponse<XapiVmStatsRaw>
export type XapiPoolStats = Record<XoHost['id'], XapiHostStats | { error: Record<string, unknown> }>
export type TrafficRuleProtocol = (typeof TRAFFIC_RULE_PROTOCOLS)[number]
export const SDN_CONTROLLER_OF_RULES_KEY = 'xo:sdn-controller:of-rules'
export const SDN_CONTROLLER_OF_METHOD_KEY = 'xo:sdn-controller:of-method'
export const SDN_CONTROLLER_OF_FORMAT_KEY = 'xo:sdn-controller:of-format'
export const TRAFFIC_RULE_PROTOCOLS = ['ARP', 'ICMP', 'IP', 'TCP', 'UDP'] as const
export const TRAFFIC_RULE_PROTOCOLS_WITH_PORT: readonly TrafficRuleProtocol[] = ['TCP', 'UDP']
export type TrafficRuleTargetType = XoNetwork['type'] | XoVif['type']
export type TrafficRuleDirection = 'from' | 'to' | 'from/to'
export type RawTrafficRule = {
allow: boolean
protocol: string
ipRange: string
direction: string
port?: string
}
type BaseTrafficRule = RawTrafficRule & {
id: string
networkId: XoNetwork['id']
}
export type TrafficRule =
| (BaseTrafficRule & { type: XoVif['type']; sourceId: XoVif['id'] })
| (BaseTrafficRule & { type: XoNetwork['type']; sourceId: XoNetwork['id'] })

View File

@@ -3,4 +3,6 @@ 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'
export * from './lib/rest-api.mjs'

View File

@@ -0,0 +1,102 @@
import type { NextFunction, Request, Response } from 'express'
import type { VatesTask } from './vates-task.mjs'
import type { XoApp } from '../xo-app.mjs'
export type SecurityName = '*' | 'token' | 'basic' | 'none'
export type FieldDefinition =
| {
type: 'string'
example?: string
optional?: boolean
}
| {
type: 'boolean'
example?: boolean
optional?: boolean
}
| {
type: 'number'
example?: number
optional?: boolean
}
| {
type: 'enum'
enum: string[]
example?: string
optional?: boolean
}
| {
type: 'object'
fields: Record<string, FieldDefinition>
optional?: boolean
}
| {
type: 'array'
items: FieldDefinition
example?: unknown[]
optional?: boolean
}
export type ParamFieldDefinition = Exclude<
FieldDefinition,
{ type: 'boolean' } | { type: 'object' } | { type: 'array' }
>
export type QueryFieldDefinition = Exclude<FieldDefinition, { type: 'object' } | { type: 'array' }>
type RestApi = object & { xoApp: XoApp }
export type CreateAction = <Result>(
callback: (task: VatesTask) => MaybePromise<Result>,
opts: {
sync?: boolean
statusCode?: number
taskProperties: { name: string; [key: string]: unknown }
}
) => Promise<Result | undefined>
export interface BaseRouteDefinition<Middleware> {
method: 'get' | 'post' | 'put' | 'delete' | 'patch'
endpoint: string
description?: string
tags?: string[]
params?: Record<string, ParamFieldDefinition>
query?: Record<string, QueryFieldDefinition>
body?: Record<string, FieldDefinition>
responses?: Array<{
status: number
description: string
schema?: Record<string, FieldDefinition>
}>
middlewares?: Middleware[]
scope?: 'acl'
callback: (params: {
req: Request
res: Response
next: NextFunction
restApi: RestApi
createAction: CreateAction
}) => MaybePromise<unknown>
security?: SecurityName
}
export type MiddlewareDescriptor =
| { name: 'json' | 'urlencoded' | 'text' | 'raw'; options?: Record<string, unknown> }
| { name: 'acl'; acls: LooseAclEntry | LooseAclEntry[] }
type MaybePromise<T> = T | Promise<T>
/**
* Loosely-typed mirror of {@link https://github.com/vatesfr/xen-orchestra/blob/master/@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts | AclEntry}
* from `@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts`.
*/
export type LooseAclEntry = {
resource: string
action?: string | ((opts: { req: object; restApi: RestApi }) => string | undefined)
actions?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
objectId?: string | ((opts: { req: object; restApi: RestApi }) => string)
objectIds?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
object?: object | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object> | undefined)
objects?: object[] | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object[]> | undefined)
getObject?: (opts: { restApi: RestApi }) => (id: string) => MaybePromise<object>
}
export type PluginRestRouteDefinition = BaseRouteDefinition<MiddlewareDescriptor>

View File

@@ -0,0 +1,83 @@
import { Branded } from '../common.mjs'
import type { XoGroup, XoUser } from '../xo.mjs'
type XoAclBaseRole = {
id: Branded<'acl-v2-role'>
name: string
description?: string
privilegeIds: XoAclBasePrivilege['id'][]
}
type XoAclRegularRole = XoAclBaseRole & {
groupIds: XoGroup['id'][]
userIds: XoUser['id'][]
}
type XoAclTemplateRole = XoAclBaseRole & {
isTemplate: true
roleTemplateId: number
}
export type XoAclRole = XoAclRegularRole | XoAclTemplateRole
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,19 @@ 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,
VM_OPERATIONS,
} from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
XapiXoRecord,
XoGpuGroup,
XoHost,
XoNetwork,
@@ -27,8 +38,67 @@ import type {
XoVm,
XoVmTemplate,
XoVif,
XoVmSnapshot,
} from '../xo.mjs'
/**
* Properties accepted by {@link Xapi.editVm}.
*
* Field names match the canonical (camelCase) properties defined by the
* underlying `_editVm` (via `makeEditObject`). `_editVm` still resolves the
* snake_case and legacy aliases, but the canonical names are used here.
*/
export interface EditVmProps {
affinityHost?: string | null
autoPoweron?: boolean
blockedOperations?: Partial<Record<VM_OPERATIONS, boolean | string | null>>
coresPerSocket?: number | string | null
cpuCap?: number | null
cpuMask?: number[]
cpuWeight?: number | null
cpus?: number
cpusStaticMax?: number | string
/**
* Update VM creation metadata stored under `other_config.xo:*`. The object is
* merged with the existing data.
*/
creation?: { user?: string }
expNestedHvm?: boolean
hasVendorDevice?: boolean
highAvailability?: 'best-effort' | 'restart' | ''
hvmBootFirmware?: string | null
memory?: number | string
memoryMax?: number | string
memoryMin?: number | string
memoryStaticMax?: number | string
nameDescription?: string
nameLabel?: string
nestedVirt?: boolean
nicType?: string | null
notes?: string | null
PV_args?: string
secureBoot?: boolean
startDelay?: number
suspendSr?: string | null
tags?: string[]
uefiMode?: 'setup' | 'user'
vga?: 'std' | 'cirrus'
videoram?: 1 | 2 | 4 | 8 | 16
viridian?: boolean
virtualizationMode?: 'pv' | 'hvm'
xenStoreData?: Record<string, string | null>
}
/** Properties accepted by {@link Xapi.editVif}. */
export interface EditVifProps {
ipv4Allowed?: string[]
ipv6Allowed?: string[]
lockingMode?: VIF_LOCKING_MODE
// in kB/s, `null` removes the limit
rateLimit?: number | null
txChecksumming?: boolean
}
export type XcpPatches = {
changelog?: {
author: string
@@ -59,15 +129,29 @@ export type XsPatches = {
}
export interface Xapi {
status: string
pool?: { uuid: string }
sessionId: string
_auth: { user: string; password: string }
_url: { protocol: string; hostname: string; hostnameRaw: string; port?: string }
call: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
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,9 +168,24 @@ 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>
emergencyShutdownHost(hostId: XoHost['id']): Promise<void>
ejectHostFromPool(hostId: XoHost['id']): Promise<void>
host_smartReboot(
hostRef: XenApiHost['$ref'],
bypassBlockedSuspend?: boolean,
bypassCurrentVmCheck?: boolean
): Promise<void>
migrateVm(
vmId: XoVm['id'],
hostXapi: Xapi,
@@ -100,10 +199,70 @@ export interface Xapi {
bypassAssert?: boolean
}
): Promise<void>
joinPool(masterAddress: string, masterUsername: string, masterPassword: string, force?: boolean): Promise<void>
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>
forgetHost(hostId: XoHost['id']): Promise<void>
powerOnHost(hostId: XoHost['id']): Promise<void>
rebootHost(hostId: XoHost['id'], force?: boolean): Promise<void>
shutdownHost(hostId: XoHost['id'], opts?: { force?: boolean; bypassEvacuate?: boolean }): Promise<void>
SR_create(params: {
content_type?: XoSr['content_type']
// index signature, not `Record<string, string>`: tsoa emits an empty schema for `Record`
// (https://github.com/lukeautry/tsoa/pull/1858). device_config keys are driver-specific (SR_type).
device_config: {
chapuser?: string
chappassword?: string
device?: string
legacy_mode?: string
location?: string
nfsversion?: string
options?: string
password?: string
SCSIid?: string
server?: string
serverpath?: string
target?: string
targetIQN?: string
username?: string
} & {
[key: string]: string
}
host: XenApiHost['$ref']
name_description?: XoSr['name_description']
name_label: XoSr['name_label']
physical_size?: number
shared: boolean
sm_config?: { [key: string]: string }
type: XoSr['SR_type']
}): Promise<XenApiSr['$ref']>
SR_importVdi(
ref: XenApiSr['$ref'],
stream: Readable,
@@ -121,6 +280,13 @@ export interface Xapi {
xenstore_data?: Record<string, string>
}
): Promise<XenApiVdi['$ref']>
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
editVm(
id: XoVm['id'],
props: EditVmProps,
checkLimits?: (limits: Record<string, number>, vm: XenApiVmWrapped) => Promise<void>
): Promise<void>
editVif(id: XoVif['id'], props: EditVifProps): Promise<void>
startVm(
id: XoVm['id'],
opts?: {
@@ -138,7 +304,7 @@ export interface Xapi {
): Promise<void>
VM_export(
vmRef: XenApiVm['$ref'],
opts?: { cancelToken?: unknown; compress?: boolean; useSnapshot?: boolean }
opts?: { cancelToken?: unknown; compress?: boolean | 'zstd' | 'gzip'; useSnapshot?: boolean }
): ReturnType<Xapi['getResource']>
VM_import(
stream: Readable,
@@ -150,6 +316,7 @@ export interface Xapi {
metadataVm: {
affinityHost?: XoHost['id']
autoPoweron?: boolean
cpus?: number
memory?: number
name_description?: string
name_label: string
@@ -191,6 +358,7 @@ export interface Xapi {
vgpuType?: XoVgpuType['id']
gpuGroup?: XoGpuGroup['id']
copyHostBiosStrings?: boolean
highAvailability?: XoVm['high_availability']
hvmBootFirmware?: 'uefi' | 'bios'
secureBoot?: boolean
},
@@ -218,6 +386,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(
@@ -226,7 +411,7 @@ export interface Xapi {
): Promise<Readable & { length?: number }>
VDI_importContent(
vdiRef: XenApiVdi['$ref'],
stream: Readable,
stream: Readable | Buffer,
opts: { cancelToken?: unknown; format: SUPPORTED_VDI_FORMAT }
): Promise<void>
VIF_create(
@@ -265,6 +450,29 @@ 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,
opts?: { transient?: boolean }
): Promise<void>
disableHost(hostId: XoHost['id'], opts?: { transient?: boolean }): 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
@@ -191,6 +192,7 @@ export interface XenApiPool {
ha_host_failures_to_tolerate: number
ha_overcommitted: boolean
ha_plan_exists_for: number
ha_reboot_vm_on_internal_shutdown?: boolean
ha_statefiles: string[]
health_check_config: Record<string, string>
igmp_snooping_enabled?: boolean
@@ -490,7 +492,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
@@ -567,7 +571,9 @@ export interface XenApiHost {
uuid: string
virtual_hardware_platform_versions: number[]
}
export type XenApiHostWrapped = WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods>
export interface XenApiHostWrapped extends WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods> {
$restartAgent(): Promise<void>
}
export interface XenApiHostCrashdump {
$ref: Branded<'host_crashdump'>
@@ -622,7 +628,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

@@ -1,6 +1,5 @@
import { EventEmitter } from 'node:stream'
import type {
AnyXoBackupJob,
AnyXoJob,
AnyXoLog,
XapiXoRecord,
@@ -16,10 +15,12 @@ import type {
XoServer,
XoTask,
XoUser,
XoVif,
XoVm,
XoVmBackupArchive,
} from './xo.mjs'
import { VatesTask } from './lib/vates-task.mjs'
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
import {
Xapi,
XapiHostStats,
@@ -44,14 +45,32 @@ import {
XenApiVifWrapped,
XenApiVmWrapped,
XenApiVtpmWrapped,
XoAclBasePrivilege,
XoAclRole,
XoGroupRole,
XoUserRole,
} from './index.mjs'
export type XapiConnection = Xapi & {
status: string
pool?: { uuid: string }
sessionId: string
_url?: { protocol: string; hostname: string; port?: string }
}
type FeatureCode =
| 'BACKUP.DELTA'
| 'BACKUP.DELTA_REPLICATION'
| 'BACKUP.FULL'
| 'BACKUP.HEALTHCHECK'
| 'BACKUP.METADATA'
| 'BACKUP.MIRROR'
| 'BACKUP.WITH_RAM'
| 'BACKUP.SMART_BACKUP'
| 'BACKUP.S3'
| 'DOCKER'
| 'EXPORT.XVA'
| 'LIST_MISSING_PATCHES'
| 'POOL_EMERGENCY_SHUTDOWN'
| 'RBAC'
| 'ROLLING_POOL_UPDATE'
| 'ROLLING_POOL_REBOOT'
| 'SMART_REBOOT'
| 'WARM_MIGRATION'
| 'PLUGIN.OPENMETRICS'
type XapiRecordByXapiXoRecord = {
gpuGroup: XenApiGpuGroupWrapped
@@ -78,10 +97,45 @@ 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
getOptional<T = unknown>(path: string): T | undefined
getOptionalDuration(path: string): number | undefined
getGuiRoutes(): Promise<{
default: {
@@ -128,16 +182,42 @@ export type XoApp = {
}
// methods ------------
allocIpAddresses(vifId: XoVif['id'], addAddresses?: string[], removeAddresses?: string[]): Promise<void>
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
registerRestRoutes: (routes: PluginRestRouteDefinition[], base?: string) => () => void
authenticateUser: (
credentials: { token?: string; username?: string; password?: string },
userData?: { ip?: string },
opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean }
) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }>
backupGuard(poolId: XoPool['id']): Promise<void>
/* Throw if no authorization */
checkFeatureAuthorization(featureCode: string): Promise<void>
checkFeatureAuthorization(featureCode: FeatureCode): 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
@@ -147,19 +227,54 @@ export type XoApp = {
expiresIn?: string | number
userId: XoUser['id']
}): Promise<XoAuthenticationToken>
createRemote(params: {
name: string
options?: string
proxy?: XoProxy['id']
url: string
}): Promise<XoBackupRepository>
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
deleteAclV2GroupRole(
groupId: XoGroup['id'],
roleId: XoAclRole['id'],
opts?: { bypassAuthorization?: boolean }
): 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>
detachHostFromPool(hostId: XoHost['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>
findEnabledScheduleSequenceFromSchedule(id: XoSchedule['id']): Promise<XoSchedule | undefined>
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
getAclV2RolePrivileges(roleId: XoAclRole['id']): Promise<XoAclBasePrivilege[]>
getAclV2Role(id: XoAclRole['id'], opts?: { bypassAuthorization?: boolean }): Promise<XoAclRole>
deleteAclV2UserRole(
userId: XoUser['id'],
roleId: XoAclRole['id'],
opts?: { bypassAuthorization?: boolean }
): Promise<boolean>
getAclV2GroupRoles(
groupId: XoGroup['id'],
opts?: { bypassAuthorization?: boolean }
): Promise<Exclude<XoAclRole, { isTemplate: true }>[]>
getAclV2Roles(): Promise<XoAclRole[]>
getAclV2UserPrivileges(userId: XoUser['id']): Promise<XoAclBasePrivilege[]>
getAclV2UserRoles(
userId: XoUser['id'],
opts?: { bypassAuthorization?: boolean; fromGroup?: boolean; fromUser?: boolean }
): Promise<XoAclRole[]>
getAllGroups(): Promise<XoGroup[]>
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoBackupJob['type']>(type: T): Promise<Extract<AnyXoBackupJob, { type: T }>[]>
getAllJobs<T extends AnyXoJob['type']>(type: T): Promise<Extract<AnyXoJob, { type: T }>[]>
getAllJobs(type?: string): Promise<AnyXoJob[]>
getProxy(id: XoProxy['id']): Promise<XoProxy>
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]>
removeRemote(id: XoBackupRepository['id']): Promise<void>
getAllRemotesInfo(): Promise<
Record<
XoBackupRepository['id'],
@@ -217,6 +332,7 @@ export type XoApp = {
backupRepositoryIds: XoBackupRepository['id'][],
opts?: { _forceRefresh?: boolean; vmId: XoVm['id'] }
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]>>>
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
registerXenServer(
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
@@ -225,11 +341,22 @@ export type XoApp = {
readOnly?: XoServer['readOnly']
}
): Promise<XoServer>
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask }): Promise<void>
rollingPoolUpdate(pool: XoPool, opts?: { rebootVm?: boolean; parentTask?: VatesTask }): Promise<void>
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask; shutdownPinnedVms?: boolean }): Promise<void>
rollingPoolUpdate(
pool: XoPool,
opts?: { rebootVm?: boolean; parentTask?: VatesTask; shutdownPinnedVms?: boolean }
): Promise<void>
setVmResourceSet(vmId: XoVm['id'], resourceSetId: string | null, force?: boolean): Promise<void>
shareVmResourceSet(vmId: XoVm['id']): Promise<void>
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
testRemote(
id: XoBackupRepository['id']
): Promise<
| { success: true; readRate: number; writeRate: number }
| { success: false; step: string; file: string; error: unknown }
>
/** Remove a server from the DB (XCP-ng/XenServer) */
unregisterXenServer(id: XoServer['id']): Promise<void>
updateUser(
@@ -246,12 +373,44 @@ 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: {
name?: string
}
): void
getAllXapis(): Record<string, XapiConnection>
updateRemote(
id: XoBackupRepository['id'],
params: {
enabled?: boolean
name?: string
options?: string | null
proxy?: XoProxy['id'] | null
url?: string
}
): Promise<XoBackupRepository>
getAllXapis(): Record<string, Xapi>
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 = {
@@ -75,7 +76,7 @@ type BaseXoVm = BaseXapiXo & {
expNestedHvm: boolean
isNestedVirtEnabled: boolean
hasVendorDevice: boolean
high_availability: string
high_availability: 'best-effort' | 'restart' | ''
installTime?: number | null
isFirmwareSupported: boolean
memory: {
@@ -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
@@ -217,6 +222,7 @@ export type XoGroup = {
provider?: string
providerGroupId?: string
users: XoUser['id'][]
aclRoleIds: XoAclRole['id'][]
}
export type XoHost = BaseXapiXo & {
@@ -330,6 +336,7 @@ export type XoNetwork = BaseXapiXo & {
defaultIsLocked: boolean
id: Branded<'network'>
insecureNbd?: boolean
isBonded: boolean
name_description: string
name_label: string
nbd?: boolean
@@ -427,6 +434,7 @@ export type XoPool = BaseXapiXo & {
current_operations: Record<string, POOL_ALLOWED_OPERATIONS>
default_SR?: XoSr['id']
HA_enabled: boolean
haRebootVmOnInternalShutdown: boolean
haSrs: XoSr['id'][]
id: Branded<'pool'>
master: XoHost['id']
@@ -558,6 +566,18 @@ export type XoMirrorBackupJob = BaseXoJob & {
export type XoJob = BaseXoJob & {
type: 'call'
method: string
paramsVector?: {
type: string
items: {
type?: string
values?: {
schedules?: XoSchedule['id'][]
[key: string]: unknown
}[]
[key: string]: unknown
}[]
}
}
export type XoSchedule = {
@@ -594,7 +614,7 @@ export type XoSr = BaseXapiXo & {
VDIs: AnyXoVdi['id'][]
allocationStrategy?: 'thin' | 'thick' | 'unknown'
content_type: string
content_type: 'user' | 'iso' | 'disk'
current_operations: Record<string, STORAGE_OPERATIONS>
id: Branded<'SR'>
inMaintenanceMode: boolean
@@ -605,7 +625,28 @@ export type XoSr = BaseXapiXo & {
shared: boolean
size: number
sm_config: Record<string, string>
SR_type: string
// SM drivers shipped with XCP-ng/XenServer
SR_type:
| 'cephfs'
| 'dummy'
| 'ext'
| 'file'
| 'glusterfs'
| 'hba'
| 'iso'
| 'largeblock'
| 'linstor'
| 'lvm'
| 'lvmofcoe'
| 'lvmohba'
| 'lvmoiscsi'
| 'moosefs'
| 'nfs'
| 'shm'
| 'smb'
| 'udev'
| 'xfs'
| 'zfs'
tags: string[]
type: 'SR'
usage: number
@@ -652,13 +693,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 +715,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 +768,7 @@ type BaseXoVdi = BaseXapiXo & {
export type XoVdi = BaseXoVdi & {
id: Branded<'VDI'>
chainPhysicalUsage: number
type: 'VDI'
}
@@ -845,22 +889,28 @@ export type XapiXoRecord =
| XoVtpm
| XoSm
export type NonXapiXoRecord =
export type NonXapiXoRecord<
TActionsByResource extends XoAclSupportedActionsByResource = never,
TResource extends string = never,
> =
| AnyXoBackupArchive
| AnyXoJob
| AnyXoLog
| XoGroup
| XoProxy
| XoGroup
| XoProxy
| XoJob
| XoBackupRepository
| XoSchedule
| 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.5.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.30.1",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.61.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,13 @@
export default {
benchmark: true,
create: true,
read: true,
forget: true,
update: {
enabled: true,
name: true,
options: true,
proxy: true,
url: 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,28 @@
export default {
'allow-vm': true,
detach: true,
disable: true,
enable: true,
evacuate: true,
export: {
logs: true,
},
forget: true,
'join-pool': true,
'migrate-receive': true,
read: true,
reboot: {
clean: true,
smart: true,
},
'restart-toolstack': true,
'scan-pifs': true,
shutdown: {
clean: true,
emergency: true,
},
start: 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,9 @@
export default {
create: true,
delete: true,
read: true,
update: {
tags: true,
other_config: true,
},
}

View File

@@ -0,0 +1,5 @@
export default {
read: true,
plug: true,
unplug: 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,14 @@
export default {
'add-host': true,
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,16 @@
export default {
create: true,
delete: true,
forget: true,
import: {
vdi: true,
vm: true,
},
'migrate-receive': true,
read: true,
'reclaim-space': true,
scan: 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,7 @@
export default {
connect: true,
create: true,
delete: true,
disconnect: true,
read: true,
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
export default {
boot: true,
create: true,
delete: true,
'export-content': true,
'import-content': true,
'migrate-send': true,
read: true,
update: {
name_description: true,
name_label: true,
size: true,
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,15 @@
export default {
connect: true,
create: true,
delete: true,
disconnect: true,
read: true,
update: {
allowedIpv4Addresses: true,
allowedIpv6Addresses: true,
lockingMode: true,
rateLimit: true,
txChecksumming: true,
other_config: 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,61 @@
export default {
clone: true,
delete: true,
export: true,
'migrate-send': 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: {
affinityHost: true,
autoPoweron: true,
blockedOperations: true,
coresPerSocket: true,
cpuCap: true,
cpuMask: true,
cpuWeight: true,
cpus: true,
cpusStaticMax: true,
creation: true,
datasources: true,
expNestedHvm: true,
hasVendorDevice: true,
highAvailability: true,
hvmBootFirmware: true,
memory: true,
memoryMax: true,
memoryMin: true,
memoryStaticMax: true,
nameDescription: true,
nameLabel: true,
nestedVirt: true,
nicType: true,
notes: true,
PV_args: true,
resourceSet: true,
secureBoot: true,
share: true,
startDelay: true,
suspendSr: true,
tags: true,
uefiMode: true,
vga: true,
videoram: true,
viridian: true,
virtualizationMode: true,
xenStoreData: 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,56 @@
{
"name": "@xen-orchestra/backup-archive",
"version": "1.1.1",
"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.61.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.30.1"
},
"dependencies": {
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/disk-transform": "^1.3.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.16.1"
},
"scripts": {
"build": "yarn run clean && tsc",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"test": "yarn run clean && tsc && node --test **/*.test.mjs",
"test-integration": "yarn run clean && tsc && node --test **/*.integ.mjs",
"clean": "rimraf dist/"
},
"private": false,
"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,438 @@
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)
const MERGE_STATE_FILE_RE = /^\.(.+)\.merge\.json$/
/**
* 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> {
let files: string[]
try {
files = await this.#handler.list(this.#vdiDir, { prependDir: true })
} catch (error) {
if (error?.code === 'NOT_SUPPORTED') throw error
this.#opts.logWarn('failed to list VDI directory', { vdiDir: this.#vdiDir, error })
return
}
for (const filePath of files) {
if (isDisk(filePath)) {
this.#diskPaths.add(normalize(filePath))
}
}
const mergeParentPaths = new Set<string>()
for (const filePath of files) {
const match = MERGE_STATE_FILE_RE.exec(basename(filePath))
if (match !== null) {
mergeParentPaths.add(normalize(this.#vdiDir + '/' + match[1]))
}
}
const uuidToPath = new Map<string, string>()
for (const diskPath of this.#diskPaths) {
let disk: RemoteDisk | undefined
try {
if (mergeParentPaths.has(diskPath)) {
disk = await openDisk({ handler: this.#handler, path: diskPath, force: true })
} else {
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 })
// Remove from tracking so #cleanOrphanDataFiles skips this now-deleted path
this.#unregisterDisk(path)
} 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)
// parentPath alias deleted by parentDisk.rename(mergeTargetPath)
// intermediates deleted by childDisk.unlink() when removeUnused=true
// Unregister so #cleanOrphanDataFiles does not read already-deleted aliases
// and produce false "missing target of alias" warnings.
for (const deletedPath of chain.slice(0, -1)) {
this.#unregisterDisk(deletedPath)
}
},
{ 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,74 @@
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
// 'true' when written by code including the non-NBD qcow2 corruption fix and its force-full
// gate; its absence marks a potentially-corrupt older backup and makes the gate re-probe.
// Preserved (never stamped) across merges here.
// @todo remove this code and the logic associated on 2027 01 01
includeNonNbdQcow2Fix?: boolean
}
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,289 @@
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
// Set by #checkCacheCount(): the on-disk cache did not match the archives found
// on disk, so clean() must regenerate it even when nothing was merged/removed.
#cacheOutOfSync = false
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 || this.#cacheOutOfSync) {
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
this.#cacheOutOfSync = actual !== expected
if (this.#cacheOutOfSync) {
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,157 @@
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 VmBackupDirectory.cleanVm(handler, rootPath, { 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('Missing XVA cleans the metadata', async () => {
await createFullBackupMetadata('metadata.json', 'missing.xva')
await handler.unlink(`${rootPath}/missing.xva`)
let logged = ''
const logInfo = message => {
logged += message + '\n'
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logInfo, logWarn: logInfo })
const remainingFiles = await handler.list(rootPath)
assert.ok(logged.match(/XVA is missing/g))
assert.ok(!remainingFiles.includes('metadata.json'))
})
test('clean() preserves cache.json.gz', async () => {
await createFullBackupMetadata('backup1.json', 'backup1.xva')
await handler.writeFile(`${rootPath}/cache.json.gz`, 'cache')
await VmBackupDirectory.cleanVm(handler, rootPath)
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 VmBackupDirectory.cleanVm(handler, rootPath, { 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,179 @@
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,
metadataSize?: number
): Promise<boolean> {
// size is longer when encrypted + reading part of an encrypted file is not implemented
if (handler.isEncrypted) {
const size = await handler.getSizeOnDisk(filePath)
return size > 20
}
let isValid = 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
}
let validSize = true
if (metadataSize !== undefined) {
validSize = metadataSize === size
}
const validDisk = (await isCompressedFile(handler, fd))
? true // compressed files cannot be validated at this time
: await isValidTar(handler, size, fd)
isValid = validSize && validDisk
} finally {
handler.closeFile(fd).catch(noop)
}
return isValid
}
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) {
try {
this.isValid = await isValidXva(this.handler, this.xvaPath, this.metadata.size)
} catch (error) {
this.isValid = false
if (error?.code === 'ENOENT') {
this.missingDisk = true
}
this.opts.logWarn('Issue while checking XVA', { error })
}
if (!this.handler.isEncrypted) {
try {
await this.handler.getSize(`${this.xvaPath}.checksum`)
} catch (error) {
this.opts.logWarn('Checksum file not valid, not blocking', { error })
}
}
}
if (!this.isValid) {
if (this.missingDisk) {
this.opts.logWarn('XVA is missing', { path: this.xvaPath })
} else {
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,859 @@
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 { RemoteVhdDisk } from '../dist/disks/RemoteVhdDisk.mjs'
import { MergeRemoteDisk } from '../dist/disks/MergeRemoteDisk.mjs'
import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
import { VhdFile, Constants, VhdDirectory, VhdAbstract } from 'vhd-lib'
import { dirname, basename } from 'node:path'
import { promisify } from 'node:util'
import zlib from 'node:zlib'
import { rimraf } from 'rimraf'
const { beforeEach, afterEach, describe } = test
const gunzip = promisify(zlib.gunzip)
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, through cleanVm, an interrupted merge whose ancestor lost its footer', async () => {
// Reproduces the production "footer1 !== footer2" failure end-to-end via the full cleanVm
// pipeline. ancestor (full) <- child (differencing, the active backup).
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1] })
await generateVhd(`${basePath}/child.vhd`, {
header: { parentUnicodeName: 'ancestor.vhd', parentUuid: ancestor.footer.uuid },
blocks: [2, 3, 4, 5],
})
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', size: 12000, vhds: [`${relativePath}/child.vhd`] })
)
// Simulate a merge killed mid-transfer: write 2 of the 4 child blocks into the ancestor
// (which overwrites its end footer) then throw before mergeMetadata() rewrites the footer.
// This leaves the ancestor footerless AND a `.merge.json` state file — the exact post-crash
// state that makes cleanVm take the resume path.
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
await parent.init({ force: false })
await child.init({ force: false })
const merger = new MergeRemoteDisk(handler, { removeUnused: true, writeStateDelay: 0 })
let written = 0
const realWriteBlock = parent.writeBlock.bind(parent)
parent.writeBlock = async diskBlock => {
if (written === 2) {
throw new Error('simulated interruption')
}
written++
return realWriteBlock(diskBlock)
}
await assert.rejects(merger.merge(parent, child), /simulated interruption/)
assert.ok(
(await handler.list(basePath)).includes('.ancestor.vhd.merge.json'),
'an interrupted-merge state file should remain'
)
// Full pipeline: cleanVm must recognize the interrupted merge and resume it — opening the
// footerless ancestor through the chain without throwing "footer1 !== footer2".
await VmBackupDirectory.cleanVm(handler, rootPath, {
remove: true,
merge: true,
logInfo: () => {},
logWarn: () => {},
})
// The merge completed: the ancestor was consolidated into child.vhd and the state file is gone.
const remaining = await handler.list(basePath)
assert.equal(remaining.includes('.ancestor.vhd.merge.json'), false, 'state file removed after successful resume')
assert.equal(remaining.includes('ancestor.vhd'), false, 'ancestor consolidated into the merge target')
assert.equal(remaining.includes('child.vhd'), true, 'merge target remains')
// No blocks lost: the resumed merge wrote the blocks (4, 5) that were never written before
// the crash, so the consolidated disk holds the full set.
const merged = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
await merged.init({ force: false })
try {
assert.deepEqual(merged.getBlockIndexes(), [0, 1, 2, 3, 4, 5], 'all blocks present after resumed merge')
} finally {
await merged.close()
}
// The backup is consistent, so its metadata is preserved.
assert.ok((await handler.list(rootPath)).includes('metadata.json'), 'metadata should be preserved')
})
test('it does not warn "missing target of alias" for aliases deleted by merge', async () => {
// Regression: RemoteDiskLineage.#cleanOrphanDataFiles iterated #diskPaths entries just
// deleted during the same clean() call's merge phase, producing false "missing target of
// alias" warnings
const orphan = await generateVhd(`${basePath}/orphan.vhd`, {
useAlias: true,
blocks: [0],
})
await generateVhd(`${basePath}/child.vhd`, {
useAlias: true,
header: {
parentUnicodeName: 'orphan.vhd.alias.vhd',
parentUuid: orphan.footer.uuid,
},
blocks: [1],
})
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [`${relativePath}/child.vhd.alias.vhd`],
})
)
const warnings = []
await VmBackupDirectory.cleanVm(handler, rootPath, {
remove: true,
merge: true,
logWarn: (message, data) => warnings.push({ message, data }),
logInfo: () => {},
})
const missingTargetWarnings = warnings.filter(w => w.message === 'missing target of alias')
assert.equal(
missingTargetWarnings.length,
0,
`"missing target of alias" must not fire after merge; got: ${JSON.stringify(missingTargetWarnings)}`
)
const remaining = await handler.list(basePath)
assert.ok(!remaining.includes('orphan.vhd.alias.vhd'), 'orphan alias must be deleted after merge')
assert.ok(remaining.includes('child.vhd.alias.vhd'), 'child alias must survive as merge target')
})
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')
})
test('it preserves VDI directory when handler.list() throws during lineage init', async () => {
// Regression: when handler.list(vdiDir) threw (e.g. S3 404), the lineage
// was never added to #uniqueLineages, so cleanOrphanDiskDirs treated the directory
// as an orphan and deleted it with rmtree()
await generateVhd(`${basePath}/snapshot.vhd`)
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/snapshot.vhd`] })
)
const originalList = handler.list.bind(handler)
handler.list = async (path, opts) => {
if (typeof path === 'string' && path.includes(vdiId)) {
throw Object.assign(new Error('Simulated transient storage error'), { code: 'ENOENT' })
}
return originalList(path, opts)
}
try {
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logWarn: () => {}, logInfo: () => {} })
} finally {
handler.list = originalList
}
const remaining = await handler.list(basePath)
assert.ok(remaining.includes('snapshot.vhd'), 'VHD must survive when handler.list() throws during lineage init')
})
test('it regenerates the cache when its entry count is out of sync, even with nothing to merge/remove', async () => {
// a referenced disk + its metadata, but no cache.json.gz on disk
await generateVhd(`${basePath}/diskA1.vhd`)
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/diskA1.vhd`] })
)
await assert.rejects(handler.readFile(`${rootPath}/cache.json.gz`), { code: 'ENOENT' })
const logged = []
// remove/merge both false: cacheNeedsRegen stays false and no merge happens,
// so only the cache-count mismatch can trigger the regeneration
await VmBackupDirectory.cleanVm(handler, rootPath, {
remove: false,
merge: false,
logInfo: () => {},
logWarn: message => logged.push(message),
})
assert.ok(logged.includes('unexpected number of entries in backup cache'), 'mismatch should be reported')
const cache = JSON.parse((await gunzip(await handler.readFile(`${rootPath}/cache.json.gz`))).toString())
assert.equal(Object.keys(cache).length, 1, 'cache should be regenerated to match the archives on disk')
})

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
for (const diskPath of this.diskPaths) {
const vdiDir = dirname(diskPath)
if (!this.diskLineages.has(vdiDir)) {
const lineage = new RemoteDiskLineage(this.handler, vdiDir, this.opts)
this.diskLineages.set(vdiDir, lineage)
try {
await lineage.init()
} catch (error) {
if (error?.code === 'NOT_SUPPORTED') throw error
this.opts.logWarn('failed to scan VDI directory', { vdiDir, 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

@@ -2,7 +2,7 @@
/**
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
*/
import assert from 'assert'
@@ -10,18 +10,21 @@ 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 {{ 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 {
@@ -31,6 +34,7 @@ export class MergeRemoteDisk {
#state = {
child: { uuid: '0' },
parent: { uuid: '0' },
chain: undefined,
currentBlock: 0,
mergedDataSize: 0,
step: 'mergeBlocks',
@@ -78,18 +82,18 @@ export class MergeRemoteDisk {
#lastStateWrittenAt
/**
* @type {FileAccessor}
* @type {RemoteHandlerAbstract}
*/
#handler
/**
* @param {FileAccessor} 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
* @param {Function} [params.onProgress]
* @param {Logger | Function} [params.logInfo]
* @param {boolean} [params.removeUnused]
* @param {number} [params.mergeBlockConcurrency]
* @param {number} [params.writeStateDelay]
*/
constructor(
handler,
@@ -138,10 +142,11 @@ export class MergeRemoteDisk {
const mergeStateContent = await this.#handler.readFile(this.#statePath)
this.#state = JSON.parse(mergeStateContent)
// work-around a bug introduce in 97d94b795
// work-around to bugs introduced in 97d94b795 and 4e50858
//
// currentBlock could be `null` due to the JSON.stringify of a `NaN` value
if (this.#state?.currentBlock === null) this.#state.currentBlock = 0
// could also be -1 due to being set as Math.min(...merging) - 1 if merging contains 0
if (this.#state?.currentBlock === null || this.#state?.currentBlock < 0) this.#state.currentBlock = 0
this.#isResuming = true
} catch (error) {
// @ts-ignore
@@ -190,6 +195,7 @@ export class MergeRemoteDisk {
*/
async #step_mergeBlocks(parentDisk, childDisk) {
const getMaxBlockCount = childDisk.getMaxBlockCount()
await parentDisk.resize(getMaxBlockCount)
if (this.#isResuming) {
const alreadyMergedBlocks = []
@@ -201,8 +207,11 @@ export class MergeRemoteDisk {
parentDisk.setAllocatedBlocks(alreadyMergedBlocks)
} else {
this.#state.child = { uuid: childDisk.getUuid() ?? 0 }
this.#state.parent = { uuid: parentDisk.getUuid() ?? 0 }
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)) {
@@ -213,7 +222,8 @@ export class MergeRemoteDisk {
await this.#mergeBlocks(parentDisk, childDisk)
await parentDisk.flushMetadata(childDisk)
parentDisk.mergeMetadata(childDisk)
await parentDisk.mergeMetadata(childDisk)
this.#state.diskSize = parentDisk.getSizeOnDisk()
}
/**
@@ -246,7 +256,7 @@ export class MergeRemoteDisk {
const blockSize = await parentDisk.mergeBlock(childDisk, blockId, this.#isResuming)
this.#state.mergedDataSize += blockSize
this.#state.currentBlock = Math.min(...merging) - 1
this.#state.currentBlock = Math.max(0, Math.min(...merging) - 1)
merging.delete(blockId)
this.#onProgress({ total: nBlocks, done: counter + 1 })
@@ -258,8 +268,6 @@ export class MergeRemoteDisk {
await this.#writeState()
this.#state.diskSize = childDisk.getSizeOnDisk()
this.#onProgress({ total: nBlocks, done: nBlocks })
}
@@ -314,4 +322,44 @@ export class MergeRemoteDisk {
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

@@ -55,6 +55,16 @@ export class RemoteDisk extends RandomAccessDisk {
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}
@@ -63,6 +73,30 @@ export class RemoteDisk extends RandomAccessDisk {
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
@@ -72,11 +106,22 @@ export class RemoteDisk extends RandomAccessDisk {
}
/**
* Abstract
* @returns {number} getMaxBlockCount
*/
getMaxBlockCount() {
throw new Error(`getMaxBlockCount must be implemented`)
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
}
/**
@@ -148,6 +193,15 @@ export class RemoteDisk extends RandomAccessDisk {
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
@@ -160,6 +214,7 @@ export class RemoteDisk extends RandomAccessDisk {
/**
* Abstract
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
mergeMetadata(childDisk) {
throw new Error(`mergeMetadata must be implemented`)
@@ -186,11 +241,34 @@ export class RemoteDisk extends RandomAccessDisk {
/**
* Abstract
* Deletes alias/disk/disks
* @param {Object} options
*/
async unlink() {
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>}
*/

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