mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
269 Commits
feat/rpu-r
...
2ac7c0ffb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac7c0ffb8 | ||
|
|
68ef82a9f3 | ||
|
|
565dbfa835 | ||
|
|
a8952875ac | ||
|
|
7b44a15a51 | ||
|
|
22ed4ac652 | ||
|
|
ee90cea718 | ||
|
|
1f2469b711 | ||
|
|
533f69244b | ||
|
|
fbcf5ad7f7 | ||
|
|
104cdfbcb1 | ||
|
|
574e427b0a | ||
|
|
48b445211b | ||
|
|
a54e37c808 | ||
|
|
48ad080ada | ||
|
|
210e4f124e | ||
|
|
e7da1be6ba | ||
|
|
d10aa60358 | ||
|
|
374d2c9d39 | ||
|
|
b82d641783 | ||
|
|
3a41fba148 | ||
|
|
1667ed2a16 | ||
|
|
55cf10d8bf | ||
|
|
7ec1e941a5 | ||
|
|
f8dc85855b | ||
|
|
8ae1f4e811 | ||
|
|
1c1960a09c | ||
|
|
892fff90ab | ||
|
|
d4c8b7aef4 | ||
|
|
11b3302b62 | ||
|
|
587d273e67 | ||
|
|
3dd6126425 | ||
|
|
92496e7a19 | ||
|
|
d6e7324892 | ||
|
|
13986f2828 | ||
|
|
f7cad58fa3 | ||
|
|
d1e023bdd4 | ||
|
|
710cc1915e | ||
|
|
873644d9e0 | ||
|
|
35082b5d9e | ||
|
|
a8ab6a1f99 | ||
|
|
fb1da9305e | ||
|
|
04d86aca15 | ||
|
|
73207050a0 | ||
|
|
a61182a466 | ||
|
|
6866393ba8 | ||
|
|
d1358444e7 | ||
|
|
75e990343a | ||
|
|
8d25cb6b94 | ||
|
|
c099fabb2b | ||
|
|
b20630c512 | ||
|
|
e63e6a3810 | ||
|
|
8d69b4095e | ||
|
|
43eefcf01f | ||
|
|
67ea6b5a53 | ||
|
|
c1c2673441 | ||
|
|
23ffab2373 | ||
|
|
2a4c19d281 | ||
|
|
ba7059af89 | ||
|
|
a51c206614 | ||
|
|
3532de374d | ||
|
|
f27fdb5c96 | ||
|
|
79d303db55 | ||
|
|
e70963dc74 | ||
|
|
a654570dd1 | ||
|
|
38522688ca | ||
|
|
b5a6a0f2b2 | ||
|
|
9f10b3f942 | ||
|
|
8c14ebbb0c | ||
|
|
6838e0b536 | ||
|
|
4d671f71cd | ||
|
|
dc43545389 | ||
|
|
30a21d98a4 | ||
|
|
bacd60115c | ||
|
|
a27f0cb5ed | ||
|
|
60272ed6cf | ||
|
|
745b6a7012 | ||
|
|
8583fcdd32 | ||
|
|
2a4e45f4dc | ||
|
|
0d7fba2b6a | ||
|
|
9efc4df5e6 | ||
|
|
cdda6f6e2a | ||
|
|
d85415f28e | ||
|
|
890a8aedae | ||
|
|
422512514a | ||
|
|
ee217dbf45 | ||
|
|
f6f5f05696 | ||
|
|
1acd6533fc | ||
|
|
12cf925a89 | ||
|
|
d550e4356b | ||
|
|
03fc6b5b1a | ||
|
|
5d79b4486e | ||
|
|
2ea5887ad1 | ||
|
|
9fc2dc2b7c | ||
|
|
c89eab12bf | ||
|
|
d63f6a794c | ||
|
|
2739715642 | ||
|
|
26eadbd6a4 | ||
|
|
79fff5af37 | ||
|
|
8be55f783b | ||
|
|
2655842321 | ||
|
|
f593012837 | ||
|
|
a2f590be31 | ||
|
|
bcecc5ded0 | ||
|
|
7fe262fbbe | ||
|
|
dafc829cee | ||
|
|
8ea46eb530 | ||
|
|
9e59db67ee | ||
|
|
73694a0da6 | ||
|
|
6c201f2117 | ||
|
|
3be9f3e94a | ||
|
|
b1741f44f5 | ||
|
|
ffd581def2 | ||
|
|
12cc94e98b | ||
|
|
d80a5f52f0 | ||
|
|
da9044c98e | ||
|
|
ba01b16d1a | ||
|
|
3687817765 | ||
|
|
8bd11632f0 | ||
|
|
b2fd9180b1 | ||
|
|
8c00429922 | ||
|
|
3a1191dfe1 | ||
|
|
9336c1c8ac | ||
|
|
a3bacc45a3 | ||
|
|
de2cfc864d | ||
|
|
dfa0baf1fa | ||
|
|
501bb71dc1 | ||
|
|
de1d6ed21d | ||
|
|
7d4f325330 | ||
|
|
f1b316bd01 | ||
|
|
166c4d1e46 | ||
|
|
a2c83b402f | ||
|
|
d4047e3b9b | ||
|
|
3e6ea4953f | ||
|
|
ed9911daf9 | ||
|
|
3f2d102f81 | ||
|
|
c7f16ed2ac | ||
|
|
a9dba30376 | ||
|
|
c9d1514342 | ||
|
|
989bb8591a | ||
|
|
879b5d5991 | ||
|
|
1f9b45a004 | ||
|
|
0cb3f0a992 | ||
|
|
93fb8bad35 | ||
|
|
6f8fbb57cf | ||
|
|
2c63b82e84 | ||
|
|
fde6f8d91f | ||
|
|
5ce0c65ead | ||
|
|
843d8b0f22 | ||
|
|
e0fd2b9a8f | ||
|
|
5cc86ea8dc | ||
|
|
a50776cb7d | ||
|
|
b477167b41 | ||
|
|
909fa94161 | ||
|
|
79e88245da | ||
|
|
4802815bc3 | ||
|
|
7985244daf | ||
|
|
406f4436c6 | ||
|
|
1609d2f825 | ||
|
|
a953ca748d | ||
|
|
5385f9f229 | ||
|
|
092133f811 | ||
|
|
54c52e8f24 | ||
|
|
9e1fc78f84 | ||
|
|
e2ca66130c | ||
|
|
d618707ac3 | ||
|
|
2685f3b95c | ||
|
|
4cfe9e81bd | ||
|
|
3359eb898f | ||
|
|
1ce24b85bd | ||
|
|
c234800468 | ||
|
|
a846ae68dd | ||
|
|
666a99706e | ||
|
|
5dd65197bb | ||
|
|
c2bda04112 | ||
|
|
0be905e7cf | ||
|
|
fd5b54f972 | ||
|
|
248a6e025c | ||
|
|
d4dd0791f2 | ||
|
|
1329b7bc69 | ||
|
|
b9fa292b9d | ||
|
|
767fc2cb76 | ||
|
|
d2ffe1ad56 | ||
|
|
a508ca7897 | ||
|
|
96e789c35e | ||
|
|
f013adf7e4 | ||
|
|
3923756ff5 | ||
|
|
988d466cba | ||
|
|
e0c9112429 | ||
|
|
61f3cbc9ce | ||
|
|
eac0b1e2c9 | ||
|
|
5a384d60c4 | ||
|
|
bc927fdffb | ||
|
|
7c3f861a98 | ||
|
|
0ebc92b50f | ||
|
|
57282c0457 | ||
|
|
ee54d78259 | ||
|
|
10208cbfe0 | ||
|
|
6f5caa409c | ||
|
|
2233bad7a7 | ||
|
|
b630456835 | ||
|
|
c77d22f422 | ||
|
|
191317630d | ||
|
|
d32c9879b1 | ||
|
|
ab4d372ce2 | ||
|
|
5cc6500def | ||
|
|
8a70c4abc0 | ||
|
|
1639a5f6a6 | ||
|
|
d525a7d5bc | ||
|
|
776993e119 | ||
|
|
8ae4ea25b2 | ||
|
|
eecba81f22 | ||
|
|
05aeff101b | ||
|
|
b7af59b6ed | ||
|
|
12e1a10394 | ||
|
|
954a2034e6 | ||
|
|
b819ba1527 | ||
|
|
ff90a80e99 | ||
|
|
cfa75ef652 | ||
|
|
a350c62cc3 | ||
|
|
bb5a79e6c6 | ||
|
|
e4e7c05104 | ||
|
|
52c68a28cc | ||
|
|
635f02bcae | ||
|
|
db5cb01fc9 | ||
|
|
928408fe8e | ||
|
|
db98f15bc1 | ||
|
|
decdf61919 | ||
|
|
6115c74bbe | ||
|
|
025f6eb533 | ||
|
|
3e74189479 | ||
|
|
0695c6b44c | ||
|
|
0bb2f12b2b | ||
|
|
865b6a84c7 | ||
|
|
6b82340fdc | ||
|
|
d846e00dff | ||
|
|
f93bb67409 | ||
|
|
51bb88a1ce | ||
|
|
11335b465b | ||
|
|
295b8838a6 | ||
|
|
e0b0c75682 | ||
|
|
286dafa267 | ||
|
|
b08ad1669f | ||
|
|
4731cbd629 | ||
|
|
1c4890be10 | ||
|
|
4abb9d80bc | ||
|
|
87fe4e2b53 | ||
|
|
6058f07813 | ||
|
|
d451d5c35b | ||
|
|
375b9fe91b | ||
|
|
d53294f15c | ||
|
|
40e5ca9a73 | ||
|
|
cf760d26cd | ||
|
|
d4b85421fe | ||
|
|
61a06e817b | ||
|
|
1b1ae506b6 | ||
|
|
641f988781 | ||
|
|
b6b94a80b1 | ||
|
|
70b0824dc4 | ||
|
|
4a9d434559 | ||
|
|
341bd0301d | ||
|
|
70be55528f | ||
|
|
71ff8b0d24 | ||
|
|
33caeacb49 | ||
|
|
854e5dbd89 | ||
|
|
c069b7e9c7 | ||
|
|
8338851c4c | ||
|
|
7edeb7a253 | ||
|
|
1a012bdd94 |
11
.github/workflows/vectura.yml
vendored
11
.github/workflows/vectura.yml
vendored
@@ -45,8 +45,8 @@ jobs:
|
||||
- name: Hold the coverage floor
|
||||
run: cargo test --locked --test coverage -- --ignored
|
||||
|
||||
# builds the binary as a release does, then checks it and the one in git,
|
||||
# which xo-server runs
|
||||
# builds the binary as release.sh does, checks it, then checks the one in git,
|
||||
# which xo-server runs, is that build
|
||||
binary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -65,4 +65,9 @@ jobs:
|
||||
|
||||
- run: scripts/build.sh "$RUNNER_TEMP/build"
|
||||
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/vectura"
|
||||
- run: scripts/check-binary.sh vectura
|
||||
- name: The binary in git is the build of these sources
|
||||
run: |
|
||||
cmp "$RUNNER_TEMP/build/vectura" vectura || {
|
||||
echo '::error::vectura is not the build of these sources: run scripts/build.sh . in vectura/ and commit the binary'
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.9.5",
|
||||
"@xen-orchestra/fs": "^4.10.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.16.2"
|
||||
"vhd-lib": "^4.17.0"
|
||||
},
|
||||
"bin": {
|
||||
"xo-fuse-vhd": "./cli.mjs"
|
||||
|
||||
@@ -15,7 +15,7 @@ The target drives login negotiation onto a single, fixed code path:
|
||||
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
|
||||
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
|
||||
commands a Linux initiator issues to attach and use a block device
|
||||
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`, `REPORT LUNS`, `READ CAPACITY 10/16`,
|
||||
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
|
||||
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
|
||||
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
|
||||
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
|
||||
@@ -28,6 +28,19 @@ The package also ships a userspace **initiator** (`IscsiInitiator`) and an
|
||||
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
|
||||
target-initiated NOP keepalives, multiple connections/LUNs.
|
||||
|
||||
What a peer can make the target do is bounded:
|
||||
|
||||
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
|
||||
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
|
||||
source addresses of the expected initiators) closes the other ones as soon as
|
||||
they are accepted, reporting them to `onConnectionRefused`
|
||||
- a PDU data segment is at most 8 KiB during login, then the declared
|
||||
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
|
||||
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
|
||||
advertised in the block limits VPD page so that initiators split their I/O
|
||||
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
|
||||
is refused with `DATA PROTECT` before any data is solicited
|
||||
|
||||
### CLI
|
||||
|
||||
The package ships a `vates-iscsi` binary that serves a file as a LUN:
|
||||
@@ -139,6 +152,8 @@ class RandomAccessDiskLun {
|
||||
}
|
||||
return out
|
||||
}
|
||||
// reported write-protected: WRITE is refused before reaching write()
|
||||
readOnly = true
|
||||
write() {
|
||||
throw new Error('read-only LUN')
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ The package has two independent roles with very different maturity:
|
||||
|
||||
## Session and connection
|
||||
|
||||
| Aspect | Target | Initiator |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
|
||||
| Connections per session | `MaxConnections=1`, enforced | opens exactly one |
|
||||
| Session reinstatement | **no** — no ISID tracking; a new connection always tears down and replaces whatever connection currently exists, rather than RFC 7143's ISID-matched reinstatement (see `index.mts`'s class doc for the reasoning: safe here because the target is ephemeral, single-consumer, and CHAP-guarded) | n/a (single connection) |
|
||||
| Multiple LUNs | **no** — exactly one LUN, always LUN 0 | reads are hardcoded to LUN 0 |
|
||||
| `ErrorRecoveryLevel` | `0` only | requests `0` |
|
||||
| Header/data digests | `HeaderDigest=None`, `DataDigest=None` — no CRC32C on the wire | same |
|
||||
| Markers (`IFMarker`/`OFMarker`) | not offered (RFC-deprecated) | not offered |
|
||||
| Aspect | Target | Initiator |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
|
||||
| Connections per session | `MaxConnections=1`, enforced | opens exactly one |
|
||||
| Session reinstatement | **no** — no ISID tracking; once a new connection has established a Normal session (CHAP included), it tears down and replaces whatever connection currently exists, rather than RFC 7143's ISID-matched reinstatement (see `index.mts`'s class doc for the reasoning: safe here because the target is ephemeral, single-consumer, and CHAP-guarded). Until then it does not affect the current one, and is bounded by `loginTimeoutMs` and `maxPendingConnections` | n/a (single connection) |
|
||||
| Multiple LUNs | **no** — exactly one LUN, always LUN 0 | reads are hardcoded to LUN 0 |
|
||||
| `ErrorRecoveryLevel` | `0` only | requests `0` |
|
||||
| Header/data digests | `HeaderDigest=None`, `DataDigest=None` — no CRC32C on the wire | same |
|
||||
| Markers (`IFMarker`/`OFMarker`) | not offered (RFC-deprecated) | not offered |
|
||||
|
||||
## Login / text negotiation
|
||||
|
||||
@@ -72,17 +72,17 @@ keys if it declines transit on the first exchange.
|
||||
Decoded explicitly (`types.mts#decodeCdb`); anything else maps to `unsupported` → `CHECK CONDITION` /
|
||||
`ILLEGAL_REQUEST` / `INVALID COMMAND OPERATION CODE`:
|
||||
|
||||
| Command | Variants |
|
||||
| ------------------- | ---------------------------------------------------------------- |
|
||||
| `INQUIRY` | standard + EVPD (vendor/product/revision/serial from `identity`) |
|
||||
| `REPORT LUNS` | |
|
||||
| `READ CAPACITY` | `(10)` and `(16)` |
|
||||
| `READ` | `(10)` and `(16)` |
|
||||
| `WRITE` | `(10)` and `(16)` |
|
||||
| `TEST UNIT READY` | |
|
||||
| `REQUEST SENSE` | |
|
||||
| `MODE SENSE` | `(6)` and `(10)` |
|
||||
| `SYNCHRONIZE CACHE` | `(10)` and `(16)` — flushes the backing `BlockDevice` |
|
||||
| Command | Variants |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `INQUIRY` | standard + EVPD `0x00`/`0x80`/`0x83` (vendor/product/revision/serial from `identity`) and `0xb0` (block limits: `maxTransferLength`) |
|
||||
| `REPORT LUNS` | |
|
||||
| `READ CAPACITY` | `(10)` and `(16)` |
|
||||
| `READ` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB` |
|
||||
| `WRITE` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB`; on a `readOnly` LUN → `DATA PROTECT` / `WRITE PROTECTED`, without soliciting data |
|
||||
| `TEST UNIT READY` | |
|
||||
| `REQUEST SENSE` | |
|
||||
| `MODE SENSE` | `(6)` and `(10)`; `WP` set on a `readOnly` LUN |
|
||||
| `SYNCHRONIZE CACHE` | `(10)` and `(16)` — flushes the backing `BlockDevice` |
|
||||
|
||||
**Not implemented**: persistent reservations, VAAI/thin-provisioning primitives (`UNMAP`, `WRITE SAME`, `COMPARE
|
||||
AND WRITE`, ...), any vendor-specific commands — anything beyond the ~10 commands a Linux initiator actually
|
||||
|
||||
@@ -33,7 +33,7 @@ The target drives login negotiation onto a single, fixed code path:
|
||||
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
|
||||
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
|
||||
commands a Linux initiator issues to attach and use a block device
|
||||
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`, `REPORT LUNS`, `READ CAPACITY 10/16`,
|
||||
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
|
||||
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
|
||||
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
|
||||
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
|
||||
@@ -46,6 +46,19 @@ The package also ships a userspace **initiator** (`IscsiInitiator`) and an
|
||||
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
|
||||
target-initiated NOP keepalives, multiple connections/LUNs.
|
||||
|
||||
What a peer can make the target do is bounded:
|
||||
|
||||
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
|
||||
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
|
||||
source addresses of the expected initiators) closes the other ones as soon as
|
||||
they are accepted, reporting them to `onConnectionRefused`
|
||||
- a PDU data segment is at most 8 KiB during login, then the declared
|
||||
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
|
||||
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
|
||||
advertised in the block limits VPD page so that initiators split their I/O
|
||||
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
|
||||
is refused with `DATA PROTECT` before any data is solicited
|
||||
|
||||
### CLI
|
||||
|
||||
The package ships a `vates-iscsi` binary that serves a file as a LUN:
|
||||
@@ -157,6 +170,8 @@ class RandomAccessDiskLun {
|
||||
}
|
||||
return out
|
||||
}
|
||||
// reported write-protected: WRITE is refused before reaching write()
|
||||
readOnly = true
|
||||
write() {
|
||||
throw new Error('read-only LUN')
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ VM boot mechanics, and deployment specifics live elsewhere and aren't reproduced
|
||||
- Re-verified end-to-end after deploying the fix: same S3-backed boot scenario completed successfully
|
||||
(~5 min total, vs ~20-25s for the equivalent local-storage boot — S3 itself is slow, but the mount no
|
||||
longer wedges).
|
||||
- Refined afterwards: the replacement used to happen as soon as the TCP connection was accepted, so any
|
||||
peer reaching the port (a port scan, a failed CHAP login, a Discovery session) interrupted the session in
|
||||
use. It now only happens once the new connection has established a Normal session; until then,
|
||||
connections are bounded by `loginTimeoutMs` and `maxPendingConnections`. Regression tests:
|
||||
`loopback.integ.mts`, `describe('connection admission', ...)`.
|
||||
|
||||
### Independently landed, cross-referenced against this pass's coverage
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/iscsi",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"main": "dist/index.mjs",
|
||||
"types": "dist/index.d.mts",
|
||||
"bin": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/read-chunk": "^1.2.1",
|
||||
"@xen-orchestra/disk-transform": "^1.3.0",
|
||||
"@xen-orchestra/disk-transform": "^1.3.4",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"promise-toolbox": "^0.21.0"
|
||||
},
|
||||
|
||||
@@ -102,10 +102,10 @@ export class DiskBlockDevice implements BlockDevice {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Always throws: the source disk is read-only. The target answers CHECK
|
||||
* CONDITION / MEDIUM ERROR, which initiators surface as an I/O error.
|
||||
*/
|
||||
// the target refuses WRITE commands before they reach `write()`
|
||||
readonly readOnly = true
|
||||
|
||||
/** Always throws: the source disk is read-only. */
|
||||
async write(): Promise<void> {
|
||||
throw new Error('DiskBlockDevice is read-only')
|
||||
}
|
||||
|
||||
129
@vates/iscsi/src/backend.integ.mts
Normal file
129
@vates/iscsi/src/backend.integ.mts
Normal file
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { after, before, describe, it } from 'node:test'
|
||||
|
||||
import { FileBlockDevice } from './index.mjs'
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
|
||||
const BLOCK_SIZE = 512
|
||||
const SIZE = 4 * 1024 * 1024 // 4 MiB
|
||||
|
||||
/**
|
||||
* Why this cannot run here, or `false` if it can. A real block device is the
|
||||
* whole point, and the cheapest one to make is a loop device — which needs root
|
||||
* and `losetup`.
|
||||
*/
|
||||
function skipReason(): string | false {
|
||||
if (process.getuid === undefined || process.getuid() !== 0) {
|
||||
return 'requires root to attach a loop device'
|
||||
}
|
||||
try {
|
||||
execFileSync('losetup', ['--version'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
return 'requires util-linux (losetup not found)'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
|
||||
|
||||
describe('FileBlockDevice on a loop device', { skip: skipReason() }, () => {
|
||||
let dir: string
|
||||
let devicePath: string
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-loop-'))
|
||||
const backing = join(dir, 'backing.img')
|
||||
await writeFile(backing, '')
|
||||
await truncate(backing, SIZE)
|
||||
const { stdout } = await execFileP('losetup', ['--find', '--show', backing])
|
||||
devicePath = stdout.trim()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
if (devicePath !== undefined) {
|
||||
await execFileP('losetup', ['--detach', devicePath])
|
||||
}
|
||||
await rm(dir, { force: true, recursive: true })
|
||||
})
|
||||
|
||||
const withDevice = async (fn: (device: FileBlockDevice) => Promise<void>): Promise<void> => {
|
||||
const device = new FileBlockDevice({ path: devicePath, size: SIZE })
|
||||
await device.open()
|
||||
try {
|
||||
await fn(device)
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts the device node and reports the capacity it was given', async () => {
|
||||
await withDevice(async device => {
|
||||
assert.equal(device.getSize(), SIZE)
|
||||
assert.equal(device.getBlockSize(), BLOCK_SIZE)
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips block-aligned data', async () => {
|
||||
await withDevice(async device => {
|
||||
const payload = Buffer.alloc(8 * BLOCK_SIZE)
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
payload[i] = bytePattern(i)
|
||||
}
|
||||
await device.write(16 * BLOCK_SIZE, payload)
|
||||
assert.deepEqual(await device.read(16 * BLOCK_SIZE, payload.length), payload)
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips data straddling block boundaries, at an unaligned offset', async () => {
|
||||
await withDevice(async device => {
|
||||
const payload = Buffer.from('a payload that is not a whole number of sectors long')
|
||||
const offset = 3 * BLOCK_SIZE + 17
|
||||
await device.write(offset, payload)
|
||||
assert.deepEqual(await device.read(offset, payload.length), payload)
|
||||
// the surrounding sector was read-modify-written by the kernel, not clobbered
|
||||
assert.deepEqual(await device.read(3 * BLOCK_SIZE, 17), Buffer.alloc(17))
|
||||
})
|
||||
})
|
||||
|
||||
it('survives a reopen, which is what makes it a cache', async () => {
|
||||
const payload = Buffer.alloc(BLOCK_SIZE, 0x5a)
|
||||
await withDevice(device => device.write(2 * BLOCK_SIZE, payload))
|
||||
await withDevice(async device => {
|
||||
await device.flush()
|
||||
assert.deepEqual(await device.read(2 * BLOCK_SIZE, BLOCK_SIZE), payload)
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a large range in one call', async () => {
|
||||
await withDevice(async device => {
|
||||
const payload = Buffer.alloc(SIZE / 2)
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
payload[i] = bytePattern(i + 7)
|
||||
}
|
||||
await device.write(0, payload)
|
||||
assert.deepEqual(await device.read(0, payload.length), payload)
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on a read running past the end, instead of zero-filling it', async () => {
|
||||
// the behaviour that sets the block device mode apart: a short read on a
|
||||
// device of a known size is an I/O error, never a sparse tail
|
||||
await withDevice(async device => {
|
||||
await assert.rejects(device.read(SIZE, BLOCK_SIZE), /ended after 0 bytes/)
|
||||
await assert.rejects(device.read(SIZE - BLOCK_SIZE, 2 * BLOCK_SIZE), /ended after 512 bytes/)
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes without error', async () => {
|
||||
await withDevice(async device => {
|
||||
await device.write(0, Buffer.alloc(BLOCK_SIZE, 0xff))
|
||||
await device.flush()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,11 @@ export interface BlockDevice {
|
||||
getSize(): number
|
||||
/** Logical block size in bytes (typically 512). */
|
||||
getBlockSize(): number
|
||||
/**
|
||||
* When true, the LUN is reported as write-protected and WRITE commands are
|
||||
* refused up front (DATA PROTECT), without soliciting their data.
|
||||
*/
|
||||
readonly readOnly?: boolean
|
||||
/** Read exactly `length` bytes starting at byte `offset`. */
|
||||
read(offset: number, length: number): Promise<Buffer>
|
||||
/** Write `data` starting at byte `offset`. */
|
||||
@@ -29,31 +34,60 @@ export interface BlockDevice {
|
||||
const DEFAULT_BLOCK_SIZE = 512
|
||||
|
||||
export interface FileBlockDeviceOptions {
|
||||
/** Path to the backing file. It must already exist and be sized to capacity. */
|
||||
/**
|
||||
* Path to the backing file. It must already exist and be sized to capacity —
|
||||
* or be a block device node, see {@link FileBlockDeviceOptions.size}.
|
||||
*/
|
||||
readonly path: string
|
||||
/** Logical block size in bytes. Defaults to 512. */
|
||||
readonly blockSize?: number
|
||||
/**
|
||||
* Capacity in bytes, for a block device node (e.g. a VDI hot-plugged onto the
|
||||
* appliance's VM, `/dev/xvdX`), whose inode reports a size of 0.
|
||||
*
|
||||
* Setting it switches to the block device mode:
|
||||
* - `path` must be a block device: a typo naming the wrong node is the
|
||||
* difference between a cache and a destroyed disk;
|
||||
* - a short read is an I/O error, not a sparse tail: zero-filling it would hand
|
||||
* the initiator fabricated data.
|
||||
*/
|
||||
readonly size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link BlockDevice} backed by a regular (sparse) file opened read/write.
|
||||
* A {@link BlockDevice} backed by a regular (sparse) file, or a block device
|
||||
* node, opened read/write.
|
||||
*
|
||||
* The file must already exist and be sized to the desired capacity (e.g. via
|
||||
* `fs.truncate`); this keeps the device a pure data store with no provisioning
|
||||
* policy of its own.
|
||||
*
|
||||
* I/O is buffered, never `O_DIRECT`, which would need buffers aligned to the
|
||||
* logical block size that Node cannot allocate.
|
||||
*/
|
||||
export class FileBlockDevice implements BlockDevice {
|
||||
readonly #path: string
|
||||
readonly #blockSize: number
|
||||
readonly #isBlockDevice: boolean
|
||||
#handle?: FileHandle
|
||||
#size = 0
|
||||
|
||||
constructor({ path, blockSize = DEFAULT_BLOCK_SIZE }: FileBlockDeviceOptions) {
|
||||
constructor({ path, blockSize = DEFAULT_BLOCK_SIZE, size }: FileBlockDeviceOptions) {
|
||||
if (!Number.isInteger(blockSize) || blockSize <= 0) {
|
||||
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
|
||||
}
|
||||
if (size !== undefined) {
|
||||
if (!Number.isInteger(size) || size <= 0) {
|
||||
throw new Error(`size must be a positive integer, got ${size}`)
|
||||
}
|
||||
if (size % blockSize !== 0) {
|
||||
throw new Error(`size (${size}) is not a multiple of block size (${blockSize})`)
|
||||
}
|
||||
this.#size = size
|
||||
}
|
||||
this.#path = path
|
||||
this.#blockSize = blockSize
|
||||
this.#isBlockDevice = size !== undefined
|
||||
}
|
||||
|
||||
/** Open the backing file and read its current size. Must be called before any I/O. */
|
||||
@@ -63,12 +97,18 @@ export class FileBlockDevice implements BlockDevice {
|
||||
}
|
||||
const handle = await open(this.#path, 'r+')
|
||||
try {
|
||||
const { size } = await handle.stat()
|
||||
if (size % this.#blockSize !== 0) {
|
||||
throw new Error(`backing file size (${size}) is not a multiple of block size (${this.#blockSize})`)
|
||||
const stats = await handle.stat()
|
||||
if (this.#isBlockDevice) {
|
||||
if (!stats.isBlockDevice()) {
|
||||
throw new Error(`${this.#path} is not a block device`)
|
||||
}
|
||||
} else {
|
||||
if (stats.size % this.#blockSize !== 0) {
|
||||
throw new Error(`backing file size (${stats.size}) is not a multiple of block size (${this.#blockSize})`)
|
||||
}
|
||||
this.#size = stats.size
|
||||
}
|
||||
this.#handle = handle
|
||||
this.#size = size
|
||||
} catch (error) {
|
||||
await handle.close()
|
||||
throw error
|
||||
@@ -99,6 +139,10 @@ export class FileBlockDevice implements BlockDevice {
|
||||
while (read < length) {
|
||||
const { bytesRead } = await handle.read(buffer, read, length - read, offset + read)
|
||||
if (bytesRead === 0) {
|
||||
if (this.#isBlockDevice) {
|
||||
// the device is exactly as long as the caller said: the I/O failed
|
||||
throw new Error(`read of ${length} bytes at ${offset} on ${this.#path} ended after ${read} bytes`)
|
||||
}
|
||||
// Reading past EOF (sparse tail): zero-fill the remainder.
|
||||
buffer.fill(0, read)
|
||||
break
|
||||
|
||||
173
@vates/iscsi/src/backend.test.mts
Normal file
173
@vates/iscsi/src/backend.test.mts
Normal file
@@ -0,0 +1,173 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { FileBlockDevice } from './backend.mjs'
|
||||
|
||||
const BLOCK_SIZE = 512
|
||||
|
||||
/** A temporary directory removed whatever the test does. */
|
||||
const withTmpDir = async (fn: (dir: string) => Promise<void>): Promise<void> => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-backend-'))
|
||||
try {
|
||||
await fn(dir)
|
||||
} finally {
|
||||
await rm(dir, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** A sparse file of `size` bytes, as the class requires: existing and pre-sized. */
|
||||
const makeFile = async (dir: string, size: number, name = 'lun.img'): Promise<string> => {
|
||||
const path = join(dir, name)
|
||||
await writeFile(path, '')
|
||||
await truncate(path, size)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('FileBlockDevice', () => {
|
||||
it('rejects a non-positive-integer block size', () => {
|
||||
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: 0 }), /positive integer/)
|
||||
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: -1 }), /positive integer/)
|
||||
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: 1.5 }), /positive integer/)
|
||||
})
|
||||
|
||||
it('reads the capacity from the backing file', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
const device = new FileBlockDevice({ path: await makeFile(dir, 4 * BLOCK_SIZE) })
|
||||
await device.open()
|
||||
try {
|
||||
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
|
||||
assert.equal(device.getBlockSize(), BLOCK_SIZE)
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a file whose size is not a multiple of the block size', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE + 1) })
|
||||
await assert.rejects(device.open(), /not a multiple of block size/)
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on I/O before open()', async () => {
|
||||
const device = new FileBlockDevice({ path: '/nope' })
|
||||
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
|
||||
await assert.rejects(device.write(0, Buffer.alloc(BLOCK_SIZE)), /must be called before I\/O/)
|
||||
await assert.rejects(device.flush(), /must be called before I\/O/)
|
||||
})
|
||||
|
||||
it('round-trips a write, at an offset and unaligned', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
const device = new FileBlockDevice({ path: await makeFile(dir, 4 * BLOCK_SIZE) })
|
||||
await device.open()
|
||||
try {
|
||||
const payload = Buffer.from('hello, lun')
|
||||
await device.write(BLOCK_SIZE + 3, payload)
|
||||
assert.deepEqual(await device.read(BLOCK_SIZE + 3, payload.length), payload)
|
||||
|
||||
// the bytes around it are untouched
|
||||
assert.deepEqual(await device.read(BLOCK_SIZE, 3), Buffer.alloc(3))
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('zero-fills the sparse tail of a read past EOF', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
// a hole: nothing was ever written, so the whole file reads as zeroes
|
||||
const device = new FileBlockDevice({ path: await makeFile(dir, 2 * BLOCK_SIZE) })
|
||||
await device.open()
|
||||
try {
|
||||
assert.deepEqual(await device.read(0, 2 * BLOCK_SIZE), Buffer.alloc(2 * BLOCK_SIZE))
|
||||
|
||||
// past the end of the file, `fh.read` reports 0 bytes read: the remainder is
|
||||
// zero-filled rather than being reported as an error
|
||||
assert.deepEqual(await device.read(2 * BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('reads back a large buffer in full', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
const size = 1024 * BLOCK_SIZE
|
||||
const device = new FileBlockDevice({ path: await makeFile(dir, size) })
|
||||
await device.open()
|
||||
try {
|
||||
const payload = Buffer.alloc(size)
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
payload[i] = (i * 31 + 11) & 0xff
|
||||
}
|
||||
await device.write(0, payload)
|
||||
assert.deepEqual(await device.read(0, size), payload)
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes and closes, and tolerates being opened or closed twice', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE) })
|
||||
await device.open()
|
||||
await device.open() // no-op
|
||||
await device.write(0, Buffer.alloc(BLOCK_SIZE, 0xaa))
|
||||
await device.flush()
|
||||
await device.close()
|
||||
await device.close() // no-op
|
||||
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves no handle behind when open() fails', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE + 1) })
|
||||
await assert.rejects(device.open())
|
||||
// the handle opened before the size check was closed again
|
||||
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// A real device node cannot be created without root, so what is covered here is
|
||||
// everything around the I/O: the guards, the capacity, and the lifecycle. The
|
||||
// I/O itself is covered by `backend.integ.mts`, against a loop device.
|
||||
describe('FileBlockDevice with a size, over a block device', () => {
|
||||
it('rejects a non-positive-integer size', () => {
|
||||
for (const size of [0, -BLOCK_SIZE, 1.5]) {
|
||||
assert.throws(() => new FileBlockDevice({ path: '/dev/null', size }), /size must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a size that is not a multiple of the block size', () => {
|
||||
assert.throws(
|
||||
() => new FileBlockDevice({ path: '/dev/null', size: BLOCK_SIZE + 1 }),
|
||||
/is not a multiple of block size/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the capacity it was given, never the inode', () => {
|
||||
// nothing may be read from the inode, which for a real device node reports 0
|
||||
const device = new FileBlockDevice({ path: '/dev/null', size: 4 * BLOCK_SIZE })
|
||||
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
|
||||
assert.equal(device.getBlockSize(), BLOCK_SIZE)
|
||||
})
|
||||
|
||||
it('refuses to open a path that is not a block device', async () => {
|
||||
await withTmpDir(async dir => {
|
||||
const path = await makeFile(dir, 4 * BLOCK_SIZE)
|
||||
const device = new FileBlockDevice({ path, size: 4 * BLOCK_SIZE })
|
||||
// the guard against a misconfigured path naming, say, a real disk's
|
||||
// filesystem instead of the cache device
|
||||
await assert.rejects(device.open(), new RegExp(`${path} is not a block device`))
|
||||
// and the handle it opened to check was closed again
|
||||
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
TEXT_FLAG_FINAL,
|
||||
} from './constants.mjs'
|
||||
import { buildFixedSense, handleScsiCommand, type ScsiIdentity } from './scsi.mjs'
|
||||
import { buildSendTargetsResponse, LoginNegotiator, parseTextKeys } from './login.mjs'
|
||||
import {
|
||||
buildSendTargetsResponse,
|
||||
LOGIN_MAX_DATA_SEGMENT_LENGTH,
|
||||
LoginNegotiator,
|
||||
OUR_MAX_RECV_DATA_SEGMENT_LENGTH,
|
||||
parseTextKeys,
|
||||
} from './login.mjs'
|
||||
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
|
||||
import {
|
||||
decodeCdb,
|
||||
@@ -81,6 +87,16 @@ export interface ConnectionDeps {
|
||||
allocateTsih(): number
|
||||
/** When set, require one-way CHAP: the target challenges and verifies the initiator. */
|
||||
readonly chap?: ChapCredentials
|
||||
/**
|
||||
* Time allowed to establish a Normal session, in ms (0 or unset disables):
|
||||
* past it, the connection is closed. Also bounds Discovery sessions, which
|
||||
* never become Normal ones.
|
||||
*/
|
||||
readonly loginTimeoutMs?: number
|
||||
/** Called once this connection has established a Normal session. */
|
||||
onSessionEstablished?(connection: Connection): void
|
||||
/** Max length of a single READ or WRITE, in bytes. */
|
||||
readonly maxTransferLength?: number
|
||||
}
|
||||
|
||||
type Phase = 'login' | 'fullFeature' | 'closed'
|
||||
@@ -123,6 +139,8 @@ export class Connection implements CommandContext {
|
||||
readonly #pendingWrites = new Map<number, PendingWrite>()
|
||||
#nextTargetTransferTag = 1
|
||||
|
||||
#loginTimer?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(socket: Socket, deps: ConnectionDeps) {
|
||||
this.#socket = socket
|
||||
this.#deps = deps
|
||||
@@ -142,9 +160,19 @@ export class Connection implements CommandContext {
|
||||
|
||||
/** Read and service PDUs until the peer logs out or the connection drops. */
|
||||
async serve(): Promise<void> {
|
||||
const { loginTimeoutMs = 0 } = this.#deps
|
||||
if (loginTimeoutMs > 0) {
|
||||
this.#loginTimer = setTimeout(() => {
|
||||
log.warn('no session established in time, closing the connection', { remote: this.#socket.remoteAddress })
|
||||
this.destroy()
|
||||
}, loginTimeoutMs).unref()
|
||||
}
|
||||
try {
|
||||
for (;;) {
|
||||
const pdu = await readPdu(this.#socket)
|
||||
const pdu = await readPdu(
|
||||
this.#socket,
|
||||
this.#phase === 'login' ? LOGIN_MAX_DATA_SEGMENT_LENGTH : OUR_MAX_RECV_DATA_SEGMENT_LENGTH
|
||||
)
|
||||
if (pdu === null) {
|
||||
break // peer closed the connection
|
||||
}
|
||||
@@ -156,6 +184,7 @@ export class Connection implements CommandContext {
|
||||
} catch (error) {
|
||||
this.#fail(error)
|
||||
} finally {
|
||||
clearTimeout(this.#loginTimer)
|
||||
this.#phase = 'closed'
|
||||
this.#socket.destroy()
|
||||
}
|
||||
@@ -299,6 +328,13 @@ export class Connection implements CommandContext {
|
||||
bhs[37] = statusDetail
|
||||
await this.#send(assemblePdu(bhs, data))
|
||||
|
||||
// destroyed while the response was being written (login timeout, target
|
||||
// closing): the write still succeeds, but this connection must not go on
|
||||
// and replace the current session
|
||||
if (this.#phase === 'closed') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (statusClass !== LoginStatusClass.SUCCESS) {
|
||||
// RFC 7143 §6.3: the failure response goes out first (above), then the
|
||||
// target closes the connection.
|
||||
@@ -316,6 +352,10 @@ export class Connection implements CommandContext {
|
||||
sessionType: this.#sessionType,
|
||||
target: this.#login.targetName,
|
||||
})
|
||||
if (this.#sessionType === 'Normal') {
|
||||
clearTimeout(this.#loginTimer)
|
||||
this.#deps.onSessionEstablished?.(this)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -389,13 +429,13 @@ export class Connection implements CommandContext {
|
||||
// Not awaited: the read loop must move on immediately rather than block on
|
||||
// this `lun.read()`. See the class doc for why no extra ordering is needed.
|
||||
void this.#readGate
|
||||
.run(() => handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity))
|
||||
.run(() => handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength))
|
||||
.catch(error => {
|
||||
this.#fail(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity)
|
||||
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength)
|
||||
}
|
||||
|
||||
async #sendReject(pdu: IncomingPdu, reason: number): Promise<void> {
|
||||
|
||||
@@ -166,6 +166,7 @@ export const SenseKey = {
|
||||
HARDWARE_ERROR: 0x04,
|
||||
ILLEGAL_REQUEST: 0x05,
|
||||
UNIT_ATTENTION: 0x06,
|
||||
DATA_PROTECT: 0x07,
|
||||
} as const
|
||||
|
||||
/** Additional Sense Code / Qualifier pairs we may emit. */
|
||||
@@ -174,6 +175,7 @@ export const Asc = {
|
||||
INVALID_COMMAND_OPERATION_CODE: { asc: 0x20, ascq: 0x00 },
|
||||
INVALID_FIELD_IN_CDB: { asc: 0x24, ascq: 0x00 },
|
||||
LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE: { asc: 0x21, ascq: 0x00 },
|
||||
WRITE_PROTECTED: { asc: 0x27, ascq: 0x00 },
|
||||
} as const
|
||||
|
||||
/** Reject reason codes (Reject PDU byte 2). */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
import { Connection, type ConnectionDeps } from './connection.mjs'
|
||||
import type { ScsiIdentity } from './scsi.mjs'
|
||||
import { DEFAULT_MAX_TRANSFER_LENGTH, type ScsiIdentity } from './scsi.mjs'
|
||||
import type { ChapCredentials } from './types.mjs'
|
||||
|
||||
export { FileBlockDevice } from './backend.mjs'
|
||||
@@ -25,6 +25,8 @@ const DEFAULT_PORT = 3260
|
||||
const DEFAULT_WRITE_TIMEOUT_MS = 30_000
|
||||
const DEFAULT_CMD_WINDOW = 64
|
||||
const DEFAULT_READ_CONCURRENCY = 16
|
||||
const DEFAULT_LOGIN_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_MAX_PENDING_CONNECTIONS = 8
|
||||
|
||||
const DEFAULT_IDENTITY: ScsiIdentity = {
|
||||
vendor: 'VATES',
|
||||
@@ -61,18 +63,62 @@ export interface IscsiTargetOptions {
|
||||
* open-iscsi `node.session.auth` username/password). Omit for no authentication.
|
||||
*/
|
||||
readonly chap?: ChapCredentials
|
||||
/**
|
||||
* Time a new connection has to establish a Normal session, in ms (0
|
||||
* disables). Also bounds Discovery sessions. Defaults to 15000.
|
||||
*/
|
||||
readonly loginTimeoutMs?: number
|
||||
/**
|
||||
* Max number of connections that have not established a Normal session yet;
|
||||
* past it, new ones are refused. Defaults to 8.
|
||||
*/
|
||||
readonly maxPendingConnections?: number
|
||||
/**
|
||||
* Source addresses the initiators may connect from. When set, connections
|
||||
* from any other address are closed as soon as they are accepted, before any
|
||||
* login: only these initiators can take a pending connection slot.
|
||||
* IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) match their IPv4 form.
|
||||
*/
|
||||
readonly allowedInitiatorAddresses?: Iterable<string>
|
||||
/**
|
||||
* Called with the source address of each connection closed because it is
|
||||
* not in `allowedInitiatorAddresses`, e.g. to tell why an initiator behind a
|
||||
* NAT cannot connect.
|
||||
*/
|
||||
readonly onConnectionRefused?: (address: string) => void
|
||||
/**
|
||||
* Max length of a single READ or WRITE, in bytes, advertised in the block
|
||||
* limits VPD page; longer commands are refused. At least one logical block.
|
||||
* Defaults to 8 MiB.
|
||||
*/
|
||||
readonly maxTransferLength?: number
|
||||
}
|
||||
|
||||
const IPV4_MAPPED_PREFIX = '::ffff:'
|
||||
|
||||
function normalizeAddress(address: string): string {
|
||||
return address.startsWith(IPV4_MAPPED_PREFIX) && address.includes('.')
|
||||
? address.slice(IPV4_MAPPED_PREFIX.length)
|
||||
: address
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal iSCSI target exposing exactly one read/write LUN.
|
||||
*
|
||||
* Single initiator, single connection (`MaxConnections=1`),
|
||||
* `ErrorRecoveryLevel=0`, no digests. A new connection always replaces the
|
||||
* current one rather than being refused (see `#onConnection`): there is no
|
||||
* way to tell a stale, abandoned connection (initiator gave up without
|
||||
* closing the socket) from a healthy one, so refusing would let one stuck
|
||||
* connection wedge the target forever. Not RFC 7143 session reinstatement (no
|
||||
* ISID matching) — safe here since the target is ephemeral, CHAP-guarded, and
|
||||
* `ErrorRecoveryLevel=0`, no digests.
|
||||
*
|
||||
* A new connection replaces the current one rather than being refused (see
|
||||
* `#onSessionEstablished`): there is no way to tell a stale, abandoned
|
||||
* connection (initiator gave up without closing the socket) from a healthy
|
||||
* one, so refusing would let one stuck connection wedge the target forever.
|
||||
* The replacement only happens once the new connection has established a
|
||||
* Normal session, CHAP included when configured, so that a port scan, a failed
|
||||
* login or a Discovery session cannot interrupt the current one. Until then,
|
||||
* connections are bounded in time (`loginTimeoutMs`) and in number
|
||||
* (`maxPendingConnections`); with `allowedInitiatorAddresses`, only the expected
|
||||
* initiators can take those slots. Not RFC 7143 session reinstatement (no ISID
|
||||
* matching) — safe here since the target is ephemeral, CHAP-guarded, and
|
||||
* single-consumer.
|
||||
*/
|
||||
export class IscsiTarget {
|
||||
@@ -85,9 +131,17 @@ export class IscsiTarget {
|
||||
readonly #cmdWindow: number
|
||||
readonly #readConcurrency: number
|
||||
readonly #chap?: ChapCredentials
|
||||
readonly #loginTimeoutMs: number
|
||||
readonly #maxPendingConnections: number
|
||||
readonly #allowedInitiatorAddresses?: ReadonlySet<string>
|
||||
readonly #onConnectionRefused?: (address: string) => void
|
||||
readonly #maxTransferLength: number
|
||||
|
||||
#server?: Server
|
||||
// the connection with an established Normal session, serving the LUN
|
||||
#connection?: Connection
|
||||
// every open connection, so that close() can drop them all
|
||||
readonly #connections = new Set<Connection>()
|
||||
#tsih = 0
|
||||
|
||||
constructor(options: IscsiTargetOptions) {
|
||||
@@ -99,6 +153,16 @@ export class IscsiTarget {
|
||||
this.#cmdWindow = options.cmdWindow ?? DEFAULT_CMD_WINDOW
|
||||
this.#readConcurrency = options.readConcurrency ?? DEFAULT_READ_CONCURRENCY
|
||||
this.#chap = options.chap
|
||||
this.#loginTimeoutMs = options.loginTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
|
||||
this.#maxPendingConnections = options.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS
|
||||
if (options.allowedInitiatorAddresses !== undefined) {
|
||||
this.#allowedInitiatorAddresses = new Set(Array.from(options.allowedInitiatorAddresses, normalizeAddress))
|
||||
}
|
||||
this.#onConnectionRefused = options.onConnectionRefused
|
||||
this.#maxTransferLength = options.maxTransferLength ?? DEFAULT_MAX_TRANSFER_LENGTH
|
||||
if (this.#maxTransferLength < options.lun.getBlockSize()) {
|
||||
throw new RangeError('maxTransferLength must be at least one logical block')
|
||||
}
|
||||
this.#identity = {
|
||||
...DEFAULT_IDENTITY,
|
||||
// Default the serial to the IQN so the LUN has a stable, unique identity.
|
||||
@@ -122,28 +186,44 @@ export class IscsiTarget {
|
||||
readConcurrency: this.#readConcurrency,
|
||||
allocateTsih: () => this.#allocateTsih(),
|
||||
chap: this.#chap,
|
||||
loginTimeoutMs: this.#loginTimeoutMs,
|
||||
onSessionEstablished: connection => this.#onSessionEstablished(connection),
|
||||
maxTransferLength: this.#maxTransferLength,
|
||||
}
|
||||
}
|
||||
|
||||
#onConnection(socket: Socket): void {
|
||||
const existing = this.#connection
|
||||
if (existing !== undefined) {
|
||||
// Replaces a possibly-stale connection rather than refusing; see the class doc.
|
||||
log.warn('replacing existing connection with a new one', { remote: socket.remoteAddress })
|
||||
existing.destroy()
|
||||
const allowed = this.#allowedInitiatorAddresses
|
||||
const remote = normalizeAddress(socket.remoteAddress ?? '')
|
||||
if (allowed !== undefined && !allowed.has(remote)) {
|
||||
log.warn('connection from an address that is not allowed, closing it', { remote })
|
||||
socket.destroy()
|
||||
this.#onConnectionRefused?.(remote)
|
||||
return
|
||||
}
|
||||
socket.setNoDelay(true)
|
||||
const connection = new Connection(socket, this.#deps())
|
||||
this.#connection = connection
|
||||
this.#connections.add(connection)
|
||||
// Long-lived per-connection task; serve() handles its own errors and never
|
||||
// rejects, so detaching it here is safe.
|
||||
void connection.serve().finally(() => {
|
||||
this.#connections.delete(connection)
|
||||
if (this.#connection === connection) {
|
||||
this.#connection = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#onSessionEstablished(connection: Connection): void {
|
||||
const existing = this.#connection
|
||||
this.#connection = connection
|
||||
if (existing !== undefined) {
|
||||
// Replaces a possibly-stale connection rather than refusing; see the class doc.
|
||||
log.warn('replacing existing connection with a newly logged in one')
|
||||
existing.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the LUN (if needed) and start accepting connections. */
|
||||
async listen(): Promise<void> {
|
||||
if (this.#server !== undefined) {
|
||||
@@ -152,6 +232,11 @@ export class IscsiTarget {
|
||||
await this.#lun.open?.()
|
||||
|
||||
const server = createServer(socket => this.#onConnection(socket))
|
||||
// + 1: the connection serving the LUN does not take a pending slot
|
||||
server.maxConnections = this.#maxPendingConnections + 1
|
||||
server.on('drop', data => {
|
||||
log.warn('too many connections without a session, refusing a new one', { remote: data?.remoteAddress })
|
||||
})
|
||||
this.#server = server
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -175,17 +260,20 @@ export class IscsiTarget {
|
||||
return address !== null && typeof address === 'object' ? address : undefined
|
||||
}
|
||||
|
||||
/** Stop accepting connections, drop the active connection, and close the LUN. */
|
||||
/** Stop accepting connections, drop every connection, and close the LUN. */
|
||||
async close(): Promise<void> {
|
||||
const server = this.#server
|
||||
const connection = this.#connection
|
||||
const connections = [...this.#connections]
|
||||
this.#server = undefined
|
||||
this.#connection = undefined
|
||||
this.#connections.clear()
|
||||
if (server !== undefined) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(error => (error ? reject(error) : resolve()))
|
||||
// Drop the live connection so server.close() can complete.
|
||||
connection?.destroy()
|
||||
// Drop the live connections so server.close() can complete.
|
||||
for (const connection of connections) {
|
||||
connection.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
await this.#lun.close()
|
||||
|
||||
@@ -35,9 +35,12 @@ const log: Logger = createLogger('vates:iscsi:login')
|
||||
// Operational values the target imposes. The target drives negotiation, so these
|
||||
// are pinned to a single code path (see plan): all writes are R2T-solicited
|
||||
// (InitialR2T=Yes, ImmediateData=No), no digests, one connection.
|
||||
const OUR_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
|
||||
export const OUR_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
|
||||
const OUR_MAX_BURST_LENGTH = 262144
|
||||
const DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH = 8192 // RFC 7143 default
|
||||
// a declared MaxRecvDataSegmentLength only applies once logged in: until then,
|
||||
// both sides keep to the 8192-byte default (RFC 7143 §13.12)
|
||||
export const LOGIN_MAX_DATA_SEGMENT_LENGTH = 8192
|
||||
|
||||
/** Parse a NUL-delimited iSCSI text key=value data segment. */
|
||||
export function parseTextKeys(data: Buffer): Map<string, string> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { connect, type Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { once } from 'node:events'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { after, before, describe, it } from 'node:test'
|
||||
|
||||
import {
|
||||
@@ -642,6 +643,200 @@ describe('connection replacement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A connection only takes over once it has itself logged in: until then, it
|
||||
// must not disturb the session in use, whatever it does or fails to do.
|
||||
describe('connection admission', () => {
|
||||
const CHAP = { user: 'alice', secret: 's3cr3t-s3cr3t' }
|
||||
|
||||
let dir: string
|
||||
let backingPath: string
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-admission-'))
|
||||
backingPath = join(dir, 'lun.img')
|
||||
await writeFile(backingPath, Buffer.alloc(LUN_SIZE, 0x5a))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function withTarget(
|
||||
options: {
|
||||
loginTimeoutMs?: number
|
||||
maxPendingConnections?: number
|
||||
allowedInitiatorAddresses?: string[]
|
||||
onConnectionRefused?: (address: string) => void
|
||||
},
|
||||
fn: (port: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
const target = new IscsiTarget({
|
||||
iqn: IQN,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
|
||||
chap: CHAP,
|
||||
...options,
|
||||
})
|
||||
await target.listen()
|
||||
try {
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
await fn(address.port)
|
||||
} finally {
|
||||
await target.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function rawConnect(port: number): Promise<Socket> {
|
||||
const socket = connect(port, '127.0.0.1')
|
||||
await once(socket, 'connect')
|
||||
return socket
|
||||
}
|
||||
|
||||
async function assertStillServing(initiator: IscsiInitiator): Promise<void> {
|
||||
// leave the target time to act on whatever just happened
|
||||
await delay(100)
|
||||
assert.deepEqual(await initiator.read(0, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE, 0x5a))
|
||||
}
|
||||
|
||||
it('a connection that sends nothing does not interrupt the session', async () => {
|
||||
await withTarget({}, async port => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await initiator.connect()
|
||||
const probe = await rawConnect(port)
|
||||
try {
|
||||
await assertStillServing(initiator)
|
||||
} finally {
|
||||
probe.destroy()
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('a failed CHAP login does not interrupt the session', async () => {
|
||||
await withTarget({}, async port => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await initiator.connect()
|
||||
try {
|
||||
const intruder = new IscsiInitiator({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
targetIqn: IQN,
|
||||
chap: { user: CHAP.user, secret: 'wrong-wrong-wrong' },
|
||||
})
|
||||
await assert.rejects(intruder.connect(), /login rejected/)
|
||||
await assertStillServing(initiator)
|
||||
} finally {
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('a Discovery session does not interrupt the session', async () => {
|
||||
await withTarget({}, async port => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await initiator.connect()
|
||||
const socket = await rawConnect(port)
|
||||
try {
|
||||
const discovery = new MiniInitiator(socket)
|
||||
await discovery.login('Discovery')
|
||||
assert.equal((await discovery.sendTargets()).get('TargetName'), IQN)
|
||||
await assertStillServing(initiator)
|
||||
await discovery.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('an authenticated login replaces the session', async () => {
|
||||
await withTarget({}, async port => {
|
||||
const stale = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await stale.connect()
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await initiator.connect()
|
||||
try {
|
||||
await assert.rejects(stale.read(0, BLOCK_SIZE), /connection closed/)
|
||||
await assertStillServing(initiator)
|
||||
} finally {
|
||||
await stale.close().catch(() => {})
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('closes a connection that does not log in in time', async () => {
|
||||
await withTarget({ loginTimeoutMs: 100 }, async port => {
|
||||
const probe = await rawConnect(port)
|
||||
const closed = once(probe, 'close')
|
||||
try {
|
||||
await Promise.race([closed, delay(5000).then(() => assert.fail('the connection should have been closed'))])
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('closes connections from an address that is not allowed, and reports it', async () => {
|
||||
const refused: string[] = []
|
||||
await withTarget(
|
||||
{ allowedInitiatorAddresses: ['192.0.2.1'], onConnectionRefused: address => refused.push(address) },
|
||||
async port => {
|
||||
const probe = await rawConnect(port)
|
||||
try {
|
||||
await Promise.race([
|
||||
once(probe, 'close'),
|
||||
delay(5000).then(() => assert.fail('the connection should have been closed')),
|
||||
])
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
}
|
||||
)
|
||||
assert.deepEqual(refused, ['127.0.0.1'])
|
||||
})
|
||||
|
||||
it('serves an allowed address, also in its IPv4-mapped IPv6 form', async () => {
|
||||
for (const allowed of ['127.0.0.1', '::ffff:127.0.0.1']) {
|
||||
await withTarget({ allowedInitiatorAddresses: [allowed] }, async port => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await initiator.connect()
|
||||
try {
|
||||
await assertStillServing(initiator)
|
||||
} finally {
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses connections without a session past the limit', async () => {
|
||||
await withTarget({ maxPendingConnections: 2 }, async port => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await initiator.connect()
|
||||
const probes = [await rawConnect(port), await rawConnect(port)]
|
||||
try {
|
||||
const extra = await rawConnect(port)
|
||||
probes.push(extra)
|
||||
await Promise.race([
|
||||
once(extra, 'close'),
|
||||
delay(5000).then(() => assert.fail('the connection should have been refused')),
|
||||
])
|
||||
assert.equal(probes[0].destroyed, false)
|
||||
assert.equal(probes[1].destroyed, false)
|
||||
await assertStillServing(initiator)
|
||||
} finally {
|
||||
for (const probe of probes) {
|
||||
probe.destroy()
|
||||
}
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// One loopback that exercises BOTH one-way CHAP roles at once: our target is the
|
||||
// authenticator (challenges + verifies) and our initiator is the responder
|
||||
// (answers the challenge). This is the same code path each production role uses
|
||||
|
||||
@@ -86,8 +86,12 @@ export class IncomingPdu {
|
||||
* No per-read timeout, intentionally — a target may sit idle between commands
|
||||
* indefinitely. Liveness is a transport concern (socket timeout / NOP
|
||||
* keepalives), handled elsewhere.
|
||||
*
|
||||
* A PDU announcing a data segment longer than `maxDataSegmentLength` is
|
||||
* rejected before its payload is read, so a peer cannot make us buffer up to
|
||||
* the 16 MiB the 24-bit length field allows.
|
||||
*/
|
||||
export async function readPdu(stream: Readable): Promise<IncomingPdu | null> {
|
||||
export async function readPdu(stream: Readable, maxDataSegmentLength = Infinity): Promise<IncomingPdu | null> {
|
||||
const bhs = await readChunk(stream, BHS_LENGTH)
|
||||
if (bhs === null) {
|
||||
return null // clean EOF between PDUs
|
||||
@@ -98,6 +102,9 @@ export async function readPdu(stream: Readable): Promise<IncomingPdu | null> {
|
||||
|
||||
const totalAhsLength = bhs.readUInt8(4)
|
||||
const dataSegmentLength = bhs.readUIntBE(5, 3)
|
||||
if (dataSegmentLength > maxDataSegmentLength) {
|
||||
throw new Error(`iSCSI data segment too long (${dataSegmentLength} > ${maxDataSegmentLength} bytes)`)
|
||||
}
|
||||
const ahsBytes = totalAhsLength * 4
|
||||
const dataBytesPadded = roundUp4(dataSegmentLength)
|
||||
|
||||
|
||||
@@ -56,6 +56,24 @@ describe('assemblePdu / readPdu round-trip', () => {
|
||||
})
|
||||
|
||||
describe('readPdu framing', () => {
|
||||
it('rejects a data segment longer than the limit before reading it', async () => {
|
||||
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
|
||||
bhs.writeUIntBE(16 * 1024 * 1024 - 1, 5, 3) // announced data segment length
|
||||
const stream = new PassThrough()
|
||||
stream.write(bhs) // the announced payload is never sent
|
||||
await assert.rejects(readPdu(stream, 8192), /data segment too long/)
|
||||
})
|
||||
|
||||
it('accepts a data segment of exactly the limit', async () => {
|
||||
const data = Buffer.alloc(8192, 0x5a)
|
||||
const pdu = await (async () => {
|
||||
const stream = new PassThrough()
|
||||
stream.end(buildScsiCommandPdu(1, 1, Buffer.alloc(16), data))
|
||||
return readPdu(stream, 8192)
|
||||
})()
|
||||
assert.deepEqual(pdu?.data, data)
|
||||
})
|
||||
|
||||
it('reassembles a PDU split across multiple TCP chunks', async () => {
|
||||
const data = Buffer.from('hello world!!') // 13 bytes -> padded to 16
|
||||
const wire = buildScsiCommandPdu(7, 7, Buffer.alloc(16), data)
|
||||
|
||||
@@ -40,13 +40,20 @@ export function buildStandardInquiry(identity: ScsiIdentity): Buffer {
|
||||
return buffer
|
||||
}
|
||||
|
||||
const SUPPORTED_VPD_PAGES = [0x00, 0x80, 0x83]
|
||||
const SUPPORTED_VPD_PAGES = [0x00, 0x80, 0x83, 0xb0]
|
||||
|
||||
/** Default max length of a single READ or WRITE, in bytes. */
|
||||
export const DEFAULT_MAX_TRANSFER_LENGTH = 8 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Build an INQUIRY EVPD page, or `undefined` for an unsupported page code (the
|
||||
* caller then returns CHECK CONDITION / INVALID FIELD IN CDB).
|
||||
*/
|
||||
export function buildVpd(pageCode: number, identity: ScsiIdentity): Buffer | undefined {
|
||||
export function buildVpd(
|
||||
pageCode: number,
|
||||
identity: ScsiIdentity,
|
||||
maxTransferBlocks = DEFAULT_MAX_TRANSFER_LENGTH / 512
|
||||
): Buffer | undefined {
|
||||
switch (pageCode) {
|
||||
case 0x00: {
|
||||
// Supported VPD pages.
|
||||
@@ -83,6 +90,15 @@ export function buildVpd(pageCode: number, identity: ScsiIdentity): Buffer | und
|
||||
descriptor.copy(buffer, 4)
|
||||
return buffer
|
||||
}
|
||||
case 0xb0: {
|
||||
// Block limits: initiators split their I/O to the max transfer length
|
||||
// instead of seeing it refused.
|
||||
const buffer = Buffer.alloc(64)
|
||||
buffer[1] = 0xb0
|
||||
buffer.writeUInt16BE(0x3c, 2)
|
||||
buffer.writeUInt32BE(maxTransferBlocks, 8) // maximum transfer length
|
||||
return buffer
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -125,15 +141,21 @@ export function buildFixedSense({ key, asc, ascq }: SenseInfo): Buffer {
|
||||
return buffer
|
||||
}
|
||||
|
||||
// WP bit of the device-specific parameter in the mode parameter header
|
||||
const MODE_SENSE_WRITE_PROTECT = 0x80
|
||||
|
||||
/** Minimal MODE SENSE parameter data: a header with no block descriptor or pages. */
|
||||
export function buildModeSense(long: boolean): Buffer {
|
||||
export function buildModeSense(long: boolean, writeProtected = false): Buffer {
|
||||
const deviceSpecific = writeProtected ? MODE_SENSE_WRITE_PROTECT : 0
|
||||
if (long) {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeUInt16BE(6, 0) // mode data length (8 - 2)
|
||||
buffer[3] = deviceSpecific
|
||||
return buffer
|
||||
}
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer[0] = 3 // mode data length (4 - 1)
|
||||
buffer[2] = deviceSpecific
|
||||
return buffer
|
||||
}
|
||||
|
||||
@@ -150,6 +172,7 @@ const ILLEGAL_REQUEST_LBA_OUT_OF_RANGE: SenseInfo = {
|
||||
...Asc.LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE,
|
||||
}
|
||||
const MEDIUM_ERROR_NO_ADDITIONAL_SENSE: SenseInfo = { key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE }
|
||||
const DATA_PROTECT_WRITE_PROTECTED: SenseInfo = { key: SenseKey.DATA_PROTECT, ...Asc.WRITE_PROTECTED }
|
||||
|
||||
function checkCondition(ctx: CommandContext, itt: number, sense: SenseInfo): Promise<void> {
|
||||
return ctx.sendScsiResponse(itt, ScsiStatus.CHECK_CONDITION, { sense: buildFixedSense(sense) })
|
||||
@@ -161,16 +184,22 @@ function checkCondition(ctx: CommandContext, itt: number, sense: SenseInfo): Pro
|
||||
* `cdb` is the 16-byte CDB from the SCSI Command PDU and `itt` its Initiator
|
||||
* Task Tag. Read commands stream Data-In; WRITE solicits Data-Out via R2T;
|
||||
* everything else returns status (with sense on error).
|
||||
*
|
||||
* A READ or WRITE longer than `maxTransferLength` bytes, advertised in the
|
||||
* block limits VPD page, is refused with INVALID FIELD IN CDB: it also bounds
|
||||
* the buffer a WRITE allocates.
|
||||
*/
|
||||
export async function handleScsiCommand(
|
||||
cdb: Buffer,
|
||||
itt: number,
|
||||
ctx: CommandContext,
|
||||
identity: ScsiIdentity
|
||||
identity: ScsiIdentity,
|
||||
maxTransferLength = DEFAULT_MAX_TRANSFER_LENGTH
|
||||
): Promise<void> {
|
||||
const { lun } = ctx
|
||||
const blockSize = lun.getBlockSize()
|
||||
const blockCount = lun.getSize() / blockSize
|
||||
const maxTransferBlocks = Math.floor(maxTransferLength / blockSize)
|
||||
const request = decodeCdb(cdb)
|
||||
log.debug('scsi command', { itt, ...request })
|
||||
|
||||
@@ -183,7 +212,7 @@ export async function handleScsiCommand(
|
||||
}
|
||||
return ctx.sendReadData(itt, buildStandardInquiry(identity), request.allocationLength)
|
||||
}
|
||||
const page = buildVpd(request.pageCode, identity)
|
||||
const page = buildVpd(request.pageCode, identity, maxTransferBlocks)
|
||||
if (page === undefined) {
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
|
||||
}
|
||||
@@ -203,6 +232,9 @@ export async function handleScsiCommand(
|
||||
if (request.lba + request.blocks > blockCount) {
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
|
||||
}
|
||||
if (request.blocks > maxTransferBlocks) {
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
|
||||
}
|
||||
const byteLength = request.blocks * blockSize
|
||||
if (byteLength === 0) {
|
||||
return ctx.sendReadData(itt, Buffer.alloc(0), 0)
|
||||
@@ -221,9 +253,15 @@ export async function handleScsiCommand(
|
||||
}
|
||||
|
||||
case 'write': {
|
||||
if (lun.readOnly === true) {
|
||||
return checkCondition(ctx, itt, DATA_PROTECT_WRITE_PROTECTED)
|
||||
}
|
||||
if (request.lba + request.blocks > blockCount) {
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
|
||||
}
|
||||
if (request.blocks > maxTransferBlocks) {
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
|
||||
}
|
||||
const byteLength = request.blocks * blockSize
|
||||
if (byteLength === 0) {
|
||||
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
|
||||
@@ -243,7 +281,7 @@ export async function handleScsiCommand(
|
||||
}
|
||||
|
||||
case 'modeSense':
|
||||
return ctx.sendReadData(itt, buildModeSense(request.long), request.allocationLength)
|
||||
return ctx.sendReadData(itt, buildModeSense(request.long, lun.readOnly === true), request.allocationLength)
|
||||
|
||||
case 'syncCache':
|
||||
await lun.flush()
|
||||
|
||||
@@ -145,9 +145,17 @@ describe('response encoders', () => {
|
||||
it('advertises supported VPD pages and rejects unknown ones', () => {
|
||||
const page00 = buildVpd(0x00, IDENTITY)
|
||||
assert.ok(page00 !== undefined)
|
||||
assert.deepEqual([...page00.subarray(4)], [0x00, 0x80, 0x83])
|
||||
assert.deepEqual([...page00.subarray(4)], [0x00, 0x80, 0x83, 0xb0])
|
||||
assert.equal(buildVpd(0x55, IDENTITY), undefined)
|
||||
})
|
||||
|
||||
it('advertises the max transfer length in the block limits page', () => {
|
||||
const page = buildVpd(0xb0, IDENTITY, 2048)
|
||||
assert.ok(page !== undefined)
|
||||
assert.equal(page[1], 0xb0)
|
||||
assert.equal(page.readUInt16BE(2), 0x3c)
|
||||
assert.equal(page.readUInt32BE(8), 2048)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleScsiCommand', () => {
|
||||
@@ -234,4 +242,61 @@ describe('handleScsiCommand', () => {
|
||||
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(ctx.responses[0].options?.sense?.[12], 0x20) // INVALID COMMAND OPERATION CODE
|
||||
})
|
||||
|
||||
for (const [name, opcode] of [
|
||||
['READ(10)', 0x28],
|
||||
['WRITE(10)', 0x2a],
|
||||
] as const) {
|
||||
it(`refuses a ${name} longer than the max transfer length, without reading or soliciting data`, async () => {
|
||||
let reads = 0
|
||||
const lun = new MemoryBlockDevice(8192)
|
||||
const read = lun.read.bind(lun)
|
||||
lun.read = (offset, length) => {
|
||||
reads++
|
||||
return read(offset, length)
|
||||
}
|
||||
const ctx = new FakeContext(lun)
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = opcode
|
||||
buffer.writeUInt16BE(3, 7) // 3 blocks, the limit is 2
|
||||
await handleScsiCommand(buffer, 1, ctx, IDENTITY, 1024)
|
||||
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
|
||||
assert.equal(ctx.responses[0].options?.sense?.[12], 0x24) // INVALID FIELD IN CDB
|
||||
assert.equal(reads, 0)
|
||||
assert.equal(ctx.writes.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
describe('read-only LUN', () => {
|
||||
class ReadOnlyBlockDevice extends MemoryBlockDevice {
|
||||
readonly readOnly = true
|
||||
}
|
||||
|
||||
it('refuses WRITE(10) with DATA PROTECT, without soliciting its data', async () => {
|
||||
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x2a
|
||||
buffer.writeUInt16BE(1, 7)
|
||||
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
|
||||
assert.equal(ctx.writes.length, 0)
|
||||
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.DATA_PROTECT)
|
||||
assert.equal(ctx.responses[0].options?.sense?.[12], 0x27) // WRITE PROTECTED
|
||||
})
|
||||
|
||||
it('reports write protection in MODE SENSE(6) and MODE SENSE(10)', async () => {
|
||||
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
|
||||
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
|
||||
await handleScsiCommand(cdb(0x5a, 0x00, 0x3f, 0, 0, 0, 0, 0x01, 0x00), 2, ctx, IDENTITY)
|
||||
assert.equal(ctx.reads[0].data[2] & 0x80, 0x80)
|
||||
assert.equal(ctx.reads[1].data[3] & 0x80, 0x80)
|
||||
})
|
||||
|
||||
it('does not report a writable LUN as write-protected', async () => {
|
||||
const ctx = new FakeContext(new MemoryBlockDevice(4096))
|
||||
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
|
||||
assert.equal(ctx.reads[0].data[2] & 0x80, 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,17 +56,21 @@ function parseAttachedDisk(sessionOutput: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* `stat()`ing the device node only proves udev created it, not that the SCSI
|
||||
* layer has finished attaching it — `open()` right after can still fail with
|
||||
* ENXIO. `/sys/class/block/<name>/size` reporting nonzero is what actually
|
||||
* means ready, the same check `_cache.mjs`'s `openLocalDevice` uses for the
|
||||
* same reason on XAPI-plugged devices.
|
||||
* Neither the device node nor a nonzero `/sys/class/block/<name>/size` means
|
||||
* the disk can be opened: the sd driver sets the size before
|
||||
* `device_add_disk()`, which creates the sysfs entry, then the `/dev` node, then
|
||||
* registers the block device: until then `open()` fails with ENOENT or ENXIO.
|
||||
* Only an `open()` that succeeds means ready.
|
||||
*/
|
||||
async function isDeviceReady(name: string): Promise<boolean> {
|
||||
try {
|
||||
return Number.parseInt(await readFile(`/sys/class/block/${name}/size`, 'utf8'), 10) > 0
|
||||
} catch {
|
||||
return false
|
||||
await (await openFile(`/dev/${name}`, 'r')).close()
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error.code === 'ENXIO' || error.code === 'ENOENT')) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ export default class AbstractNbdClient {
|
||||
// publish itself
|
||||
#connectGeneration = 0
|
||||
|
||||
// generation of the attempt which published the current transport, undefined
|
||||
// when not connected: an error on a transport of another generation is stale
|
||||
// and must be ignored
|
||||
#connectedGeneration
|
||||
|
||||
#waitBeforeReconnect
|
||||
#readBlockRetries
|
||||
#reconnectRetry
|
||||
@@ -67,7 +72,6 @@ export default class AbstractNbdClient {
|
||||
#nextCommandQueryId = BigInt(0)
|
||||
// map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
|
||||
#commandQueryBacklog = new Map()
|
||||
#connected = false
|
||||
|
||||
#reconnectingPromise
|
||||
|
||||
@@ -108,7 +112,7 @@ export default class AbstractNbdClient {
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.#connected
|
||||
return this.#connectedGeneration !== undefined
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -202,22 +206,31 @@ export default class AbstractNbdClient {
|
||||
* connection
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#onTransportError = error => {
|
||||
#onTransportError(generation, error) {
|
||||
// without this listener an error on the transport would be thrown as an
|
||||
// uncaught exception, and pending reads would only fail on message timeout
|
||||
//
|
||||
// an error outside of the connected window is expected (the other end can
|
||||
// be gone already while we're closing), and always reported to the caller
|
||||
// through the connect()/readBlock() rejection
|
||||
const log = this.#connected ? warn : debug
|
||||
log('error on the nbd transport', { error })
|
||||
// an error on a transport which is not the current one is expected (a
|
||||
// closed transport can be reset late by the other end, after a reconnection)
|
||||
// and must not reject the reads of the current transport
|
||||
//
|
||||
// an error during the handshake is reported by the handshake reads
|
||||
// themselves, through the connect() rejection
|
||||
if (generation !== this.#connectedGeneration) {
|
||||
debug('error on a stale nbd transport', { error })
|
||||
return
|
||||
}
|
||||
warn('error on the nbd transport', { error })
|
||||
this.#rejectAll(error)
|
||||
}
|
||||
|
||||
#watchTransport(transport) {
|
||||
transport.readable.on('error', this.#onTransportError)
|
||||
// the generation is used instead of the transport itself since an attempt can
|
||||
// own multiple transports (the raw one and the one upgraded to TLS)
|
||||
#watchTransport(transport, generation) {
|
||||
const onError = error => this.#onTransportError(generation, error)
|
||||
transport.readable.on('error', onError)
|
||||
if (transport.writable !== transport.readable) {
|
||||
transport.writable.on('error', this.#onTransportError)
|
||||
transport.writable.on('error', onError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +240,10 @@ export default class AbstractNbdClient {
|
||||
// not cancel on timeout, so a superseded attempt must not be able to use,
|
||||
// replace or destroy the transport of the attempt which replaced it
|
||||
let transport = await this._openTransport()
|
||||
this.#watchTransport(transport)
|
||||
this.#watchTransport(transport, generation)
|
||||
try {
|
||||
// the transport can be replaced during the handshake (TLS upgrade)
|
||||
transport = await this.#handshake(transport)
|
||||
transport = await this.#handshake(transport, generation)
|
||||
|
||||
if (generation !== this.#connectGeneration) {
|
||||
const error = new Error('this connection has been superseded by a newer one')
|
||||
@@ -244,7 +257,7 @@ export default class AbstractNbdClient {
|
||||
throw error
|
||||
}
|
||||
this.#transport = transport
|
||||
this.#connected = true
|
||||
this.#connectedGeneration = generation
|
||||
// reset internal state if we reconnected a nbd client
|
||||
this.#commandQueryBacklog = new Map()
|
||||
this.#waitingForResponse = false
|
||||
@@ -267,10 +280,10 @@ export default class AbstractNbdClient {
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (!this.#connected) {
|
||||
if (this.#connectedGeneration === undefined) {
|
||||
return
|
||||
}
|
||||
this.#connected = false
|
||||
this.#connectedGeneration = undefined
|
||||
const transport = this.#transport
|
||||
this.#transport = undefined
|
||||
if (transport === undefined) {
|
||||
@@ -366,9 +379,10 @@ export default class AbstractNbdClient {
|
||||
//
|
||||
/**
|
||||
* @param {NbdTransport} transport
|
||||
* @param {number} generation - the connection attempt owning the transport
|
||||
* @returns {Promise<NbdTransport>}
|
||||
*/
|
||||
async #handshake(transport) {
|
||||
async #handshake(transport, generation) {
|
||||
assert((await this.#read(transport, 8)).equals(INIT_PASSWD))
|
||||
assert((await this.#read(transport, 8)).equals(OPTS_MAGIC))
|
||||
const flagsBuffer = await this.#read(transport, 2)
|
||||
@@ -379,7 +393,7 @@ export default class AbstractNbdClient {
|
||||
// let the subclass upgrade the transport if it needs to (TLS)
|
||||
const secured = await this._secureTransport(transport)
|
||||
if (secured !== transport) {
|
||||
this.#watchTransport(secured)
|
||||
this.#watchTransport(secured, generation)
|
||||
transport = secured
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,28 @@ describe('AbstractNbdClient', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an error on a superseded transport disturb the current one', async () => {
|
||||
const client = new InMemoryNbdClient({}, { readBlockRetries: 1, waitBeforeReconnect: 0 })
|
||||
await client.connect()
|
||||
await client.reconnect()
|
||||
try {
|
||||
assert.equal(client.transports.length, 2)
|
||||
const [supersededTransport] = client.transports
|
||||
|
||||
// the read is queued synchronously, the in memory server answers later
|
||||
const promise = client.readBlock(0, BLOCK_SIZE)
|
||||
// the other end of the closed transport resets it late
|
||||
supersededTransport.readable.emit('error', Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
|
||||
|
||||
const block = await promise
|
||||
assert.ok(block.equals(DATA.subarray(0, BLOCK_SIZE)))
|
||||
// no reconnection has been triggered
|
||||
assert.equal(client.transports.length, 2)
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not support getMap()', async () => {
|
||||
const client = new InMemoryNbdClient()
|
||||
await assert.rejects(client.getMap(), ({ code }) => code === 'NBD_MAP_UNSUPPORTED')
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Socket } from 'node:net'
|
||||
import { once } from 'node:events'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { request as httpsRequest } from 'node:https'
|
||||
import { isIPv6, Socket } from 'node:net'
|
||||
import { connect } from 'node:tls'
|
||||
import { spawn } from 'node:child_process'
|
||||
import AbstractNbdClient from './AbstractNbdClient.mjs'
|
||||
@@ -11,6 +14,9 @@ import { NBD_DEFAULT_PORT, NBD_OPT_STARTTLS } from './constants.mjs'
|
||||
* @extends {AbstractNbdClient}
|
||||
*/
|
||||
export default class NbdTcpClient extends AbstractNbdClient {
|
||||
#httpProxy
|
||||
#proxyRejectUnauthorized
|
||||
#proxyTimeout
|
||||
#serverAddress
|
||||
#serverCert
|
||||
#serverPort
|
||||
@@ -21,18 +27,103 @@ export default class NbdTcpClient extends AbstractNbdClient {
|
||||
* @param {number} [settings.port]
|
||||
* @param {string} [settings.exportname]
|
||||
* @param {string} [settings.cert] - PEM certificate, enables TLS when set
|
||||
* @param {string} [settings.httpProxy] - URL of an HTTP(S) proxy, the connection is tunneled through it with `CONNECT`
|
||||
* @param {boolean} [settings.proxyRejectUnauthorized=true] - whether to check the certificate of an HTTPS proxy
|
||||
* @param {number} [settings.proxyTimeout=6e4] - delay in ms after which an unanswered `CONNECT` is aborted
|
||||
* @param {object} [options] - see {@link AbstractNbdClient}
|
||||
*/
|
||||
constructor({ address, port = NBD_DEFAULT_PORT, exportname, cert }, options) {
|
||||
constructor(
|
||||
{
|
||||
address,
|
||||
port = NBD_DEFAULT_PORT,
|
||||
exportname,
|
||||
cert,
|
||||
httpProxy,
|
||||
proxyRejectUnauthorized = true,
|
||||
proxyTimeout = 6e4,
|
||||
},
|
||||
options
|
||||
) {
|
||||
super({ exportname }, options)
|
||||
// other proxy protocols (e.g. SOCKS) are not supported for now
|
||||
if (httpProxy !== undefined) {
|
||||
const { protocol } = new URL(httpProxy)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
const error = new Error(`unsupported proxy protocol ${protocol}, only http: and https: are supported`)
|
||||
error.code = 'NBD_PROXY_UNSUPPORTED_PROTOCOL'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.#serverAddress = address
|
||||
this.#serverPort = port
|
||||
this.#serverCert = cert
|
||||
this.#httpProxy = httpProxy
|
||||
this.#proxyRejectUnauthorized = proxyRejectUnauthorized
|
||||
this.#proxyTimeout = proxyTimeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a raw TCP tunnel to the NBD server through an HTTP proxy
|
||||
*
|
||||
* https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6
|
||||
*
|
||||
* @returns {Promise<import('node:net').Socket>}
|
||||
*/
|
||||
async #connectThroughHttpProxy() {
|
||||
const proxy = new URL(this.#httpProxy)
|
||||
const isHttps = proxy.protocol === 'https:'
|
||||
|
||||
const address = this.#serverAddress
|
||||
const target = `${isIPv6(address) ? `[${address}]` : address}:${this.#serverPort}`
|
||||
const headers = { host: target }
|
||||
if (proxy.username !== '' || proxy.password !== '') {
|
||||
const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
|
||||
headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`
|
||||
}
|
||||
|
||||
const req = (isHttps ? httpsRequest : httpRequest)({
|
||||
agent: false,
|
||||
headers,
|
||||
// URL keeps the brackets around IPv6 addresses
|
||||
hostname: proxy.hostname.replace(/^\[(.*)\]$/, '$1'),
|
||||
method: 'CONNECT',
|
||||
path: target,
|
||||
port: proxy.port,
|
||||
rejectUnauthorized: this.#proxyRejectUnauthorized,
|
||||
timeout: this.#proxyTimeout,
|
||||
})
|
||||
// `timeout` only emits an event, the request must be aborted explicitly
|
||||
req.on('timeout', () => {
|
||||
const error = new Error(`HTTP proxy did not answer the CONNECT to ${target} in time`)
|
||||
error.code = 'NBD_PROXY_CONNECT_TIMEOUT'
|
||||
req.destroy(error)
|
||||
})
|
||||
req.end()
|
||||
|
||||
// the NBD server may already have sent some data, it is in `head`
|
||||
const [res, socket, head] = await once(req, 'connect')
|
||||
// the tunnel is established, NBD has its own timeouts
|
||||
socket.setTimeout(0)
|
||||
if (res.statusCode !== 200) {
|
||||
socket.destroy()
|
||||
const error = new Error(`HTTP proxy refused to connect to ${target}: ${res.statusCode} ${res.statusMessage}`)
|
||||
error.code = 'NBD_PROXY_CONNECT_FAILED'
|
||||
throw error
|
||||
}
|
||||
if (head.length !== 0) {
|
||||
socket.unshift(head)
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
// mandatory , at least to start the handshake: the connection always starts
|
||||
// unsecured, and is upgraded to TLS during the handshake
|
||||
async _openTransport() {
|
||||
if (this.#httpProxy !== undefined) {
|
||||
const socket = await this.#connectThroughHttpProxy()
|
||||
return { readable: socket, writable: socket }
|
||||
}
|
||||
|
||||
const socket = new Socket()
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.connect(this.#serverPort, this.#serverAddress)
|
||||
|
||||
107
@vates/nbd-client/NbdTcpClient.test.mjs
Normal file
107
@vates/nbd-client/NbdTcpClient.test.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, before, describe, it } from 'node:test'
|
||||
import { createServer as createHttpServer } from 'node:http'
|
||||
import { connect, createServer as createTcpServer } from 'node:net'
|
||||
import { once } from 'node:events'
|
||||
|
||||
import NbdTcpClient from './NbdTcpClient.mjs'
|
||||
import { serveNbd } from './tests/fake-nbd-server.mjs'
|
||||
|
||||
const BLOCK_SIZE = 64 * 1024
|
||||
const DATA = Buffer.alloc(4 * BLOCK_SIZE)
|
||||
for (let i = 0; i < DATA.length; i++) {
|
||||
DATA[i] = i % 251
|
||||
}
|
||||
const CREDENTIALS = 'Basic ' + Buffer.from('user:p@ss').toString('base64')
|
||||
|
||||
describe('NbdTcpClient through an HTTP proxy', () => {
|
||||
let nbdServer, proxyServer, nbdPort, proxyPort
|
||||
const connectRequests = []
|
||||
|
||||
before(async () => {
|
||||
nbdServer = createTcpServer(socket => {
|
||||
serveNbd({ readable: socket, writable: socket, data: DATA }).catch(() => {})
|
||||
})
|
||||
nbdServer.listen(0, '127.0.0.1')
|
||||
await once(nbdServer, 'listening')
|
||||
nbdPort = nbdServer.address().port
|
||||
|
||||
// minimal CONNECT proxy, similar to the XO Proxy one
|
||||
proxyServer = createHttpServer()
|
||||
proxyServer.on('connect', (req, clientSocket, head) => {
|
||||
connectRequests.push(req.url)
|
||||
if (req.headers['proxy-authorization'] !== CREDENTIALS) {
|
||||
clientSocket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n')
|
||||
return
|
||||
}
|
||||
const { hostname, port } = new URL('http://' + req.url)
|
||||
const serverSocket = connect(port, hostname, () => {
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
||||
serverSocket.write(head)
|
||||
serverSocket.pipe(clientSocket).pipe(serverSocket)
|
||||
})
|
||||
serverSocket.on('error', () => clientSocket.destroy())
|
||||
clientSocket.on('error', () => serverSocket.destroy())
|
||||
})
|
||||
proxyServer.listen(0, '127.0.0.1')
|
||||
await once(proxyServer, 'listening')
|
||||
proxyPort = proxyServer.address().port
|
||||
})
|
||||
|
||||
after(() => {
|
||||
proxyServer.closeAllConnections()
|
||||
proxyServer.close()
|
||||
nbdServer.close()
|
||||
})
|
||||
|
||||
it('tunnels the NBD connection with CONNECT', async () => {
|
||||
const client = new NbdTcpClient({
|
||||
address: '127.0.0.1',
|
||||
port: nbdPort,
|
||||
httpProxy: `http://user:p%40ss@127.0.0.1:${proxyPort}`,
|
||||
})
|
||||
await client.connect()
|
||||
try {
|
||||
assert.equal(client.exportSize, BigInt(DATA.length))
|
||||
const block = await client.readBlock(2, BLOCK_SIZE)
|
||||
assert.deepEqual(block, DATA.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE))
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
assert.equal(connectRequests.at(-1), `127.0.0.1:${nbdPort}`)
|
||||
})
|
||||
|
||||
it('fails when the proxy refuses the connection', async () => {
|
||||
const client = new NbdTcpClient({
|
||||
address: '127.0.0.1',
|
||||
port: nbdPort,
|
||||
httpProxy: `http://user:wrong@127.0.0.1:${proxyPort}`,
|
||||
})
|
||||
await assert.rejects(client.connect(), { code: 'NBD_PROXY_CONNECT_FAILED' })
|
||||
await client.disconnect().catch(() => {})
|
||||
})
|
||||
|
||||
it('aborts the CONNECT when the proxy does not answer', async () => {
|
||||
// accepts the TCP connection but never answers
|
||||
const silentProxy = createTcpServer(() => {})
|
||||
silentProxy.listen(0, '127.0.0.1')
|
||||
await once(silentProxy, 'listening')
|
||||
try {
|
||||
const client = new NbdTcpClient({
|
||||
address: '127.0.0.1',
|
||||
port: nbdPort,
|
||||
httpProxy: `http://127.0.0.1:${silentProxy.address().port}`,
|
||||
proxyTimeout: 50,
|
||||
})
|
||||
await assert.rejects(client.connect(), { code: 'NBD_PROXY_CONNECT_TIMEOUT' })
|
||||
} finally {
|
||||
silentProxy.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unsupported proxy protocols', () => {
|
||||
assert.throws(() => new NbdTcpClient({ address: '127.0.0.1', httpProxy: 'socks5://127.0.0.1:1080' }), {
|
||||
code: 'NBD_PROXY_UNSUPPORTED_PROTOCOL',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.0",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
@@ -22,10 +22,10 @@
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/read-chunk": "^1.2.1",
|
||||
"@xen-orchestra/async-map": "^0.1.5",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3",
|
||||
"@xen-orchestra/disk-transform": "^1.3.4",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^5.0.2"
|
||||
"xen-api": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^18.7.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@vates/types",
|
||||
"private": false,
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"main": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -34,7 +34,7 @@
|
||||
"typescript": "~5.6.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3"
|
||||
"@xen-orchestra/disk-transform": "^1.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -663,6 +663,19 @@ export const SUPPORTED_VDI_FORMAT = {
|
||||
|
||||
export type SUPPORTED_VDI_FORMAT = (typeof SUPPORTED_VDI_FORMAT)[keyof typeof SUPPORTED_VDI_FORMAT]
|
||||
|
||||
/**
|
||||
* Formats a VDI can be exported to by XO: the XAPI ones, plus the ones XO generates
|
||||
* itself from the blocks of the disk.
|
||||
*/
|
||||
export const SUPPORTED_VDI_EXPORT_FORMAT = {
|
||||
raw: 'raw',
|
||||
vhd: 'vhd',
|
||||
qcow2: 'qcow2',
|
||||
vmdk: 'vmdk',
|
||||
} as const
|
||||
|
||||
export type SUPPORTED_VDI_EXPORT_FORMAT = (typeof SUPPORTED_VDI_EXPORT_FORMAT)[keyof typeof SUPPORTED_VDI_EXPORT_FORMAT]
|
||||
|
||||
// ----- XAPI Stats
|
||||
|
||||
type XapiStatsResponse<T> = {
|
||||
@@ -800,3 +813,7 @@ export const XAPI_TYPES: readonly string[] = [
|
||||
export const RPU_RECOVERY_STEP_NAMES = ['evacuate', 'update', 'reboot', 'enable', 'restoreVms'] as const
|
||||
|
||||
export type RPU_RECOVERY_STEP_NAME = (typeof RPU_RECOVERY_STEP_NAMES)[number]
|
||||
|
||||
export const COMMON_TAG = {
|
||||
NOBAK: 'xo:no-bak',
|
||||
} as const
|
||||
|
||||
@@ -4,5 +4,8 @@ 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/xen-orchestra-plugins.mjs'
|
||||
export * from './lib/xen-orchestra-v2v.mjs'
|
||||
export * from './lib/complex-matcher.mjs'
|
||||
export * from './lib/rest-api.mjs'
|
||||
export * from './lib/xen-orchestra-vmware-explorer.mjs'
|
||||
|
||||
1261
@vates/types/src/lib/xen-orchestra-plugins.mts
Normal file
1261
@vates/types/src/lib/xen-orchestra-plugins.mts
Normal file
File diff suppressed because it is too large
Load Diff
219
@vates/types/src/lib/xen-orchestra-v2v.mts
Normal file
219
@vates/types/src/lib/xen-orchestra-v2v.mts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { Branded } from '../common.mjs'
|
||||
import type { XoNetwork, XoSr, XoVmTemplate } from '../xo.mjs'
|
||||
|
||||
// Stateless API: requests needing the source carry the connection in a POST body (never in URLs or access logs) and
|
||||
// xo-server stores nothing it reads from the source.
|
||||
|
||||
export const V2V_PLATFORM = {
|
||||
VMWARE: 'vmware',
|
||||
} as const
|
||||
|
||||
export type V2V_PLATFORM = (typeof V2V_PLATFORM)[keyof typeof V2V_PLATFORM]
|
||||
|
||||
// 422 rather than 401 for invalid source credentials: a 401 would open the browser Basic authentication prompt
|
||||
export const V2V_ERROR_CODE = {
|
||||
INVALID_CREDENTIALS: 'V2V_INVALID_CREDENTIALS',
|
||||
} as const
|
||||
|
||||
export type V2V_ERROR_CODE = (typeof V2V_ERROR_CODE)[keyof typeof V2V_ERROR_CODE]
|
||||
|
||||
// Clones, restores and replicas inherit `other_config`: the imported VM records its own UUID to be told apart from
|
||||
// them, and the source identity because a moref is only unique within one vCenter. The source identity is the
|
||||
// inventory `instanceUuid`, or `<lowercased host>:<port>` for a standalone ESXi which has none.
|
||||
export const V2V_VM_OTHER_CONFIG = {
|
||||
SOURCE_VM_ID: 'sourceVmId',
|
||||
SOURCE_SNAPSHOT_ID: 'sourceSnapshotId',
|
||||
IMPORTED_VM_UUID: 'xo:v2v:importedVmUuid',
|
||||
SOURCE_ID: 'xo:v2v:sourceId',
|
||||
SOURCE_VM_INSTANCE_UUID: 'xo:v2v:sourceVmInstanceUuid',
|
||||
} as const
|
||||
|
||||
export type V2V_VM_OTHER_CONFIG = (typeof V2V_VM_OTHER_CONFIG)[keyof typeof V2V_VM_OTHER_CONFIG]
|
||||
|
||||
export type XoV2vVmOtherConfig = Partial<Record<V2V_VM_OTHER_CONFIG, string>>
|
||||
|
||||
// The disk of the source chain an imported VDI last received, one of the `chainPaths` of its source disk: the CID
|
||||
// cannot tell a fresh snapshot delta from its parent, the path can
|
||||
export const V2V_VDI_OTHER_CONFIG = {
|
||||
DISK_PATH: 'esxi_diskPath',
|
||||
} as const
|
||||
|
||||
export type V2V_VDI_OTHER_CONFIG = (typeof V2V_VDI_OTHER_CONFIG)[keyof typeof V2V_VDI_OTHER_CONFIG]
|
||||
|
||||
export const V2V_START_BLOCKED_REASON = 'Esxi migration in progress...'
|
||||
|
||||
export const V2V_PARTIAL_IMPORT_TAG = 'xo:no-bak=V2V partial import'
|
||||
|
||||
export const V2V_SNAPSHOT_NAME = {
|
||||
AFTER_PARTIAL_IMPORT: 'after partial import from V2V',
|
||||
AFTER_COMPLETE_IMPORT: 'after complete import from V2V',
|
||||
COMPLETE_IMPORT: 'complete import from V2V',
|
||||
} as const
|
||||
|
||||
export type V2V_SNAPSHOT_NAME = (typeof V2V_SNAPSHOT_NAME)[keyof typeof V2V_SNAPSHOT_NAME]
|
||||
|
||||
// Set on the task of a start so the front can tell which source VMs are being imported
|
||||
export const V2V_TASK_PROPERTY = {
|
||||
SOURCE_VM_IDS: 'sourceVmIds',
|
||||
} as const
|
||||
|
||||
export type XoV2vConnection = {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
skipSslCheck: boolean
|
||||
}
|
||||
|
||||
export type XoV2vStatus = {
|
||||
vectura: { status: 'success' } | { status: 'error'; error: string }
|
||||
}
|
||||
|
||||
export type XoV2vInventoryPayload = {
|
||||
platform: V2V_PLATFORM
|
||||
connection: XoV2vConnection
|
||||
}
|
||||
|
||||
export type XoV2vDatacenter = {
|
||||
id: Branded<'v2v-datacenter'>
|
||||
name: string
|
||||
}
|
||||
|
||||
export type XoV2vCluster = {
|
||||
id: Branded<'v2v-cluster'>
|
||||
name: string
|
||||
datacenterId: XoV2vDatacenter['id']
|
||||
}
|
||||
|
||||
export type XoV2vHost = {
|
||||
id: Branded<'v2v-host'>
|
||||
name: string
|
||||
clusterId?: XoV2vCluster['id']
|
||||
datacenterId: XoV2vDatacenter['id']
|
||||
}
|
||||
|
||||
// Sizes are in bytes, as in the rest of XO, not in the KB/MB VMware returns
|
||||
export type XoV2vDatastore = {
|
||||
id: Branded<'v2v-datastore'>
|
||||
name: string
|
||||
type: string
|
||||
capacity: number
|
||||
free: number
|
||||
hostIds: XoV2vHost['id'][]
|
||||
}
|
||||
|
||||
export type XoV2vPortGroup = {
|
||||
id: Branded<'v2v-port-group'>
|
||||
name: string
|
||||
vlan?: number
|
||||
}
|
||||
|
||||
export const V2V_DISK_MODE = {
|
||||
PERSISTENT: 'persistent',
|
||||
NON_PERSISTENT: 'nonpersistent',
|
||||
INDEPENDENT_PERSISTENT: 'independent_persistent',
|
||||
INDEPENDENT_NON_PERSISTENT: 'independent_nonpersistent',
|
||||
UNDOABLE: 'undoable',
|
||||
APPEND: 'append',
|
||||
} as const
|
||||
|
||||
export type V2V_DISK_MODE = (typeof V2V_DISK_MODE)[keyof typeof V2V_DISK_MODE]
|
||||
|
||||
// `key` is the vmx device node (`scsi0:0`, `ethernet0`…), the identifier the import matches the start maps against
|
||||
export type XoV2vDisk = {
|
||||
key: Branded<'v2v-disk'>
|
||||
label: string
|
||||
datastoreId: XoV2vDatastore['id']
|
||||
capacity: number
|
||||
mode: V2V_DISK_MODE
|
||||
isRdm: boolean
|
||||
isEncrypted: boolean
|
||||
// `dir/disk.vmdk` in its datastore (no `[datastore]` prefix) of the base disk, then of each snapshot delta down to
|
||||
// the active disk: the chain the import walks for this vmx node
|
||||
chainPaths: string[]
|
||||
}
|
||||
|
||||
// No port group for opaque (NSX) or missing backings
|
||||
export type XoV2vNic = {
|
||||
key: Branded<'v2v-nic'>
|
||||
label: string
|
||||
portGroupId?: XoV2vPortGroup['id']
|
||||
macAddress: string
|
||||
}
|
||||
|
||||
export const V2V_VM_POWER_STATE = {
|
||||
POWERED_ON: 'poweredOn',
|
||||
POWERED_OFF: 'poweredOff',
|
||||
SUSPENDED: 'suspended',
|
||||
} as const
|
||||
|
||||
export type V2V_VM_POWER_STATE = (typeof V2V_VM_POWER_STATE)[keyof typeof V2V_VM_POWER_STATE]
|
||||
|
||||
export const V2V_FIRMWARE = {
|
||||
BIOS: 'bios',
|
||||
UEFI: 'uefi',
|
||||
} as const
|
||||
|
||||
export type V2V_FIRMWARE = (typeof V2V_FIRMWARE)[keyof typeof V2V_FIRMWARE]
|
||||
|
||||
export type XoV2vVm = {
|
||||
id: Branded<'v2v-vm'>
|
||||
instanceUuid: string
|
||||
name: string
|
||||
description: string
|
||||
powerState: V2V_VM_POWER_STATE
|
||||
hostId: XoV2vHost['id']
|
||||
nCpus: number
|
||||
memory: number
|
||||
firmware: V2V_FIRMWARE
|
||||
secureBoot: boolean
|
||||
datastoreIds: XoV2vDatastore['id'][]
|
||||
snapshotCount: number
|
||||
// Compared with `other_config.sourceSnapshotId` of a partial import: a delta is only possible while they match
|
||||
currentSnapshotId?: string
|
||||
disks: XoV2vDisk[]
|
||||
nics: XoV2vNic[]
|
||||
}
|
||||
|
||||
export type XoV2vInventory = {
|
||||
product: string
|
||||
version: string
|
||||
// Absent on a standalone ESXi, identified by its connection address instead
|
||||
instanceUuid?: string
|
||||
datacenters: XoV2vDatacenter[]
|
||||
clusters: XoV2vCluster[]
|
||||
hosts: XoV2vHost[]
|
||||
datastores: XoV2vDatastore[]
|
||||
portGroups: XoV2vPortGroup[]
|
||||
vms: XoV2vVm[]
|
||||
}
|
||||
|
||||
export type XoV2vStartOptions = {
|
||||
stopSource: boolean
|
||||
startVm: boolean
|
||||
cleanup: boolean
|
||||
}
|
||||
|
||||
// A resumed import sends no disk or NIC map: the back end reads its mapping from the existing XCP-ng VM
|
||||
export type XoV2vStartPayloadVm = Partial<XoV2vStartOptions> & {
|
||||
id: XoV2vVm['id']
|
||||
disks?: Record<XoV2vDisk['key'], XoSr['id']>
|
||||
nics?: Record<XoV2vNic['key'], XoNetwork['id']>
|
||||
template?: XoVmTemplate['id']
|
||||
tags: string[]
|
||||
keepMac: boolean
|
||||
}
|
||||
|
||||
// Body of `POST /pools/{id}/actions/import_vms_from_vmware`: the pool is in the path. Checked against the body of the
|
||||
// route when the REST API is built, which refuses any extra key
|
||||
export type XoV2vStartPayload = {
|
||||
connection: XoV2vConnection
|
||||
defaults: XoV2vStartOptions & {
|
||||
sr: XoSr['id']
|
||||
network: XoNetwork['id']
|
||||
template: XoVmTemplate['id']
|
||||
}
|
||||
vms: XoV2vStartPayloadVm[]
|
||||
stopOnError: boolean
|
||||
concurrency: number
|
||||
}
|
||||
101
@vates/types/src/lib/xen-orchestra-vmware-explorer.mts
Normal file
101
@vates/types/src/lib/xen-orchestra-vmware-explorer.mts
Normal file
@@ -0,0 +1,101 @@
|
||||
// shape of `Esxi#getInventory()` in `@xen-orchestra/vmware-explorer`
|
||||
// the objects reference each other through their VMware managed object references (moref)
|
||||
|
||||
export type VmwareDatacenter = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type VmwareCluster = {
|
||||
id: string
|
||||
name: string
|
||||
datacenter?: string
|
||||
}
|
||||
|
||||
export type VmwareHost = {
|
||||
id: string
|
||||
name: string
|
||||
/** a cluster, or the compute resource of a standalone host */
|
||||
parent?: string
|
||||
datacenter?: string
|
||||
}
|
||||
|
||||
export type VmwareDatastore = {
|
||||
id: string
|
||||
name?: string
|
||||
type?: string
|
||||
capacity?: number
|
||||
freeSpace?: number
|
||||
/** the hosts which mount the datastore and can reach it */
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
export type VmwareVlan = number | { start: number; end: number }[]
|
||||
|
||||
export type VmwareStandardNetwork = {
|
||||
id: string
|
||||
name: string
|
||||
distributed: false
|
||||
}
|
||||
|
||||
export type VmwareDistributedPortgroup = {
|
||||
id: string
|
||||
name: string
|
||||
distributed: true
|
||||
vlan?: VmwareVlan
|
||||
}
|
||||
|
||||
export type VmwareNetwork = VmwareStandardNetwork | VmwareDistributedPortgroup
|
||||
|
||||
export type VmwareVmDisk = {
|
||||
/** node of the disk, as the vmx names it, e.g. `scsi0:0` */
|
||||
key: string
|
||||
label: string
|
||||
capacity: number
|
||||
datastore?: string
|
||||
diskMode?: string
|
||||
rdm: boolean
|
||||
encrypted: boolean
|
||||
}
|
||||
|
||||
export type VmwareVmNic = {
|
||||
/** device key of the card */
|
||||
key: string
|
||||
label: string
|
||||
macAddress?: string
|
||||
addressType?: string
|
||||
network?: string
|
||||
}
|
||||
|
||||
export type VmwareVm = {
|
||||
id: string
|
||||
name?: string
|
||||
annotation?: string
|
||||
instanceUuid?: string
|
||||
firmware?: string
|
||||
secureBoot: boolean
|
||||
isTemplate: boolean
|
||||
cpus?: number
|
||||
memory?: number
|
||||
powerState?: 'poweredOff' | 'poweredOn' | 'suspended'
|
||||
host?: string
|
||||
datastores: string[]
|
||||
currentSnapshot?: string
|
||||
snapshotCount: number
|
||||
disks: VmwareVmDisk[]
|
||||
nics: VmwareVmNic[]
|
||||
}
|
||||
|
||||
export type VmwareInventory = {
|
||||
/** e.g. `VMware vCenter Server`, `VMware ESXi` */
|
||||
product?: string
|
||||
version?: string
|
||||
/** only set by a vCenter */
|
||||
instanceUuid?: string
|
||||
datacenters: VmwareDatacenter[]
|
||||
clusters: VmwareCluster[]
|
||||
hosts: VmwareHost[]
|
||||
datastores: VmwareDatastore[]
|
||||
networks: VmwareNetwork[]
|
||||
vms: VmwareVm[]
|
||||
}
|
||||
@@ -130,6 +130,8 @@ export type XsPatches = {
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
type WrappedRecordOfType<Type extends WrappedXenApiRecord['$type']> = Extract<WrappedXenApiRecord, { $type: Type }>
|
||||
|
||||
export interface Xapi {
|
||||
status: string
|
||||
pool?: { uuid: string }
|
||||
@@ -147,6 +149,14 @@ export interface Xapi {
|
||||
ref: T['$ref'],
|
||||
field: K
|
||||
): Promise<T[K]>
|
||||
// the record is inferred from `type`, for a JS caller which cannot spell it out as a type argument
|
||||
getField<Type extends WrappedXenApiRecord['$type'], Field extends keyof WrappedRecordOfType<Type>>(
|
||||
type: Type,
|
||||
ref: WrappedRecordOfType<Type>['$ref'],
|
||||
field: Field
|
||||
): Promise<WrappedRecordOfType<Type>[Field]>
|
||||
/** Synchronous: the record as the object cache currently holds it. */
|
||||
getObjectByRef<Ref extends WrappedXenApiRecord['$ref']>(ref: Ref): Extract<WrappedXenApiRecord, { $ref: Ref }>
|
||||
getObject: <
|
||||
XoRecord extends XapiXoRecord,
|
||||
WrappedRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: XoRecord['type'] }>,
|
||||
@@ -283,6 +293,42 @@ export interface Xapi {
|
||||
}
|
||||
): Promise<XenApiVdi['$ref']>
|
||||
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
|
||||
VBD_create(params: {
|
||||
bootable?: boolean
|
||||
currently_attached?: boolean
|
||||
device?: string
|
||||
empty?: boolean
|
||||
mode?: VBD_MODE
|
||||
other_config?: Record<string, string>
|
||||
qos_algorithm_params?: Record<string, string>
|
||||
qos_algorithm_type?: string
|
||||
/** a plug failure, only logged otherwise, is thrown: the VBD exists all the same */
|
||||
throwVbdPlug?: boolean
|
||||
type?: VBD_TYPE
|
||||
unpluggable?: boolean
|
||||
userdevice?: string
|
||||
VDI?: XenApiVdi['$ref']
|
||||
VM: XenApiVm['$ref']
|
||||
}): Promise<XenApiVbd['$ref']>
|
||||
/** unplugs the VBD first, if needed */
|
||||
VBD_destroy(ref: XenApiVbd['$ref']): Promise<void>
|
||||
VDI_create(
|
||||
params: {
|
||||
name_description?: string
|
||||
name_label: string
|
||||
other_config?: Record<string, string>
|
||||
read_only?: boolean
|
||||
sharable?: boolean
|
||||
SR?: XenApiSr['$ref']
|
||||
tags?: string[]
|
||||
type?: VDI_TYPE
|
||||
virtual_size: number
|
||||
xenstore_data?: Record<string, string>
|
||||
},
|
||||
opts?: { sm_config?: Record<string, string> }
|
||||
): Promise<XenApiVdi['$ref']>
|
||||
/** a VDI which no longer exists counts as destroyed */
|
||||
VDI_destroy(ref: XenApiVdi['$ref']): Promise<void>
|
||||
editVm(
|
||||
id: XoVm['id'],
|
||||
props: EditVmProps,
|
||||
|
||||
@@ -824,7 +824,10 @@ export interface XenApiSr {
|
||||
VDIs: XenApiVdi['$ref'][]
|
||||
virtual_allocation: number
|
||||
}
|
||||
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR', XenApiSrCallMethods>
|
||||
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR', XenApiSrCallMethods> & {
|
||||
// resolved by xen-api from `PBDs`
|
||||
$PBDs: XenApiPbdWrapped[]
|
||||
}
|
||||
|
||||
export interface XenApiSrStat {
|
||||
$ref: Branded<'sr_stat'>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventEmitter } from 'node:stream'
|
||||
import type { EventEmitter, Readable } from 'node:stream'
|
||||
import type {
|
||||
AnyXoJob,
|
||||
AnyXoLog,
|
||||
@@ -9,19 +9,25 @@ import type {
|
||||
XoConfigBackupArchive,
|
||||
XoGroup,
|
||||
XoHost,
|
||||
XoNetwork,
|
||||
XoPlugin,
|
||||
XoPool,
|
||||
XoPoolBackupArchive,
|
||||
XoProxy,
|
||||
XoSchedule,
|
||||
XoServer,
|
||||
XoSr,
|
||||
XoTask,
|
||||
XoUser,
|
||||
XoVif,
|
||||
XoVm,
|
||||
XoVmBackupArchive,
|
||||
XoVmTemplate,
|
||||
} from './xo.mjs'
|
||||
import { VatesTask } from './lib/vates-task.mjs'
|
||||
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
|
||||
import type { VmwareInventory } from './lib/xen-orchestra-vmware-explorer.mjs'
|
||||
import type { XoV2vStatus } from './lib/xen-orchestra-v2v.mjs'
|
||||
import type { RPU_RECOVERY_STEP_NAME } from './common.mjs'
|
||||
import {
|
||||
Xapi,
|
||||
@@ -175,13 +181,13 @@ export type PoolRollingUpdateRecoveryBlocked = {
|
||||
|
||||
export type PoolRollingUpdateRecovery = PoolRollingUpdateRecoveryRun | PoolRollingUpdateRecoveryBlocked
|
||||
|
||||
/** A disk of a backup archive currently served as a read-only iSCSI LUN */
|
||||
/** A disk of a backup archive currently served as an iSCSI LUN */
|
||||
export type BackupArchiveDiskMount = {
|
||||
/** Handle to pass to `unmountBackupArchiveDisk` */
|
||||
id: string
|
||||
/** UUID of the SR introduced on the host */
|
||||
srUuid: string
|
||||
/** UUID of the read-only VDI exposing the backup disk */
|
||||
/** UUID of the VDI exposing the backup disk, read-only unless it is cached */
|
||||
vdiUuid: string
|
||||
/** IQN of the target serving the disk */
|
||||
iqn: string
|
||||
@@ -191,7 +197,25 @@ export type BackupArchiveDiskMount = {
|
||||
port: number
|
||||
}
|
||||
|
||||
export type XoApp = {
|
||||
type XoStoreRangeOptions = {
|
||||
gt?: string
|
||||
gte?: string
|
||||
lt?: string
|
||||
lte?: string
|
||||
limit?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
// `@xen-orchestra/vmware-explorer` is not typed
|
||||
export type EsxiConnectionParams = {
|
||||
/** `host` or `host:port` */
|
||||
host: string
|
||||
user: string
|
||||
password: string
|
||||
sslVerify: boolean
|
||||
}
|
||||
|
||||
export type XoApp = EventEmitter & {
|
||||
hooks: EventEmitter
|
||||
_redis: {
|
||||
get(key: string): Promise<string | null>
|
||||
@@ -233,12 +257,6 @@ export type XoApp = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* the VM backup archives of the backup repositories, as a collection: `add`, `update` and
|
||||
* `remove` events carrying the archive and its previous value
|
||||
*/
|
||||
vmBackupArchives: EventEmitter
|
||||
|
||||
tasks: EventEmitter & {
|
||||
abort(id: XoTask['id'], reason?: string): Promise<void>
|
||||
clearLogs(): Promise<void>
|
||||
@@ -252,6 +270,7 @@ export type XoApp = {
|
||||
user?: XoUser
|
||||
permission?: XoUser['permission'] | 'none' | null
|
||||
}
|
||||
backupLogsEe: EventEmitter
|
||||
|
||||
// methods ------------
|
||||
allocIpAddresses(vifId: XoVif['id'], addAddresses?: string[], removeAddresses?: string[]): Promise<void>
|
||||
@@ -276,6 +295,8 @@ export type XoApp = {
|
||||
backupGuard(objectId: XapiXoRecord['id'], opts?: { bypassBackupCheck?: boolean; operation: string }): Promise<void>
|
||||
/* Throw if no authorization */
|
||||
checkFeatureAuthorization(featureCode: FeatureCode): Promise<void>
|
||||
/* whether this machine can run the tools of an import from VMware */
|
||||
checkEsxiDependencies(): Promise<XoV2vStatus>
|
||||
/* validate, apply and persist the configuration of a plugin */
|
||||
configurePlugin(id: string, configuration: unknown, mergeWithExisting?: boolean): Promise<void>
|
||||
/* connect a server (XCP-ng/XenServer) */
|
||||
@@ -296,6 +317,13 @@ export type XoApp = {
|
||||
description?: XoAclRole['description']
|
||||
privileges?: Omit<XoAclBasePrivilege, 'id' | 'roleId'>[]
|
||||
}): Promise<XoAclRole>
|
||||
createApiConnection(remoteAddress: string): EventEmitter & {
|
||||
close(): void
|
||||
get<T = unknown>(key: string, defaultValue: unknown): T
|
||||
has(key: string): boolean
|
||||
set(key: string, value: unknown): void
|
||||
unset(key: string): void
|
||||
}
|
||||
createAuthenticationToken(opts: {
|
||||
client?: {
|
||||
id?: string
|
||||
@@ -330,8 +358,10 @@ export type XoApp = {
|
||||
deleteUser(id: XoUser['id']): Promise<void>
|
||||
detachHostFromPool(hostId: XoHost['id']): Promise<void>
|
||||
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
|
||||
disablePluginAutoload(id: XoPlugin['id']): Promise<void>
|
||||
/* disconnect a server (XCP-ng/XenServer) */
|
||||
disconnectXenServer(id: XoServer['id']): Promise<void>
|
||||
enablePluginAutoload(id: XoPlugin['id']): Promise<void>
|
||||
findEnabledScheduleSequenceFromSchedule(id: XoSchedule['id']): Promise<XoSchedule | undefined>
|
||||
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
|
||||
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
|
||||
@@ -342,6 +372,7 @@ export type XoApp = {
|
||||
roleId: XoAclRole['id'],
|
||||
opts?: { bypassAuthorization?: boolean }
|
||||
): Promise<boolean>
|
||||
deleteSchedule(id: XoSchedule['id']): Promise<void>
|
||||
getAclV2GroupRoles(
|
||||
groupId: XoGroup['id'],
|
||||
opts?: { bypassAuthorization?: boolean }
|
||||
@@ -356,6 +387,8 @@ export type XoApp = {
|
||||
getAllProxies(): Promise<XoProxy[]>
|
||||
getAllJobs<T extends AnyXoJob['type']>(type: T): Promise<Extract<AnyXoJob, { type: T }>[]>
|
||||
getAllJobs(type?: string): Promise<AnyXoJob[]>
|
||||
getPlugin(id: XoPlugin['id']): Promise<XoPlugin>
|
||||
getPlugins(): Promise<XoPlugin[]>
|
||||
getProxy(id: XoProxy['id']): Promise<XoProxy>
|
||||
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
|
||||
getAllRemotes(): Promise<XoBackupRepository[]>
|
||||
@@ -388,6 +421,10 @@ export type XoApp = {
|
||||
}
|
||||
getBackupNgLogs(): Promise<Record<string, AnyXoLog>>
|
||||
getBackupNgLogs(id: AnyXoLog['id']): Promise<AnyXoLog>
|
||||
/** Please use `REMOVE_CACHE_ENTRY` const to clear the cache */
|
||||
getBackupNgLogs(removeCacheEntry: {}): void
|
||||
/** Please use `REMOVE_CACHE_ENTRY` const to clear a cache entry */
|
||||
getBackupNgLogs(removeCacheEntry: {}, id: AnyXoLog['id']): void
|
||||
getBackupNgLogsSorted(opts: {
|
||||
after?: number
|
||||
before?: number
|
||||
@@ -405,8 +442,21 @@ export type XoApp = {
|
||||
) => Record<T['id'], T> | undefined
|
||||
getTotalBackupSizeOnRemote(id: XoBackupRepository['id']): Promise<{ onDisk: number }>
|
||||
getSchedule(id: XoSchedule['id']): Promise<XoSchedule>
|
||||
getStore(namespace: string): Promise<{
|
||||
/** rejects with an error having `notFound: true` when the key does not exist */
|
||||
get(key: string): Promise<unknown>
|
||||
has(key: string): Promise<boolean>
|
||||
put(key: string, value: unknown): Promise<void>
|
||||
del(key: string): Promise<void>
|
||||
del(key: string, cb: (error?: Error | null) => void): void
|
||||
/** object mode stream of `{ key, value }` */
|
||||
createReadStream(opts?: XoStoreRangeOptions): Readable
|
||||
/** object mode stream of keys */
|
||||
createKeyStream(opts?: XoStoreRangeOptions): Readable
|
||||
}>
|
||||
getUser: (id: XoUser['id'], opts?: { obfuscatePassword?: boolean }) => Promise<XoUser>
|
||||
getUserIdentityFields(): string[]
|
||||
getEsxiInventory(params: EsxiConnectionParams): Promise<VmwareInventory>
|
||||
getXapi(maybeId: XapiXoRecord['id'] | XapiXoRecord): Xapi
|
||||
getXapiHostStats: (hostId: XoHost['id'], granularity?: XapiStatsGranularity) => Promise<XapiHostStats>
|
||||
getXapiObject: <T extends XapiXoRecord>(
|
||||
@@ -416,9 +466,13 @@ export type XoApp = {
|
||||
getXapiPoolStats(poolId: XoPool['id'], granularity?: XapiStatsGranularity): Promise<XapiPoolStats>
|
||||
getXapiVmStats: (vmId: XoVm['id'], granularity?: XapiStatsGranularity) => Promise<XapiVmStats>
|
||||
getXenServer(id: XoServer['id']): Promise<XoServer>
|
||||
/** the connection of the pool holding this object, which may not be connected */
|
||||
getXenServerIdByObject<T extends XapiXoRecord>(maybeId: T['id'] | T, type?: T['type'] | T['type'][]): XoServer['id']
|
||||
getXenServerWithCredentials(id: XoServer['id']): Promise<XoServer & { password: string }>
|
||||
getXoEventEmitterByType(type: string): EventEmitter
|
||||
hasFeatureAuthorization(featureCode: string): Promise<boolean>
|
||||
hasObject<T extends XapiXoRecord>(id: T['id'], type: T['type']): boolean
|
||||
loadPlugin(id: XoPlugin['id']): Promise<void>
|
||||
listMetadataBackups(backupRepositoryIds: XoBackupRepository['id'][]): Promise<{
|
||||
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
|
||||
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
|
||||
@@ -428,19 +482,41 @@ export type XoApp = {
|
||||
backupRepositoryIds: XoBackupRepository['id'][],
|
||||
opts?: { _forceRefresh?: boolean; vmId?: XoVm['id'] }
|
||||
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]> | null>>
|
||||
/** imports one VM from VMware, resuming a previous partial import when possible, returns the UUID of the XCP-ng VM */
|
||||
migrationfromEsxi(
|
||||
params: EsxiConnectionParams & {
|
||||
sr: XoSr['id']
|
||||
network: XoNetwork['id']
|
||||
/** overrides `sr`, by disk node (e.g. `scsi0:0`) */
|
||||
disks?: Record<string, XoSr['id']>
|
||||
/** overrides `network`, by NIC device key (e.g. `4000`) */
|
||||
nics?: Record<string, XoNetwork['id']>
|
||||
template: XoVmTemplate['id']
|
||||
stopSource: boolean
|
||||
/** VMware moref of the VM */
|
||||
vm: string
|
||||
}
|
||||
): Promise<XoVm['uuid']>
|
||||
/**
|
||||
* Serve one disk of a backup archive as a read-only iSCSI LUN and attach it to
|
||||
* `host` as an SR. Undone by `unmountBackupArchiveDisk`.
|
||||
* Serve one disk of a backup archive as an iSCSI LUN and attach it to `host`
|
||||
* as an SR, read-only unless `cacheSrId` is set. Undone by `unmountBackupArchiveDisk`.
|
||||
*
|
||||
* The LUN is served by whoever can read the backup repository: this appliance, or the proxy the
|
||||
* repository is linked to.
|
||||
*/
|
||||
mountBackupArchiveDisk(params: {
|
||||
archiveId: XoVmBackupArchive['id']
|
||||
/**
|
||||
* SR of a VDI of the serving appliance, which the disk is materialized into as it is read and
|
||||
* which holds the writes; unset, nothing is cached and the mount is read-only. On a local SR,
|
||||
* the serving appliance must not be migrated while the mount lasts: the VDI cannot follow it
|
||||
*/
|
||||
cacheSrId?: XoSr['id']
|
||||
/** One of the archive's `disks[].id` */
|
||||
diskId: string
|
||||
hostId: XoHost['id']
|
||||
}): Promise<BackupArchiveDiskMount>
|
||||
purgePluginConfiguration(id: XoPlugin['id']): Promise<void>
|
||||
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
|
||||
/**
|
||||
* Record the live mounts a proxy created by itself, while running a restore: they never went
|
||||
@@ -481,6 +557,7 @@ export type XoApp = {
|
||||
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
|
||||
runJob(job: AnyXoJob, schedule: XoSchedule): void
|
||||
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
|
||||
testPlugin(id: XoPlugin['id'], data?: unknown): Promise<void>
|
||||
testRemote(
|
||||
id: XoBackupRepository['id']
|
||||
): Promise<
|
||||
@@ -490,6 +567,7 @@ export type XoApp = {
|
||||
touchXoObject(type: string, id: NonXapiXoRecord['id']): Promise<void>
|
||||
/** Detach a disk mounted by `mountBackupArchiveDisk` and stop serving it */
|
||||
unmountBackupArchiveDisk(id: BackupArchiveDiskMount['id']): Promise<void>
|
||||
unloadPlugin(id: XoPlugin['id']): Promise<void>
|
||||
reclaimSpace(
|
||||
remoteId: XoBackupRepository['id'],
|
||||
opts?: { vmUuid?: XoVm['id']; merge?: boolean; remove?: boolean }
|
||||
|
||||
@@ -21,6 +21,29 @@ import type {
|
||||
import type * as CMType from './lib/complex-matcher.mjs'
|
||||
import { XoAclBasePrivilege, XoAclRole } from './lib/xen-orchestra-acl.mjs'
|
||||
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
|
||||
import type {
|
||||
XoAuditPlugin,
|
||||
XoAuthGithubPlugin,
|
||||
XoAuthGooglePlugin,
|
||||
XoAuthLdapPlugin,
|
||||
XoAuthOidcPlugin,
|
||||
XoAuthSamlPlugin,
|
||||
XoBackupReportsPlugin,
|
||||
XoIpmiSensorsPlugin,
|
||||
XoLoadBalancerPlugin,
|
||||
XoNetboxPlugin,
|
||||
XoOpenmetricsPlugin,
|
||||
XoPerfAlertPlugin,
|
||||
XoSdnControllerPlugin,
|
||||
XoTestPluginPlugin,
|
||||
XoTransportEmailPlugin,
|
||||
XoTransportIcinga2Plugin,
|
||||
XoTransportNagiosPlugin,
|
||||
XoTransportSlackPlugin,
|
||||
XoTransportXmppPlugin,
|
||||
XoUsageReportPlugin,
|
||||
XoWebHooksPlugin,
|
||||
} from './lib/xen-orchestra-plugins.mjs'
|
||||
|
||||
type BaseXapiXo = {
|
||||
$pool: XoPool['id']
|
||||
@@ -118,7 +141,7 @@ type BaseXoVm = BaseXapiXo & {
|
||||
/**
|
||||
* @deprecated use pvDriversVersion instead
|
||||
*/
|
||||
xentools?:
|
||||
xenTools?:
|
||||
| false
|
||||
| {
|
||||
major: null | number
|
||||
@@ -208,6 +231,7 @@ export type XoBackupRepositoryBenchmark = {
|
||||
}
|
||||
|
||||
export type XoBackupRepository = {
|
||||
available?: number
|
||||
benchmarks?: XoBackupRepositoryBenchmark[]
|
||||
enabled: boolean
|
||||
error?: Record<string, unknown>
|
||||
@@ -215,7 +239,9 @@ export type XoBackupRepository = {
|
||||
name: string
|
||||
options?: string
|
||||
proxy?: XoProxy['id']
|
||||
size?: number
|
||||
url: string
|
||||
used?: number
|
||||
}
|
||||
|
||||
export type XoGpuGroup = BaseXapiXo & {
|
||||
@@ -457,6 +483,29 @@ export type XoPool = BaseXapiXo & {
|
||||
zstdSupported: boolean
|
||||
}
|
||||
|
||||
export type XoPlugin =
|
||||
| XoAuditPlugin
|
||||
| XoAuthGithubPlugin
|
||||
| XoAuthGooglePlugin
|
||||
| XoAuthLdapPlugin
|
||||
| XoAuthOidcPlugin
|
||||
| XoAuthSamlPlugin
|
||||
| XoBackupReportsPlugin
|
||||
| XoIpmiSensorsPlugin
|
||||
| XoLoadBalancerPlugin
|
||||
| XoNetboxPlugin
|
||||
| XoOpenmetricsPlugin
|
||||
| XoPerfAlertPlugin
|
||||
| XoSdnControllerPlugin
|
||||
| XoTestPluginPlugin
|
||||
| XoTransportEmailPlugin
|
||||
| XoTransportIcinga2Plugin
|
||||
| XoTransportNagiosPlugin
|
||||
| XoTransportSlackPlugin
|
||||
| XoTransportXmppPlugin
|
||||
| XoUsageReportPlugin
|
||||
| XoWebHooksPlugin
|
||||
|
||||
export type XoProxy = {
|
||||
id: Branded<'proxy'>
|
||||
url: string
|
||||
@@ -609,6 +658,7 @@ export type XoServer = {
|
||||
label?: string
|
||||
master?: XoHost['id']
|
||||
poolId?: XoPool['id']
|
||||
poolMembersAddresses?: string[]
|
||||
poolNameDescription?: string
|
||||
poolNameLabel?: string
|
||||
readOnly: boolean
|
||||
@@ -697,6 +747,7 @@ export type XoTask = {
|
||||
| 'backup-log'
|
||||
| 'backup-repository'
|
||||
| 'group'
|
||||
| 'plugin'
|
||||
| 'proxy'
|
||||
| 'restore'
|
||||
| 'restore-log'
|
||||
@@ -910,7 +961,9 @@ export type NonXapiXoRecord =
|
||||
| AnyXoBackupArchive
|
||||
| AnyXoJob
|
||||
| AnyXoLog
|
||||
| XoAuthenticationToken
|
||||
| XoGroup
|
||||
| XoPlugin
|
||||
| XoProxy
|
||||
| XoJob
|
||||
| XoBackupRepository
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"main": "dist/index.mjs",
|
||||
"name": "@xen-orchestra/acl",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/acl",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@vates/types": "^2.3.0",
|
||||
"@vates/types": "^2.5.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "~5.6",
|
||||
"typescript-eslint": "^8.61.0"
|
||||
|
||||
@@ -25,4 +25,14 @@ export default {
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
probe: {
|
||||
nfs: true,
|
||||
zfs: true,
|
||||
hba: true,
|
||||
iscsiiqn: true,
|
||||
iscsilun: true,
|
||||
'iscsi-exists': true,
|
||||
'hba-exists': true,
|
||||
'nfs-exists': true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 plugin } from './plugin.mjs'
|
||||
import { default as pool } from './pool.mjs'
|
||||
import { default as proxy } from './proxy.mjs'
|
||||
import { default as restoreLog } from './restore-log.mjs'
|
||||
@@ -56,6 +57,7 @@ export const SUPPORTED_ACTIONS_BY_RESOURCE = {
|
||||
pci,
|
||||
pgpu,
|
||||
pif,
|
||||
plugin,
|
||||
pool,
|
||||
proxy,
|
||||
'restore-log': restoreLog,
|
||||
|
||||
10
@xen-orchestra/acl/src/actions/plugin.mts
Normal file
10
@xen-orchestra/acl/src/actions/plugin.mts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
load: true,
|
||||
read: true,
|
||||
test: true,
|
||||
unload: true,
|
||||
update: {
|
||||
autoload: true,
|
||||
configuration: true,
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import assert from 'node:assert'
|
||||
import type { XoUser } from '@vates/types/xo'
|
||||
|
||||
import { SUPPORTED_ACTIONS_BY_RESOURCE } from './actions/index.mjs'
|
||||
@@ -42,10 +41,21 @@ export function hasPrivilegeOn<T extends SupportedResource>({
|
||||
}) {
|
||||
// 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)
|
||||
if (typeof user?.permission !== 'string') {
|
||||
throw new TypeError('user.permission must be a string')
|
||||
}
|
||||
|
||||
if (typeof action !== 'string') {
|
||||
throw new TypeError('action must be a string')
|
||||
}
|
||||
|
||||
if (typeof resource !== 'string') {
|
||||
throw new TypeError('resource must be a string')
|
||||
}
|
||||
|
||||
if (objects === undefined) {
|
||||
throw new TypeError('objects must be defined')
|
||||
}
|
||||
|
||||
CPrivilege.checkActionIsValid(resource, action as SupportedActions<typeof resource>)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/backup-archive",
|
||||
"version": "1.1.3",
|
||||
"version": "1.2.0",
|
||||
"description": "Helper to handle generic backup archives",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
@@ -21,18 +21,18 @@
|
||||
"tar-stream": "^3.1.8",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"tmp": "^0.2.1",
|
||||
"@vates/types": "^2.3.0"
|
||||
"@vates/types": "^2.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"@xen-orchestra/fs": "^4.9.5",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3",
|
||||
"@xen-orchestra/fs": "^4.10.0",
|
||||
"@xen-orchestra/disk-transform": "^1.3.4",
|
||||
"@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.2"
|
||||
"vhd-lib": "^4.17.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn run clean && tsc",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @typedef {import('vhd-lib/Vhd/VhdFile.js').VhdFile} VhdFile
|
||||
* @typedef {import('vhd-lib/_createFooterHeader.js').VhdFooter} VhdFooter
|
||||
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
|
||||
* @typedef {import('vhd-lib/disk-consumer/compressedBlock.mjs').CompressedDiskBlock} CompressedDiskBlock
|
||||
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
|
||||
*
|
||||
|
||||
@@ -45,26 +46,36 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
*/
|
||||
#blockSize = 2 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
#bitmapSize = 512
|
||||
|
||||
/**
|
||||
* @type {() => any}
|
||||
*/
|
||||
#dispose = () => {}
|
||||
|
||||
/**
|
||||
* @type {string | undefined}
|
||||
*/
|
||||
#rawBlocksCompression
|
||||
|
||||
/**
|
||||
* set in init(): this disk is a VHD directory using #rawBlocksCompression
|
||||
* @type {boolean}
|
||||
*/
|
||||
#rawBlocks = false
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {RemoteHandlerAbstract} params.handler
|
||||
* @param {string} params.path
|
||||
* @param {string} [params.rawBlocksCompression] if this disk is a VHD directory using this compression, its blocks
|
||||
* are read as stored (see CompressedDiskBlock), to be written as is in a VHD directory using the same compression.
|
||||
* Otherwise (plain VHD, other compression), they are read normally
|
||||
*/
|
||||
constructor({ handler, path }) {
|
||||
constructor({ handler, path, rawBlocksCompression }) {
|
||||
super()
|
||||
// @todo : ensure this is the full path from the root of the remote
|
||||
this.#path = path
|
||||
this.#handler = handler
|
||||
this.#rawBlocksCompression = rawBlocksCompression
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +98,9 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
this.#vhd = undefined
|
||||
throw Object.assign(new Error("Can't init vhd directory without using alias"), { code: 'NOT_SUPPORTED' })
|
||||
}
|
||||
// the disk may predate a change of the remote configuration: only use its raw blocks if they match
|
||||
this.#rawBlocks =
|
||||
this.#rawBlocksCompression !== undefined && this.#getCompressionType() === this.#rawBlocksCompression
|
||||
|
||||
if (!options.ignoreBlockIndexes) {
|
||||
await this.#vhd.readBlockAllocationTable()
|
||||
@@ -263,10 +277,7 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
if (this.#vhd === undefined) {
|
||||
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
|
||||
}
|
||||
await this.#vhd.writeEntireBlock({
|
||||
id: diskBlock.index,
|
||||
buffer: Buffer.concat([Buffer.alloc(this.#bitmapSize, 255), diskBlock.data]),
|
||||
})
|
||||
await this.#vhd.writeBlockData(diskBlock.index, diskBlock.data)
|
||||
|
||||
return this.getBlockSize()
|
||||
}
|
||||
@@ -274,12 +285,22 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
/**
|
||||
* Reads a specific block from the VHD.
|
||||
* @param {number} index
|
||||
* @returns {Promise<DiskBlock>} diskBlock
|
||||
* @returns {Promise<DiskBlock | CompressedDiskBlock>} diskBlock
|
||||
*/
|
||||
async readBlock(index) {
|
||||
if (this.#vhd === undefined) {
|
||||
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
|
||||
}
|
||||
if (this.#rawBlocks) {
|
||||
/** @type {CompressedDiskBlock} */
|
||||
const block = {
|
||||
index,
|
||||
// only a VHD directory has a compression, checked in init()
|
||||
data: await /** @type {VhdDirectory} */ (this.#vhd).readCompressedBlock(index),
|
||||
compression: /** @type {string} */ (this.#rawBlocksCompression),
|
||||
}
|
||||
return block
|
||||
}
|
||||
const { data } = await this.#vhd.readBlock(index)
|
||||
return {
|
||||
index,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@xen-orchestra/async-map": "^0.1.5",
|
||||
"@xen-orchestra/backups": "^0.76.1",
|
||||
"@xen-orchestra/fs": "^4.9.5",
|
||||
"@xen-orchestra/backups": "^0.78.0",
|
||||
"@xen-orchestra/fs": "^4.10.0",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.18.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.18",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert'
|
||||
|
||||
import { formatFilenameDate } from './_filenameDate.mjs'
|
||||
import { importIncrementalVm } from './_incrementalVm.mjs'
|
||||
import { liveMountXapiLabels } from './liveMountXapiLabels.mjs'
|
||||
import { watchStreamSize } from './_watchStreamSize.mjs'
|
||||
import { decorateClass } from '@vates/decorate-with'
|
||||
import { Task } from '@vates/task'
|
||||
@@ -42,7 +43,8 @@ export class ImportVmBackup {
|
||||
* @param {object} [params.liveMount] - how to serve a disk from the backup repository instead of
|
||||
* copying it, injected by the caller since a live mount outlives the restore and cannot be run
|
||||
* from this package
|
||||
* @param {({ diskPath, hostId }) => Promise<{ id: string, vdiUuid: string }>} params.liveMount.mountDisk
|
||||
* @param {({ cacheSrUuid, diskPath, hostId, xapiLabels }) => Promise<{ id: string, vdiUuid: string }>} params.liveMount.mountDisk
|
||||
* `xapiLabels` are those of {@link liveMountXapiLabels}, `cacheSrUuid` makes the mount read/write
|
||||
* @param {(id: string) => Promise<void>} params.liveMount.unmountDisk
|
||||
* @param {object} [params.settings]
|
||||
*/
|
||||
@@ -356,11 +358,23 @@ export class ImportVmBackup {
|
||||
}
|
||||
|
||||
const diskPath = join(metadataDir, metadata.vhds[vdiRef])
|
||||
const mount = await liveMount.mountDisk({ diskPath, hostId })
|
||||
const cacheSrUuid = this._vdiRestoreTargets.get(vdi.uuid).cacheSr
|
||||
const readWrite = cacheSrUuid !== undefined
|
||||
const xapiLabels = liveMountXapiLabels({
|
||||
readWrite,
|
||||
timestamp: metadata.timestamp,
|
||||
vdiNameLabel: vdi.name_label,
|
||||
vmNameLabel: metadata.vm.name_label,
|
||||
})
|
||||
const mount = await liveMount.mountDisk({ cacheSrUuid, diskPath, hostId, xapiLabels })
|
||||
this.#liveMounts.push({ ...mount, hostId })
|
||||
info('disk live mounted', { diskPath, hostId, mountId: mount.id, vdiUuid: vdi.uuid })
|
||||
info('disk live mounted', { cacheSrUuid, diskPath, hostId, mountId: mount.id, vdiUuid: vdi.uuid })
|
||||
|
||||
const record = { ...vdi, liveMountedVdiRef: await xapi.call('VDI.get_by_uuid', mount.vdiUuid) }
|
||||
const record = {
|
||||
...vdi,
|
||||
liveMountedVdiRef: await xapi.call('VDI.get_by_uuid', mount.vdiUuid),
|
||||
liveMountedReadOnly: !readWrite,
|
||||
}
|
||||
// it is attached as is: there is nothing to clone it from, nothing to transfer into it, and
|
||||
// no SR to create it on — `SR` still holds the ref it had on the backed up pool
|
||||
delete record.baseVdi
|
||||
|
||||
@@ -141,7 +141,20 @@ describe('ImportVmBackup#_decorateIncrementalVmMetadata()', () => {
|
||||
|
||||
const backup = await importer._decorateIncrementalVmMetadata()
|
||||
|
||||
assert.deepEqual(liveMount.mounted, [{ diskPath: `/xo-vm-backups/${VM_UUID}/${vhdPath(DATA)}`, hostId: HOST }])
|
||||
assert.deepEqual(liveMount.mounted, [
|
||||
{
|
||||
// not cached: read only
|
||||
cacheSrUuid: undefined,
|
||||
diskPath: `/xo-vm-backups/${VM_UUID}/${vhdPath(DATA)}`,
|
||||
hostId: HOST,
|
||||
// named after the backed up VM and disk, not after the file on the backup repository
|
||||
xapiLabels: {
|
||||
srNameLabel: '[XO live mount] a vm (20250801T080832Z)',
|
||||
vdiNameLabel: 'data',
|
||||
vdiNameDescription: 'Read-only live mount of a vm (20250801T080832Z), removed on unmount.',
|
||||
},
|
||||
},
|
||||
])
|
||||
// no data is transferred for it
|
||||
assert.deepEqual(Object.keys(backup.disks), [SYSTEM.ref])
|
||||
})
|
||||
@@ -153,6 +166,7 @@ describe('ImportVmBackup#_decorateIncrementalVmMetadata()', () => {
|
||||
|
||||
const vdi = backup.vdis[DATA.ref]
|
||||
assert.equal(vdi.liveMountedVdiRef, 'VDI_REF:mounted-vdi-1')
|
||||
assert.equal(vdi.liveMountedReadOnly, true)
|
||||
assert.equal(vdi.SR, undefined)
|
||||
assert.equal(vdi.uuid, DATA.uuid)
|
||||
// the other disk is restored as usual
|
||||
@@ -160,6 +174,19 @@ describe('ImportVmBackup#_decorateIncrementalVmMetadata()', () => {
|
||||
assert.equal(backup.vdis[SYSTEM.ref].liveMountedVdiRef, undefined)
|
||||
})
|
||||
|
||||
it('mounts a cached disk read/write, and says so in its names', async () => {
|
||||
const { importer, liveMount } = makeImporter({
|
||||
mapVdisSrs: { [DATA.uuid]: { type: 'live-mount', host: HOST, cacheSr: OTHER_SR } },
|
||||
})
|
||||
|
||||
const backup = await importer._decorateIncrementalVmMetadata()
|
||||
|
||||
const [mounted] = liveMount.mounted
|
||||
assert.equal(mounted.cacheSrUuid, OTHER_SR)
|
||||
assert.equal(mounted.xapiLabels.srNameLabel, '[XO live restore] a vm (20250801T080832Z)')
|
||||
assert.equal(backup.vdis[DATA.ref].liveMountedReadOnly, false)
|
||||
})
|
||||
|
||||
it('rejects live mounts spread over several hosts', async () => {
|
||||
const { importer } = makeImporter({
|
||||
mapVdisSrs: {
|
||||
|
||||
@@ -40,10 +40,10 @@ afterEach(async () => {
|
||||
const uniqueId = () => uuid.v1()
|
||||
const uniqueIdBuffer = () => uuid.v1({}, Buffer.alloc(16))
|
||||
|
||||
async function getAdapter({ useVhdDirectory = false, vhdDirectoryCompression, remoteCompressionType } = {}) {
|
||||
async function getAdapter({ useVhdDirectory = false, remoteCompressionType } = {}) {
|
||||
handler = getHandler({ url: `file://${tempDir}`, useVhdDirectory, compressionType: remoteCompressionType })
|
||||
await handler.sync()
|
||||
return new RemoteAdapter(handler, { vhdDirectoryCompression })
|
||||
return new RemoteAdapter(handler)
|
||||
}
|
||||
|
||||
const listVmDir = () => handler.list(rootPath, { ignoreMissing: true })
|
||||
@@ -145,7 +145,8 @@ describe('RemoteAdapter#isMergeableParent', { concurrency: 1 }, () => {
|
||||
|
||||
test(`diskIsDirectory=${diskIsDirectory} useVhdDirectory=${useVhdDirectory} compressionMatches=${compressionMatches} uuidMatches=${uuidMatches} -> ${expected}`, async () => {
|
||||
const targetCompression = 'brotli'
|
||||
const adapter = await getAdapter({ useVhdDirectory, vhdDirectoryCompression: targetCompression })
|
||||
// no compressionType on the remote: brotli
|
||||
const adapter = await getAdapter({ useVhdDirectory })
|
||||
const targetUuid = uniqueIdBuffer()
|
||||
|
||||
await generateVhd(`${basePath}/disk.vhd`, {
|
||||
@@ -164,37 +165,32 @@ describe('RemoteAdapter#isMergeableParent', { concurrency: 1 }, () => {
|
||||
})
|
||||
|
||||
describe('RemoteAdapter#isMergeableParent compression resolution', { concurrency: 1 }, () => {
|
||||
test('the per-remote compressionType override wins over the adapter default', async () => {
|
||||
const adapter = await getAdapter({
|
||||
useVhdDirectory: true,
|
||||
vhdDirectoryCompression: 'gzip', // config.toml-style default
|
||||
remoteCompressionType: 'brotli', // per-remote override, should be the one actually compared against
|
||||
})
|
||||
test('the compressionType of the remote is the one compared against', async () => {
|
||||
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'gzip' })
|
||||
const targetUuid = uniqueIdBuffer()
|
||||
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'gzip', uuid: targetUuid })
|
||||
|
||||
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), true)
|
||||
})
|
||||
|
||||
test('a disk compressed with another codec than the remote is rejected', async () => {
|
||||
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'gzip' })
|
||||
const targetUuid = uniqueIdBuffer()
|
||||
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'brotli', uuid: targetUuid })
|
||||
|
||||
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), false)
|
||||
})
|
||||
|
||||
test('a remote without compressionType uses brotli', async () => {
|
||||
const adapter = await getAdapter({ useVhdDirectory: true })
|
||||
const targetUuid = uniqueIdBuffer()
|
||||
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'brotli', uuid: targetUuid })
|
||||
|
||||
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), true)
|
||||
})
|
||||
|
||||
test('a disk compressed with the adapter default (not the override) is rejected', async () => {
|
||||
const adapter = await getAdapter({
|
||||
useVhdDirectory: true,
|
||||
vhdDirectoryCompression: 'gzip',
|
||||
remoteCompressionType: 'brotli',
|
||||
})
|
||||
const targetUuid = uniqueIdBuffer()
|
||||
// disk uses the config default, not the remote override that's actually in effect
|
||||
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'gzip', uuid: targetUuid })
|
||||
|
||||
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), false)
|
||||
})
|
||||
|
||||
test("a remote-level 'none' override means no compression, ignoring the adapter default", async () => {
|
||||
const adapter = await getAdapter({
|
||||
useVhdDirectory: true,
|
||||
vhdDirectoryCompression: 'brotli',
|
||||
remoteCompressionType: 'none',
|
||||
})
|
||||
test("a remote-level 'none' means no compression", async () => {
|
||||
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'none' })
|
||||
const targetUuid = uniqueIdBuffer()
|
||||
// no compression option => VhdDirectory's own getCompressionType() is undefined, matching 'none'
|
||||
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', uuid: targetUuid })
|
||||
@@ -323,3 +319,105 @@ describe('RemoteAdapter#writeVhd validated by checkDisk', { concurrency: 1 }, ()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// IncrementalRemote runner when source and targets use the same compression: the blocks are copied as stored
|
||||
describe('RemoteAdapter raw blocks copy', { concurrency: 1 }, () => {
|
||||
// copy a disk the way the IncrementalRemote runner does, and check the target content
|
||||
async function copyAndCheck(adapter, sourcePath, { rawBlocksCompression, expectedCompression }) {
|
||||
const targetPath = `${basePath}/${adapter.getVhdFileName('target')}`
|
||||
const source = await adapter._createVhdDisk(handler, sourcePath, { useChain: false, rawBlocksCompression })
|
||||
await adapter.writeVhd(targetPath, source, {
|
||||
validator: tmpPath => checkDisk(handler, tmpPath),
|
||||
uuid: uniqueIdBuffer(),
|
||||
})
|
||||
|
||||
await Disposable.use(openVhd(handler, targetPath), async vhd => {
|
||||
assert.equal(vhd.compressionType, expectedCompression)
|
||||
await vhd.readBlockAllocationTable()
|
||||
assert.ok(vhd.containsBlock(0))
|
||||
assert.ok(!vhd.containsBlock(1))
|
||||
const { data } = await vhd.readBlock(2)
|
||||
assert.ok(
|
||||
data.every(byte => byte === 0x42),
|
||||
'block 2 should contain the source disk data'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const compression of ['brotli', 'gzip', 'none']) {
|
||||
test(`${compression}: a disk read with the same compression is copied as stored, readable and identical`, async () => {
|
||||
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: compression })
|
||||
const sourcePath = `${basePath}/${adapter.getVhdFileName('source')}`
|
||||
await adapter.writeVhd(sourcePath, new MockDisk(3, [0, 2], 0x42), { uuid: uniqueIdBuffer() })
|
||||
|
||||
const source = await adapter._createVhdDisk(handler, sourcePath, {
|
||||
useChain: false,
|
||||
rawBlocksCompression: compression,
|
||||
})
|
||||
const block = await source.readBlock(2)
|
||||
await source.close()
|
||||
assert.equal(block.compression, compression)
|
||||
if (compression !== 'none') {
|
||||
assert.ok(block.data.length < 2 * 1024 * 1024, 'the block should be read compressed')
|
||||
}
|
||||
|
||||
await copyAndCheck(adapter, sourcePath, { rawBlocksCompression: compression, expectedCompression: compression })
|
||||
})
|
||||
}
|
||||
|
||||
test('a disk written before a compression change is read normally and copied', async () => {
|
||||
const gzipAdapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'gzip' })
|
||||
const sourcePath = `${basePath}/${gzipAdapter.getVhdFileName('source')}`
|
||||
await gzipAdapter.writeVhd(sourcePath, new MockDisk(3, [0, 2], 0x42), { uuid: uniqueIdBuffer() })
|
||||
await handler.forget()
|
||||
|
||||
const brotliAdapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'brotli' })
|
||||
const source = await brotliAdapter._createVhdDisk(handler, sourcePath, {
|
||||
useChain: false,
|
||||
rawBlocksCompression: 'brotli',
|
||||
})
|
||||
const block = await source.readBlock(2)
|
||||
await source.close()
|
||||
assert.equal(block.compression, undefined)
|
||||
assert.equal(block.data.length, 2 * 1024 * 1024)
|
||||
|
||||
await copyAndCheck(brotliAdapter, sourcePath, { rawBlocksCompression: 'brotli', expectedCompression: 'brotli' })
|
||||
})
|
||||
|
||||
test('a VHD file written before the switch to VHD directories is read normally and copied', async () => {
|
||||
const fileAdapter = await getAdapter({ useVhdDirectory: false })
|
||||
const sourcePath = `${basePath}/${fileAdapter.getVhdFileName('source')}`
|
||||
await fileAdapter.writeVhd(sourcePath, new MockDisk(3, [0, 2], 0x42), { uuid: uniqueIdBuffer() })
|
||||
await handler.forget()
|
||||
|
||||
const directoryAdapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'brotli' })
|
||||
await copyAndCheck(directoryAdapter, sourcePath, { rawBlocksCompression: 'brotli', expectedCompression: 'brotli' })
|
||||
})
|
||||
|
||||
// blocks as read from a VHD directory using `compression`, without their real content: only the checks are tested
|
||||
class MockCompressedDisk extends MockDisk {
|
||||
#compression
|
||||
constructor(compression, ...args) {
|
||||
super(...args)
|
||||
this.#compression = compression
|
||||
}
|
||||
|
||||
async readBlock(index) {
|
||||
return { ...(await super.readBlock(index)), compression: this.#compression }
|
||||
}
|
||||
}
|
||||
|
||||
test('a VHD directory target refuses blocks compressed differently', async () => {
|
||||
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'brotli' })
|
||||
await assert.rejects(
|
||||
adapter.writeVhd(
|
||||
`${basePath}/${adapter.getVhdFileName('target')}`,
|
||||
new MockCompressedDisk('gzip', 1, [0], 0x42),
|
||||
{
|
||||
uuid: uniqueIdBuffer(),
|
||||
}
|
||||
),
|
||||
/can't write a gzip compressed block 0/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { stringify } from 'uuid'
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
import assert from 'node:assert'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { normalize } from '@xen-orchestra/fs/path'
|
||||
import { synchronized } from 'decorator-synchronized'
|
||||
@@ -317,6 +318,14 @@ export class RemoteAdapter {
|
||||
return this.handler.getConfig('useVhdDirectory')
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string | undefined} the compression of the VHD directories written on this remote ('none' if not
|
||||
* compressed), undefined if this remote writes plain VHD files
|
||||
*/
|
||||
getVhdDirectoryCompression() {
|
||||
return this.useVhdDirectory() ? (this.handler.getConfig('compressionType') ?? 'brotli') : undefined // compatibility layer
|
||||
}
|
||||
|
||||
#useAlias() {
|
||||
return this.useVhdDirectory()
|
||||
}
|
||||
@@ -676,7 +685,7 @@ export class RemoteAdapter {
|
||||
path,
|
||||
concurrency: writeBlockConcurrency,
|
||||
validator,
|
||||
compression: handler.getConfig('compressionType') ?? 'brotli', // compatibility layer
|
||||
compression: this.getVhdDirectoryCompression(),
|
||||
uuid,
|
||||
parentUuid,
|
||||
parentPath,
|
||||
@@ -702,7 +711,8 @@ export class RemoteAdapter {
|
||||
) {
|
||||
const container = watchStreamSize(input)
|
||||
await this._handler.outputStream(path, input, {
|
||||
checksum,
|
||||
// a known checksum is the one of unencrypted data: an encrypted remote stores no checksum
|
||||
checksum: typeof checksum === 'string' && this._handler.isEncrypted ? false : checksum,
|
||||
dirMode: this._dirMode,
|
||||
maxStreamLength,
|
||||
streamLength,
|
||||
@@ -715,19 +725,24 @@ export class RemoteAdapter {
|
||||
}
|
||||
|
||||
// open the hierarchy of ancestors until we find a full one
|
||||
async _createVhdDisk(handler, path, { useChain }) {
|
||||
async _createVhdDisk(handler, path, { useChain, rawBlocksCompression }) {
|
||||
let disk
|
||||
if (useChain) {
|
||||
assert.strictEqual(rawBlocksCompression, undefined, `can't read raw blocks of a disk chain`)
|
||||
disk = await openDiskChain({ handler, path })
|
||||
} else {
|
||||
disk = new RemoteVhdDisk({ handler, path })
|
||||
disk = new RemoteVhdDisk({ handler, path, rawBlocksCompression })
|
||||
await disk.init()
|
||||
}
|
||||
disk = new ReadAhead(disk)
|
||||
return disk
|
||||
}
|
||||
|
||||
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true } = {}) {
|
||||
/**
|
||||
* `rawBlocksCompression`: the blocks of the VHD directories using this compression are read as stored (see
|
||||
* RemoteVhdDisk), to be written as is in a remote using the same compression
|
||||
*/
|
||||
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true, rawBlocksCompression } = {}) {
|
||||
const handler = this._handler
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
|
||||
const dir = dirname(metadata._filename)
|
||||
@@ -735,7 +750,7 @@ export class RemoteAdapter {
|
||||
const disks = {}
|
||||
await asyncMapSettled(Object.keys(vdis), async ref => {
|
||||
delete vdis[ref].baseVdi
|
||||
disks[ref] = await this._createVhdDisk(handler, join(dir, vhds[ref]), { useChain })
|
||||
disks[ref] = await this._createVhdDisk(handler, join(dir, vhds[ref]), { useChain, rawBlocksCompression })
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -753,6 +768,28 @@ export class RemoteAdapter {
|
||||
return this._handler.createReadStream(resolve('/', dirname(metadata._filename), metadata.xva))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | undefined>} the checksum of the XVA as stored, undefined if it is unknown (no checksum
|
||||
* file, or encrypted remote: the stored data are not the data read)
|
||||
*/
|
||||
async readFullVmBackupChecksum(metadata) {
|
||||
if (this._handler.isEncrypted) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const checksum = String(
|
||||
await this._handler.readFile(resolve('/', dirname(metadata._filename), metadata.xva) + '.checksum')
|
||||
).trim()
|
||||
// $<algorithm id>$<salt>$<hash>
|
||||
return checksum.startsWith('$') ? checksum : undefined
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async readVmBackupMetadata(path) {
|
||||
let json
|
||||
let isImmutable = false
|
||||
|
||||
@@ -75,7 +75,6 @@ class BackupWorker {
|
||||
yield new RemoteAdapter(handler, {
|
||||
debounceResource: this.debounceResource,
|
||||
dirMode: this.#config.dirMode,
|
||||
vhdDirectoryCompression: this.#config.vhdDirectoryCompression,
|
||||
})
|
||||
} finally {
|
||||
await handler.forget()
|
||||
|
||||
@@ -294,9 +294,9 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
await asyncMap(Object.values(vdiVbds), vbd =>
|
||||
xapi.VBD_create({
|
||||
...vbd,
|
||||
// a live mount serves its disk read only: attach it as such, instead of letting the
|
||||
// guest discover it through I/O errors on its first write
|
||||
mode: isLiveMounted ? 'RO' : vbd.mode,
|
||||
// an uncached live mount serves its disk read only: attach it as such, instead of letting
|
||||
// the guest discover it through I/O errors on its first write
|
||||
mode: isLiveMounted && vdi.liveMountedReadOnly ? 'RO' : vbd.mode,
|
||||
VDI: newVdi.$ref,
|
||||
VM: vmRef,
|
||||
})
|
||||
@@ -307,9 +307,16 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
})
|
||||
|
||||
// 3.5. Destroy old VDIs that are no longer attached to the VM.
|
||||
// Uses ignoreErrors because some storage backends refuse to destroy a VDI that still has snapshot children;
|
||||
// those VDIs will become truly orphaned once the old snapshot VMs are cleaned up by _deleteOldEntries.
|
||||
await asyncMap([...oldVdiRefs], ref => ignoreErrors.call(xapi.call('VDI.destroy', ref)))
|
||||
// Some storage backends refuse to destroy a VDI that still has snapshot children.
|
||||
// Log it and leave it for later cleanup.
|
||||
await asyncMap([...oldVdiRefs], ref =>
|
||||
xapi.call('VDI.destroy', ref).catch(error =>
|
||||
Task.warning('failed to destroy orphaned VDI', {
|
||||
vdi: xapi.getObject(ref, undefined)?.uuid ?? ref,
|
||||
error,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 4. For updates, destroy existing VIFs before recreating them.
|
||||
if (isUpdate) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import { AbstractRemote } from './_AbstractRemote.mjs'
|
||||
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
|
||||
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
|
||||
@@ -5,6 +7,8 @@ import { watchStreamSize } from '../../_watchStreamSize.mjs'
|
||||
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
const { warn } = createLogger('xo:backups:FullRemoteVmBackup')
|
||||
|
||||
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
|
||||
_getRemoteWriters() {
|
||||
return [FullRemoteWriter, AggregatedFullRemoteWriter]
|
||||
@@ -21,12 +25,23 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
|
||||
for (const metadata of transferList) {
|
||||
const stream = this._throttleStream(await this._sourceRemoteAdapter.readFullVmBackup(metadata))
|
||||
const sizeContainer = watchStreamSize(stream)
|
||||
let checksum
|
||||
try {
|
||||
// the unencrypted destinations store the same data as the source: no need to compute their checksum again
|
||||
checksum = await this._sourceRemoteAdapter.readFullVmBackupChecksum(metadata)
|
||||
} catch (error) {
|
||||
warn('error while copying reading source checksum, recomputing it', error)
|
||||
}
|
||||
|
||||
// a single writer can consume the stream itself: no need to fork it
|
||||
const isForked = this._writers.size > 1
|
||||
|
||||
// @todo shouldn't transfer backup if it will be deleted by retention policy (higher retention on source than destination)
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.run({
|
||||
stream: forkStreamUnpipe(stream),
|
||||
checksum,
|
||||
stream: isForked ? forkStreamUnpipe(stream) : stream,
|
||||
// stream will be forked and transformed, it's not safe to attach additional properties to it
|
||||
streamLength: stream.length,
|
||||
maxStreamLength: stream.maxStreamLength, // for encrypted destination/source without length
|
||||
|
||||
@@ -62,14 +62,33 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
})
|
||||
// yeah , let's go
|
||||
}
|
||||
|
||||
/**
|
||||
* the block files can be copied as stored, never decompressed nor recompressed, if all the writers write VHD
|
||||
* directories with the same compression (the source disks must also use it, checked per disk when reading)
|
||||
* @returns {string | undefined} the common compression, undefined if a writer writes plain VHD files or if they differ
|
||||
*/
|
||||
#getRawBlocksCompression() {
|
||||
const compressions = new Set([...this._writers].map(writer => writer.getVhdDirectoryCompression()))
|
||||
// a single undefined value means plain VHD files on every writer: no raw blocks either
|
||||
return compressions.size === 1 ? [...compressions][0] : undefined
|
||||
}
|
||||
|
||||
async _run() {
|
||||
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
|
||||
// the source remote writes with the same compression as all the writers: the blocks can be copied as stored
|
||||
// this is based on the remote configurations, each source disk checks its own compression since it may predate a
|
||||
// configuration change, and falls back to a normal read
|
||||
const writersCompression = this.#getRawBlocksCompression()
|
||||
const rawBlocksCompression =
|
||||
writersCompression === this._sourceRemoteAdapter.getVhdDirectoryCompression() ? writersCompression : undefined
|
||||
const nbTransferrableVms = transferList.length
|
||||
let nbTransferredVms = 0
|
||||
for (const metadata of transferList) {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
|
||||
useChain: false,
|
||||
rawBlocksCompression,
|
||||
})
|
||||
// don't trust metadata too much
|
||||
// recompute if it's a base backup
|
||||
|
||||
@@ -13,7 +13,9 @@ export class TaskProgressHandler {
|
||||
if (progress < 0 || progress > 1) {
|
||||
return
|
||||
}
|
||||
// the end is always emitted: throttled, a last step under the threshold would leave the task at 99%
|
||||
if (
|
||||
progress !== 1 &&
|
||||
this.#lastProgressDate !== undefined &&
|
||||
this.#lastProgressValue !== undefined &&
|
||||
Date.now() - this.#lastProgressDate < MAX_DURATION_BETWEEN_PROGRESS_EMIT &&
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { AbstractAggregatedRemoteWriter } from './_AbstractAggregatedRemoteWriter.mjs'
|
||||
export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteWriter {
|
||||
/**
|
||||
* the backup can land on any of the adapters, the compression is only known if they all use the same one
|
||||
* (based on the adapters since the child writers are only created in beforeBackup())
|
||||
* @returns {string | undefined} see RemoteAdapter#getVhdDirectoryCompression
|
||||
*/
|
||||
getVhdDirectoryCompression() {
|
||||
const compressions = new Set(Object.values(this.adapters).map(adapter => adapter.getVhdDirectoryCompression()))
|
||||
return compressions.size === 1 ? [...compressions][0] : undefined
|
||||
}
|
||||
|
||||
async deleteOldBackupsOnAdapter(adapter, backups) {
|
||||
await adapter.deleteDeltaVmBackups(backups)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
|
||||
)
|
||||
}
|
||||
|
||||
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} [params.checksum] - checksum of the stream data if known (full mirror of an unencrypted backup)
|
||||
*/
|
||||
async _run({ checksum, maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
|
||||
const settings = this._settings
|
||||
const job = this._job
|
||||
const scheduleId = this._schedule.id
|
||||
@@ -67,6 +71,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
|
||||
|
||||
await Task.run({ properties: { name: 'transfer' } }, async () => {
|
||||
await adapter.outputStream(dataFilename, stream, {
|
||||
checksum,
|
||||
maxStreamLength,
|
||||
streamLength,
|
||||
validator: tmpPath => adapter.isValidXva(tmpPath),
|
||||
|
||||
@@ -25,6 +25,14 @@ const { warn } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
|
||||
#parentVdiPaths
|
||||
#parentUuids
|
||||
|
||||
/**
|
||||
* @returns {string | undefined} see RemoteAdapter#getVhdDirectoryCompression
|
||||
*/
|
||||
getVhdDirectoryCompression() {
|
||||
return this._adapter.getVhdDirectoryCompression()
|
||||
}
|
||||
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
this.#parentVdiPaths = {}
|
||||
this.#parentUuids = {}
|
||||
|
||||
@@ -391,6 +391,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
mostRecentEntry = this._oldEntries.pop()
|
||||
}
|
||||
await this._deleteOldEntries()
|
||||
await this._deleteOrphanVdis()
|
||||
this._oldEntries = mostRecentEntry !== undefined ? [mostRecentEntry] : []
|
||||
}
|
||||
}
|
||||
@@ -398,6 +399,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
async cleanup() {
|
||||
if (!this._settings.skipDeleteOldEntries) {
|
||||
await this._deleteOldEntries()
|
||||
await this._deleteOrphanVdis()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +407,35 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
return asyncMapSettled(this._oldEntries, vm => vm.$destroy({ bypassBlockedOperation: true }))
|
||||
}
|
||||
|
||||
// VDIs from this job/VM/SR that ended up detached from any VM, because a previous run
|
||||
// was interrupted or a storage backend refused to destroy a VDI at import time.
|
||||
// Destroying a VM only reaches the VDIs still attached to it, _deleteOldEntries never cleans them.
|
||||
async _deleteOrphanVdis() {
|
||||
const sr = this._sr
|
||||
const vmUuid = this._vmUuid
|
||||
const jobId = this._job.id
|
||||
|
||||
const orphanVdis = sr.$VDIs.filter(
|
||||
vdi =>
|
||||
vdi?.managed &&
|
||||
vdi.other_config[JOB_ID] === jobId &&
|
||||
vdi.other_config[VM_UUID] === vmUuid &&
|
||||
vdi.other_config[REPLICATED_TO_SR_UUID] === sr.uuid &&
|
||||
vdi.type !== 'suspend' &&
|
||||
// a VBD or VM missing from the cache (e.g. just destroyed) keeps the VDI, next run will handle it
|
||||
vdi.$VBDs.every(vbd => vbd?.$VM?.is_control_domain === true)
|
||||
)
|
||||
|
||||
await asyncEach(
|
||||
orphanVdis,
|
||||
vdi =>
|
||||
sr.$xapi
|
||||
.call('VDI.destroy', vdi.$ref)
|
||||
.catch(error => Task.warning('failed to delete orphan VDI', { vdi: vdi.uuid, error })),
|
||||
{ concurrency: 5 }
|
||||
)
|
||||
}
|
||||
|
||||
#decorateVmMetadata(backup, timestamp) {
|
||||
const { _warmMigration } = this._settings
|
||||
const sr = this._sr
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { AbstractWriter } from './_AbstractWriter.mjs'
|
||||
|
||||
export class AbstractFullWriter extends AbstractWriter {
|
||||
async run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
|
||||
async run({ checksum, maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
|
||||
try {
|
||||
return await this._run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot })
|
||||
return await this._run({
|
||||
checksum,
|
||||
maxStreamLength,
|
||||
timestamp,
|
||||
sizeContainer,
|
||||
stream,
|
||||
streamLength,
|
||||
vm,
|
||||
vmSnapshot,
|
||||
})
|
||||
} finally {
|
||||
// ensure stream is properly closed
|
||||
stream.destroy()
|
||||
|
||||
@@ -12,11 +12,14 @@ const { warn } = createLogger('xo:backups:vdiRestoreTargets')
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attach the backup disk itself to the restored VM, through a live mount: no copy, read only.
|
||||
* Attach the backup disk itself to the restored VM, through a live mount: no copy, read only —
|
||||
* unless it is cached, which makes it read/write.
|
||||
*
|
||||
* @typedef {object} LiveMountVdiTarget
|
||||
* @property {'live-mount'} type
|
||||
* @property {string} host - uuid of the host the disk is attached to
|
||||
* @property {string} [cacheSr] - uuid of the SR of the local VDI the disk is materialized into, which
|
||||
* also holds the writes, lost on unmount
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -133,6 +136,20 @@ export function hasLiveMountTarget(mapVdisSrs) {
|
||||
return Object.values(mapVdisSrs ?? {}).some(value => value?.type === 'live-mount')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a raw `mapVdisSrs` setting asks for at least one cached, hence read/write, live mount.
|
||||
*
|
||||
* Such a mount needs the VM of the appliance serving it, which the caller has to look up. A `null`
|
||||
* cache SR does not ask for one: it is invalid, and left to {@link normalizeVdiRestoreTargets} to
|
||||
* reject, rather than failing on the lookup of a VM which was never needed.
|
||||
*
|
||||
* @param {object} [mapVdisSrs]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasCachedLiveMountTarget(mapVdisSrs) {
|
||||
return Object.values(mapVdisSrs ?? {}).some(value => value?.type === 'live-mount' && value.cacheSr != null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the `mapVdisSrs` restore setting into per-VDI targets.
|
||||
*
|
||||
@@ -186,10 +203,17 @@ function normalizeTarget(vdiUuid, value, useDifferentialRestore) {
|
||||
}
|
||||
|
||||
if (type === 'live-mount') {
|
||||
if (!isNonEmptyString(value.host)) {
|
||||
const { cacheSr, host } = value
|
||||
if (!isNonEmptyString(host)) {
|
||||
throw invalidTarget(vdiUuid, 'a live mount requires the id of the host the disk is attached to')
|
||||
}
|
||||
return { type: 'live-mount', host: value.host }
|
||||
if (cacheSr === undefined) {
|
||||
return { type: 'live-mount', host }
|
||||
}
|
||||
if (!isNonEmptyString(cacheSr)) {
|
||||
throw invalidTarget(vdiUuid, `expected the uuid of the cache SR, got ${typeof cacheSr}`)
|
||||
}
|
||||
return { type: 'live-mount', host, cacheSr }
|
||||
}
|
||||
|
||||
if (type === 'restore') {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { hasLiveMountTarget, normalizeVdiRestoreTargets } from './_vdiRestoreTargets.mjs'
|
||||
import { hasCachedLiveMountTarget, hasLiveMountTarget, normalizeVdiRestoreTargets } from './_vdiRestoreTargets.mjs'
|
||||
|
||||
const VDI_1 = 'vdi-uuid-1'
|
||||
const VDI_2 = 'vdi-uuid-2'
|
||||
@@ -64,6 +64,11 @@ describe('normalizeVdiRestoreTargets()', () => {
|
||||
assert.equal(targets.getLiveMountHost(), HOST)
|
||||
})
|
||||
|
||||
it('keeps the cache SR of a live mount', () => {
|
||||
const targets = normalizeVdiRestoreTargets({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: SR } })
|
||||
assert.deepEqual(targets.get(VDI_1), { type: 'live-mount', host: HOST, cacheSr: SR })
|
||||
})
|
||||
|
||||
it('accepts a restore without SR', () => {
|
||||
const targets = normalizeVdiRestoreTargets({ [VDI_1]: { type: 'restore' } })
|
||||
assert.deepEqual(targets.get(VDI_1), { type: 'restore', sr: undefined, useDifferential: false })
|
||||
@@ -97,6 +102,13 @@ describe('normalizeVdiRestoreTargets()', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a non string cache SR', () => {
|
||||
assert.throws(
|
||||
() => normalizeVdiRestoreTargets({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: null } }),
|
||||
/expected the uuid of the cache SR/
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a non string SR', () => {
|
||||
assert.throws(() => normalizeVdiRestoreTargets({ [VDI_1]: { type: 'restore', sr: 42 } }), /expected an SR uuid/)
|
||||
})
|
||||
@@ -130,3 +142,20 @@ describe('hasLiveMountTarget()', () => {
|
||||
assert.equal(hasLiveMountTarget({ [VDI_1]: SR, [VDI_2]: null, [VDI_3]: { type: 'ignore' } }), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasCachedLiveMountTarget()', () => {
|
||||
it('detects a cached live mount entry', () => {
|
||||
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: SR } }), true)
|
||||
})
|
||||
|
||||
it('is false for an uncached live mount, and every other shape', () => {
|
||||
assert.equal(hasCachedLiveMountTarget(undefined), false)
|
||||
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: { type: 'live-mount', host: HOST } }), false)
|
||||
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: SR, [VDI_2]: null, [VDI_3]: { type: 'ignore' } }), false)
|
||||
})
|
||||
|
||||
// the normalization rejects it, which is the error worth reporting
|
||||
it('is false for a null cache SR', () => {
|
||||
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: null } }), false)
|
||||
})
|
||||
})
|
||||
|
||||
28
@xen-orchestra/backups/liveMountXapiLabels.mjs
Normal file
28
@xen-orchestra/backups/liveMountXapiLabels.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { formatFilenameDate } from './_filenameDate.mjs'
|
||||
|
||||
/**
|
||||
* XAPI names of what a live mount of a backed up disk shows the user, so the ones created by the REST
|
||||
* API and by a restore read the same.
|
||||
*
|
||||
* The VDI attached to the host keeps the name of the backed up disk: that is the disk of the VM as
|
||||
* the user knows it. The SR says what it is, and which restore point it serves, the same way as a
|
||||
* restored VM is named.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.vmNameLabel - name of the backed up VM
|
||||
* @param {string} params.vdiNameLabel - name of the backed up disk
|
||||
* @param {number} params.timestamp - of the restore point, in ms
|
||||
* @param {boolean} [params.readWrite] - whether the mount is a live restore rather than a live mount
|
||||
* @returns {{ srNameLabel: string, vdiNameLabel: string, vdiNameDescription: string }} - the `xapiLabels` of the live mount mixin
|
||||
*/
|
||||
export function liveMountXapiLabels({ vmNameLabel, vdiNameLabel, timestamp, readWrite = false }) {
|
||||
const what = readWrite ? 'live restore' : 'live mount'
|
||||
const restorePoint = `${vmNameLabel} (${formatFilenameDate(new Date(timestamp))})`
|
||||
return {
|
||||
srNameLabel: `[XO ${what}] ${restorePoint}`,
|
||||
vdiNameLabel,
|
||||
vdiNameDescription: readWrite
|
||||
? `Live restore of ${restorePoint}: what is written to it is lost on unmount.`
|
||||
: `Read-only live mount of ${restorePoint}, removed on unmount.`,
|
||||
}
|
||||
}
|
||||
27
@xen-orchestra/backups/liveMountXapiLabels.test.mjs
Normal file
27
@xen-orchestra/backups/liveMountXapiLabels.test.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { liveMountXapiLabels } from './liveMountXapiLabels.mjs'
|
||||
|
||||
const TIMESTAMP = 1754035712000 // 2025-08-01T08:08:32Z
|
||||
|
||||
describe('liveMountXapiLabels', () => {
|
||||
it('keeps the name of the disk and names the SR after the restore point', () => {
|
||||
assert.deepEqual(liveMountXapiLabels({ timestamp: TIMESTAMP, vdiNameLabel: 'system', vmNameLabel: 'web01' }), {
|
||||
srNameLabel: '[XO live mount] web01 (20250801T080832Z)',
|
||||
vdiNameLabel: 'system',
|
||||
vdiNameDescription: 'Read-only live mount of web01 (20250801T080832Z), removed on unmount.',
|
||||
})
|
||||
})
|
||||
|
||||
it('says a live restore loses what is written to it', () => {
|
||||
const xapiLabels = liveMountXapiLabels({
|
||||
readWrite: true,
|
||||
timestamp: TIMESTAMP,
|
||||
vdiNameLabel: 'system',
|
||||
vmNameLabel: 'web01',
|
||||
})
|
||||
assert.equal(xapiLabels.srNameLabel, '[XO live restore] web01 (20250801T080832Z)')
|
||||
assert.match(xapiLabels.vdiNameDescription, /lost on unmount/)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.76.1",
|
||||
"version": "0.78.0",
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
},
|
||||
@@ -27,17 +27,17 @@
|
||||
"@vates/disposable": "^0.1.6",
|
||||
"@vates/fuse-vhd": "^2.1.3",
|
||||
"@vates/generator-toolbox": "^1.1.3",
|
||||
"@vates/nbd-client": "^3.6.0",
|
||||
"@vates/nbd-client": "^3.7.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@vates/task": "^0.7.0",
|
||||
"@vates/types": "^2.3.0",
|
||||
"@vates/types": "^2.5.0",
|
||||
"@xen-orchestra/async-map": "^0.1.5",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3",
|
||||
"@xen-orchestra/fs": "^4.9.5",
|
||||
"@xen-orchestra/disk-transform": "^1.3.4",
|
||||
"@xen-orchestra/fs": "^4.10.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@xen-orchestra/qcow2": "^1.4.0",
|
||||
"@xen-orchestra/qcow2": "^1.4.1",
|
||||
"@xen-orchestra/template": "^0.1.1",
|
||||
"@xen-orchestra/backup-archive": "^1.1.3",
|
||||
"@xen-orchestra/backup-archive": "^1.2.0",
|
||||
"app-conf": "^3.0.0",
|
||||
"compare-versions": "^6.0.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
@@ -55,8 +55,8 @@
|
||||
"tar": "^7.5.19",
|
||||
"uuid": "^9.0.0",
|
||||
"value-matcher": "^0.2.0",
|
||||
"vhd-lib": "^4.16.2",
|
||||
"xen-api": "^5.0.2",
|
||||
"vhd-lib": "^4.17.0",
|
||||
"xen-api": "^5.1.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -67,7 +67,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^9.0.1"
|
||||
"@xen-orchestra/xapi": "^9.1.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^5.0.2"
|
||||
"xen-api": "^5.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/disk-cli",
|
||||
"version": "2.2.3",
|
||||
"version": "2.2.5",
|
||||
"description": "CLI tool to inspect and manage disks in backup repositories",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -20,13 +20,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@xen-orchestra/backups": "^0.76.1",
|
||||
"@xen-orchestra/backup-archive": "^1.1.3",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3",
|
||||
"@xen-orchestra/fs": "^4.9.5",
|
||||
"@xen-orchestra/qcow2": "^1.4.0",
|
||||
"@xen-orchestra/backups": "^0.78.0",
|
||||
"@xen-orchestra/backup-archive": "^1.2.0",
|
||||
"@xen-orchestra/disk-transform": "^1.3.4",
|
||||
"@xen-orchestra/fs": "^4.10.0",
|
||||
"@xen-orchestra/qcow2": "^1.4.1",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.16.2"
|
||||
"vhd-lib": "^4.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/disk-transform",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"main": "./dist/index.mjs",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
|
||||
@@ -12,11 +12,11 @@ export class ThrottledDisk extends DiskPassthrough {
|
||||
const generator = await this.source.buildDiskBlockGenerator()
|
||||
//throttle want to be able to know the length of the data
|
||||
async function* generatorWithLength() {
|
||||
for await (const { index, data } of generator) {
|
||||
for await (const block of generator) {
|
||||
// keep the other properties of the block (like the compression of a raw VHD directory block)
|
||||
yield {
|
||||
index,
|
||||
data,
|
||||
length: data.length,
|
||||
...block,
|
||||
length: block.data.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
@xen-orchestra/disk-transform/src/Throttled.test.mts
Normal file
32
@xen-orchestra/disk-transform/src/Throttled.test.mts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Throttle } from '@vates/generator-toolbox'
|
||||
import { DiskBlock } from './Disk.mjs'
|
||||
import { DebugDisk } from './utils/DebugDisk.mjs'
|
||||
import { ThrottledDisk } from './Throttled.mjs'
|
||||
|
||||
type TaggedDiskBlock = DiskBlock & { compression: string }
|
||||
|
||||
// a disk yielding blocks with an additional property, like the raw blocks of a VHD directory
|
||||
class TaggedDebugDisk extends DebugDisk {
|
||||
async readBlock(index: number): Promise<TaggedDiskBlock> {
|
||||
return { ...(await super.readBlock(index)), compression: 'brotli' }
|
||||
}
|
||||
}
|
||||
|
||||
test('ThrottledDisk keeps the other properties of the blocks', async () => {
|
||||
const blockSize = 1024
|
||||
const nbBlocks = 5
|
||||
const source = new TaggedDebugDisk({ nbBlocks, blockSize, fillRate: 100 })
|
||||
await source.init()
|
||||
// speed 0 disables the throttling
|
||||
const disk = new ThrottledDisk(source, new Throttle(0))
|
||||
|
||||
let nbBlocksRead = 0
|
||||
for await (const block of disk.diskBlocks()) {
|
||||
assert.strictEqual((block as TaggedDiskBlock).compression, 'brotli')
|
||||
assert.strictEqual(block.data.length, blockSize)
|
||||
nbBlocksRead++
|
||||
}
|
||||
assert.strictEqual(nbBlocksRead, nbBlocks)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "4.9.5",
|
||||
"version": "4.10.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
|
||||
@@ -28,6 +28,7 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
|
||||
ivLength: 0,
|
||||
authTagLength: 0,
|
||||
encryptData: buffer => buffer,
|
||||
encryptDataParts: buffers => buffers,
|
||||
encryptStream: stream => stream,
|
||||
decryptData: buffer => buffer,
|
||||
decryptStream: stream => stream,
|
||||
@@ -109,11 +110,17 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
|
||||
)
|
||||
}
|
||||
|
||||
function encryptData(buffer) {
|
||||
// data can be an array of buffers, encrypted as their concatenation
|
||||
// returns the encrypted file as an array of buffers, to be written one after the other
|
||||
function encryptDataParts(data) {
|
||||
const iv = crypto.randomBytes(ivLength)
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
|
||||
const encrypted = cipher.update(buffer)
|
||||
return Buffer.concat([iv, encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)])
|
||||
const encrypted = Array.isArray(data) ? data.map(buffer => cipher.update(buffer)) : [cipher.update(data)]
|
||||
return [iv, ...encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)]
|
||||
}
|
||||
|
||||
function encryptData(data) {
|
||||
return Buffer.concat(encryptDataParts(data))
|
||||
}
|
||||
|
||||
function decryptData(buffer) {
|
||||
@@ -128,7 +135,10 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
|
||||
encrypted = buffer.slice(ivLength)
|
||||
}
|
||||
const decrypted = decipher.update(encrypted)
|
||||
return Buffer.concat([decrypted, decipher.final()])
|
||||
// final() must always be called: it checks the auth tag
|
||||
const final = decipher.final()
|
||||
// nothing is left with stream modes (like GCM): no need to copy the data
|
||||
return final.length === 0 ? decrypted : Buffer.concat([decrypted, final])
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -138,6 +148,7 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
|
||||
authTagLength,
|
||||
ivLength,
|
||||
encryptData,
|
||||
encryptDataParts,
|
||||
encryptStream,
|
||||
decryptData,
|
||||
decryptStream,
|
||||
|
||||
@@ -36,6 +36,16 @@ algorithms.forEach(algorithm => {
|
||||
assert.equal(decrypted.equals(buffer), true)
|
||||
})
|
||||
|
||||
it('handle an array of buffers', () => {
|
||||
// like a VHD block: a bitmap then the data, sizes not aligned on the cipher block size
|
||||
const parts = [crypto.randomBytes(512), crypto.randomBytes(1024 * 1024 + 1)]
|
||||
const encrypted = Buffer.concat(encryptor.encryptDataParts(parts))
|
||||
|
||||
// same layout as a single buffer: existing remotes must still be readable
|
||||
const decrypted = encryptor.decryptData(encrypted)
|
||||
assert.equal(decrypted.equals(Buffer.concat(parts)), true)
|
||||
})
|
||||
|
||||
it('handle stream', async () => {
|
||||
const stream = Readable.from(buffer)
|
||||
stream.length = buffer.length
|
||||
|
||||
@@ -26,6 +26,10 @@ const computeRate = (hrtime, size) => {
|
||||
const DEFAULT_TIMEOUT = 12e5 // 20 min
|
||||
const DEFAULT_MAX_PARALLEL_OPERATIONS = 10
|
||||
|
||||
// size of the chunks of the file streams, same as the backup streams: with the 64 KiB of Node, each chunk costs a
|
||||
// round trip to the thread pool, which limits a sequential read to about 1.5 GB/s
|
||||
const DEFAULT_HIGH_WATER_MARK = 10 * 1024 * 1024
|
||||
|
||||
const ENCRYPTION_DESC_FILENAME = 'encryption.json'
|
||||
const ENCRYPTION_METADATA_FILENAME = 'metadata.json'
|
||||
|
||||
@@ -158,7 +162,7 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
}
|
||||
;({
|
||||
highWaterMark: this._highWaterMark,
|
||||
highWaterMark: this._highWaterMark = DEFAULT_HIGH_WATER_MARK,
|
||||
timeout: this._timeout = DEFAULT_TIMEOUT,
|
||||
withLimit: this._withLimit = WITH_LIMIT,
|
||||
withTimeout: this._withTimeout = WITH_TIMEOUT,
|
||||
@@ -289,12 +293,19 @@ export default class RemoteHandlerAbstract {
|
||||
* @param {string} path
|
||||
* @param {ReadableStream} input
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.checksum]
|
||||
* @param {boolean|string} [options.checksum=true] - whether to compute and store the checksum of the stored data,
|
||||
* or this checksum itself when it is already known (an unencrypted file copied from another remote): it is then
|
||||
* stored as is, without reading the data
|
||||
* @param {number} [options.dirMode]
|
||||
* @param {(this: RemoteHandlerAbstract, path: string) => Promise<undefined>} [options.validator] Function that will be called before the data is commited to the remote, if it fails, file should not exist
|
||||
*/
|
||||
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
|
||||
path = normalizePath(path)
|
||||
const knownChecksum = typeof checksum === 'string' ? checksum : undefined
|
||||
if (knownChecksum !== undefined) {
|
||||
// the checksum is the one of the stored data, which are different once encrypted
|
||||
assert.strictEqual(this.isEncrypted, false, `a known checksum can't be used on an encrypted remote for ${path}`)
|
||||
}
|
||||
let checksumStream
|
||||
info('will output stream ', { path, streamLength, maxStreamLength, isEncrypted: this.isEncrypted })
|
||||
if (this.isEncrypted) {
|
||||
@@ -305,7 +316,7 @@ export default class RemoteHandlerAbstract {
|
||||
info('stream is updated ', { path, streamLength, maxStreamLength, overhead })
|
||||
}
|
||||
input = this.#encryptor.encryptStream(input)
|
||||
if (checksum) {
|
||||
if (checksum === true) {
|
||||
checksumStream = createChecksumStream()
|
||||
pipeline(input, checksumStream, noop)
|
||||
input = checksumStream
|
||||
@@ -319,7 +330,10 @@ export default class RemoteHandlerAbstract {
|
||||
if (checksum) {
|
||||
// using _outputFile means the checksum will NOT be encrypted
|
||||
// it is by design to allow checking of encrypted files without the key
|
||||
await this._outputFile(checksumFile(path), await checksumStream.checksum, { dirMode, flags: 'wx' })
|
||||
await this._outputFile(checksumFile(path), knownChecksum ?? (await checksumStream.checksum), {
|
||||
dirMode,
|
||||
flags: 'wx',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,8 +397,15 @@ export default class RemoteHandlerAbstract {
|
||||
await this._mktree(normalizePath(dir), { mode })
|
||||
}
|
||||
|
||||
// data can be an array of buffers, the file is then their concatenation
|
||||
async outputFile(file, data, { dirMode, flags = 'wx' } = {}) {
|
||||
const encryptedData = this.#encryptor.encryptData(data)
|
||||
// the encrypted parts are not concatenated when the handler can write them as is
|
||||
let encryptedData = this._writesBufferArrays
|
||||
? this.#encryptor.encryptDataParts(data)
|
||||
: this.#encryptor.encryptData(data)
|
||||
if (Array.isArray(encryptedData) && !this._writesBufferArrays) {
|
||||
encryptedData = Buffer.concat(encryptedData)
|
||||
}
|
||||
await this._outputFile(normalizePath(file), encryptedData, { dirMode, flags })
|
||||
}
|
||||
|
||||
@@ -789,6 +810,11 @@ export default class RemoteHandlerAbstract {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
// whether _writeFile accepts an array of buffers (written one after the other, without concatenating them)
|
||||
get _writesBufferArrays() {
|
||||
return false
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
return this.#encryptor.id !== 'NULL_ENCRYPTOR'
|
||||
}
|
||||
|
||||
@@ -249,8 +249,19 @@ export default class AzureHandler extends RemoteHandlerAbstract {
|
||||
}
|
||||
file = this.#makeFullPath(file)
|
||||
const blobClient = this.#containerClient.getBlobClient(file)
|
||||
const properties = await blobClient.getProperties()
|
||||
return properties.contentLength
|
||||
try {
|
||||
const properties = await blobClient.getProperties()
|
||||
return properties.contentLength
|
||||
} catch (e) {
|
||||
// a HEAD response has no body, so there is no BlobNotFound code to match
|
||||
if (e.name === 'RestError' && e.statusCode === 404) {
|
||||
const error = new Error(`ENOENT: no such file '${file}'`, { cause: e })
|
||||
error.code = 'ENOENT'
|
||||
error.path = file
|
||||
throw error
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async _read(file, buffer, position = 0) {
|
||||
|
||||
@@ -51,6 +51,22 @@ function dontAddSyncStackTrace(fn, ...args) {
|
||||
return fn.apply(this, args)
|
||||
}
|
||||
|
||||
// writes the buffers one after the other with writev: no need to concatenate them
|
||||
// libuv already resumes the partial writes, a short count means the write stopped (like a full disk)
|
||||
// fs-extra goes through graceful-fs: an EMFILE on open is queued, like with fs.writeFile
|
||||
async function writeBuffers(path, buffers, flags) {
|
||||
const fd = await fs.open(path, flags)
|
||||
try {
|
||||
const length = buffers.reduce((sum, buffer) => sum + buffer.length, 0)
|
||||
const { bytesWritten } = await fs.writev(fd, buffers, 0)
|
||||
if (bytesWritten !== length) {
|
||||
throw new Error(`incomplete write of ${path}: ${bytesWritten} of ${length} bytes`)
|
||||
}
|
||||
} finally {
|
||||
await fs.close(fd)
|
||||
}
|
||||
}
|
||||
|
||||
export default class LocalHandler extends RemoteHandlerAbstract {
|
||||
#addSyncStackTrace
|
||||
#retriesOnEagain
|
||||
@@ -250,6 +266,13 @@ export default class LocalHandler extends RemoteHandlerAbstract {
|
||||
}
|
||||
|
||||
_writeFile(file, data, { flags }) {
|
||||
if (Array.isArray(data)) {
|
||||
return this.#addSyncStackTrace(writeBuffers, this.getFilePath(file), data, flags)
|
||||
}
|
||||
return this.#addSyncStackTrace(fs.writeFile, this.getFilePath(file), data, { flag: flags })
|
||||
}
|
||||
|
||||
get _writesBufferArrays() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +303,14 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
}
|
||||
|
||||
async _unlink(path) {
|
||||
await this.#s3.send(new DeleteObjectCommand(this.#createParams(path)))
|
||||
try {
|
||||
await this.#s3.send(new DeleteObjectCommand(this.#createParams(path)))
|
||||
} catch (error) {
|
||||
// Some providers answers 404 NoSuchKey where AWS answers 204
|
||||
if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.#isNotEmptyDir(path)) {
|
||||
const error = new Error(`EISDIR: illegal operation on a directory, unlink '${path}'`)
|
||||
@@ -370,8 +377,20 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
if (typeof file !== 'string') {
|
||||
file = file.fd
|
||||
}
|
||||
const result = await this.#s3.send(new HeadObjectCommand(this.#createParams(file)))
|
||||
return +result.ContentLength
|
||||
try {
|
||||
const result = await this.#s3.send(new HeadObjectCommand(this.#createParams(file)))
|
||||
return +result.ContentLength
|
||||
} catch (error) {
|
||||
// a HEAD response has no body, so a missing object is reported as
|
||||
// NotFound instead of the NoSuchKey the other methods get
|
||||
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
|
||||
const enoentError = new Error(`ENOENT: no such file '${file}'`, { cause: error })
|
||||
enoentError.code = 'ENOENT'
|
||||
enoentError.path = file
|
||||
throw enoentError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async _read(file, buffer, position = 0) {
|
||||
|
||||
@@ -136,7 +136,7 @@ export abstract class RemoteHandlerAbstract {
|
||||
abstract read(file: FileDescriptor, buffer: Buffer, position?: number): Promise<ReadResult>
|
||||
|
||||
abstract outputStream(path: string, input: Readable, options?: OutputStreamOptions): Promise<void>
|
||||
abstract outputFile(file: string, data: string | Buffer, options?: OutputFileOptions): Promise<void>
|
||||
abstract outputFile(file: string, data: string | Buffer | Buffer[], options?: OutputFileOptions): Promise<void>
|
||||
abstract writeFile(file: string, data: string | Buffer, options?: WriteFileOptions): Promise<void>
|
||||
|
||||
abstract write(file: FileArg, buffer: Buffer, position?: number): Promise<void>
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
"xo-immutable-remote": "./dist/protectRemotes.mjs"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "3.0.7",
|
||||
"version": "3.0.9",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@xen-orchestra/backups": "^0.76.1",
|
||||
"@xen-orchestra/backups": "^0.78.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"app-conf": "^3.0.0",
|
||||
"execa": "^5.0.0",
|
||||
|
||||
@@ -139,7 +139,13 @@ describe('protectRemotes/watchRemote', async () => {
|
||||
await fs.writeFile(path.join(vmDir, `${BACKUP_DATE}.json`), '{}')
|
||||
|
||||
// The whole directory (and its contents) must become immutable.
|
||||
// `chattr -R` flags the directory before recursing into its children, so
|
||||
// waiting on the directory alone races with the files inside it.
|
||||
await waitFor(() => Directory.isImmutable(vhdDir))
|
||||
await waitFor(() => Directory.isImmutable(blocks))
|
||||
await waitFor(() => File.isImmutable(bat))
|
||||
await waitFor(() => File.isImmutable(header))
|
||||
await waitFor(() => File.isImmutable(footer))
|
||||
|
||||
assert.strictEqual(await Directory.isImmutable(vhdDir), true, 'vhd dir should be immutable')
|
||||
assert.strictEqual(await File.isImmutable(bat), true, 'bat should be immutable')
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
# ChangeLog
|
||||
|
||||
## **next**
|
||||
## **0.27.0** (2026-10-29)
|
||||
|
||||
- [New network] Choose between no NBD, secure NBD and insecure NBD (not encrypted) when creating a network (PR [#10540](https://github.com/vatesfr/xen-orchestra/pull/10540))
|
||||
- [VM/System] Fix video RAM displayed in bytes instead of MiB (PR [#10486](https://github.com/vatesfr/xen-orchestra/pull/10486))
|
||||
- [Treeview] Add a search bar and virtualize the tree (PR [#10414](https://github.com/vatesfr/xen-orchestra/pull/10414))
|
||||
- [New/VM] Hide guest tools ISO SR in new VM ISO selector (PR [#10430](https://github.com/vatesfr/xen-orchestra/pull/10430))
|
||||
- [Host] Add possibility to enable/disable a host (PR [#10337](https://github.com/vatesfr/xen-orchestra/pull/10337))
|
||||
- [Host] Add possibility to restart a host toolstack (PR [#10480](https://github.com/vatesfr/xen-orchestra/pull/10480))
|
||||
- [Host] Add possibility to detach a host (PR [#10520](https://github.com/vatesfr/xen-orchestra/pull/10520))
|
||||
- [Side panels] Harmonize card titles across side panels (PR [#10289](https://github.com/vatesfr/xen-orchestra/pull/10289))
|
||||
- [VM/New] Fix UEFI VMs booting in BIOS mode when created with a network install or without disk (PR [#10521](https://github.com/vatesfr/xen-orchestra/pull/10521))
|
||||
- [Treeview] Press Ctrl+K (Cmd+K on macOS) to focus the treeview search (PR [#10492](https://github.com/vatesfr/xen-orchestra/pull/10492))
|
||||
- [Pool] Fix connection, stats and consoles when overriding the pool master with `?master=`: use HTTPS by default instead of the protocol and port of the page (PR [#9955](https://github.com/vatesfr/xen-orchestra/pull/9955))
|
||||
- [Pool/Host/VM] Add copy button for multiple information on Host/VM Dashboard and Pool/Host/VM System tabs (PR [#9976](https://github.com/vatesfr/xen-orchestra/pull/9976))
|
||||
- [New/VM] Fix `MAP_DUPLICATE_KEY` error that could randomly prevent the VM creation (PR [#10552](https://github.com/vatesfr/xen-orchestra/pull/10552))
|
||||
- [Host/Network] Add the possibility to forget a PIF (PR [#10239](https://github.com/vatesfr/xen-orchestra/pull/10239))
|
||||
- [Lite] Display dates and times as `YYYY-MM-DD HH:mm:ss` (PR [#10547](https://github.com/vatesfr/xen-orchestra/pull/10547))
|
||||
- [Pool/Hosts] Add a side panel displaying the selected host's details, and a search bar to filter the hosts table (PR [#10580](https://github.com/vatesfr/xen-orchestra/pull/10580))
|
||||
- [VM/Host] Add console clipboard management (PR [#10625](https://github.com/vatesfr/xen-orchestra/pull/10625))
|
||||
- [VM/Network] Add the possibility to edit a VIF (PR [#10505](https://github.com/vatesfr/xen-orchestra/pull/10505))
|
||||
|
||||
## **0.26.0** (2026-10-01)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.26.0",
|
||||
"version": "0.27.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
@@ -27,13 +27,13 @@
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18.19.80",
|
||||
"@vates/types": "^2.3.0",
|
||||
"@vates/types": "^2.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"@vueuse/math": "^14.0.0",
|
||||
"@vueuse/shared": "^14.0.0",
|
||||
"@xen-orchestra/web-core": "^0.63.1",
|
||||
"@xen-orchestra/web-core": "^0.65.0",
|
||||
"complex-matcher": "^1.1.1",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{ powerState.text }}
|
||||
</template>
|
||||
</VtsKeyValueRow>
|
||||
<VtsKeyValueRow :label="t('ip-address')" :value="host.address" />
|
||||
<VtsKeyValueRow :label="t('ip-address')" :value="host.address" copy />
|
||||
<VtsKeyValueRow :label="t('started')" :value="isRunning ? relativeStartTime : undefined" />
|
||||
<VtsKeyValueRow :label="t('master')">
|
||||
<template #value>
|
||||
@@ -27,19 +27,13 @@
|
||||
</VtsKeyValueRow>
|
||||
</VtsKeyValueList>
|
||||
<VtsKeyValueList>
|
||||
<VtsKeyValueRow :label="t('uuid')" :value="host.uuid" />
|
||||
<VtsKeyValueRow :label="t('uuid')" :value="host.uuid" copy />
|
||||
<VtsKeyValueRow :label="t('description')" :value="host.name_description" />
|
||||
<VtsKeyValueRow :label="t('version')" :value="host.software_version.product_version" />
|
||||
<VtsKeyValueRow
|
||||
:label="t('hardware')"
|
||||
:value="`${host.bios_strings['system-manufacturer']} (${host.bios_strings['system-product-name']})`"
|
||||
/>
|
||||
<VtsKeyValueRow :label="t('hardware')" :value="manufacturerInfo" />
|
||||
</VtsKeyValueList>
|
||||
<VtsKeyValueList>
|
||||
<VtsKeyValueRow
|
||||
:label="t('cores-with-sockets')"
|
||||
:value="`${host.cpu_info.cpu_count} (${host.cpu_info.socket_count})`"
|
||||
/>
|
||||
<VtsKeyValueRow :label="t('cores-with-sockets')" :value="coreSocketInfo" />
|
||||
<VtsKeyValueRow :label="t('ram')" :value="`${ram?.value} ${ram?.prefix}`" />
|
||||
<VtsKeyValueRow :label="t('tags')">
|
||||
<template #value>
|
||||
@@ -54,10 +48,11 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useHostUtils } from '@/modules/host/composables/host-utils.composable.ts'
|
||||
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/host.util.ts'
|
||||
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
|
||||
import { useHostStore } from '@/stores/xen-api/host.store.ts'
|
||||
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
|
||||
import type { IconName } from '@core/icons'
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
import VtsKeyValueList from '@core/components/key-value-list/VtsKeyValueList.vue'
|
||||
import VtsKeyValueRow from '@core/components/key-value-row/VtsKeyValueRow.vue'
|
||||
@@ -65,10 +60,8 @@ import VtsQuickInfoCard from '@core/components/quick-info-card/VtsQuickInfoCard.
|
||||
import VtsTag from '@core/components/tag/VtsTag.vue'
|
||||
import UiLink from '@core/components/ui/link/UiLink.vue'
|
||||
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
|
||||
import useRelativeTime from '@core/composables/relative-time.composable.ts'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive.ts'
|
||||
import { formatSizeRaw } from '@core/utils/size.util.ts'
|
||||
import { parseDateTime } from '@core/utils/time.util.ts'
|
||||
import { logicAnd } from '@vueuse/math'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -79,24 +72,17 @@ const { host } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isMasterHost, masterHost, isReady: isPoolReady } = usePoolStore().subscribe()
|
||||
const { masterHost, isReady: isPoolReady } = usePoolStore().subscribe()
|
||||
const { isReady: isHostReady } = useHostStore().subscribe()
|
||||
const { isHostRunning, getHostMemory, isReady: isHostMetricsReady } = useHostMetricsStore().subscribe()
|
||||
const { getHostMemory, isReady: isHostMetricsReady } = useHostMetricsStore().subscribe()
|
||||
|
||||
const { isMaster, isRunning, powerState, relativeStartTime } = useHostUtils(() => host)
|
||||
|
||||
const isReady = logicAnd(isPoolReady, isHostReady, isHostMetricsReady)
|
||||
|
||||
const isRunning = computed(() => isHostRunning(host))
|
||||
const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
|
||||
|
||||
const powerState = computed<{ text: string; icon: IconName }>(() => ({
|
||||
text: isRunning.value ? t(`host:status:running`) : t('host:status:halted'),
|
||||
icon: isRunning.value ? 'status:running-circle' : 'status:halted-circle',
|
||||
}))
|
||||
|
||||
const date = computed(() => new Date(parseDateTime(Number(host.other_config.boot_time) * 1000)))
|
||||
|
||||
const relativeStartTime = useRelativeTime(date)
|
||||
|
||||
const isMaster = computed(() => isMasterHost(host.$ref))
|
||||
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
|
||||
|
||||
const ram = computed(() => {
|
||||
const memory = getHostMemory(host)
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<template>
|
||||
<VtsSidePanel :has-selection="!!pif" class="host-pif-side-panel" @close="emit('close')">
|
||||
<template v-if="pif" #actions>
|
||||
<VtsForgetButton
|
||||
:busy="isForgettingPifs"
|
||||
:disabled="!canForgetPifs"
|
||||
:tooltip="!canForgetPifs && forgetPifsErrorMessage"
|
||||
@click="forgetPifs()"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="pif" #default>
|
||||
<!-- PIF -->
|
||||
<UiPanelCard>
|
||||
<VtsCardObjectTitle :id="pif.uuid" :label="isBond ? t('bond') : t('pif')" />
|
||||
<UiPanelCardTitle :id="pif.uuid" size="medium" :label="isBond ? t('bond') : t('pif')" />
|
||||
<div class="content">
|
||||
<!-- NETWORK -->
|
||||
<VtsCardRowKeyValue>
|
||||
@@ -78,7 +86,7 @@
|
||||
</UiPanelCard>
|
||||
<!-- NETWORK INFORMATION -->
|
||||
<UiPanelCard>
|
||||
<UiCardTitle>{{ t('network-information') }}</UiCardTitle>
|
||||
<UiPanelCardTitle size="medium" :label="t('network-information')" />
|
||||
<div class="content">
|
||||
<!-- IP ADDRESSES -->
|
||||
<div v-if="ipAddresses.length">
|
||||
@@ -183,7 +191,7 @@
|
||||
</UiPanelCard>
|
||||
<!-- PROPERTIES -->
|
||||
<UiPanelCard>
|
||||
<UiCardTitle>{{ t('properties') }}</UiCardTitle>
|
||||
<UiPanelCardTitle size="medium" :label="t('properties')" />
|
||||
<div class="content">
|
||||
<!-- MTU -->
|
||||
<VtsCardRowKeyValue>
|
||||
@@ -226,19 +234,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { usePifForget } from '@/modules/pif/composables/use-pif-forget.composable.ts'
|
||||
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
|
||||
import { usePifMetricsStore } from '@/stores/xen-api/pif-metrics.store.ts'
|
||||
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
|
||||
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
|
||||
import VtsCardObjectTitle from '@core/components/card-object-title/VtsCardObjectTitle.vue'
|
||||
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
|
||||
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
|
||||
import VtsForgetButton from '@core/components/forget-button/VtsForgetButton.vue'
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
|
||||
import VtsStatus from '@core/components/status/VtsStatus.vue'
|
||||
import VtsTag from '@core/components/tag/VtsTag.vue'
|
||||
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
|
||||
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
|
||||
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
|
||||
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive.ts'
|
||||
import humanFormat from 'human-format'
|
||||
@@ -259,6 +268,10 @@ const { getBondsDevices, isBondMaster } = usePifStore().subscribe()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { forgetPifs, canForgetPifs, isForgettingPifs, forgetPifsErrorMessage } = usePifForget(() =>
|
||||
pif !== undefined ? [pif] : []
|
||||
)
|
||||
|
||||
const ipAddresses = computed(() => {
|
||||
if (pif === undefined) {
|
||||
return []
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { XenApiNetwork, XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { usePifForget } from '@/modules/pif/composables/use-pif-forget.composable.ts'
|
||||
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
|
||||
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
@@ -111,7 +112,7 @@ function getManagementIcon(pif: XenApiPif) {
|
||||
}
|
||||
|
||||
const { HeadCells, BodyCells } = usePifColumns({
|
||||
exclude: ['actions'],
|
||||
exclude: ['selectItem'],
|
||||
body: (pif: XenApiPif) => {
|
||||
const name = computed(() => getNetworkName(pif.network))
|
||||
const status = computed(() => getPifStatus(pif))
|
||||
@@ -120,6 +121,8 @@ const { HeadCells, BodyCells } = usePifColumns({
|
||||
const ipMode = computed(() => getIpConfigurationMode(pif.ip_configuration_mode))
|
||||
const rightIcon = computed(() => getManagementIcon(pif))
|
||||
|
||||
const { forgetPifs, canForgetPifs, isForgettingPifs, forgetPifsErrorMessage } = usePifForget(() => [pif])
|
||||
|
||||
return {
|
||||
network: r => r({ label: name.value }),
|
||||
device: r => r(pif.device, { rightIcon: rightIcon.value }),
|
||||
@@ -128,7 +131,21 @@ const { HeadCells, BodyCells } = usePifColumns({
|
||||
ip: r => r(ipAddresses.value),
|
||||
mac: r => r(pif.MAC),
|
||||
mode: r => r(ipMode.value),
|
||||
selectItem: r => r(() => (selectedPifId.value = pif.uuid)),
|
||||
actions: r =>
|
||||
r({
|
||||
onClick: () => (selectedPifId.value = pif.uuid),
|
||||
actions: [
|
||||
{
|
||||
label: t('action:forget'),
|
||||
icon: 'action:forget',
|
||||
onClick: () => forgetPifs(),
|
||||
busy: isForgettingPifs.value,
|
||||
disabled: !canForgetPifs.value,
|
||||
hint: forgetPifsErrorMessage.value,
|
||||
accent: 'danger',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
{{ t('general-information') }}
|
||||
</UiTitle>
|
||||
<VtsTabularKeyValueList>
|
||||
<VtsTabularKeyValueRow :label="t('name')" :value="host.name_label" />
|
||||
<VtsTabularKeyValueRow :label="t('uuid')" :value="host.uuid" />
|
||||
<VtsTabularKeyValueRow :label="t('name')" :value="host.name_label" copy />
|
||||
<VtsTabularKeyValueRow :label="t('uuid')" :value="host.uuid" copy />
|
||||
<VtsTabularKeyValueRow :label="t('description')" :value="host.name_description" />
|
||||
<VtsTabularKeyValueRow :label="t('tags')">
|
||||
<template v-if="host.tags.length > 0" #value>
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
|
||||
import { useHostUtils } from '@/modules/host/composables/host-utils.composable.ts'
|
||||
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
import VtsRelativeTime from '@core/components/relative-time/VtsRelativeTime.vue'
|
||||
@@ -71,7 +71,6 @@ import UiLink from '@core/components/ui/link/UiLink.vue'
|
||||
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive.ts'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { host } = defineProps<{
|
||||
@@ -80,9 +79,7 @@ const { host } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { pool, isMasterHost, masterHost } = usePoolStore().subscribe()
|
||||
const { isHostRunning } = useHostMetricsStore().subscribe()
|
||||
const { pool, masterHost } = usePoolStore().subscribe()
|
||||
|
||||
const isMaster = computed(() => isMasterHost(host.$ref))
|
||||
const isRunning = computed(() => isHostRunning(host))
|
||||
const { isMaster, isRunning } = useHostUtils(() => host)
|
||||
</script>
|
||||
|
||||
@@ -4,19 +4,13 @@
|
||||
{{ t('hardware-specifications') }}
|
||||
</UiTitle>
|
||||
<VtsTabularKeyValueList>
|
||||
<VtsTabularKeyValueRow
|
||||
:label="t('manufacturer-info')"
|
||||
:value="`${host.bios_strings['system-manufacturer']} (${host.bios_strings['system-product-name']})`"
|
||||
/>
|
||||
<VtsTabularKeyValueRow :label="t('manufacturer-info')" :value="manufacturerInfo" />
|
||||
<VtsTabularKeyValueRow
|
||||
:label="t('bios-info')"
|
||||
:value="`${host.bios_strings['bios-vendor']} (${host.bios_strings['bios-version']})`"
|
||||
/>
|
||||
<VtsTabularKeyValueRow :label="t('cpu-model')" :value="host.cpu_info.modelname" />
|
||||
<VtsTabularKeyValueRow
|
||||
:label="t('core-socket')"
|
||||
:value="`${host.cpu_info.cpu_count} (${host.cpu_info.socket_count})`"
|
||||
/>
|
||||
<VtsTabularKeyValueRow :label="t('core-socket')" :value="coreSocketInfo" />
|
||||
<VtsTabularKeyValueRow :label="t('gpus')">
|
||||
<template v-if="isReady" #value>
|
||||
<template v-if="devicesNames">
|
||||
@@ -33,6 +27,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/host.util.ts'
|
||||
import { usePciStore } from '@/stores/xen-api/pci.store.ts'
|
||||
import { usePgpuStore } from '@/stores/xen-api/pgpu.store.ts'
|
||||
import VtsTabularKeyValueList from '@core/components/tabular-key-value-list/VtsTabularKeyValueList.vue'
|
||||
@@ -41,6 +36,7 @@ import UiCard from '@core/components/ui/card/UiCard.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { logicAnd } from '@vueuse/math'
|
||||
import { useArrayReduce } from '@vueuse/shared'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { host } = defineProps<{
|
||||
@@ -54,6 +50,10 @@ const { getByOpaqueRef: getPciByOpaqueRef, isReady: isPciReady } = usePciStore()
|
||||
|
||||
const isReady = logicAnd(isPgpuReady, isPciReady)
|
||||
|
||||
const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
|
||||
|
||||
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
|
||||
|
||||
const devicesNames = useArrayReduce(
|
||||
() => pGpusByHost.value.get(host.$ref) ?? [],
|
||||
(acc, pGpu) => {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
{{ t('networking') }}
|
||||
</UiTitle>
|
||||
<VtsTabularKeyValueList>
|
||||
<VtsTabularKeyValueRow :label="t('ip-address')" :value="host.address" />
|
||||
<VtsTabularKeyValueRow :label="t('ip-address')" :value="host.address" copy />
|
||||
<VtsTabularKeyValueRow :label="t('remote-syslog')" :value="host.logging.syslog_destination" />
|
||||
<VtsTabularKeyValueRow :label="t('iscsi-iqn')" :value="host.iscsi_iqn" />
|
||||
<VtsTabularKeyValueRow :label="t('iscsi-iqn')" :value="host.iscsi_iqn" copy />
|
||||
<VtsTabularKeyValueRow :label="t('multi-pathing')">
|
||||
<template #value>
|
||||
<VtsStatus :status="host.multipathing" />
|
||||
|
||||
@@ -17,13 +17,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
|
||||
import { useHostUtils } from '@/modules/host/composables/host-utils.composable.ts'
|
||||
import VtsRelativeTime from '@core/components/relative-time/VtsRelativeTime.vue'
|
||||
import VtsTabularKeyValueList from '@core/components/tabular-key-value-list/VtsTabularKeyValueList.vue'
|
||||
import VtsTabularKeyValueRow from '@core/components/tabular-key-value-row/VtsTabularKeyValueRow.vue'
|
||||
import UiCard from '@core/components/ui/card/UiCard.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { host } = defineProps<{
|
||||
@@ -32,7 +31,5 @@ const { host } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isHostRunning } = useHostMetricsStore().subscribe()
|
||||
|
||||
const isRunning = computed(() => isHostRunning(host))
|
||||
const { isRunning } = useHostUtils(() => host)
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { getHostState } from '@/modules/host/utils/host.util.ts'
|
||||
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
|
||||
import { useHostStore } from '@/stores/xen-api/host.store.ts'
|
||||
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
|
||||
@@ -41,7 +42,7 @@ const { pif } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const { getByOpaqueRef } = useHostStore().subscribe()
|
||||
const { isHostRunning } = useHostMetricsStore().subscribe()
|
||||
const { getHostPowerState } = useHostMetricsStore().subscribe()
|
||||
const { getPifStatus } = usePifStore().subscribe()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -57,7 +58,7 @@ const pifHost = computed(() => {
|
||||
|
||||
return {
|
||||
label: host.name_label,
|
||||
powerState: isHostRunning(host) ? 'running' : 'halted',
|
||||
powerState: getHostState(host, getHostPowerState(host)),
|
||||
redirect() {
|
||||
router.push({
|
||||
name: '/host/[uuid]/network',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</template>
|
||||
<template v-if="network" #default>
|
||||
<UiPanelCard>
|
||||
<VtsCardObjectTitle :id="network.uuid" :label="network.name_label" />
|
||||
<UiPanelCardTitle :id="network.uuid" size="medium" :label="network.name_label" />
|
||||
<div class="content">
|
||||
<!-- DESCRIPTION -->
|
||||
<VtsCardRowKeyValue truncate align-top>
|
||||
@@ -66,10 +66,7 @@
|
||||
</div>
|
||||
</UiPanelCard>
|
||||
<UiPanelCard v-if="pifsCount && pifsCount > 0">
|
||||
<div class="typo-body-bold">
|
||||
{{ t('pifs') }}
|
||||
<UiCounter :value="pifsCount" variant="primary" size="small" accent="neutral" />
|
||||
</div>
|
||||
<UiPanelCardTitle size="medium" :label="t('pifs')" :counter="pifsCount" />
|
||||
<table class="simple-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -100,15 +97,14 @@ import type { XenApiNetwork } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useNetworkDelete } from '@/modules/network/composables/use-network-delete.composable.ts'
|
||||
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
|
||||
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
|
||||
import VtsCardObjectTitle from '@core/components/card-object-title/VtsCardObjectTitle.vue'
|
||||
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
|
||||
import VtsDeleteButton from '@core/components/delete-button/VtsDeleteButton.vue'
|
||||
import MenuItem from '@core/components/menu/MenuItem.vue'
|
||||
import MenuList from '@core/components/menu/MenuList.vue'
|
||||
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
|
||||
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
|
||||
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
|
||||
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
|
||||
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive.ts'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
{{ t('general-information') }}
|
||||
</UiTitle>
|
||||
<VtsTabularKeyValueList>
|
||||
<VtsTabularKeyValueRow :label="t('name')" :value="pool.name_label" />
|
||||
<VtsTabularKeyValueRow :label="t('uuid')" :value="pool.uuid" />
|
||||
<VtsTabularKeyValueRow :label="t('name')" :value="pool.name_label" copy />
|
||||
<VtsTabularKeyValueRow :label="t('uuid')" :value="pool.uuid" copy />
|
||||
<VtsTabularKeyValueRow :label="t('description')" :value="pool.name_description" />
|
||||
<VtsTabularKeyValueRow :label="t('tags')">
|
||||
<template v-if="pool.tags.length > 0" #value>
|
||||
|
||||
@@ -62,8 +62,8 @@ const useTaskColumns = defineColumns(() => {
|
||||
name: useTextColumn({ headerLabel: t('name') }),
|
||||
host: useLinkColumn({ headerLabel: t('host') }),
|
||||
progress: useProgressBarColumn({ headerLabel: t('progress') }),
|
||||
started: useDateColumn({ headerLabel: t('started'), dateStyle: 'short', timeStyle: 'short' }),
|
||||
estimatedEnd: useDateColumn({ headerLabel: t('estimated-end'), dateStyle: 'short', timeStyle: 'short' }),
|
||||
started: useDateColumn({ headerLabel: t('started') }),
|
||||
estimatedEnd: useDateColumn({ headerLabel: t('estimated-end') }),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ const state = useTableState({
|
||||
})
|
||||
|
||||
const { HeadCells, BodyCells } = useVmColumns({
|
||||
exclude: ['actions'],
|
||||
exclude: ['actions', 'checkbox'],
|
||||
body: (vm: XenApiVm) => {
|
||||
const guestMetrics = computed(() => getGuestMetrics(vm.guest_metrics))
|
||||
const vmMetrics = computed(() => getVmMetrics(vm.metrics))
|
||||
|
||||
@@ -9,19 +9,19 @@
|
||||
</span>
|
||||
</template>
|
||||
</VtsKeyValueRow>
|
||||
<VtsKeyValueRow :label="t('ip-address')" :value="mainIpAddress" />
|
||||
<VtsKeyValueRow :label="t('ip-address')" :value="mainIpAddress" copy />
|
||||
<VtsKeyValueRow :label="t('created-on')" :value="installDateFormatted" />
|
||||
<VtsKeyValueRow :label="t('started')" :value="relativeStartTime" />
|
||||
</VtsKeyValueList>
|
||||
<VtsKeyValueList>
|
||||
<VtsKeyValueRow :label="t('uuid')" :value="vm.uuid" />
|
||||
<VtsKeyValueRow :label="t('uuid')" :value="vm.uuid" copy />
|
||||
<VtsKeyValueRow :label="t('host')">
|
||||
<template #value>
|
||||
<UiLink
|
||||
v-if="host"
|
||||
:to="`/host/${host.uuid}`"
|
||||
size="medium"
|
||||
:icon="`object:host:${hostPowerState}`"
|
||||
:icon="hostIcon"
|
||||
:is-primary="isPoolMaster"
|
||||
:primary-tooltip="t('master')"
|
||||
>
|
||||
@@ -56,6 +56,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
|
||||
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { getHostIcon } from '@/modules/host/utils/host.util.ts'
|
||||
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
|
||||
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
|
||||
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
|
||||
@@ -71,7 +72,7 @@ import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
|
||||
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
|
||||
import { useMapper } from '@core/packages/mapper'
|
||||
import { formatSizeRaw } from '@core/utils/size.util.ts'
|
||||
import { parseDateTime } from '@core/utils/time.util.ts'
|
||||
import { formatDate, parseDateTime } from '@core/utils/time.util.ts'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -79,12 +80,12 @@ const { vm } = defineProps<{
|
||||
vm: XenApiVm
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isReady, getVmHost } = useVmStore().subscribe()
|
||||
const { getByOpaqueRef: getGuestMetricsByOpaqueRef } = useVmGuestMetricsStore().subscribe()
|
||||
const { getByOpaqueRef: getMetricsByOpaqueRef } = useVmMetricsStore().subscribe()
|
||||
const { isHostRunning } = useHostMetricsStore().subscribe()
|
||||
const { getHostPowerState } = useHostMetricsStore().subscribe()
|
||||
const { pool } = usePoolStore().subscribe()
|
||||
|
||||
const guestMetrics = computed(() => getGuestMetricsByOpaqueRef(vm.guest_metrics))
|
||||
@@ -95,13 +96,7 @@ const host = computed(() => getVmHost(vm))
|
||||
|
||||
const isPoolMaster = computed(() => pool.value?.master === host.value?.$ref)
|
||||
|
||||
const hostPowerState = computed(() => {
|
||||
if (host.value === undefined) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
return isHostRunning(host.value) ? 'running' : 'halted'
|
||||
})
|
||||
const hostIcon = computed(() => getHostIcon(host.value, getHostPowerState(host.value)))
|
||||
|
||||
const powerState = useMapper(
|
||||
() => vm.power_state,
|
||||
@@ -141,7 +136,7 @@ const installDateFormatted = computed(() => {
|
||||
return t('unknown')
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(new Date(parseDateTime(installTime)))
|
||||
return formatDate(installTime)
|
||||
})
|
||||
|
||||
const ram = computed(() => formatSizeRaw(vm.memory_dynamic_max, 0))
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
{{ t('general-information') }}
|
||||
</UiTitle>
|
||||
<VtsTabularKeyValueList>
|
||||
<VtsTabularKeyValueRow :label="t('name')" :value="vm.name_label" />
|
||||
<VtsTabularKeyValueRow :label="t('id')" :value="vm.uuid" />
|
||||
<VtsTabularKeyValueRow :label="t('name')" :value="vm.name_label" copy />
|
||||
<VtsTabularKeyValueRow :label="t('id')" :value="vm.uuid" copy />
|
||||
<VtsTabularKeyValueRow :label="t('description')" :value="vm.name_description" />
|
||||
<VtsTabularKeyValueRow :label="t('tags')">
|
||||
<template v-if="vm.tags.length > 0" #value>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchMissingHostPatches } from '@/libs/host.ts'
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import type { XenApiPatch } from '@/types/xen-api.ts'
|
||||
import { type Pausable, useTimeoutPoll, watchArray } from '@vueuse/core'
|
||||
import { type Pausable, tryOnScopeDispose, useTimeoutPoll, watchArray } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter, reactive, toValue } from 'vue'
|
||||
|
||||
export type XenApiPatchWithHostRefs = XenApiPatch & { $hostRefs: Set<string> }
|
||||
@@ -56,6 +56,8 @@ export const useHostPatches = (hosts: MaybeRefOrGetter<XenApiHost[]>) => {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
tryOnScopeDispose(() => configByHost.forEach(config => config.timeoutPoll.pause()))
|
||||
|
||||
const patches = computed(() => {
|
||||
const records = new Map<string, XenApiPatchWithHostRefs>()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipToHostname } from '@/libs/utils.ts'
|
||||
import { useHostStore } from '@/stores/xen-api/host.store.ts'
|
||||
import { useXenApiStore } from '@/stores/xen-api.store.ts'
|
||||
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
|
||||
import { reactiveComputed, whenever } from '@vueuse/core'
|
||||
import { difference } from 'lodash-es'
|
||||
@@ -8,11 +9,12 @@ import { ref, watch } from 'vue'
|
||||
export const useUnreachableHosts = () => {
|
||||
const { records: hosts } = useHostStore().subscribe()
|
||||
const unreachableHostsUrls = ref<Set<string>>(new Set())
|
||||
const { protocol } = new URL(useXenApiStore().getXapi().hostUrl)
|
||||
|
||||
watch(hosts, (nextHosts, previousHosts) => {
|
||||
difference(nextHosts, previousHosts).forEach(host => {
|
||||
const url = new URL('http://localhost')
|
||||
url.protocol = window.location.protocol
|
||||
url.protocol = protocol
|
||||
url.hostname = ipToHostname(host.address)
|
||||
fetch(url, { mode: 'no-cors' }).catch(() => unreachableHostsUrls.value.add(url.toString()))
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ export function createNetworkOperations(xenApi: XenApi) {
|
||||
nameDescription?: string
|
||||
mtu?: number
|
||||
nbd?: boolean
|
||||
/** NBD in clear, only on a network dedicated to backups. Mutually exclusive with `nbd` */
|
||||
insecureNbd?: boolean
|
||||
}
|
||||
|
||||
type NetworkCreateParams = BaseNetworkCreateParams & {
|
||||
@@ -34,18 +36,25 @@ export function createNetworkOperations(xenApi: XenApi) {
|
||||
},
|
||||
])
|
||||
|
||||
// nbd and insecure_nbd are incompatible purposes, at most one of them is added
|
||||
const addNbdPurpose = async (networkRef: XenApiNetwork['$ref'], { nbd, insecureNbd }: BaseNetworkCreateParams) => {
|
||||
if (nbd) {
|
||||
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
|
||||
} else if (insecureNbd) {
|
||||
await xenApi.call('network.add_purpose', [networkRef, 'insecure_nbd'])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
create: async (params: NetworkCreateParams): Promise<XenApiNetwork['$ref']> => {
|
||||
const { nameLabel, nameDescription = '', mtu = 1500, vlan = 0, pifRef, nbd } = params
|
||||
const { nameLabel, nameDescription = '', mtu = 1500, vlan = 0, pifRef } = params
|
||||
|
||||
const networkRef = await createEmptyNetwork(nameLabel, nameDescription, mtu)
|
||||
|
||||
try {
|
||||
await xenApi.call('pool.create_VLAN_from_PIF', [pifRef, networkRef, vlan])
|
||||
|
||||
if (nbd) {
|
||||
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
|
||||
}
|
||||
await addNbdPurpose(networkRef, params)
|
||||
} catch (error) {
|
||||
await xenApi.call('network.destroy', [networkRef])
|
||||
throw error
|
||||
@@ -55,7 +64,7 @@ export function createNetworkOperations(xenApi: XenApi) {
|
||||
},
|
||||
|
||||
createBonded: async (params: BondedNetworkCreateParams): Promise<XenApiNetwork['$ref']> => {
|
||||
const { nameLabel, nameDescription = '', mtu = 1500, pifRefs, bondMode, nbd } = params
|
||||
const { nameLabel, nameDescription = '', mtu = 1500, pifRefs, bondMode } = params
|
||||
|
||||
const networkRef = await createEmptyNetwork(nameLabel, nameDescription, mtu)
|
||||
|
||||
@@ -87,9 +96,7 @@ export function createNetworkOperations(xenApi: XenApi) {
|
||||
)
|
||||
)
|
||||
|
||||
if (nbd) {
|
||||
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
|
||||
}
|
||||
await addNbdPurpose(networkRef, params)
|
||||
} catch (error) {
|
||||
await xenApi.call('network.destroy', [networkRef])
|
||||
throw error
|
||||
@@ -99,17 +106,15 @@ export function createNetworkOperations(xenApi: XenApi) {
|
||||
},
|
||||
|
||||
createInternal: async (params: BaseNetworkCreateParams): Promise<XenApiNetwork['$ref']> => {
|
||||
const { nameLabel, nameDescription = '', mtu = 1500, nbd } = params
|
||||
const { nameLabel, nameDescription = '', mtu = 1500 } = params
|
||||
|
||||
const networkRef = await createEmptyNetwork(nameLabel, nameDescription, mtu)
|
||||
|
||||
if (nbd) {
|
||||
try {
|
||||
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
|
||||
} catch (error) {
|
||||
await xenApi.call('network.destroy', [networkRef])
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await addNbdPurpose(networkRef, params)
|
||||
} catch (error) {
|
||||
await xenApi.call('network.destroy', [networkRef])
|
||||
throw error
|
||||
}
|
||||
|
||||
return networkRef
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import type XenApi from '@/libs/xen-api/xen-api.ts'
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import type { XenApiHost, XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import type { MaybeArray } from '@core/types/utility.type.ts'
|
||||
import { toArray } from '@core/utils/to-array.utils.ts'
|
||||
import { OPAQUE_REF } from '@vates/types'
|
||||
|
||||
export function createPifOperations(xenApi: XenApi) {
|
||||
return {
|
||||
scan: (hostRef: XenApiHost['$ref']) => xenApi.call('PIF.scan', [hostRef]),
|
||||
forget: (pifs: MaybeArray<XenApiPif>) =>
|
||||
Promise.all(
|
||||
toArray(pifs).map(async pif => {
|
||||
if (pif.VLAN_master_of !== OPAQUE_REF.EMPTY) {
|
||||
return xenApi.call('VLAN.destroy', [pif.VLAN_master_of])
|
||||
}
|
||||
|
||||
if (pif.bond_master_of.length > 0) {
|
||||
return Promise.all(pif.bond_master_of.map(bondRef => xenApi.call('Bond.destroy', [bondRef])))
|
||||
}
|
||||
|
||||
if (pif.tunnel_access_PIF_of.length > 0) {
|
||||
await xenApi.call('PIF.unplug', [pif.$ref])
|
||||
return Promise.all(pif.tunnel_access_PIF_of.map(tunnelRef => xenApi.call('tunnel.destroy', [tunnelRef])))
|
||||
}
|
||||
|
||||
return xenApi.call('PIF.forget', [pif.$ref])
|
||||
})
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type XenApi from '@/libs/xen-api/xen-api.ts'
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
|
||||
|
||||
export function createPoolOperations(xenApi: XenApi) {
|
||||
type HostRef = XenApiHost['$ref']
|
||||
|
||||
const eject = (hostRef: HostRef) => xenApi.call('pool.eject', [hostRef])
|
||||
|
||||
return {
|
||||
eject,
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,14 @@ export function createVifOperations(xenApi: XenApi) {
|
||||
qos_algorithm_type?: string
|
||||
}
|
||||
|
||||
const setOtherConfig = (vifRefs: VifRefs, key: string, value: string) =>
|
||||
Promise.all(
|
||||
toArray(vifRefs).map(async vifRef => {
|
||||
await xenApi.call('VIF.remove_from_other_config', [vifRef, key])
|
||||
await xenApi.call('VIF.add_to_other_config', [vifRef, key, value])
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
create: async (vifs: VifCreateParams[]) => {
|
||||
const results: VifRefs = []
|
||||
@@ -78,5 +86,34 @@ export function createVifOperations(xenApi: XenApi) {
|
||||
plug: (vifRefs: VifRefs) => Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.plug', [vifRef]))),
|
||||
|
||||
unplug: (vifRefs: VifRefs) => Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.unplug', [vifRef]))),
|
||||
|
||||
unplugForce: (vifRefs: VifRefs) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.unplug_force', [vifRef]))),
|
||||
|
||||
move: (vifRefs: VifRefs, networkRef: NetworkRef) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.move', [vifRef, networkRef]))),
|
||||
|
||||
setIpv4Allowed: (vifRefs: VifRefs, ips: string[]) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_ipv4_allowed', [vifRef, ips]))),
|
||||
|
||||
setIpv6Allowed: (vifRefs: VifRefs, ips: string[]) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_ipv6_allowed', [vifRef, ips]))),
|
||||
|
||||
setLockingMode: (vifRefs: VifRefs, lockingMode: VIF_LOCKING_MODE) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_locking_mode', [vifRef, lockingMode]))),
|
||||
|
||||
setRateLimit: (vifRefs: VifRefs, kbps: number | null) =>
|
||||
Promise.all(
|
||||
toArray(vifRefs).map(async vifRef => {
|
||||
await xenApi.call('VIF.set_qos_algorithm_type', [vifRef, kbps === null ? '' : 'ratelimit'])
|
||||
await xenApi.call('VIF.remove_from_qos_algorithm_params', [vifRef, 'kbps'])
|
||||
|
||||
if (kbps !== null) {
|
||||
await xenApi.call('VIF.add_to_qos_algorithm_params', [vifRef, 'kbps', String(kbps)])
|
||||
}
|
||||
})
|
||||
),
|
||||
|
||||
setTxChecksumming: (vifRefs: VifRefs, enabled: boolean) => setOtherConfig(vifRefs, 'ethtool-tx', String(enabled)),
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user