mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
382 Commits
kubernetes
...
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 | ||
|
|
493aa7f15d | ||
|
|
cb1e45b6b0 | ||
|
|
0ff6196e72 | ||
|
|
2ee51a3694 | ||
|
|
87aca3ff5e | ||
|
|
305e66becc | ||
|
|
d3fea1296d | ||
|
|
8c4e4d110a | ||
|
|
cc8f039341 | ||
|
|
59c8935e37 | ||
|
|
779e448f21 | ||
|
|
3a64afb226 | ||
|
|
cfdf0de9ee | ||
|
|
d6e6e963bf | ||
|
|
29c7c0e6f1 | ||
|
|
72a9959a3c | ||
|
|
e5d4c665a4 | ||
|
|
0070c7f709 | ||
|
|
a84584d24e | ||
|
|
786610cba3 | ||
|
|
08e6e1bf49 | ||
|
|
5999d53e0b | ||
|
|
decc09117d | ||
|
|
d11cf2d2fc | ||
|
|
abdddcf105 | ||
|
|
8c2f38c004 | ||
|
|
5de02b6c19 | ||
|
|
440624a09c | ||
|
|
718b348e85 | ||
|
|
278f7d1706 | ||
|
|
b59c8f3d20 | ||
|
|
7711b6d6f5 | ||
|
|
b0c0d9361a | ||
|
|
83fdb2ed1f | ||
|
|
c9eaa7a617 | ||
|
|
a2bd181d35 | ||
|
|
989cd48559 | ||
|
|
b2f0bfc907 | ||
|
|
ce26dc2231 | ||
|
|
2bd4e2a747 | ||
|
|
75098c3cda | ||
|
|
6f091b9380 | ||
|
|
4b578d439f | ||
|
|
93e9069614 | ||
|
|
c17bcead07 | ||
|
|
3dbf8738f1 | ||
|
|
f187b79319 | ||
|
|
0ae1cf917c | ||
|
|
e287eaeb55 | ||
|
|
5a8393aa17 | ||
|
|
323f69d773 | ||
|
|
a883cef0d6 | ||
|
|
d3b3248937 | ||
|
|
45ca13ba5d | ||
|
|
2f846bf07e | ||
|
|
ae3e04fb3d | ||
|
|
986c6b8687 | ||
|
|
386e2afe12 | ||
|
|
d7a75b943b | ||
|
|
aea3717f81 | ||
|
|
5972cf5c59 | ||
|
|
2b466d6ac8 | ||
|
|
8ac91f9d7d | ||
|
|
f9cafe6a0f | ||
|
|
a9caea651c | ||
|
|
27882fb1dc | ||
|
|
99af2031c4 | ||
|
|
22706164d6 | ||
|
|
dc57333f58 | ||
|
|
3fea79d899 | ||
|
|
f4fa020d2e | ||
|
|
b5f119f1d4 | ||
|
|
d1029ffb24 | ||
|
|
a4b17d5abb | ||
|
|
b1b5097b6b | ||
|
|
5ba393ca2b | ||
|
|
b3b02b1fef | ||
|
|
6b4a7e5ebd | ||
|
|
9e38c0bac2 | ||
|
|
4d6a5ffa60 | ||
|
|
fc571c00d4 | ||
|
|
974c2cfee9 | ||
|
|
fc6124b1b8 | ||
|
|
97d338710d | ||
|
|
e957c2d821 | ||
|
|
a1dba1ed4e | ||
|
|
26a71c4d4b | ||
|
|
c40741ccf4 | ||
|
|
c95dec183e | ||
|
|
ee0b8f0468 | ||
|
|
75d9e4afda | ||
|
|
eb7471a248 | ||
|
|
cae1b72571 | ||
|
|
f56150c25b | ||
|
|
5d1d0eba2f | ||
|
|
fe69a029e1 | ||
|
|
12a32dce22 | ||
|
|
cafea84fee | ||
|
|
13480c3457 | ||
|
|
4fc992da52 | ||
|
|
6f73bde276 | ||
|
|
70cbf5cda3 | ||
|
|
44b7db2fd9 | ||
|
|
f56d241c18 | ||
|
|
ce7d94ec77 | ||
|
|
f91d64235a | ||
|
|
116f50821e | ||
|
|
43f63ee784 | ||
|
|
11ec9fd5da | ||
|
|
ea2acac77b | ||
|
|
14b7115cf1 | ||
|
|
d0645598b9 | ||
|
|
83b2fd9056 |
2
.github/workflows/changelog-check.yml
vendored
2
.github/workflows/changelog-check.yml
vendored
@@ -10,6 +10,8 @@ on:
|
||||
|
||||
jobs:
|
||||
changed-packages:
|
||||
# the release PR bumps every listed package and empties the list
|
||||
if: github.head_ref != 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# https://github.com/actions/checkout
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
- name: Install packages
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit
|
||||
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit open-iscsi
|
||||
|
||||
# https://turbo.build/repo/docs/guides/ci-vendors/github-actions#caching-with-github-actionscache
|
||||
- name: Cache Turbo
|
||||
|
||||
73
.github/workflows/vectura.yml
vendored
Normal file
73
.github/workflows/vectura.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: Vectura
|
||||
|
||||
# only when vectura changes: the rest of the repository does not build it
|
||||
#
|
||||
# pull requests rather than every push: GitHub filters a push on the diff with
|
||||
# the previous push, which after a rebase on `master` (or a merge of it)
|
||||
# contains the vectura changes of `master`, whereas it filters a pull request
|
||||
# on the diff with the merge base, i.e. the changes of the branch only
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '@xen-orchestra/vmware-explorer/vectura/**'
|
||||
- .github/workflows/vectura.yml
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '@xen-orchestra/vmware-explorer/vectura/**'
|
||||
- .github/workflows/vectura.yml
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: '@xen-orchestra/vmware-explorer/vectura'
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# https://github.com/actions/checkout
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# rustup, on the runner image, installs the toolchain rust-toolchain.toml
|
||||
# names on the first cargo command
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo test --locked
|
||||
|
||||
# https://github.com/taiki-e/install-action
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-deny@0.20.2,cargo-llvm-cov@0.8.5
|
||||
|
||||
- run: cargo deny --locked check
|
||||
- run: cargo llvm-cov --locked --json --summary-only --output-path target/coverage.json
|
||||
- name: Hold the coverage floor
|
||||
run: cargo test --locked --test coverage -- --ignored
|
||||
|
||||
# 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:
|
||||
# https://github.com/actions/checkout
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# https://github.com/mlugg/setup-zig
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
# https://github.com/taiki-e/install-action
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild@0.23.4
|
||||
|
||||
- run: scripts/build.sh "$RUNNER_TEMP/build"
|
||||
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/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
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/multi-key-map": "^0.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.4",
|
||||
"@xen-orchestra/async-map": "^0.1.5",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.9.3",
|
||||
"@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.1"
|
||||
"vhd-lib": "^4.17.0"
|
||||
},
|
||||
"bin": {
|
||||
"xo-fuse-vhd": "./cli.mjs"
|
||||
|
||||
261
@vates/iscsi/.USAGE.md
Normal file
261
@vates/iscsi/.USAGE.md
Normal file
@@ -0,0 +1,261 @@
|
||||
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
|
||||
exposes exactly one read/write LUN backed by a pluggable byte-range
|
||||
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
|
||||
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
|
||||
|
||||
### Scope
|
||||
|
||||
The target drives login negotiation onto a single, fixed code path:
|
||||
|
||||
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
|
||||
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
|
||||
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
|
||||
- one LUN (LUN 0)
|
||||
|
||||
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`/`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
|
||||
by Initiator Task Tag), so it does not stall under real guest I/O.
|
||||
|
||||
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
|
||||
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
|
||||
`RandomAccessDisk` — see [Initiator](#initiator).
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
> vates-iscsi /srv/lun.img
|
||||
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
|
||||
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
|
||||
```
|
||||
|
||||
```
|
||||
Usage: vates-iscsi <file> [options]
|
||||
|
||||
Options:
|
||||
--serial <serial> LUN serial number (default: derived from the IQN).
|
||||
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
|
||||
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
|
||||
--host <host> Listen address (default: 0.0.0.0).
|
||||
--port <port> Listen port (default: 3260).
|
||||
--block-size <bytes> Logical block size (default: 512).
|
||||
-h, --help Show this help.
|
||||
```
|
||||
|
||||
### Library
|
||||
|
||||
```js
|
||||
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
|
||||
|
||||
// The backing file must already exist and be sized to the LUN capacity,
|
||||
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
|
||||
const target = new IscsiTarget({
|
||||
iqn: 'iqn.2024-01.tech.vates:lun0',
|
||||
host: '0.0.0.0',
|
||||
port: 3260,
|
||||
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
|
||||
})
|
||||
|
||||
await target.listen()
|
||||
// ... target is now discoverable and attachable by open-iscsi ...
|
||||
await target.close()
|
||||
```
|
||||
|
||||
#### Adding a target backend
|
||||
|
||||
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
|
||||
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
|
||||
single small interface, so a backend only has to provide flat random access — it
|
||||
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
|
||||
`BlockDevice` to expose anything else (a raw device, an object store, an
|
||||
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
|
||||
|
||||
```ts
|
||||
interface BlockDevice {
|
||||
open?(): Promise<void> // optional, awaited once before serving I/O
|
||||
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
|
||||
getBlockSize(): number // logical block size, typically 512
|
||||
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
|
||||
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
|
||||
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
|
||||
close(): Promise<void> // release resources
|
||||
}
|
||||
```
|
||||
|
||||
Contract to respect:
|
||||
|
||||
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
|
||||
the device. `open()` is the place to compute it (e.g. `stat` the file).
|
||||
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
|
||||
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
|
||||
bounds `offset + length` to the capacity.
|
||||
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
|
||||
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
|
||||
backend must be safe under overlapping calls; honour back-pressure and don't
|
||||
buffer unboundedly.
|
||||
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
|
||||
attach context to the error.
|
||||
|
||||
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
|
||||
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
|
||||
|
||||
```js
|
||||
import { IscsiTarget } from '@vates/iscsi'
|
||||
|
||||
class RandomAccessDiskLun {
|
||||
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
|
||||
constructor(disk) {
|
||||
this.disk = disk
|
||||
this.blockSize = disk.getBlockSize()
|
||||
}
|
||||
getSize() {
|
||||
return this.disk.getVirtualSize()
|
||||
}
|
||||
getBlockSize() {
|
||||
return this.blockSize
|
||||
}
|
||||
async read(offset, length) {
|
||||
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
|
||||
const first = Math.floor(offset / this.blockSize)
|
||||
const last = Math.floor((offset + length - 1) / this.blockSize)
|
||||
for (let index = first; index <= last; index++) {
|
||||
if (!this.disk.hasBlock(index)) {
|
||||
continue // unallocated: leave zeros
|
||||
}
|
||||
const { data } = await this.disk.readBlock(index)
|
||||
const blockStart = index * this.blockSize
|
||||
const from = Math.max(0, offset - blockStart)
|
||||
const to = Math.min(this.blockSize, offset + length - blockStart)
|
||||
data.copy(out, blockStart + from - offset, from, to)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// reported write-protected: WRITE is refused before reaching write()
|
||||
readOnly = true
|
||||
write() {
|
||||
throw new Error('read-only LUN')
|
||||
}
|
||||
async flush() {}
|
||||
async close() {
|
||||
await this.disk.close()
|
||||
}
|
||||
}
|
||||
|
||||
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
|
||||
await target.listen()
|
||||
```
|
||||
|
||||
For a writable backend, implement `write(offset, data)` symmetrically and make
|
||||
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
|
||||
|
||||
### Initiator
|
||||
|
||||
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
|
||||
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
|
||||
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
|
||||
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
|
||||
`NbdDisk`). It is **read-only**, single-connection, digests off.
|
||||
|
||||
```js
|
||||
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
|
||||
|
||||
// Low-level byte reads:
|
||||
const initiator = new IscsiInitiator({
|
||||
host: '10.0.0.10',
|
||||
port: 3260,
|
||||
targetIqn: 'iqn.2024-01.tech.vates:lun0',
|
||||
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
|
||||
})
|
||||
await initiator.connect()
|
||||
console.log(initiator.getSize(), initiator.getBlockSize())
|
||||
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
|
||||
await initiator.close()
|
||||
|
||||
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
|
||||
const disk = new IscsiDisk(
|
||||
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
|
||||
{ blockSize: 2 * 1024 * 1024 }
|
||||
)
|
||||
await disk.init()
|
||||
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
|
||||
await disk.close()
|
||||
```
|
||||
|
||||
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
|
||||
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
|
||||
can narrow it later.
|
||||
|
||||
### Authentication (CHAP)
|
||||
|
||||
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
|
||||
(two-way) CHAP is not yet implemented.
|
||||
|
||||
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
|
||||
credential; the target challenges the initiator and rejects the login (Login
|
||||
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
|
||||
|
||||
```js
|
||||
const target = new IscsiTarget({
|
||||
iqn: 'iqn.2024-01.tech.vates:lun0',
|
||||
lun,
|
||||
chap: { user: 'alice', secret: 's3cr3t' },
|
||||
})
|
||||
```
|
||||
|
||||
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
|
||||
configure its node record's incoming credential:
|
||||
|
||||
```sh
|
||||
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
|
||||
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
|
||||
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
|
||||
```
|
||||
|
||||
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
|
||||
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
|
||||
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
|
||||
characters.
|
||||
|
||||
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
|
||||
target's challenge (e.g. a Pure array acting as authenticator). If the target
|
||||
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
|
||||
|
||||
### Security
|
||||
|
||||
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
|
||||
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
|
||||
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
|
||||
exposed to eavesdropping and tampering on the wire. Expose targets only on a
|
||||
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
|
||||
or equivalent if confidentiality is required.
|
||||
|
||||
### Testing
|
||||
|
||||
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
|
||||
negotiation, sequence-number arithmetic).
|
||||
- `yarn test-integration` — a self-contained loopback test (an in-process
|
||||
minimal initiator drives discovery, identity, write/read-back, and command
|
||||
interleaving), plus an open-iscsi interop test that **requires root and
|
||||
`iscsiadm`** and skips gracefully otherwise.
|
||||
1
@vates/iscsi/.npmignore
Symbolic link
1
@vates/iscsi/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
124
@vates/iscsi/PROTOCOL.md
Normal file
124
@vates/iscsi/PROTOCOL.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Protocol coverage
|
||||
|
||||
`@vates/iscsi` implements a deliberately narrow subset of iSCSI (RFC 7143, formerly RFC 3720) and the SCSI
|
||||
command set it carries (SPC-3 primary commands, SBC-3 block commands) — enough to serve or consume exactly one
|
||||
read/write LUN over a single connection, not a general-purpose iSCSI stack. This is the detailed companion to
|
||||
the README's "Scope" section, verified directly against the source (`constants.mts`, `login.mts`,
|
||||
`connection.mts`, `types.mts`, `initiator.mts`, `chap.mts`) rather than inferred.
|
||||
|
||||
The package has two independent roles with very different maturity:
|
||||
|
||||
- **Target** (`IscsiTarget`) — the primary, production-used role (backs `@xen-orchestra/mixins/live-mount`).
|
||||
Handles real-world Linux/XCP-ng initiators robustly: concurrent commands, real write ordering, keepalives,
|
||||
connection recovery.
|
||||
- **Initiator** (`IscsiInitiator`) — a minimal, read-only client used to read from a third-party array as a
|
||||
disk source. Built against `IscsiTarget` and standard-tooling targets (`tgt`); interop testing this session
|
||||
found it does **not** complete login against a LIO kernel target (see below) — a real, previously-unknown
|
||||
gap, not yet fixed.
|
||||
|
||||
## Session and connection
|
||||
|
||||
| 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
|
||||
|
||||
**Target** (`login.mts`) reactively answers whatever the initiator proposes, fixed to:
|
||||
`HeaderDigest=None`, `DataDigest=None`, `MaxConnections=1`, `InitialR2T=Yes`, `ImmediateData=No`,
|
||||
`ErrorRecoveryLevel=0`, `DataPDUInOrder=Yes`, `DataSequenceInOrder=Yes`, `DefaultTime2Wait=2`,
|
||||
`DefaultTime2Retain=0`, `MaxOutstandingR2T=1` — `MaxBurstLength`/`FirstBurstLength` are the only two keys
|
||||
actually negotiated down from the initiator's offer (capped at an internal maximum). Both `Normal` and
|
||||
`Discovery` session types are supported (`SessionType`), with CHAP skipped for `Discovery` per RFC — text-mode
|
||||
negotiation beyond that is limited to `SendTargets` discovery (`#handleText`), not general text negotiation.
|
||||
|
||||
**Initiator** (`initiator.mts#operationalLogin`) sends exactly **one** operational-stage login PDU proposing
|
||||
its own fixed key set (`HeaderDigest=None`, `DataDigest=None`, `InitialR2T=Yes`, `ImmediateData=No`,
|
||||
`ErrorRecoveryLevel=0`, `MaxRecvDataSegmentLength`) with the transit bit (`T=1`) set immediately, and only
|
||||
checks whether the reply also has `T=1` — there is no second round to answer a target's own counter-proposed
|
||||
keys if it declines transit on the first exchange.
|
||||
|
||||
- Works against `tgt` and against this package's own `IscsiTarget` — both transit on the first round.
|
||||
- **Does not complete against a LIO kernel target** (`targetcli-fb`): LIO counter-proposes its own operational
|
||||
keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in the same response
|
||||
and withholds transit until they're answered — confirmed directly via `dmesg`:
|
||||
```
|
||||
No response for proposed key "MaxBurstLength".
|
||||
iSCSI Login negotiation failed.
|
||||
```
|
||||
This is a real, previously-unexercised limitation of the initiator's login state machine (single
|
||||
operational round-trip only), not a bug on LIO's side. Worth weighing given the initiator's real intended
|
||||
use — reading from unknown-strictness third-party array software, not `tgt`/this package's own target.
|
||||
|
||||
## Authentication (CHAP)
|
||||
|
||||
- One-way only (RFC 1994, algorithm MD5 — the only one RFC 7143 defines, so this isn't a partial
|
||||
implementation of a wider set).
|
||||
- **Target as authenticator**: when `chap` is configured, every login must present the right `CHAP_N`/`CHAP_R`
|
||||
or the login is rejected (`INITIATOR_ERROR`/`AUTHENTICATION_FAILURE`). Interop-tested against the real
|
||||
`open-iscsi` kernel initiator (both correct-secret and wrong-secret paths).
|
||||
- **Initiator as responder**: when `chap` is configured, answers a target's challenge with the same MD5
|
||||
scheme. Never issues its own challenge.
|
||||
- **Not supported**: mutual (two-way) CHAP in either role — a target that _requires_ mutual auth will reject
|
||||
the initiator's login, surfaced as a normal login failure rather than attempted.
|
||||
|
||||
## SCSI commands
|
||||
|
||||
Decoded explicitly (`types.mts#decodeCdb`); anything else maps to `unsupported` → `CHECK CONDITION` /
|
||||
`ILLEGAL_REQUEST` / `INVALID COMMAND OPERATION CODE`:
|
||||
|
||||
| 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
|
||||
issues to attach and use a plain block device.
|
||||
|
||||
## Data transfer
|
||||
|
||||
- **Read**: `Data-In`, concurrent commands dispatched up to `readConcurrency` (default 16) regardless of the
|
||||
advertised command window — bounds how many `lun.read()` calls run at once against the real backend.
|
||||
Residual under/overflow reported against the CDB's requested transfer length.
|
||||
- **Write**: always solicited (`InitialR2T=Yes`, `ImmediateData=No`) — every write goes through `R2T` →
|
||||
`Data-Out`, one outstanding R2T at a time (`MaxOutstandingR2T=1`). `Data-Out` PDUs are routed by Initiator
|
||||
Task Tag, so writes/reads for different commands can interleave; within one write, out-of-order or
|
||||
overlapping `Data-Out` segments are rejected outright (`writePath.mts`) rather than silently accepted, to
|
||||
avoid leaking uninitialized staging-buffer memory to the LUN (a real bug this was hardened against — see
|
||||
`VALIDATION.md`).
|
||||
- **Not implemented**: unsolicited/immediate data, `MaxOutstandingR2T > 1`.
|
||||
|
||||
## Task management, error recovery, keepalives
|
||||
|
||||
- **SCSI Task Management** (`ABORT TASK`, `LUN RESET`, etc.) — not implemented; any `SCSI Task Mgmt Request`
|
||||
is rejected (`COMMAND_NOT_SUPPORTED`).
|
||||
- **SNACK** (retransmission requests, part of `ErrorRecoveryLevel > 0`) — not implemented; rejected the same
|
||||
way. Consistent with `ErrorRecoveryLevel=0` — nothing should ever need to SNACK.
|
||||
- **NOP / keepalives**:
|
||||
- Target replies to an initiator-sent `NOP-Out` ping, but does **not** proactively send its own periodic
|
||||
`NOP-In` probes (no `nop_interval` equivalent) — it relies entirely on the initiator (or TCP-level
|
||||
connection loss) to notice a dead peer.
|
||||
- Initiator answers a target-initiated `NOP-In` ping with `NOP-Out` and keeps its read loop draining for the
|
||||
life of the connection specifically so it never misses one (this was a real, fixed bug — see
|
||||
`VALIDATION.md`, item 5).
|
||||
|
||||
## Summary: not implemented
|
||||
|
||||
Mutual (two-way) CHAP · SCSI Task Management (abort/reset) · target-initiated NOP-In keepalives · multiple
|
||||
connections per session · multiple LUNs · session reinstatement (ISID) · header/data digests · unsolicited
|
||||
data / `MaxOutstandingR2T>1` · SNACK/error recovery beyond level 0 · persistent reservations and VAAI-style
|
||||
primitives · general text-mode negotiation beyond `SendTargets` · initiator-side multi-round operational login
|
||||
negotiation (works against this package's own target and `tgt`; does not against LIO — see above).
|
||||
294
@vates/iscsi/README.md
Normal file
294
@vates/iscsi/README.md
Normal file
@@ -0,0 +1,294 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/iscsi
|
||||
|
||||
[](https://npmjs.org/package/@vates/iscsi)  [](https://bundlephobia.com/result?p=@vates/iscsi) [](https://npmjs.org/package/@vates/iscsi)
|
||||
|
||||
> Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/iscsi):
|
||||
|
||||
```sh
|
||||
npm install --save @vates/iscsi
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
|
||||
exposes exactly one read/write LUN backed by a pluggable byte-range
|
||||
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
|
||||
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
|
||||
|
||||
### Scope
|
||||
|
||||
The target drives login negotiation onto a single, fixed code path:
|
||||
|
||||
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
|
||||
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
|
||||
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
|
||||
- one LUN (LUN 0)
|
||||
|
||||
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`/`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
|
||||
by Initiator Task Tag), so it does not stall under real guest I/O.
|
||||
|
||||
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
|
||||
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
|
||||
`RandomAccessDisk` — see [Initiator](#initiator).
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
> vates-iscsi /srv/lun.img
|
||||
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
|
||||
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
|
||||
```
|
||||
|
||||
```
|
||||
Usage: vates-iscsi <file> [options]
|
||||
|
||||
Options:
|
||||
--serial <serial> LUN serial number (default: derived from the IQN).
|
||||
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
|
||||
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
|
||||
--host <host> Listen address (default: 0.0.0.0).
|
||||
--port <port> Listen port (default: 3260).
|
||||
--block-size <bytes> Logical block size (default: 512).
|
||||
-h, --help Show this help.
|
||||
```
|
||||
|
||||
### Library
|
||||
|
||||
```js
|
||||
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
|
||||
|
||||
// The backing file must already exist and be sized to the LUN capacity,
|
||||
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
|
||||
const target = new IscsiTarget({
|
||||
iqn: 'iqn.2024-01.tech.vates:lun0',
|
||||
host: '0.0.0.0',
|
||||
port: 3260,
|
||||
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
|
||||
})
|
||||
|
||||
await target.listen()
|
||||
// ... target is now discoverable and attachable by open-iscsi ...
|
||||
await target.close()
|
||||
```
|
||||
|
||||
#### Adding a target backend
|
||||
|
||||
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
|
||||
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
|
||||
single small interface, so a backend only has to provide flat random access — it
|
||||
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
|
||||
`BlockDevice` to expose anything else (a raw device, an object store, an
|
||||
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
|
||||
|
||||
```ts
|
||||
interface BlockDevice {
|
||||
open?(): Promise<void> // optional, awaited once before serving I/O
|
||||
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
|
||||
getBlockSize(): number // logical block size, typically 512
|
||||
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
|
||||
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
|
||||
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
|
||||
close(): Promise<void> // release resources
|
||||
}
|
||||
```
|
||||
|
||||
Contract to respect:
|
||||
|
||||
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
|
||||
the device. `open()` is the place to compute it (e.g. `stat` the file).
|
||||
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
|
||||
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
|
||||
bounds `offset + length` to the capacity.
|
||||
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
|
||||
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
|
||||
backend must be safe under overlapping calls; honour back-pressure and don't
|
||||
buffer unboundedly.
|
||||
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
|
||||
attach context to the error.
|
||||
|
||||
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
|
||||
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
|
||||
|
||||
```js
|
||||
import { IscsiTarget } from '@vates/iscsi'
|
||||
|
||||
class RandomAccessDiskLun {
|
||||
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
|
||||
constructor(disk) {
|
||||
this.disk = disk
|
||||
this.blockSize = disk.getBlockSize()
|
||||
}
|
||||
getSize() {
|
||||
return this.disk.getVirtualSize()
|
||||
}
|
||||
getBlockSize() {
|
||||
return this.blockSize
|
||||
}
|
||||
async read(offset, length) {
|
||||
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
|
||||
const first = Math.floor(offset / this.blockSize)
|
||||
const last = Math.floor((offset + length - 1) / this.blockSize)
|
||||
for (let index = first; index <= last; index++) {
|
||||
if (!this.disk.hasBlock(index)) {
|
||||
continue // unallocated: leave zeros
|
||||
}
|
||||
const { data } = await this.disk.readBlock(index)
|
||||
const blockStart = index * this.blockSize
|
||||
const from = Math.max(0, offset - blockStart)
|
||||
const to = Math.min(this.blockSize, offset + length - blockStart)
|
||||
data.copy(out, blockStart + from - offset, from, to)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// reported write-protected: WRITE is refused before reaching write()
|
||||
readOnly = true
|
||||
write() {
|
||||
throw new Error('read-only LUN')
|
||||
}
|
||||
async flush() {}
|
||||
async close() {
|
||||
await this.disk.close()
|
||||
}
|
||||
}
|
||||
|
||||
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
|
||||
await target.listen()
|
||||
```
|
||||
|
||||
For a writable backend, implement `write(offset, data)` symmetrically and make
|
||||
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
|
||||
|
||||
### Initiator
|
||||
|
||||
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
|
||||
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
|
||||
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
|
||||
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
|
||||
`NbdDisk`). It is **read-only**, single-connection, digests off.
|
||||
|
||||
```js
|
||||
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
|
||||
|
||||
// Low-level byte reads:
|
||||
const initiator = new IscsiInitiator({
|
||||
host: '10.0.0.10',
|
||||
port: 3260,
|
||||
targetIqn: 'iqn.2024-01.tech.vates:lun0',
|
||||
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
|
||||
})
|
||||
await initiator.connect()
|
||||
console.log(initiator.getSize(), initiator.getBlockSize())
|
||||
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
|
||||
await initiator.close()
|
||||
|
||||
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
|
||||
const disk = new IscsiDisk(
|
||||
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
|
||||
{ blockSize: 2 * 1024 * 1024 }
|
||||
)
|
||||
await disk.init()
|
||||
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
|
||||
await disk.close()
|
||||
```
|
||||
|
||||
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
|
||||
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
|
||||
can narrow it later.
|
||||
|
||||
### Authentication (CHAP)
|
||||
|
||||
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
|
||||
(two-way) CHAP is not yet implemented.
|
||||
|
||||
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
|
||||
credential; the target challenges the initiator and rejects the login (Login
|
||||
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
|
||||
|
||||
```js
|
||||
const target = new IscsiTarget({
|
||||
iqn: 'iqn.2024-01.tech.vates:lun0',
|
||||
lun,
|
||||
chap: { user: 'alice', secret: 's3cr3t' },
|
||||
})
|
||||
```
|
||||
|
||||
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
|
||||
configure its node record's incoming credential:
|
||||
|
||||
```sh
|
||||
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
|
||||
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
|
||||
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
|
||||
```
|
||||
|
||||
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
|
||||
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
|
||||
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
|
||||
characters.
|
||||
|
||||
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
|
||||
target's challenge (e.g. a Pure array acting as authenticator). If the target
|
||||
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
|
||||
|
||||
### Security
|
||||
|
||||
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
|
||||
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
|
||||
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
|
||||
exposed to eavesdropping and tampering on the wire. Expose targets only on a
|
||||
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
|
||||
or equivalent if confidentiality is required.
|
||||
|
||||
### Testing
|
||||
|
||||
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
|
||||
negotiation, sequence-number arithmetic).
|
||||
- `yarn test-integration` — a self-contained loopback test (an in-process
|
||||
minimal initiator drives discovery, identity, write/read-back, and command
|
||||
interleaving), plus an open-iscsi interop test that **requires root and
|
||||
`iscsiadm`** and skips gracefully otherwise.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
|
||||
170
@vates/iscsi/VALIDATION.md
Normal file
170
@vates/iscsi/VALIDATION.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Validation notes
|
||||
|
||||
Extracted from a longer production-readiness pass covering both this package and the `LiveMount` mixin that
|
||||
consumes it; this file keeps only what's specific to `@vates/iscsi` itself — protocol-level correctness,
|
||||
concurrency, and interop with standard tooling. Local testing notes covering the LiveMount/XAPI integration,
|
||||
VM boot mechanics, and deployment specifics live elsewhere and aren't reproduced here.
|
||||
|
||||
## Bugs found and fixed
|
||||
|
||||
### In this validation pass
|
||||
|
||||
1. **Read-path error isolation** (`scsi.mts`) — `lun.read()` failures were previously uncaught, killing the
|
||||
whole connection for one bad block. Now wrapped in try/catch, reporting `CHECK_CONDITION`/`MEDIUM_ERROR` for
|
||||
that command only, mirroring the write path's existing `#handleDataOut` error handling.
|
||||
- Unit test: `scsi.test.mts` — "reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an
|
||||
uncaught throw".
|
||||
- Regression test: `loopback.integ.mts`, `describe('READ error isolation', ...)` — a LUN whose `read()`
|
||||
throws for one LBA still returns `CHECK_CONDITION` for that command; the connection stays alive and a
|
||||
different, healthy LBA read afterward still succeeds.
|
||||
|
||||
2. **Connection replacement instead of refusal** (`index.mts`) — found via a real end-to-end guest-boot test
|
||||
against an S3-backed backup repository: `IscsiTarget#onConnection` used to permanently _refuse_ any new
|
||||
connection while an existing one was tracked (`MaxConnections=1` enforcement), with no way to tell a
|
||||
genuinely stale/abandoned connection (initiator gave up and reconnected without ever closing the old TCP
|
||||
socket) from a healthy one still in use. Under a slow backend this wedges the mount forever: the host's
|
||||
initiator times out and retries, gets refused, retries again, forever — reproduced live as an 8+ minute
|
||||
guest boot stall, `WARN refusing concurrent connection (MaxConnections=1)` repeating in the log the whole
|
||||
time. Now a new connection always tears down whatever connection currently exists and takes over, rather
|
||||
than being refused. Deliberately not full RFC 7143 session reinstatement (no ISID matching) — chosen as the
|
||||
simpler, sufficient fix for this target's single-consumer, ephemeral, CHAP-guarded design; see the class doc
|
||||
comment in `index.mts` for the reasoning and the more rigorous alternative considered.
|
||||
- Regression test: `loopback.integ.mts`, `describe('connection replacement', ...)` — a second connection is
|
||||
accepted (not refused) while the first is still open, the first's socket gets torn down, and the new
|
||||
connection is fully usable (read + logout succeed).
|
||||
- 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
|
||||
|
||||
Five more fixes landed on this branch outside this validation effort. Read in full and checked against what
|
||||
had (and hadn't) actually been exercised black-box, since two are severe:
|
||||
|
||||
1. **`CachedDiskBlockDevice.mts` — acknowledged writes silently and permanently reverted.** A write fully
|
||||
covering a not-yet-cached block used to skip fetching the source (an "optimization" — the write supplies
|
||||
all the bytes anyway). But if a _read_ to that same block had already triggered an in-flight fetch, that
|
||||
fetch could land **after** the write and silently overwrite it with stale source data — guest gets `GOOD`,
|
||||
data reverts moments later. Fixed by always joining any in-flight fetch before writing. Has a white-box
|
||||
regression test (pauses a fake disk mid-fetch to force the race). This is exactly the class of bug this
|
||||
whole effort was looking for, and its vulnerable path — read+write racing on the same never-touched block —
|
||||
was never actually exercised by this pass's own earlier black-box testing (sequential `dd` and single-job
|
||||
`fio` both avoid same-offset self-overlap by construction), so the "clean" results up to that point were
|
||||
consistent with the bug being present, not proof of its absence. Closed that gap afterward (see below).
|
||||
|
||||
2. **`connection.mts`/`writePath.mts` — uninitialized heap memory leaked to the LUN on a malformed Data-Out.**
|
||||
Staging buffer is `Buffer.allocUnsafe`, never zeroed; the old completion check summed received bytes, but
|
||||
`Buffer.copy` silently clamps on out-of-range/overlapping offsets — a malformed/out-of-order Data-Out
|
||||
sequence could reach "totalLength received" with gaps of recycled heap memory that then get written to the
|
||||
LUN and read back by any guest. Real info-disclosure bug, needs a misbehaving/malicious initiator to trigger
|
||||
(a well-behaved one never sends malformed Data-Out). Fixed with strict contiguity validation; extensively
|
||||
tested (`connection.test.mts` +586 lines, `writePath.test.mts` +168 lines, both white-box, covering the
|
||||
malformed-input space directly — not something black-box testing with real tooling can easily reach at all).
|
||||
|
||||
3. **`connection.mts` — CmdSN window corrupted by every Data-Out (i.e. every write).** Command-window tracking
|
||||
read bytes 24-27 as CmdSN off every inbound PDU, but those bytes are reserved on Data-Out per RFC 7143
|
||||
§11.7 — every write rewound the advertised window. Benign at normal scale (initiators discard the rewind as
|
||||
stale via serial-number comparison until session CmdSN passes 2³¹) — none of this pass's testing came close
|
||||
to that scale, so nothing here calls prior results into question. Fixed with an opcode allowlist; has its
|
||||
own regression test.
|
||||
|
||||
4. **`CachedDiskBlockDevice.hydrate()` — no longer throws/orphans on a concurrent unmount.** `hydrate()` now
|
||||
accepts an `AbortSignal`, stopping at the next block boundary and rejecting cleanly once in-flight blocks
|
||||
settle, rather than the caller having to serialize hydrate against unmount itself (the orchestration half of
|
||||
this fix — not running hydrate inside the same long-lived task as the mount — lives in the LiveMount mixin,
|
||||
outside this package). Tested at the `CachedDiskBlockDevice` level: aborting mid-hydrate keeps everything
|
||||
already cached, rejects rather than silently returning partial success, and a fresh signal that's already
|
||||
aborted refuses to start at all.
|
||||
|
||||
5. **`initiator.mts` — went silent between commands, dropped by a target's ping timeout.** `IscsiInitiator`'s
|
||||
read loop used to stop once nothing was outstanding, so during any idle period a target's NOP-In keepalive
|
||||
sat unread — real targets (LIO's `nopin_response_timeout`, 30s by default) drop sessions with unanswered
|
||||
pings, with no reconnect logic to recover. Fixed with a loop that keeps draining for the life of the
|
||||
connection and replies to NOP-In pings (`#pump`/`#replyNop`); added `initiator.integ.mts` (white-box,
|
||||
real loopback). Never exercised by this pass's own black-box testing at all — the whole effort was
|
||||
LiveMount-focused, which only ever uses the _target_ role. Attempting to close that gap surfaced two
|
||||
further, independent findings — see "Initiator interop findings" below.
|
||||
|
||||
## Black-box protocol-level testing (standalone target, real tooling)
|
||||
|
||||
- **Data integrity under concurrent load**: 30-minute `fio --rw=randrw --bs=4k --iodepth=64 --numjobs=1
|
||||
--verify=crc32c` soak against the standalone target over real `iscsiadm` loopback — 0 verify errors, ~16.4k
|
||||
read + ~10.6k write IOPS sustained throughout. (An earlier `numjobs=4` run produced ~116 "bad header"
|
||||
errors; bisected and reproduced identically against plain local file I/O with zero iSCSI involvement —
|
||||
a known fio limitation where independent jobs pointed at the same file race each other's own bookkeeping,
|
||||
not a target bug. `numjobs=1` at higher `iodepth` avoids the false positive while still exercising real
|
||||
queue depth.)
|
||||
- **Filesystem round-trip across a full session teardown**: real corpus (2419 files, 53 MB) written, hashed,
|
||||
then a full cold logout/login cycle plus a host-side page-cache drop before re-reading — all 2419 files
|
||||
byte-identical.
|
||||
- **Kill the target process mid-write**: `kill -9`'d mid-transfer with a direct-I/O write in flight. The write
|
||||
correctly blocked (not a fast, silent "success") until the session recovered, then completed with the exact
|
||||
intended pattern across the full range — no silent data loss, no torn/partial write at the interruption point.
|
||||
- **Real cross-machine network partition** (two separate VMs, not loopback): a mid-transfer `iptables DROP`
|
||||
held for 15s against the target's own port. Compared directly to `tgt` as a baseline over the identical
|
||||
topology — both stalled through the outage and completed with 0 corrupted bytes once restored; no divergent
|
||||
behavior from a mature reference implementation.
|
||||
- **Sequential throughput vs `tgt`** (same two-VM link, plain iSCSI, no XAPI/SM in the path): `@vates/iscsi`
|
||||
reached ~70% (write) / ~76% (read) of `tgt`'s numbers — noticeably slower but the same order of magnitude,
|
||||
not a large gap. (A separate, much larger ~10x slowdown measured through the full LiveMount/XAPI/SM stack
|
||||
was isolated to the host's own `iscsi` SM/tapdisk driver by elimination — outside this package.)
|
||||
- **Targeted regression testing for bug 1 above**, using a single-job, high-`iodepth`, small-range `fio`
|
||||
pattern (deliberately avoiding the `numjobs>1` false-positive from the first bullet) to force many concurrent
|
||||
operations onto the same underlying cache block without needing multiple uncoordinated processes: clean
|
||||
(0 verify errors) against local storage, against a slow S3-backed remote (a wider fetch-latency window gives
|
||||
a racing write far more time to land mid-fetch — the exact scenario the bug was about), and against an
|
||||
actively-running `hydrate()` sweep overlapping guest I/O on the same disk. All three passed.
|
||||
|
||||
## Flagged, not fixed
|
||||
|
||||
- **`FileBlockDevice.read()` zero-fills past a short/EOF read instead of throwing** — unlike `LocalBlockDevice`
|
||||
(the actual production cache backend), which throws on the identical condition. Confirmed via grep: only the
|
||||
standalone CLI tool and this package's own tests use `FileBlockDevice` today, so there's no reachable data-
|
||||
loss path in production right now — but it's a public export, so a future feature backing a LUN directly
|
||||
with a file would silently inherit the zero-fill-over-error behavior. This is a design-intent question (is
|
||||
the CLI tool's "sparse tail" leniency deliberate?), not a clear bug — flagging for a decision rather than
|
||||
silently changing it.
|
||||
|
||||
## Initiator interop findings (this session)
|
||||
|
||||
Bug 5's fix (above) had never been exercised against a real target under real conditions — only the white-box
|
||||
`initiator.integ.mts`. Attempting to close that gap with a live idle-period/NOP-In test against two different
|
||||
real target implementations surfaced two independent, real findings before the actual keepalive behavior could
|
||||
be exercised:
|
||||
|
||||
- **`tgt` reserves LUN 0 as a built-in, zero-size "controller" device** on every target, and this is not
|
||||
removable via `tgtadm` (`tgtadm: this logical unit number already exists` / `invalid request` on any attempt
|
||||
to delete or replace it). `IscsiInitiator.connect()`/`read()` are hardcoded to LUN 0 (matching this package's
|
||||
own single-LUN-at-0 target design), so `connect()` fails immediately with `CHECK_CONDITION` against `tgt`
|
||||
regardless of where a real data LUN is added (tried LUN 1). Pure tooling mismatch — `tgt`'s own convention
|
||||
doesn't fit a client that only ever speaks to LUN 0 — not a bug in either side.
|
||||
|
||||
- **LIO (`targetcli-fb`/kernel target) does complete login differently, and `IscsiInitiator` can't get through
|
||||
it.** With a real data LUN at LUN 0 (LIO doesn't reserve it), `connect()` still fails — `#operationalLogin`
|
||||
(`initiator.mts`) sends exactly one operational-stage login PDU, requests immediate transit (`T=1`), and only
|
||||
checks the transit bit on the reply; there's no second round to answer a target's own counter-proposal if it
|
||||
declines transit on the first exchange. LIO's kernel target does exactly that: it counter-proposes several of
|
||||
its own operational keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in
|
||||
the same response and withholds transit until they're answered, confirmed directly in `dmesg`:
|
||||
```
|
||||
No response for proposed key "MaxBurstLength".
|
||||
No response for proposed key "DefaultTime2Wait".
|
||||
No response for proposed key "DefaultTime2Retain".
|
||||
No response for proposed key "MaxOutstandingR2T".
|
||||
iSCSI Login negotiation failed.
|
||||
```
|
||||
This works fine against `tgt` and against this package's own `IscsiTarget` (both transit on the first round),
|
||||
so it's never been visible before — but it's a **real, previously-unknown limitation of `IscsiInitiator`'s
|
||||
login state machine**: single operational round-trip only, no handling for a target that insists on its own
|
||||
multi-round negotiation before agreeing to transit. Worth knowing given the initiator role's real intended
|
||||
use (reading from a third-party storage array as an initiator) targets unknown-strictness third-party target
|
||||
software, not `tgt` or this package's own target.
|
||||
|
||||
Given both blockers are about _login/LUN setup_, not the keepalive mechanism itself, the actual idle-period/
|
||||
NOP-In behavior could not be exercised live this session. The white-box `initiator.integ.mts` coverage for
|
||||
bug 5 stands as the only current test of that specific fix.
|
||||
6
@vates/iscsi/eslint.config.mjs
Normal file
6
@vates/iscsi/eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended)
|
||||
61
@vates/iscsi/package.json
Normal file
61
@vates/iscsi/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@vates/iscsi",
|
||||
"version": "0.4.0",
|
||||
"main": "dist/index.mjs",
|
||||
"types": "dist/index.d.mts",
|
||||
"bin": {
|
||||
"vates-iscsi": "dist/cli.mjs"
|
||||
},
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP",
|
||||
"keywords": [
|
||||
"iscsi",
|
||||
"target",
|
||||
"initiator",
|
||||
"chap",
|
||||
"scsi",
|
||||
"lun",
|
||||
"block",
|
||||
"storage"
|
||||
],
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/read-chunk": "^1.2.1",
|
||||
"@xen-orchestra/disk-transform": "^1.3.4",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"promise-toolbox": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/node": "^22.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "~5.6",
|
||||
"typescript-eslint": "^8.61.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist/",
|
||||
"dev": "tsc --watch",
|
||||
"postversion": "npm publish --access public",
|
||||
"prebuild": "rimraf dist/",
|
||||
"predev": "yarn run prebuild",
|
||||
"test": "tsc && node --test \"dist/**/*.test.mjs\"",
|
||||
"test-integration": "tsc && node --test \"dist/**/*.integ.mjs\""
|
||||
},
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/iscsi",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/iscsi",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
321
@vates/iscsi/src/CachedDiskBlockDevice.mts
Normal file
321
@vates/iscsi/src/CachedDiskBlockDevice.mts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
import type { ProgressHandler, RandomAccessDisk } from '@xen-orchestra/disk-transform'
|
||||
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
|
||||
const log: Logger = createLogger('vates:iscsi:cached-disk-block-device')
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 512
|
||||
const DEFAULT_HYDRATE_CONCURRENCY = 8
|
||||
|
||||
export interface CachedDiskBlockDeviceOptions {
|
||||
/**
|
||||
* Source disk, already `init()`ed by the caller. Only ever read from, one
|
||||
* whole block at a time.
|
||||
*/
|
||||
readonly disk: RandomAccessDisk
|
||||
/**
|
||||
* Writable store holding what has been read from {@link disk} so far, already
|
||||
* `open()`ed and at least as large as the disk's virtual size.
|
||||
*/
|
||||
readonly cache: BlockDevice
|
||||
/**
|
||||
* Logical block size advertised to initiators, in bytes. Defaults to 512.
|
||||
* The disk's own block size must be a multiple of it.
|
||||
*/
|
||||
readonly blockSize?: number
|
||||
/**
|
||||
* Notified with the materialized fraction (0..1) every time a new block is
|
||||
* cached — on demand or through {@link hydrate} — and via `done()` once
|
||||
* every block has been. Reused from the `disk-transform` export/import
|
||||
* pipelines rather than inventing a new shape.
|
||||
*/
|
||||
readonly progressHandler?: ProgressHandler
|
||||
/**
|
||||
* A previously-persisted bitmap to resume from, one byte per block (see
|
||||
* {@link CachedDiskBlockDevice}'s class doc). Its length must match the
|
||||
* block count computed from `disk`/`blockSize`; a mismatch (e.g. the disk
|
||||
* was resized since it was saved) is treated as stale and discarded rather
|
||||
* than rejected, so a corrupt/outdated resume file never blocks a mount.
|
||||
*/
|
||||
readonly initialBitmap?: Buffer
|
||||
/**
|
||||
* Notified with a block's index the moment it is newly cached — narrower
|
||||
* than `progressHandler`, which only carries an aggregate fraction and
|
||||
* cannot say *which* block just landed. Meant for persisting the bitmap
|
||||
* one byte at a time; best-effort like `progressHandler`, a failure here
|
||||
* must never break block materialization.
|
||||
*/
|
||||
readonly onBlockCached?: (index: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-write {@link BlockDevice} over a {@link RandomAccessDisk}, backed by
|
||||
* a local writable store: each source block is materialized into the store
|
||||
* the first time it's needed, tracked by a bitmap so repeat access never goes
|
||||
* back to the source. Call {@link hydrate} to force the whole disk in upfront.
|
||||
*
|
||||
* The bitmap is one byte per block, not one bit: a caller that persists it
|
||||
* incrementally (see `onBlockCached`) writes one block's byte at a time, and
|
||||
* every block's offset must be independent of every other's for concurrent
|
||||
* writes to be safe. A real bit-packed bitmap fails that: several blocks'
|
||||
* bits sharing one byte would need a read-modify-write of that byte, which
|
||||
* two blocks completing concurrently could race on and lose an update. One
|
||||
* byte per block costs a little more memory (negligible in absolute terms —
|
||||
* a few hundred KB even for a huge disk) and buys that independence for free.
|
||||
*
|
||||
* Blocks the source doesn't have are marked present without being written —
|
||||
* relies on the store reading as zero where nothing was ever written.
|
||||
*
|
||||
* Writes land in the store only; the source is never written to, so the
|
||||
* mount diverges from it as soon as anything writes.
|
||||
*/
|
||||
export class CachedDiskBlockDevice implements BlockDevice {
|
||||
readonly #disk: RandomAccessDisk
|
||||
readonly #cache: BlockDevice
|
||||
readonly #blockSize: number
|
||||
readonly #progressHandler?: ProgressHandler
|
||||
readonly #onBlockCached?: (index: number) => void
|
||||
readonly #initialBitmap?: Buffer
|
||||
|
||||
// one byte per source block, 0 or 1, set once the block is in the cache
|
||||
#bitmap: Buffer = Buffer.alloc(0)
|
||||
#blockCount = 0
|
||||
#cachedCount = 0
|
||||
// source blocks being materialized right now, so concurrent reads of
|
||||
// overlapping ranges fetch each block once
|
||||
readonly #inFlight: Map<number, Promise<void>> = new Map()
|
||||
#diskBlockSize?: number
|
||||
#size?: number
|
||||
|
||||
constructor({
|
||||
disk,
|
||||
cache,
|
||||
blockSize = DEFAULT_BLOCK_SIZE,
|
||||
progressHandler,
|
||||
initialBitmap,
|
||||
onBlockCached,
|
||||
}: CachedDiskBlockDeviceOptions) {
|
||||
if (!Number.isInteger(blockSize) || blockSize <= 0) {
|
||||
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
|
||||
}
|
||||
this.#disk = disk
|
||||
this.#cache = cache
|
||||
this.#blockSize = blockSize
|
||||
this.#progressHandler = progressHandler
|
||||
this.#initialBitmap = initialBitmap
|
||||
this.#onBlockCached = onBlockCached
|
||||
}
|
||||
|
||||
/** Read the source geometry and allocate (or resume) the bitmap. */
|
||||
async open(): Promise<void> {
|
||||
const blockSize = this.#blockSize
|
||||
const diskBlockSize = this.#disk.getBlockSize()
|
||||
if (diskBlockSize % blockSize !== 0) {
|
||||
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
|
||||
}
|
||||
const size = this.#disk.getVirtualSize()
|
||||
if (size <= 0 || size % blockSize !== 0) {
|
||||
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
|
||||
}
|
||||
if (this.#cache.getSize() < size) {
|
||||
throw new Error(`cache (${this.#cache.getSize()} bytes) is smaller than the disk (${size} bytes)`)
|
||||
}
|
||||
|
||||
this.#diskBlockSize = diskBlockSize
|
||||
this.#size = size
|
||||
this.#blockCount = Math.ceil(size / diskBlockSize)
|
||||
|
||||
const initialBitmap = this.#initialBitmap
|
||||
if (initialBitmap !== undefined && initialBitmap.length === this.#blockCount) {
|
||||
this.#bitmap = initialBitmap
|
||||
let cachedCount = 0
|
||||
for (const byte of initialBitmap) {
|
||||
if (byte !== 0) {
|
||||
cachedCount++
|
||||
}
|
||||
}
|
||||
this.#cachedCount = cachedCount
|
||||
} else {
|
||||
if (initialBitmap !== undefined) {
|
||||
log.warn('discarding stale initial bitmap (block count mismatch)', {
|
||||
gotLength: initialBitmap.length,
|
||||
expectedLength: this.#blockCount,
|
||||
})
|
||||
}
|
||||
this.#bitmap = Buffer.alloc(this.#blockCount)
|
||||
}
|
||||
log.debug('opened', {
|
||||
size,
|
||||
blockSize,
|
||||
diskBlockSize,
|
||||
blockCount: this.#blockCount,
|
||||
resumedBlocks: this.#cachedCount,
|
||||
})
|
||||
}
|
||||
|
||||
getSize(): number {
|
||||
const size = this.#size
|
||||
if (size === undefined) {
|
||||
throw new Error('CachedDiskBlockDevice.open() must be called before I/O')
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
/** How much of the source has been materialized into the cache. */
|
||||
getMaterialized(): { blocks: number; total: number } {
|
||||
return { blocks: this.#cachedCount, total: this.#blockCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Force every block into the cache — including holes, so `getMaterialized()`
|
||||
* reaches `total` once done, matching what a full sequential read would
|
||||
* naturally mark. Already-materialized blocks are skipped, so this resumes
|
||||
* rather than redoes whatever on-demand reads (or a previous, interrupted
|
||||
* hydration) already did.
|
||||
*
|
||||
* Hydrating a large disk takes hours, so it must be interruptible: `signal`
|
||||
* stops it at the next block boundary, rejecting once the blocks already in
|
||||
* flight settle. Nothing is rolled back — every block cached so far stays
|
||||
* cached, and because this call resumes, aborting and calling again later
|
||||
* picks up where it stopped rather than starting over.
|
||||
*/
|
||||
async hydrate({
|
||||
concurrency = DEFAULT_HYDRATE_CONCURRENCY,
|
||||
signal,
|
||||
}: { concurrency?: number; signal?: AbortSignal } = {}): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
const blockCount = this.#blockCount
|
||||
await asyncEach(
|
||||
(function* allBlockIndexes() {
|
||||
for (let index = 0; index < blockCount; index++) {
|
||||
yield index
|
||||
}
|
||||
})(),
|
||||
index => this.#ensureBlock(index),
|
||||
{ concurrency, signal }
|
||||
)
|
||||
}
|
||||
|
||||
#isCached(index: number): boolean {
|
||||
return this.#bitmap[index] !== 0
|
||||
}
|
||||
|
||||
#setCached(index: number): void {
|
||||
if (!this.#isCached(index)) {
|
||||
this.#bitmap[index] = 1
|
||||
this.#cachedCount++
|
||||
this.#reportProgress()
|
||||
// best-effort, same contract as #reportProgress: a synchronous throw
|
||||
// here must never break block materialization. Any async failure (e.g.
|
||||
// the persisted-bitmap write itself) is the callback's own job to catch.
|
||||
try {
|
||||
this.#onBlockCached?.(index)
|
||||
} catch (error) {
|
||||
log.warn('onBlockCached failed', { error, index })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// best-effort: a failure here (sync or async) must never break block
|
||||
// materialization. Wrapping the body in an `async` function, rather than a
|
||||
// plain try/catch, is what makes a *synchronous* throw from the handler
|
||||
// land in the same `.catch()` as an asynchronous rejection.
|
||||
#reportProgress(): void {
|
||||
const handler = this.#progressHandler
|
||||
if (handler === undefined) {
|
||||
return
|
||||
}
|
||||
const fraction = this.#cachedCount / this.#blockCount
|
||||
;(async () => {
|
||||
await handler.setProgress(fraction)
|
||||
// every block is in the cache: nothing more will ever be reported
|
||||
if (fraction === 1) {
|
||||
await handler.done()
|
||||
}
|
||||
})().catch(error => log.warn('progress handler failed', { error }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one source block into the cache, unless it is already there. Concurrent
|
||||
* calls for the same block share a single fetch.
|
||||
*/
|
||||
#ensureBlock(index: number): Promise<void> {
|
||||
if (this.#isCached(index)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
let pending = this.#inFlight.get(index)
|
||||
if (pending === undefined) {
|
||||
// the entry goes away whether the fetch succeeded or not, so a failed
|
||||
// block is retried later instead of being remembered as broken
|
||||
pending = this.#fetchBlock(index).finally(() => this.#inFlight.delete(index))
|
||||
this.#inFlight.set(index, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
async #fetchBlock(index: number): Promise<void> {
|
||||
const diskBlockSize = this.#diskBlockSize as number
|
||||
if (this.#disk.hasBlock(index)) {
|
||||
const offset = index * diskBlockSize
|
||||
const { data } = await this.#disk.readBlock(index)
|
||||
// a source block is always full-size: the last one may run past the end of
|
||||
// the disk, and the cache must not be written beyond it
|
||||
const length = Math.min(diskBlockSize, this.getSize() - offset)
|
||||
await this.#cache.write(offset, length < data.length ? data.subarray(0, length) : data)
|
||||
}
|
||||
// a block the source does not have stays as the cache has it, which is
|
||||
// zeroes as long as nothing has written there
|
||||
this.#setCached(index)
|
||||
}
|
||||
|
||||
#checkRange(offset: number, length: number): void {
|
||||
const size = this.getSize()
|
||||
if (offset < 0 || length < 0 || offset + length > size) {
|
||||
throw new Error(`access of ${length} bytes at ${offset} is out of range (size ${size})`)
|
||||
}
|
||||
}
|
||||
|
||||
async read(offset: number, length: number): Promise<Buffer> {
|
||||
this.#checkRange(offset, length)
|
||||
if (length === 0) {
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
const diskBlockSize = this.#diskBlockSize as number
|
||||
const last = Math.floor((offset + length - 1) / diskBlockSize)
|
||||
for (let index = Math.floor(offset / diskBlockSize); index <= last; index++) {
|
||||
await this.#ensureBlock(index)
|
||||
}
|
||||
return this.#cache.read(offset, length)
|
||||
}
|
||||
|
||||
async write(offset: number, data: Buffer): Promise<void> {
|
||||
this.#checkRange(offset, data.length)
|
||||
if (data.length === 0) {
|
||||
return
|
||||
}
|
||||
// every covered block must be materialized first, even the ones this write
|
||||
// covers entirely: skipping the fetch would let an already in-flight one
|
||||
// land *after* the payload and overwrite it with the source's bytes.
|
||||
// (optimization left out on purpose — it has to join the in-flight fetch)
|
||||
await this.read(offset, data.length)
|
||||
await this.#cache.write(offset, data)
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.#cache.flush()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#cache.close()
|
||||
} finally {
|
||||
await this.#disk.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
605
@vates/iscsi/src/CachedDiskBlockDevice.test.mts
Normal file
605
@vates/iscsi/src/CachedDiskBlockDevice.test.mts
Normal file
@@ -0,0 +1,605 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { RandomAccessDisk, type DiskBlock, type ProgressHandler } from '@xen-orchestra/disk-transform'
|
||||
|
||||
import { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
|
||||
const DISK_BLOCK_SIZE = 4096
|
||||
const BLOCK_COUNT = 4
|
||||
const DISK_SIZE = BLOCK_COUNT * DISK_BLOCK_SIZE
|
||||
|
||||
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
|
||||
|
||||
/** Sparse source: only blocks 0 and 2 exist, like a differencing chain. */
|
||||
class StubDisk extends RandomAccessDisk {
|
||||
readBlockCalls: Array<number> = []
|
||||
closed = false
|
||||
/** set to hold every fetch open until it resolves */
|
||||
pause?: Promise<void>
|
||||
|
||||
constructor(readonly allocated: Set<number> = new Set([0, 2])) {
|
||||
super()
|
||||
}
|
||||
|
||||
getBlockSize(): number {
|
||||
return DISK_BLOCK_SIZE
|
||||
}
|
||||
getVirtualSize(): number {
|
||||
return DISK_SIZE
|
||||
}
|
||||
isDifferencing(): boolean {
|
||||
return false
|
||||
}
|
||||
getBlockIndexes(): Array<number> {
|
||||
return [...this.allocated]
|
||||
}
|
||||
hasBlock(index: number): boolean {
|
||||
return this.allocated.has(index)
|
||||
}
|
||||
async init(): Promise<void> {}
|
||||
async close(): Promise<void> {
|
||||
this.closed = true
|
||||
}
|
||||
async readBlock(index: number): Promise<DiskBlock> {
|
||||
if (!this.allocated.has(index)) {
|
||||
throw new Error(`Block ${index} not found in chain`)
|
||||
}
|
||||
this.readBlockCalls.push(index)
|
||||
if (this.pause !== undefined) {
|
||||
await this.pause
|
||||
}
|
||||
const data = Buffer.alloc(DISK_BLOCK_SIZE)
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = bytePattern(index * DISK_BLOCK_SIZE + i)
|
||||
}
|
||||
return { index, data }
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory stand-in for the local VDI. */
|
||||
class StubCache implements BlockDevice {
|
||||
readonly content: Buffer
|
||||
writes: Array<{ offset: number; length: number }> = []
|
||||
closed = false
|
||||
flushed = 0
|
||||
failNextWrite = false
|
||||
|
||||
constructor(size = DISK_SIZE) {
|
||||
this.content = Buffer.alloc(size)
|
||||
}
|
||||
|
||||
getSize(): number {
|
||||
return this.content.length
|
||||
}
|
||||
getBlockSize(): number {
|
||||
return 512
|
||||
}
|
||||
async read(offset: number, length: number): Promise<Buffer> {
|
||||
return Buffer.from(this.content.subarray(offset, offset + length))
|
||||
}
|
||||
async write(offset: number, data: Buffer): Promise<void> {
|
||||
if (this.failNextWrite) {
|
||||
this.failNextWrite = false
|
||||
throw new Error('cache write failed')
|
||||
}
|
||||
this.writes.push({ offset, length: data.length })
|
||||
data.copy(this.content, offset)
|
||||
}
|
||||
async flush(): Promise<void> {
|
||||
this.flushed++
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
this.closed = true
|
||||
}
|
||||
}
|
||||
|
||||
const expectedAt = (offset: number, length: number, allocated: Set<number>): Buffer => {
|
||||
const expected = Buffer.alloc(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const absolute = offset + i
|
||||
if (allocated.has(Math.floor(absolute / DISK_BLOCK_SIZE))) {
|
||||
expected[i] = bytePattern(absolute)
|
||||
}
|
||||
}
|
||||
return expected
|
||||
}
|
||||
|
||||
const make = async (allocated?: Set<number>, cacheSize?: number) => {
|
||||
const disk = new StubDisk(allocated)
|
||||
const cache = new StubCache(cacheSize)
|
||||
const device = new CachedDiskBlockDevice({ disk, cache })
|
||||
await device.open()
|
||||
return { cache, device, disk }
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
it('exposes the disk capacity and a fully cold bitmap', async () => {
|
||||
const { device } = await make()
|
||||
assert.equal(device.getSize(), DISK_SIZE)
|
||||
assert.equal(device.getBlockSize(), 512)
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
|
||||
})
|
||||
|
||||
it('refuses a cache smaller than the disk', async () => {
|
||||
const device = new CachedDiskBlockDevice({ disk: new StubDisk(), cache: new StubCache(DISK_SIZE - 512) })
|
||||
await assert.rejects(device.open(), /smaller than the disk/)
|
||||
})
|
||||
|
||||
it('resumes from an initial bitmap of the right length', async () => {
|
||||
// all blocks allocated, so the not-yet-cached one below is a real fetch,
|
||||
// not a hole (which would never call readBlock regardless of the bitmap)
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
|
||||
initialBitmap[0] = 1
|
||||
initialBitmap[2] = 1
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap })
|
||||
await device.open()
|
||||
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 2, total: BLOCK_COUNT })
|
||||
// already marked cached: served from the (empty) cache store, source untouched
|
||||
assert.deepEqual(await device.read(0, 512), Buffer.alloc(512))
|
||||
assert.deepEqual(disk.readBlockCalls, [])
|
||||
// not marked cached: still fetched from the source as usual
|
||||
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), expectedAt(DISK_BLOCK_SIZE, 512, disk.allocated))
|
||||
assert.deepEqual(disk.readBlockCalls, [1])
|
||||
})
|
||||
|
||||
it('discards an initial bitmap of the wrong length and starts cold', async () => {
|
||||
const disk = new StubDisk()
|
||||
const cache = new StubCache()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap: Buffer.alloc(BLOCK_COUNT - 1, 1) })
|
||||
await device.open()
|
||||
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
|
||||
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('read', () => {
|
||||
it('materializes a block on first read and serves later ones from the cache', async () => {
|
||||
const { device, disk, cache } = await make()
|
||||
|
||||
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
// the whole source block was written, not just the part that was read
|
||||
assert.deepEqual(cache.writes, [{ offset: 0, length: DISK_BLOCK_SIZE }])
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
|
||||
// a second read of a different part of the same block does not go back
|
||||
assert.deepEqual(await device.read(1024, 512), expectedAt(1024, 512, disk.allocated))
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
assert.equal(cache.writes.length, 1)
|
||||
})
|
||||
|
||||
it('marks a hole as present without writing it', async () => {
|
||||
const { device, disk, cache } = await make()
|
||||
|
||||
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), Buffer.alloc(512))
|
||||
assert.deepEqual(disk.readBlockCalls, [])
|
||||
assert.deepEqual(cache.writes, [])
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
})
|
||||
|
||||
it('stitches a range spanning a present and a missing block', async () => {
|
||||
const { device, disk } = await make()
|
||||
const offset = DISK_BLOCK_SIZE - 512
|
||||
const length = DISK_BLOCK_SIZE + 1024
|
||||
|
||||
assert.deepEqual(await device.read(offset, length), expectedAt(offset, length, disk.allocated))
|
||||
assert.deepEqual(disk.readBlockCalls, [0, 2])
|
||||
})
|
||||
|
||||
it('fetches a block once for concurrent overlapping reads', async () => {
|
||||
const { device, disk } = await make()
|
||||
// hold the fetch open until both reads are in flight
|
||||
let release = () => {}
|
||||
disk.pause = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
const first = device.read(0, 1024)
|
||||
const second = device.read(512, 1024)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
release()
|
||||
|
||||
assert.deepEqual(await first, expectedAt(0, 1024, disk.allocated))
|
||||
assert.deepEqual(await second, expectedAt(512, 1024, disk.allocated))
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
})
|
||||
|
||||
it('rejects a read past the end of the disk', async () => {
|
||||
const { device } = await make()
|
||||
await assert.rejects(device.read(DISK_SIZE - 256, 512), /out of range/)
|
||||
})
|
||||
|
||||
it('clamps the tail block to the disk size', async () => {
|
||||
// 3 blocks + half a block, so the last source block runs past the end
|
||||
class ShortDisk extends StubDisk {
|
||||
getVirtualSize(): number {
|
||||
return 3 * DISK_BLOCK_SIZE + 2048
|
||||
}
|
||||
}
|
||||
const disk = new ShortDisk(new Set([3]))
|
||||
const cache = new StubCache(3 * DISK_BLOCK_SIZE + 2048)
|
||||
const device = new CachedDiskBlockDevice({ disk, cache })
|
||||
await device.open()
|
||||
|
||||
await device.read(3 * DISK_BLOCK_SIZE, 2048)
|
||||
assert.deepEqual(cache.writes, [{ offset: 3 * DISK_BLOCK_SIZE, length: 2048 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('write', () => {
|
||||
it('lands in the cache and is readable back, without touching the source', async () => {
|
||||
const { device, cache, disk } = await make()
|
||||
const payload = Buffer.alloc(512, 0xee)
|
||||
|
||||
await device.write(0, payload)
|
||||
|
||||
assert.deepEqual(await device.read(0, 512), payload)
|
||||
// the block was materialized first, since the write covers only part of it
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
// ... and the bytes after the write are still the source's
|
||||
assert.deepEqual(await device.read(512, 512), expectedAt(512, 512, disk.allocated))
|
||||
assert.equal(cache.closed, false)
|
||||
})
|
||||
|
||||
it('materializes the block even when the write covers it entirely', async () => {
|
||||
const { device, disk } = await make()
|
||||
|
||||
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
|
||||
|
||||
// the fetch cannot be skipped: see the race covered by the next test
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
})
|
||||
|
||||
it('is not overwritten by a fetch already in flight when it lands', async () => {
|
||||
const { device, disk } = await make()
|
||||
// hold block 0's fetch open, so the write lands while it is in flight
|
||||
let release = () => {}
|
||||
disk.pause = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
const reading = device.read(0, 512)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
const payload = Buffer.alloc(DISK_BLOCK_SIZE, 0xee)
|
||||
const writing = device.write(0, payload)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
release()
|
||||
|
||||
await reading
|
||||
await writing
|
||||
|
||||
// the source block was fetched once, and its bytes did not come back on top
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
assert.deepEqual(await device.read(0, DISK_BLOCK_SIZE), payload)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydrate', () => {
|
||||
it('materializes every block the source has, holes included', async () => {
|
||||
const { device, disk, cache } = await make()
|
||||
|
||||
await device.hydrate()
|
||||
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
|
||||
// only the allocated ones were actually fetched...
|
||||
assert.deepEqual([...disk.readBlockCalls].sort(), [0, 2])
|
||||
// ...and the whole disk now reads correctly straight from the cache
|
||||
assert.deepEqual(await cache.read(0, DISK_SIZE), expectedAt(0, DISK_SIZE, disk.allocated))
|
||||
})
|
||||
|
||||
it('skips blocks already materialized by an earlier read', async () => {
|
||||
const { device, disk } = await make()
|
||||
await device.read(0, 512)
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
|
||||
await device.hydrate()
|
||||
|
||||
// block 0 was not fetched a second time
|
||||
assert.deepEqual(disk.readBlockCalls, [0, 2])
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
|
||||
})
|
||||
|
||||
it('is a no-op once fully materialized', async () => {
|
||||
const { device, disk } = await make()
|
||||
await device.hydrate()
|
||||
disk.readBlockCalls.length = 0
|
||||
|
||||
await device.hydrate()
|
||||
|
||||
assert.deepEqual(disk.readBlockCalls, [])
|
||||
})
|
||||
|
||||
it('stops on an aborted signal, keeping what it already cached', async () => {
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache })
|
||||
await device.open()
|
||||
|
||||
// every block but the first parks, so the hydration is still mid-flight
|
||||
// when it is aborted
|
||||
let release = () => {}
|
||||
const parked = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
disk.readBlock = async index => {
|
||||
if (index > 0) {
|
||||
await parked
|
||||
}
|
||||
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE, index) }
|
||||
}
|
||||
const controller = new AbortController()
|
||||
|
||||
const hydrating = device.hydrate({ concurrency: 1, signal: controller.signal })
|
||||
for (let turn = 0; turn < 20 && device.getMaterialized().blocks === 0; turn++) {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
|
||||
controller.abort()
|
||||
|
||||
// aborting is a failure, not a silent partial success
|
||||
await assert.rejects(hydrating)
|
||||
// and the block already cached stays cached, so a later hydration resumes
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
release() // let the parked fetch finish rather than leaving it dangling
|
||||
})
|
||||
|
||||
it('refuses to start on an already-aborted signal', async () => {
|
||||
const { device, disk } = await make()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await assert.rejects(device.hydrate({ signal: controller.signal }))
|
||||
|
||||
assert.deepEqual(disk.readBlockCalls, [])
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
|
||||
})
|
||||
|
||||
it('respects the concurrency limit', async () => {
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache })
|
||||
await device.open()
|
||||
|
||||
let inFlight = 0
|
||||
let maxInFlight = 0
|
||||
const releases: Array<() => void> = []
|
||||
disk.readBlock = async index => {
|
||||
inFlight++
|
||||
maxInFlight = Math.max(maxInFlight, inFlight)
|
||||
await new Promise<void>(resolve => releases.push(resolve))
|
||||
inFlight--
|
||||
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE) }
|
||||
}
|
||||
|
||||
const hydrating = device.hydrate({ concurrency: 2 })
|
||||
// let the first batch start
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(maxInFlight, 2)
|
||||
while (releases.length > 0) {
|
||||
releases.shift()!()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
await hydrating
|
||||
|
||||
assert.equal(maxInFlight, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress handler', () => {
|
||||
class StubProgressHandler implements ProgressHandler {
|
||||
calls: Array<number> = []
|
||||
doneCalls = 0
|
||||
throwSync = false
|
||||
rejectAsync = false
|
||||
async setProgress(fraction: number): Promise<void> {
|
||||
this.calls.push(fraction)
|
||||
if (this.throwSync) {
|
||||
throw new Error('sync failure')
|
||||
}
|
||||
if (this.rejectAsync) {
|
||||
throw new Error('async failure')
|
||||
}
|
||||
}
|
||||
done(): void {
|
||||
this.doneCalls++
|
||||
}
|
||||
}
|
||||
|
||||
it('reports the materialized fraction as blocks are read, once each', async () => {
|
||||
const disk = new StubDisk()
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
await device.read(0, 512)
|
||||
// let the fire-and-forget notification run
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
|
||||
|
||||
// a hole is a materialization too, just with nothing to write
|
||||
await device.read(DISK_BLOCK_SIZE, 512)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
|
||||
|
||||
// re-reading an already materialized block reports nothing new
|
||||
await device.read(0, 512)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
|
||||
})
|
||||
|
||||
it('reports a fully-covered write the same way as a read', async () => {
|
||||
const disk = new StubDisk()
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
|
||||
})
|
||||
|
||||
it('does not let a synchronously throwing handler break the read', async () => {
|
||||
const disk = new StubDisk()
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
progressHandler.throwSync = true
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
})
|
||||
|
||||
it('does not let a rejecting handler surface as an unhandled rejection', async () => {
|
||||
const disk = new StubDisk()
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
progressHandler.rejectAsync = true
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
|
||||
// give the rejected promise's `.catch` a turn to run before the test ends
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
})
|
||||
|
||||
it('calls done() once every block is cached, via hydrate', async () => {
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
await device.hydrate()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
assert.deepEqual(progressHandler.calls, [0.25, 0.5, 0.75, 1])
|
||||
assert.equal(progressHandler.doneCalls, 1)
|
||||
})
|
||||
|
||||
it('calls done() once every block is cached, via on-demand reads alone', async () => {
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
await device.read(0, DISK_BLOCK_SIZE * BLOCK_COUNT)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
assert.equal(progressHandler.doneCalls, 1)
|
||||
})
|
||||
|
||||
it('does not call done() before every block is cached', async () => {
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
await device.read(0, 512)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
assert.equal(progressHandler.doneCalls, 0)
|
||||
})
|
||||
|
||||
it('does not let a throwing done() surface as an unhandled rejection', async () => {
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const progressHandler = new StubProgressHandler()
|
||||
progressHandler.done = () => {
|
||||
throw new Error('done failure')
|
||||
}
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
|
||||
await device.open()
|
||||
|
||||
await device.hydrate()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
})
|
||||
})
|
||||
|
||||
describe('onBlockCached', () => {
|
||||
it('fires with the index of each newly cached block, once each', async () => {
|
||||
const disk = new StubDisk(new Set([0, 1, 2, 3]))
|
||||
const cache = new StubCache()
|
||||
const cached: Array<number> = []
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, onBlockCached: index => cached.push(index) })
|
||||
await device.open()
|
||||
|
||||
await device.read(0, 512)
|
||||
assert.deepEqual(cached, [0])
|
||||
|
||||
// a hole is a materialization too
|
||||
await device.read(DISK_BLOCK_SIZE, 512)
|
||||
assert.deepEqual(cached, [0, 1])
|
||||
|
||||
// re-reading an already materialized block does not fire again
|
||||
await device.read(0, 512)
|
||||
assert.deepEqual(cached, [0, 1])
|
||||
})
|
||||
|
||||
it('does not fire for a block resumed from the initial bitmap', async () => {
|
||||
const disk = new StubDisk()
|
||||
const cache = new StubCache()
|
||||
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
|
||||
initialBitmap[0] = 1
|
||||
const cached: Array<number> = []
|
||||
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap, onBlockCached: index => cached.push(index) })
|
||||
await device.open()
|
||||
|
||||
await device.read(0, 512)
|
||||
assert.deepEqual(cached, [])
|
||||
})
|
||||
|
||||
it('does not let a synchronously throwing callback break the read', async () => {
|
||||
const disk = new StubDisk()
|
||||
const cache = new StubCache()
|
||||
const device = new CachedDiskBlockDevice({
|
||||
disk,
|
||||
cache,
|
||||
onBlockCached: () => {
|
||||
throw new Error('persistence failure')
|
||||
},
|
||||
})
|
||||
await device.open()
|
||||
|
||||
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
})
|
||||
})
|
||||
|
||||
describe('flush and close', () => {
|
||||
it('flushes the cache', async () => {
|
||||
const { device, cache } = await make()
|
||||
await device.flush()
|
||||
assert.equal(cache.flushed, 1)
|
||||
})
|
||||
|
||||
it('closes the cache and the disk', async () => {
|
||||
const { device, cache, disk } = await make()
|
||||
await device.close()
|
||||
assert.equal(cache.closed, true)
|
||||
assert.equal(disk.closed, true)
|
||||
})
|
||||
|
||||
it('still closes the disk when closing the cache fails', async () => {
|
||||
const { device, cache, disk } = await make()
|
||||
cache.close = async () => {
|
||||
throw new Error('nope')
|
||||
}
|
||||
await assert.rejects(device.close(), /nope/)
|
||||
assert.equal(disk.closed, true)
|
||||
})
|
||||
})
|
||||
120
@vates/iscsi/src/DiskBlockDevice.mts
Normal file
120
@vates/iscsi/src/DiskBlockDevice.mts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
import type { RandomAccessDisk } from '@xen-orchestra/disk-transform'
|
||||
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
|
||||
const log: Logger = createLogger('vates:iscsi:disk-block-device')
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 512
|
||||
|
||||
export interface DiskBlockDeviceOptions {
|
||||
/**
|
||||
* Source disk, already `init()`ed by the caller (this adapter never calls
|
||||
* `init()` — the disk may be part of a chain the caller assembled).
|
||||
*/
|
||||
readonly disk: RandomAccessDisk
|
||||
/**
|
||||
* Logical block size advertised to initiators, in bytes. Defaults to 512.
|
||||
* The disk's own block size must be a multiple of it.
|
||||
*/
|
||||
readonly blockSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only {@link BlockDevice} for a {@link RandomAccessDisk} (the inverse
|
||||
* of {@link IscsiDisk}). Unallocated blocks read as zero; the source must
|
||||
* never be asked for a block it doesn't have (e.g. `RemoteVhdDiskChain`
|
||||
* throws).
|
||||
*
|
||||
* Nothing is cached — every read re-fetches a whole source block. Put a
|
||||
* caching decorator in front of the source disk, not here.
|
||||
*/
|
||||
export class DiskBlockDevice implements BlockDevice {
|
||||
readonly #disk: RandomAccessDisk
|
||||
readonly #blockSize: number
|
||||
#diskBlockSize?: number
|
||||
#size?: number
|
||||
|
||||
constructor({ disk, blockSize = DEFAULT_BLOCK_SIZE }: DiskBlockDeviceOptions) {
|
||||
if (!Number.isInteger(blockSize) || blockSize <= 0) {
|
||||
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
|
||||
}
|
||||
this.#disk = disk
|
||||
this.#blockSize = blockSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the source disk's geometry and check it can be exposed as a LUN.
|
||||
* Awaited by the target before it serves any I/O.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
const blockSize = this.#blockSize
|
||||
const diskBlockSize = this.#disk.getBlockSize()
|
||||
if (diskBlockSize % blockSize !== 0) {
|
||||
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
|
||||
}
|
||||
const size = this.#disk.getVirtualSize()
|
||||
if (size <= 0 || size % blockSize !== 0) {
|
||||
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
|
||||
}
|
||||
this.#diskBlockSize = diskBlockSize
|
||||
this.#size = size
|
||||
log.debug('opened', { size, blockSize, diskBlockSize })
|
||||
}
|
||||
|
||||
getSize(): number {
|
||||
const size = this.#size
|
||||
if (size === undefined) {
|
||||
throw new Error('DiskBlockDevice.open() must be called before I/O')
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
async read(offset: number, length: number): Promise<Buffer> {
|
||||
const size = this.getSize()
|
||||
if (offset < 0 || length < 0 || offset + length > size) {
|
||||
throw new Error(`read of ${length} bytes at ${offset} is out of range (size ${size})`)
|
||||
}
|
||||
if (length === 0) {
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
|
||||
const diskBlockSize = this.#diskBlockSize as number
|
||||
// zero-filled: unallocated blocks, and any source block shorter than
|
||||
// announced, are left as zeroes
|
||||
const result = Buffer.alloc(length)
|
||||
const end = offset + length
|
||||
const lastIndex = Math.floor((end - 1) / diskBlockSize)
|
||||
for (let index = Math.floor(offset / diskBlockSize); index <= lastIndex; index++) {
|
||||
if (!this.#disk.hasBlock(index)) {
|
||||
continue
|
||||
}
|
||||
const blockStart = index * diskBlockSize
|
||||
const from = Math.max(offset, blockStart)
|
||||
const to = Math.min(end, blockStart + diskBlockSize)
|
||||
const { data } = await this.#disk.readBlock(index)
|
||||
data.copy(result, from - offset, from - blockStart, to - blockStart)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// nothing to flush: read-only
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.#disk.close()
|
||||
}
|
||||
}
|
||||
169
@vates/iscsi/src/DiskBlockDevice.test.mts
Normal file
169
@vates/iscsi/src/DiskBlockDevice.test.mts
Normal file
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
|
||||
|
||||
import { DiskBlockDevice } from './DiskBlockDevice.mjs'
|
||||
|
||||
const BLOCK_SIZE = 4096
|
||||
|
||||
/**
|
||||
* Sparse in-memory {@link RandomAccessDisk}: `blocks` maps a block index to its
|
||||
* content, every other index is unallocated and must never be read (the real
|
||||
* `RemoteVhdDiskChain` throws in that case, which is what this asserts).
|
||||
*/
|
||||
class StubDisk extends RandomAccessDisk {
|
||||
readBlockCalls: Array<number> = []
|
||||
closed = false
|
||||
|
||||
constructor(
|
||||
readonly blocks: Map<number, Buffer>,
|
||||
readonly virtualSize: number,
|
||||
readonly blockSize: number = BLOCK_SIZE
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
getBlockSize(): number {
|
||||
return this.blockSize
|
||||
}
|
||||
getVirtualSize(): number {
|
||||
return this.virtualSize
|
||||
}
|
||||
isDifferencing(): boolean {
|
||||
return false
|
||||
}
|
||||
getBlockIndexes(): Array<number> {
|
||||
return [...this.blocks.keys()]
|
||||
}
|
||||
hasBlock(index: number): boolean {
|
||||
return this.blocks.has(index)
|
||||
}
|
||||
async init(): Promise<void> {}
|
||||
async close(): Promise<void> {
|
||||
this.closed = true
|
||||
}
|
||||
async readBlock(index: number): Promise<DiskBlock> {
|
||||
const data = this.blocks.get(index)
|
||||
if (data === undefined) {
|
||||
throw new Error(`Block ${index} not found`)
|
||||
}
|
||||
this.readBlockCalls.push(index)
|
||||
return { index, data }
|
||||
}
|
||||
}
|
||||
|
||||
const filled = (byte: number, length = BLOCK_SIZE) => Buffer.alloc(length, byte)
|
||||
|
||||
// 4 blocks, only 0 and 2 allocated
|
||||
const makeDevice = async (blockSize?: number) => {
|
||||
const disk = new StubDisk(
|
||||
new Map([
|
||||
[0, filled(0xaa)],
|
||||
[2, filled(0xcc)],
|
||||
]),
|
||||
4 * BLOCK_SIZE
|
||||
)
|
||||
const device = new DiskBlockDevice({ disk, blockSize })
|
||||
await device.open()
|
||||
return { disk, device }
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
it('exposes the disk capacity and the requested block size', async () => {
|
||||
const { device } = await makeDevice()
|
||||
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
|
||||
assert.equal(device.getBlockSize(), 512)
|
||||
})
|
||||
|
||||
it('rejects a disk block size that is not a multiple of the LUN block size', async () => {
|
||||
const disk = new StubDisk(new Map(), 4096, 600)
|
||||
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a multiple of the LUN block size/)
|
||||
})
|
||||
|
||||
it('rejects a capacity that is not a multiple of the LUN block size', async () => {
|
||||
const disk = new StubDisk(new Map(), BLOCK_SIZE + 100)
|
||||
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a positive multiple/)
|
||||
})
|
||||
|
||||
it('rejects an invalid block size', () => {
|
||||
const disk = new StubDisk(new Map(), BLOCK_SIZE)
|
||||
assert.throws(() => new DiskBlockDevice({ disk, blockSize: 0 }), /positive integer/)
|
||||
})
|
||||
|
||||
it('refuses I/O before open()', () => {
|
||||
const disk = new StubDisk(new Map(), BLOCK_SIZE)
|
||||
assert.throws(() => new DiskBlockDevice({ disk }).getSize(), /open\(\) must be called/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read', () => {
|
||||
it('reads a whole allocated block', async () => {
|
||||
const { device } = await makeDevice()
|
||||
assert.deepEqual(await device.read(0, BLOCK_SIZE), filled(0xaa))
|
||||
})
|
||||
|
||||
it('reads an unaligned slice inside one block', async () => {
|
||||
const { device, disk } = await makeDevice()
|
||||
assert.deepEqual(await device.read(BLOCK_SIZE * 2 + 512, 1024), filled(0xcc, 1024))
|
||||
// a single source block was fetched, not the whole disk
|
||||
assert.deepEqual(disk.readBlockCalls, [2])
|
||||
})
|
||||
|
||||
it('zero-fills unallocated blocks instead of reading them', async () => {
|
||||
const { device, disk } = await makeDevice()
|
||||
assert.deepEqual(await device.read(BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
|
||||
assert.deepEqual(disk.readBlockCalls, [])
|
||||
})
|
||||
|
||||
it('stitches a range spanning allocated and unallocated blocks', async () => {
|
||||
const { device } = await makeDevice()
|
||||
const data = await device.read(BLOCK_SIZE - 512, BLOCK_SIZE + 1024)
|
||||
assert.deepEqual(
|
||||
data,
|
||||
Buffer.concat([
|
||||
filled(0xaa, 512), // tail of block 0
|
||||
Buffer.alloc(BLOCK_SIZE), // block 1, unallocated
|
||||
filled(0xcc, 512), // head of block 2
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reads the last block of the disk', async () => {
|
||||
const { device } = await makeDevice()
|
||||
assert.deepEqual(await device.read(3 * BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
|
||||
})
|
||||
|
||||
it('returns an empty buffer for a zero-length read', async () => {
|
||||
const { device, disk } = await makeDevice()
|
||||
assert.equal((await device.read(0, 0)).length, 0)
|
||||
assert.deepEqual(disk.readBlockCalls, [])
|
||||
})
|
||||
|
||||
it('rejects a read past the end of the disk', async () => {
|
||||
const { device } = await makeDevice()
|
||||
await assert.rejects(device.read(4 * BLOCK_SIZE - 512, 1024), /out of range/)
|
||||
await assert.rejects(device.read(-512, 512), /out of range/)
|
||||
})
|
||||
|
||||
it('zero-pads a source block shorter than announced', async () => {
|
||||
const disk = new StubDisk(new Map([[0, filled(0xaa, 1024)]]), BLOCK_SIZE)
|
||||
const device = new DiskBlockDevice({ disk })
|
||||
await device.open()
|
||||
assert.deepEqual(await device.read(0, BLOCK_SIZE), Buffer.concat([filled(0xaa, 1024)], BLOCK_SIZE))
|
||||
})
|
||||
})
|
||||
|
||||
describe('write', () => {
|
||||
it('always fails', async () => {
|
||||
const { device } = await makeDevice()
|
||||
await assert.rejects(device.write(), /read-only/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('close', () => {
|
||||
it('closes the underlying disk', async () => {
|
||||
const { device, disk } = await makeDevice()
|
||||
await device.close()
|
||||
assert.equal(disk.closed, true)
|
||||
})
|
||||
})
|
||||
98
@vates/iscsi/src/IscsiDisk.mts
Normal file
98
@vates/iscsi/src/IscsiDisk.mts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
|
||||
|
||||
import { IscsiInitiator, type IscsiInitiatorOptions } from './initiator.mjs'
|
||||
|
||||
export interface IscsiDiskOptions {
|
||||
/**
|
||||
* Block size, in bytes, presented to the disk-transform pipeline. Must be a
|
||||
* multiple of the LUN's logical block size (checked at {@link IscsiDisk.init}).
|
||||
* A single SCSI READ(16) fetches one whole block.
|
||||
*/
|
||||
readonly blockSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link RandomAccessDisk} backed by a remote iSCSI LUN read through an
|
||||
* {@link IscsiInitiator} — the iSCSI analogue of `@vates/nbd-client`'s `NbdDisk`.
|
||||
* It plugs a target LUN into the `@xen-orchestra/disk-transform` pipeline
|
||||
* (`ReadAhead`, `DiskLargerBlock`, block generator) unchanged.
|
||||
*
|
||||
* First implementation: the LUN is treated as fully allocated — every block is
|
||||
* reported as present (dense, like `RawDisk`). A future version can narrow this
|
||||
* with a changed-block map (e.g. a Pure "Volume Difference"). Read-only.
|
||||
*/
|
||||
export class IscsiDisk extends RandomAccessDisk {
|
||||
readonly #options: IscsiInitiatorOptions
|
||||
readonly #blockSize: number
|
||||
#initiator?: IscsiInitiator
|
||||
|
||||
constructor(options: IscsiInitiatorOptions, { blockSize }: IscsiDiskOptions) {
|
||||
super()
|
||||
this.#options = options
|
||||
this.#blockSize = blockSize
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const initiator = new IscsiInitiator(this.#options)
|
||||
await initiator.connect()
|
||||
const lunBlockSize = initiator.getBlockSize()
|
||||
if (this.#blockSize % lunBlockSize !== 0) {
|
||||
await initiator.close()
|
||||
throw new Error(`block size ${this.#blockSize} is not a multiple of the LUN block size ${lunBlockSize}`)
|
||||
}
|
||||
this.#initiator = initiator
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const initiator = this.#initiator
|
||||
this.#initiator = undefined
|
||||
await initiator?.close()
|
||||
}
|
||||
|
||||
#requireInitiator(): IscsiInitiator {
|
||||
const initiator = this.#initiator
|
||||
if (initiator === undefined) {
|
||||
throw new Error('IscsiDisk.init() must be called first')
|
||||
}
|
||||
return initiator
|
||||
}
|
||||
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
getVirtualSize(): number {
|
||||
return this.#requireInitiator().getSize()
|
||||
}
|
||||
|
||||
isDifferencing(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
getBlockIndexes(): Array<number> {
|
||||
const count = Math.ceil(this.getVirtualSize() / this.#blockSize)
|
||||
const indexes = new Array<number>(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
indexes[i] = i
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
hasBlock(index: number): boolean {
|
||||
return index >= 0 && index * this.#blockSize < this.getVirtualSize()
|
||||
}
|
||||
|
||||
async readBlock(index: number): Promise<DiskBlock> {
|
||||
const initiator = this.#requireInitiator()
|
||||
const size = this.getVirtualSize()
|
||||
const offset = index * this.#blockSize
|
||||
const length = Math.min(this.#blockSize, size - offset)
|
||||
let data = await initiator.read(offset, length)
|
||||
if (data.length < this.#blockSize) {
|
||||
// Last (short) block on a LUN whose capacity is not a multiple of the
|
||||
// disk block size: zero-pad the tail to a full block.
|
||||
data = Buffer.concat([data], this.#blockSize)
|
||||
}
|
||||
return { index, data }
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
175
@vates/iscsi/src/backend.mts
Normal file
175
@vates/iscsi/src/backend.mts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { open, type FileHandle } from 'node:fs/promises'
|
||||
|
||||
/**
|
||||
* Byte-range random-access store backing a single iSCSI LUN.
|
||||
*
|
||||
* The protocol layer translates SCSI READ/WRITE CDBs (LBA + block count) into
|
||||
* byte offsets/lengths against this interface, so an implementation only has to
|
||||
* provide flat random access — it is intentionally decoupled from the wire
|
||||
* code. `DiskBlockDevice` is such an implementation, over
|
||||
* `@xen-orchestra/disk-transform`'s `RandomAccessDisk` (VHD/raw/NBD).
|
||||
*/
|
||||
export interface BlockDevice {
|
||||
/** Optional one-time initialization, awaited by the target before serving I/O. */
|
||||
open?(): Promise<void>
|
||||
/** Total capacity in bytes. Must be a multiple of {@link BlockDevice.getBlockSize}. */
|
||||
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`. */
|
||||
write(offset: number, data: Buffer): Promise<void>
|
||||
/** Flush any buffered data to stable storage (SYNCHRONIZE CACHE). */
|
||||
flush(): Promise<void>
|
||||
/** Release underlying resources. */
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 512
|
||||
|
||||
export interface FileBlockDeviceOptions {
|
||||
/**
|
||||
* 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, 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, 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. */
|
||||
async open(): Promise<void> {
|
||||
if (this.#handle !== undefined) {
|
||||
return
|
||||
}
|
||||
const handle = await open(this.#path, 'r+')
|
||||
try {
|
||||
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
|
||||
} catch (error) {
|
||||
await handle.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
#requireHandle(): FileHandle {
|
||||
const handle = this.#handle
|
||||
if (handle === undefined) {
|
||||
throw new Error('FileBlockDevice.open() must be called before I/O')
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
getSize(): number {
|
||||
return this.#size
|
||||
}
|
||||
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
async read(offset: number, length: number): Promise<Buffer> {
|
||||
const handle = this.#requireHandle()
|
||||
const buffer = Buffer.alloc(length, 0)
|
||||
let read = 0
|
||||
// A single fh.read may return fewer bytes than requested; loop to fill.
|
||||
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
|
||||
}
|
||||
read += bytesRead
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
async write(offset: number, data: Buffer): Promise<void> {
|
||||
const handle = this.#requireHandle()
|
||||
let written = 0
|
||||
while (written < data.length) {
|
||||
const { bytesWritten } = await handle.write(data, written, data.length - written, offset + written)
|
||||
written += bytesWritten
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.#requireHandle().sync()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const handle = this.#handle
|
||||
if (handle !== undefined) {
|
||||
this.#handle = undefined
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
90
@vates/iscsi/src/chap.mts
Normal file
90
@vates/iscsi/src/chap.mts
Normal file
@@ -0,0 +1,90 @@
|
||||
// Shared CHAP (RFC 1994) primitives for both login directions: the target as
|
||||
// authenticator, the initiator as responder. Kept in one place so the MD5
|
||||
// computation and large-binary codec agree byte-for-byte between the two
|
||||
// (cross-tested against each other in loopback).
|
||||
//
|
||||
// RFC 7143 §11.1.4 pins CHAP to MD5 (algorithm id 5) — a wire-format
|
||||
// requirement, not a security choice.
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/** The only CHAP algorithm iSCSI defines / we support: MD5 (RFC 7143 §11.1.4). */
|
||||
export const CHAP_ALGORITHM_MD5 = 5
|
||||
|
||||
/** Default challenge length in bytes; 16 is what open-iscsi and most targets use. */
|
||||
const DEFAULT_CHALLENGE_LENGTH = 16
|
||||
|
||||
/**
|
||||
* Compute a CHAP response: `MD5(id ‖ secret ‖ challenge)` (RFC 1994 §4.1).
|
||||
*
|
||||
* `id` is the single CHAP_I octet (0-255) — the raw byte, NOT its ASCII decimal.
|
||||
* `secret` is the shared secret (a string is hashed as its UTF-8 bytes).
|
||||
*/
|
||||
export function computeResponse(id: number, secret: string | Buffer, challenge: Buffer): Buffer {
|
||||
return createHash('md5')
|
||||
.update(Buffer.from([id & 0xff]))
|
||||
.update(secret)
|
||||
.update(challenge)
|
||||
.digest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a binary value (CHAP_C / CHAP_R) as an iSCSI hex-constant, e.g.
|
||||
* `0xa1b2…` (RFC 7143 §5.1). We always emit hex; {@link decodeChapValue} accepts
|
||||
* both hex and base64 so we interoperate with peers that send either.
|
||||
*/
|
||||
export function encodeChapValue(value: Buffer): string {
|
||||
return '0x' + value.toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an iSCSI large-binary value: `0x`/`0X` hex-constant or `0b`/`0B`
|
||||
* base64-constant (RFC 7143 §5.1). Throws on an unrecognized encoding.
|
||||
*/
|
||||
export function decodeChapValue(value: string): Buffer {
|
||||
const prefix = value.slice(0, 2)
|
||||
const body = value.slice(2)
|
||||
if (prefix === '0x' || prefix === '0X') {
|
||||
return Buffer.from(body, 'hex')
|
||||
}
|
||||
if (prefix === '0b' || prefix === '0B') {
|
||||
return Buffer.from(body, 'base64')
|
||||
}
|
||||
throw new Error(`unsupported CHAP value encoding: ${value.slice(0, 2)}…`)
|
||||
}
|
||||
|
||||
/** Generate a random CHAP challenge (CHAP_C). */
|
||||
export function generateChallenge(length: number = DEFAULT_CHALLENGE_LENGTH): Buffer {
|
||||
return randomBytes(length)
|
||||
}
|
||||
|
||||
/** Generate a random CHAP identifier (CHAP_I), a single octet. */
|
||||
export function generateId(): number {
|
||||
return randomBytes(1)[0]
|
||||
}
|
||||
|
||||
/** Parse a CHAP_A value (`"5"` or a `"5,6,7"` preference list) into algorithm ids. */
|
||||
export function parseAlgorithmList(value: string): number[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map(part => Number.parseInt(part.trim(), 10))
|
||||
.filter(id => Number.isInteger(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the algorithm we will use from the ones the peer offered: MD5 if present,
|
||||
* else `undefined` (we only implement MD5, so an offer without it must fail —
|
||||
* never fall back to the first offered id).
|
||||
*/
|
||||
export function selectAlgorithm(offered: readonly number[]): number | undefined {
|
||||
return offered.includes(CHAP_ALGORITHM_MD5) ? CHAP_ALGORITHM_MD5 : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison of an expected response against a received one. A
|
||||
* wrong-length `got` (or the length-mismatch `timingSafeEqual` would throw on)
|
||||
* counts as a failed authentication, not an exception.
|
||||
*/
|
||||
export function verifyResponse(expected: Buffer, got: Buffer): boolean {
|
||||
return expected.length === got.length && timingSafeEqual(expected, got)
|
||||
}
|
||||
108
@vates/iscsi/src/chap.test.mts
Normal file
108
@vates/iscsi/src/chap.test.mts
Normal file
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import {
|
||||
CHAP_ALGORITHM_MD5,
|
||||
computeResponse,
|
||||
decodeChapValue,
|
||||
encodeChapValue,
|
||||
generateChallenge,
|
||||
generateId,
|
||||
parseAlgorithmList,
|
||||
selectAlgorithm,
|
||||
verifyResponse,
|
||||
} from './chap.mjs'
|
||||
|
||||
describe('computeResponse', () => {
|
||||
// Known-answer vector: MD5(0x01 ‖ "s3cr3t" ‖ <16-byte challenge>), computed
|
||||
// independently so this pins the exact byte layout (id octet first), not MD5
|
||||
// against itself.
|
||||
const id = 1
|
||||
const secret = 's3cr3t'
|
||||
const challenge = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
|
||||
const expected = 'fc14fb49f184c847514a10278d75a10f'
|
||||
|
||||
it('hashes id ‖ secret ‖ challenge (RFC 1994)', () => {
|
||||
assert.equal(computeResponse(id, secret, challenge).toString('hex'), expected)
|
||||
})
|
||||
|
||||
it('treats a string secret as its UTF-8 bytes', () => {
|
||||
assert.deepEqual(
|
||||
computeResponse(id, Buffer.from(secret, 'utf8'), challenge),
|
||||
computeResponse(id, secret, challenge)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the id as a raw octet, not its ASCII decimal', () => {
|
||||
// computeResponse(1, …) must hash the byte 0x01, whereas hashing the ASCII
|
||||
// text "1" would hash 0x31 — the two must differ.
|
||||
const rawOctet = createHash('md5')
|
||||
.update(Buffer.from([1]))
|
||||
.update(secret)
|
||||
.update(challenge)
|
||||
.digest()
|
||||
const asciiText = createHash('md5').update(Buffer.from('1', 'ascii')).update(secret).update(challenge).digest()
|
||||
assert.deepEqual(computeResponse(1, secret, challenge), rawOctet)
|
||||
assert.notDeepEqual(computeResponse(1, secret, challenge), asciiText)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CHAP value codec', () => {
|
||||
it('encodes as a 0x hex-constant', () => {
|
||||
assert.equal(encodeChapValue(Buffer.from([0xab, 0xcd, 0x01])), '0xabcd01')
|
||||
})
|
||||
|
||||
it('round-trips through hex', () => {
|
||||
const value = generateChallenge()
|
||||
assert.deepEqual(decodeChapValue(encodeChapValue(value)), value)
|
||||
})
|
||||
|
||||
it('decodes a 0b base64-constant', () => {
|
||||
const raw = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
|
||||
assert.deepEqual(decodeChapValue('0b' + raw.toString('base64')), raw)
|
||||
})
|
||||
|
||||
it('accepts uppercase 0X / 0B prefixes', () => {
|
||||
assert.deepEqual(decodeChapValue('0Xabcd'), Buffer.from('abcd', 'hex'))
|
||||
assert.deepEqual(decodeChapValue('0B' + Buffer.from([1, 2]).toString('base64')), Buffer.from([1, 2]))
|
||||
})
|
||||
|
||||
it('rejects an unknown encoding', () => {
|
||||
assert.throws(() => decodeChapValue('12345'), /unsupported CHAP value encoding/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('algorithm selection', () => {
|
||||
it('parses a preference list', () => {
|
||||
assert.deepEqual(parseAlgorithmList('5,6,7'), [5, 6, 7])
|
||||
assert.deepEqual(parseAlgorithmList('5'), [5])
|
||||
})
|
||||
|
||||
it('picks MD5 when offered', () => {
|
||||
assert.equal(selectAlgorithm([1, CHAP_ALGORITHM_MD5, 6]), CHAP_ALGORITHM_MD5)
|
||||
})
|
||||
|
||||
it('returns undefined when MD5 is not offered (never falls back)', () => {
|
||||
assert.equal(selectAlgorithm([1, 2]), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyResponse', () => {
|
||||
const secret = 's3cr3t'
|
||||
const challenge = generateChallenge()
|
||||
const id = generateId()
|
||||
const good = computeResponse(id, secret, challenge)
|
||||
|
||||
it('accepts a matching response', () => {
|
||||
assert.equal(verifyResponse(good, computeResponse(id, secret, challenge)), true)
|
||||
})
|
||||
|
||||
it('rejects a wrong secret', () => {
|
||||
assert.equal(verifyResponse(good, computeResponse(id, 'wrong', challenge)), false)
|
||||
})
|
||||
|
||||
it('rejects a wrong-length response without throwing', () => {
|
||||
assert.equal(verifyResponse(good, Buffer.alloc(4)), false)
|
||||
})
|
||||
})
|
||||
141
@vates/iscsi/src/cli.mts
Executable file
141
@vates/iscsi/src/cli.mts
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
import { stat, truncate, writeFile } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { FileBlockDevice, IscsiTarget } from './index.mjs'
|
||||
|
||||
const USAGE = `Usage: vates-iscsi <file> [options]
|
||||
|
||||
Serve a single read/write iSCSI LUN backed by <file>.
|
||||
|
||||
Arguments:
|
||||
<file> Backing file. Must exist and have a size that is a
|
||||
multiple of the block size, unless --size is given.
|
||||
|
||||
Options:
|
||||
--serial <serial> LUN serial number (default: derived from the IQN).
|
||||
--size <bytes> Create/grow the backing file to this many bytes.
|
||||
Accepts suffixes K, M, G, T (e.g. 10G).
|
||||
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
|
||||
--host <host> Listen address (default: 0.0.0.0).
|
||||
--port <port> Listen port (default: 3260).
|
||||
--block-size <bytes> Logical block size (default: 512).
|
||||
-h, --help Show this help.
|
||||
`
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`vates-iscsi: ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** Parse a positive integer with optional K/M/G/T suffix (powers of 1024). */
|
||||
function parseSize(value: string, label: string): number {
|
||||
const match = /^(\d+)([KMGT]?)$/i.exec(value.trim())
|
||||
if (match === null) {
|
||||
fail(`invalid ${label}: ${value}`)
|
||||
}
|
||||
const units: Record<string, number> = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
|
||||
return Number(match[1]) * units[match[2].toUpperCase()]
|
||||
}
|
||||
|
||||
/** Sanitize a string into the characters allowed in the IQN-specific part. */
|
||||
function sanitizeIqnPart(value: string): string {
|
||||
const cleaned = value.toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return cleaned.length > 0 ? cleaned : 'lun0'
|
||||
}
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
serial: { type: 'string' },
|
||||
size: { type: 'string' },
|
||||
iqn: { type: 'string' },
|
||||
host: { type: 'string' },
|
||||
port: { type: 'string' },
|
||||
'block-size': { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help || positionals.length === 0) {
|
||||
process.stdout.write(USAGE)
|
||||
process.exit(values.help ? 0 : 1)
|
||||
}
|
||||
if (positionals.length > 1) {
|
||||
fail(`expected a single <file>, got ${positionals.length}`)
|
||||
}
|
||||
|
||||
const path = positionals[0]
|
||||
const blockSize = values['block-size'] !== undefined ? parseSize(values['block-size'], 'block size') : 512
|
||||
const port = values.port !== undefined ? Number.parseInt(values.port, 10) : 3260
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
fail(`invalid port: ${values.port}`)
|
||||
}
|
||||
|
||||
// Create/grow the backing file when --size is requested.
|
||||
if (values.size !== undefined) {
|
||||
const size = parseSize(values.size, 'size')
|
||||
if (size % blockSize !== 0) {
|
||||
fail(`--size (${size}) must be a multiple of the block size (${blockSize})`)
|
||||
}
|
||||
const exists = await stat(path).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
if (!exists) {
|
||||
await writeFile(path, Buffer.alloc(0))
|
||||
}
|
||||
await truncate(path, size)
|
||||
}
|
||||
|
||||
// Validate the backing file before binding the socket.
|
||||
const stats = await stat(path).catch(() => fail(`backing file not found: ${path} (create one, e.g. \`truncate -s 10G ${path}\`, or pass --size)`))
|
||||
if (stats.size === 0) {
|
||||
fail(`backing file is empty: ${path} (size it first, e.g. \`truncate -s 10G ${path}\`, or pass --size)`)
|
||||
}
|
||||
|
||||
const iqn = values.iqn ?? `iqn.2024-01.tech.vates:${sanitizeIqnPart(basename(path).replace(/\.[^.]+$/, ''))}`
|
||||
|
||||
const target = new IscsiTarget({
|
||||
iqn,
|
||||
host: values.host,
|
||||
port,
|
||||
lun: new FileBlockDevice({ path, blockSize }),
|
||||
identity: values.serial !== undefined ? { serial: values.serial } : undefined,
|
||||
})
|
||||
|
||||
await target.listen()
|
||||
|
||||
const address = target.address()
|
||||
const listenHost = values.host ?? '0.0.0.0'
|
||||
const listenPort = address?.port ?? port
|
||||
process.stdout.write(
|
||||
[
|
||||
`iSCSI target listening on ${listenHost}:${listenPort}`,
|
||||
` IQN: ${iqn}`,
|
||||
` LUN: ${path} (${stats.size} bytes, ${blockSize}-byte blocks)`,
|
||||
` serial: ${values.serial ?? iqn}`,
|
||||
``,
|
||||
`Discover with: iscsiadm -m discovery -t sendtargets -p ${listenHost === '0.0.0.0' ? '127.0.0.1' : listenHost}:${listenPort}`,
|
||||
`Press Ctrl-C to stop.`,
|
||||
``,
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
let stopping = false
|
||||
function stop(): void {
|
||||
if (stopping) {
|
||||
return
|
||||
}
|
||||
stopping = true
|
||||
target.close().then(
|
||||
() => process.exit(0),
|
||||
error => {
|
||||
process.stderr.write(`vates-iscsi: error on shutdown: ${String(error)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
)
|
||||
}
|
||||
process.once('SIGINT', stop)
|
||||
process.once('SIGTERM', stop)
|
||||
622
@vates/iscsi/src/connection.mts
Normal file
622
@vates/iscsi/src/connection.mts
Normal file
@@ -0,0 +1,622 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
import {
|
||||
Asc,
|
||||
DATA_IN_FLAG_FINAL,
|
||||
DATA_IN_FLAG_OVERFLOW,
|
||||
DATA_IN_FLAG_STATUS,
|
||||
DATA_IN_FLAG_UNDERFLOW,
|
||||
FLAG_FINAL,
|
||||
InitiatorOpcode,
|
||||
ISCSI_VERSION,
|
||||
LoginStatusClass,
|
||||
LogoutResponse,
|
||||
RejectReason,
|
||||
RESERVED_TAG,
|
||||
SCSI_RESP_FLAG_OVERFLOW,
|
||||
SCSI_RESP_FLAG_UNDERFLOW,
|
||||
SCSI_RESPONSE_COMMAND_COMPLETED,
|
||||
ScsiStatus,
|
||||
SenseKey,
|
||||
TargetOpcode,
|
||||
TEXT_FLAG_FINAL,
|
||||
} from './constants.mjs'
|
||||
import { buildFixedSense, handleScsiCommand, type ScsiIdentity } from './scsi.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,
|
||||
type ChapCredentials,
|
||||
type CommandContext,
|
||||
type NegotiatedParams,
|
||||
type ScsiResponseOptions,
|
||||
type SessionType,
|
||||
} from './types.mjs'
|
||||
import { buildR2t, receiveDataOut, type PendingWrite } from './writePath.mjs'
|
||||
import { Semaphore } from './semaphore.mjs'
|
||||
|
||||
const log: Logger = createLogger('vates:iscsi')
|
||||
|
||||
/** 2^32: the modulus for all iSCSI sequence-number arithmetic. */
|
||||
export const SERIAL_MODULO = 0x1_0000_0000
|
||||
|
||||
/** Increment an iSCSI serial number with 32-bit wraparound. */
|
||||
export function incrementSerial(value: number): number {
|
||||
return (value + 1) % SERIAL_MODULO
|
||||
}
|
||||
|
||||
/** Add `delta` to an iSCSI serial number with 32-bit wraparound. */
|
||||
export function addSerial(value: number, delta: number): number {
|
||||
return (value + delta) % SERIAL_MODULO
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiator opcodes whose BHS bytes 24-27 are CmdSN. The two that are missing —
|
||||
* SCSI Data-Out and SNACK — have those bytes reserved instead; see the comment
|
||||
* on `Connection`'s `#updateCommandWindow` for what reading them would cost.
|
||||
*/
|
||||
const CARRIES_CMD_SN: ReadonlySet<number> = new Set([
|
||||
InitiatorOpcode.NOP_OUT,
|
||||
InitiatorOpcode.SCSI_COMMAND,
|
||||
InitiatorOpcode.SCSI_TASK_MGMT_REQUEST,
|
||||
InitiatorOpcode.LOGIN_REQUEST,
|
||||
InitiatorOpcode.TEXT_REQUEST,
|
||||
InitiatorOpcode.LOGOUT_REQUEST,
|
||||
])
|
||||
|
||||
/** Everything a {@link Connection} needs from the owning target. */
|
||||
export interface ConnectionDeps {
|
||||
readonly iqn: string
|
||||
readonly identity: ScsiIdentity
|
||||
readonly lun: BlockDevice
|
||||
/** Drain timeout for outbound PDUs, in ms (0 disables). */
|
||||
readonly writeTimeoutMs: number
|
||||
/** Size of the CmdSN command window advertised to the initiator. */
|
||||
readonly cmdWindow: number
|
||||
/** Max number of READs served concurrently (see {@link Connection}). */
|
||||
readonly readConcurrency: number
|
||||
/** Allocate a fresh, non-zero Target Session Identifying Handle. */
|
||||
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'
|
||||
|
||||
/**
|
||||
* Drives one iSCSI connection: login, command-window/StatSN sequencing, and
|
||||
* full-feature-phase PDU dispatch. With MaxConnections=1 a connection is also
|
||||
* the whole session.
|
||||
*
|
||||
* Everything but READ runs one at a time; the read loop advances only once a
|
||||
* command fully completes. READ is dispatched without waiting on its own
|
||||
* `lun.read()` (can be a slow network fetch) — up to `readConcurrency` at
|
||||
* once, via `#readGate` — so one slow read can't block every other command.
|
||||
*
|
||||
* StatSN is allocated and written to the socket synchronously, with no
|
||||
* `await` in between, so responses reach the wire in whatever order the event
|
||||
* loop runs their completions — no extra sequencing needed. This does not
|
||||
* order a READ against a concurrent WRITE to the same LBA range: it may see
|
||||
* old or new bytes, like an untagged SCSI command with no ordering barrier.
|
||||
*/
|
||||
export class Connection implements CommandContext {
|
||||
readonly #socket: Socket
|
||||
readonly #deps: ConnectionDeps
|
||||
readonly #login: LoginNegotiator
|
||||
readonly #readGate: Semaphore
|
||||
|
||||
#phase: Phase = 'login'
|
||||
#sessionType: SessionType = 'Normal'
|
||||
#params: NegotiatedParams = { initiatorMaxRecvDataSegmentLength: 8192, maxBurstLength: 262144 }
|
||||
|
||||
#statSN = 0
|
||||
#expCmdSN = 0
|
||||
#maxCmdSN = 0
|
||||
#cmdSNInitialized = false
|
||||
#tsih = 0
|
||||
|
||||
// In-progress WRITEs keyed by Initiator Task Tag. Data-Out PDUs are routed
|
||||
// here, so writes can be interleaved with (and outstanding alongside) other
|
||||
// commands without blocking the read loop.
|
||||
readonly #pendingWrites = new Map<number, PendingWrite>()
|
||||
#nextTargetTransferTag = 1
|
||||
|
||||
#loginTimer?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(socket: Socket, deps: ConnectionDeps) {
|
||||
this.#socket = socket
|
||||
this.#deps = deps
|
||||
this.#login = new LoginNegotiator(deps.chap)
|
||||
this.#readGate = new Semaphore(deps.readConcurrency)
|
||||
}
|
||||
|
||||
get lun(): BlockDevice {
|
||||
return this.#deps.lun
|
||||
}
|
||||
|
||||
/** Forcibly tear down the connection (used when the target is closing). */
|
||||
destroy(): void {
|
||||
this.#phase = 'closed'
|
||||
this.#socket.destroy()
|
||||
}
|
||||
|
||||
/** 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,
|
||||
this.#phase === 'login' ? LOGIN_MAX_DATA_SEGMENT_LENGTH : OUR_MAX_RECV_DATA_SEGMENT_LENGTH
|
||||
)
|
||||
if (pdu === null) {
|
||||
break // peer closed the connection
|
||||
}
|
||||
const finished = await this.#dispatch(pdu)
|
||||
if (finished) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.#fail(error)
|
||||
} finally {
|
||||
clearTimeout(this.#loginTimer)
|
||||
this.#phase = 'closed'
|
||||
this.#socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a connection-ending error and tear the socket down — the same outcome
|
||||
* an uncaught error in the main `serve()` loop already had, now also reached
|
||||
* by a concurrently-dispatched READ that fails (e.g. a backend read error):
|
||||
* with nothing else awaiting it directly, it would otherwise surface only as
|
||||
* an unhandled rejection while the initiator hangs waiting for a response
|
||||
* that will never come. Safe to call more than once — `socket.destroy()` is
|
||||
* itself idempotent.
|
||||
*/
|
||||
#fail(error: unknown): void {
|
||||
if (this.#phase === 'closed') {
|
||||
return
|
||||
}
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
// A peer dropping the socket (e.g. on logout) is normal, not a fault.
|
||||
if (code === 'ECONNRESET' || code === 'EPIPE' || code === 'ECONNABORTED') {
|
||||
log.debug('connection closed by peer', { code })
|
||||
} else {
|
||||
log.warn('connection error', err)
|
||||
}
|
||||
this.#phase = 'closed'
|
||||
this.#socket.destroy()
|
||||
}
|
||||
|
||||
// --- sequencing -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advance the command window from a freshly received PDU — but only from one
|
||||
* that actually carries a CmdSN.
|
||||
*
|
||||
* BHS bytes 24-27 hold CmdSN on command PDUs only; on a SCSI Data-Out (RFC
|
||||
* 7143 §11.7) and a SNACK they are reserved, as is the immediate bit. An
|
||||
* initiator zeroes them, so treating such a PDU as a command reads CmdSN=0,
|
||||
* takes the non-immediate branch, and rewinds the window to ExpCmdSN=1 /
|
||||
* MaxCmdSN=1+window — then advertises it on the very next R2T, SCSI Response,
|
||||
* or concurrent READ's Data-In. Initiators discard the rewind as stale only
|
||||
* while the session's CmdSN stays below 2^31; past that it compares as newer
|
||||
* under serial arithmetic, is adopted, and the window collapses into a stall.
|
||||
*
|
||||
* Since every WRITE is R2T-solicited (InitialR2T=Yes), Data-Out is the common
|
||||
* case, not an edge one. Allowlisting the opcodes that do carry CmdSN, rather
|
||||
* than skipping the two that don't, keeps a newly handled opcode from
|
||||
* silently inheriting the bug.
|
||||
*/
|
||||
#updateCommandWindow(pdu: IncomingPdu): void {
|
||||
if (!CARRIES_CMD_SN.has(pdu.opcode)) {
|
||||
return
|
||||
}
|
||||
const cmdSN = pdu.cmdSN
|
||||
if (!this.#cmdSNInitialized) {
|
||||
this.#expCmdSN = cmdSN
|
||||
this.#cmdSNInitialized = true
|
||||
}
|
||||
// Immediate PDUs (Login, NOP pings, aborts) do not consume a CmdSN slot.
|
||||
if (!pdu.immediate) {
|
||||
this.#expCmdSN = incrementSerial(cmdSN)
|
||||
}
|
||||
this.#maxCmdSN = addSerial(this.#expCmdSN, this.#deps.cmdWindow)
|
||||
}
|
||||
|
||||
/** Current StatSN without advancing (for R2T, NOP-In, Reject, non-final Data-In). */
|
||||
#currentStatSN(): number {
|
||||
return this.#statSN
|
||||
}
|
||||
|
||||
/** Return the current StatSN and advance it (for status-bearing responses). */
|
||||
#nextStatSN(): number {
|
||||
const value = this.#statSN
|
||||
this.#statSN = incrementSerial(value)
|
||||
return value
|
||||
}
|
||||
|
||||
#send(buffer: Buffer): Promise<void> {
|
||||
return writePdu(this.#socket, buffer, this.#deps.writeTimeoutMs)
|
||||
}
|
||||
|
||||
// --- dispatch -------------------------------------------------------------
|
||||
|
||||
/** Returns true when the connection should be closed (after a logout). */
|
||||
async #dispatch(pdu: IncomingPdu): Promise<boolean> {
|
||||
this.#updateCommandWindow(pdu)
|
||||
|
||||
if (this.#phase === 'login') {
|
||||
if (pdu.opcode === InitiatorOpcode.LOGIN_REQUEST) {
|
||||
return await this.#handleLogin(pdu)
|
||||
}
|
||||
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
|
||||
return false
|
||||
}
|
||||
|
||||
switch (pdu.opcode) {
|
||||
case InitiatorOpcode.SCSI_COMMAND:
|
||||
if (this.#sessionType === 'Discovery') {
|
||||
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
|
||||
} else {
|
||||
await this.#handleScsiCommand(pdu)
|
||||
}
|
||||
return false
|
||||
case InitiatorOpcode.TEXT_REQUEST:
|
||||
await this.#handleText(pdu)
|
||||
return false
|
||||
case InitiatorOpcode.NOP_OUT:
|
||||
await this.#handleNopOut(pdu)
|
||||
return false
|
||||
case InitiatorOpcode.LOGOUT_REQUEST:
|
||||
await this.#handleLogout(pdu)
|
||||
return true
|
||||
case InitiatorOpcode.SCSI_DATA_OUT:
|
||||
await this.#handleDataOut(pdu)
|
||||
return false
|
||||
default:
|
||||
await this.#sendReject(pdu, RejectReason.COMMAND_NOT_SUPPORTED)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when the login failed and the connection must be closed. */
|
||||
async #handleLogin(pdu: IncomingPdu): Promise<boolean> {
|
||||
const { flagsByte, data, statusClass = LoginStatusClass.SUCCESS, statusDetail = 0 } = this.#login.process(pdu)
|
||||
if (this.#login.complete && this.#tsih === 0) {
|
||||
this.#tsih = this.#deps.allocateTsih()
|
||||
}
|
||||
|
||||
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
|
||||
bhs[1] = flagsByte
|
||||
bhs[2] = ISCSI_VERSION // Version-max
|
||||
bhs[3] = ISCSI_VERSION // Version-active
|
||||
pdu.bhs.copy(bhs, 8, 8, 14) // echo ISID (6 bytes)
|
||||
bhs.writeUInt16BE(this.#tsih, 14)
|
||||
bhs.writeUInt32BE(pdu.itt, 16)
|
||||
bhs.writeUInt32BE(this.#nextStatSN(), 24)
|
||||
bhs.writeUInt32BE(this.#expCmdSN, 28)
|
||||
bhs.writeUInt32BE(this.#maxCmdSN, 32)
|
||||
bhs[36] = statusClass
|
||||
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.
|
||||
log.warn('login rejected', { statusClass, statusDetail, initiator: this.#login.initiatorName })
|
||||
this.#phase = 'closed'
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.#login.complete) {
|
||||
this.#phase = 'fullFeature'
|
||||
this.#sessionType = this.#login.sessionType
|
||||
this.#params = this.#login.params
|
||||
log.info('session established', {
|
||||
initiator: this.#login.initiatorName,
|
||||
sessionType: this.#sessionType,
|
||||
target: this.#login.targetName,
|
||||
})
|
||||
if (this.#sessionType === 'Normal') {
|
||||
clearTimeout(this.#loginTimer)
|
||||
this.#deps.onSessionEstablished?.(this)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async #handleText(pdu: IncomingPdu): Promise<void> {
|
||||
const keys = parseTextKeys(pdu.data)
|
||||
// SendTargets discovery: advertise this target at the address the initiator
|
||||
// actually connected to.
|
||||
const data =
|
||||
keys.get('SendTargets') !== undefined
|
||||
? buildSendTargetsResponse(
|
||||
this.#deps.iqn,
|
||||
this.#socket.localAddress ?? '0.0.0.0',
|
||||
this.#socket.localPort ?? 3260
|
||||
)
|
||||
: Buffer.alloc(0)
|
||||
|
||||
const bhs = allocBhs(TargetOpcode.TEXT_RESPONSE)
|
||||
bhs[1] = TEXT_FLAG_FINAL
|
||||
bhs.writeUInt32BE(pdu.itt, 16)
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag: none / final
|
||||
bhs.writeUInt32BE(this.#nextStatSN(), 24)
|
||||
bhs.writeUInt32BE(this.#expCmdSN, 28)
|
||||
bhs.writeUInt32BE(this.#maxCmdSN, 32)
|
||||
await this.#send(assemblePdu(bhs, data))
|
||||
}
|
||||
|
||||
async #handleNopOut(pdu: IncomingPdu): Promise<void> {
|
||||
// A NOP-Out with the reserved ITT is the initiator's reply to a NOP-In we
|
||||
// sent; nothing to do. Otherwise echo it back as a NOP-In ping reply.
|
||||
if (pdu.itt === RESERVED_TAG) {
|
||||
return
|
||||
}
|
||||
const bhs = allocBhs(TargetOpcode.NOP_IN)
|
||||
bhs[1] = FLAG_FINAL
|
||||
pdu.bhs.copy(bhs, 8, 8, 16) // echo LUN
|
||||
bhs.writeUInt32BE(pdu.itt, 16)
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
|
||||
bhs.writeUInt32BE(this.#currentStatSN(), 24) // NOP-In does not advance StatSN
|
||||
bhs.writeUInt32BE(this.#expCmdSN, 28)
|
||||
bhs.writeUInt32BE(this.#maxCmdSN, 32)
|
||||
await this.#send(assemblePdu(bhs, pdu.data)) // echo ping data
|
||||
}
|
||||
|
||||
async #handleLogout(pdu: IncomingPdu): Promise<void> {
|
||||
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs[2] = LogoutResponse.SUCCESS
|
||||
bhs.writeUInt32BE(pdu.itt, 16)
|
||||
bhs.writeUInt32BE(this.#nextStatSN(), 24)
|
||||
bhs.writeUInt32BE(this.#expCmdSN, 28)
|
||||
bhs.writeUInt32BE(this.#maxCmdSN, 32)
|
||||
await this.#send(assemblePdu(bhs))
|
||||
}
|
||||
|
||||
async #handleScsiCommand(pdu: IncomingPdu): Promise<void> {
|
||||
// Login declares ImmediateData=No unconditionally, so a data segment here is
|
||||
// write data we never solicited and have no path to apply. Ignoring it would
|
||||
// acknowledge a write missing its first bytes — exactly the silent loss the
|
||||
// solicited path is built to avoid — so fail the command loudly instead and
|
||||
// let the initiator retry or give up.
|
||||
if (pdu.data.length > 0) {
|
||||
log.warn('immediate data is not supported', { itt: pdu.itt, length: pdu.data.length })
|
||||
const sense = buildFixedSense({ key: SenseKey.ILLEGAL_REQUEST, ...Asc.INVALID_FIELD_IN_CDB })
|
||||
await this.sendScsiResponse(pdu.itt, ScsiStatus.CHECK_CONDITION, { sense })
|
||||
return
|
||||
}
|
||||
|
||||
const cdb = pdu.bhs.subarray(32, 48)
|
||||
if (decodeCdb(cdb).kind === 'read') {
|
||||
// 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, this.#deps.maxTransferLength))
|
||||
.catch(error => {
|
||||
this.#fail(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength)
|
||||
}
|
||||
|
||||
async #sendReject(pdu: IncomingPdu, reason: number): Promise<void> {
|
||||
log.debug('rejecting PDU', { opcode: pdu.opcode, itt: pdu.itt, reason, phase: this.#phase })
|
||||
const bhs = allocBhs(TargetOpcode.REJECT)
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs[2] = reason
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 16)
|
||||
bhs.writeUInt32BE(this.#currentStatSN(), 24) // Reject snapshots StatSN
|
||||
bhs.writeUInt32BE(this.#expCmdSN, 28)
|
||||
bhs.writeUInt32BE(this.#maxCmdSN, 32)
|
||||
// The rejected PDU's header is returned as the data segment.
|
||||
await this.#send(assemblePdu(bhs, pdu.bhs))
|
||||
}
|
||||
|
||||
// --- CommandContext implementation ----------------------------------------
|
||||
|
||||
async sendReadData(itt: number, data: Buffer, allocationLength: number): Promise<void> {
|
||||
let payload = data
|
||||
let residualOverflow = 0
|
||||
let residualUnderflow = 0
|
||||
if (data.length > allocationLength) {
|
||||
payload = data.subarray(0, allocationLength)
|
||||
residualOverflow = data.length - allocationLength
|
||||
} else if (data.length < allocationLength) {
|
||||
residualUnderflow = allocationLength - data.length
|
||||
}
|
||||
|
||||
if (payload.length === 0) {
|
||||
return this.sendScsiResponse(itt, ScsiStatus.GOOD, { residualOverflow, residualUnderflow })
|
||||
}
|
||||
|
||||
const maxSegment = this.#params.initiatorMaxRecvDataSegmentLength
|
||||
let offset = 0
|
||||
let dataSN = 0
|
||||
while (offset < payload.length) {
|
||||
const end = Math.min(offset + maxSegment, payload.length)
|
||||
const chunk = payload.subarray(offset, end)
|
||||
const isLast = end >= payload.length
|
||||
|
||||
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
|
||||
let flags = 0
|
||||
if (isLast) {
|
||||
// Piggyback GOOD status on the final Data-In PDU.
|
||||
flags |= DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
|
||||
if (residualOverflow > 0) {
|
||||
flags |= DATA_IN_FLAG_OVERFLOW
|
||||
}
|
||||
if (residualUnderflow > 0) {
|
||||
flags |= DATA_IN_FLAG_UNDERFLOW
|
||||
}
|
||||
bhs[3] = ScsiStatus.GOOD // Status valid only when the S bit is set
|
||||
}
|
||||
bhs[1] = flags
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
|
||||
if (isLast) {
|
||||
bhs.writeUInt32BE(this.#nextStatSN(), 24) // StatSN valid only with the S bit
|
||||
}
|
||||
bhs.writeUInt32BE(this.#expCmdSN, 28)
|
||||
bhs.writeUInt32BE(this.#maxCmdSN, 32)
|
||||
bhs.writeUInt32BE(dataSN, 36)
|
||||
bhs.writeUInt32BE(offset, 40) // Buffer Offset
|
||||
if (isLast) {
|
||||
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
|
||||
}
|
||||
await this.#send(assemblePdu(bhs, chunk))
|
||||
|
||||
offset = end
|
||||
dataSN++
|
||||
}
|
||||
}
|
||||
|
||||
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
|
||||
const pending: PendingWrite = {
|
||||
itt,
|
||||
targetTransferTag: this.#nextTargetTransferTag++,
|
||||
lunOffset,
|
||||
// zero-filled, not `allocUnsafe`: belt to `receiveDataOut`'s braces, so a
|
||||
// buffer that somehow reaches the LUN partly filled writes zeroes rather
|
||||
// than recycled heap (which the initiator could then read straight back)
|
||||
buffer: Buffer.alloc(totalLength),
|
||||
totalLength,
|
||||
contiguousReceived: 0,
|
||||
solicited: 0,
|
||||
r2tSN: 0,
|
||||
}
|
||||
this.#pendingWrites.set(itt, pending)
|
||||
log.debug('write begin', { itt, lunOffset, totalLength })
|
||||
await this.#sendNextR2t(pending)
|
||||
}
|
||||
|
||||
/** Solicit the next burst (≤ MaxBurstLength) of a pending write via an R2T. */
|
||||
async #sendNextR2t(pending: PendingWrite): Promise<void> {
|
||||
const desiredLength = Math.min(pending.totalLength - pending.solicited, this.#params.maxBurstLength)
|
||||
const bufferOffset = pending.solicited
|
||||
const r2tSN = pending.r2tSN
|
||||
pending.solicited += desiredLength
|
||||
pending.r2tSN++
|
||||
await this.#send(
|
||||
buildR2t(
|
||||
{ itt: pending.itt, r2tSN, targetTransferTag: pending.targetTransferTag, bufferOffset, desiredLength },
|
||||
{ statSN: this.#currentStatSN(), expCmdSN: this.#expCmdSN, maxCmdSN: this.#maxCmdSN }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async #handleDataOut(pdu: IncomingPdu): Promise<void> {
|
||||
const pending = this.#pendingWrites.get(pdu.itt)
|
||||
if (pending === undefined) {
|
||||
// Data-Out for an unknown command: protocol desync.
|
||||
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
|
||||
return
|
||||
}
|
||||
const bufferOffset = pdu.readU32(40) // BufferOffset is relative to the command's data
|
||||
const outcome = receiveDataOut(pending, {
|
||||
targetTransferTag: pdu.readU32(20),
|
||||
bufferOffset,
|
||||
data: pdu.data,
|
||||
})
|
||||
log.debug('write data-out', {
|
||||
itt: pending.itt,
|
||||
offset: bufferOffset,
|
||||
length: pdu.data.length,
|
||||
received: pending.contiguousReceived,
|
||||
total: pending.totalLength,
|
||||
})
|
||||
|
||||
if (!outcome.ok) {
|
||||
// The command is dropped, not just the PDU: leaving it pending would hang
|
||||
// the initiator on an R2T that never comes, until its command timeout.
|
||||
this.#pendingWrites.delete(pdu.itt)
|
||||
log.warn('unplaceable write data-out', { itt: pending.itt, reason: outcome.reason })
|
||||
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
if (outcome.complete) {
|
||||
this.#pendingWrites.delete(pdu.itt)
|
||||
try {
|
||||
await this.lun.write(pending.lunOffset, pending.buffer)
|
||||
} catch (error) {
|
||||
log.warn('LUN write failed', error instanceof Error ? error : new Error(String(error)))
|
||||
const sense = buildFixedSense({ key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE })
|
||||
await this.sendScsiResponse(pending.itt, ScsiStatus.CHECK_CONDITION, { sense })
|
||||
return
|
||||
}
|
||||
await this.sendScsiResponse(pending.itt, ScsiStatus.GOOD)
|
||||
} else if (outcome.burstComplete) {
|
||||
// Current burst complete and data remains: solicit the next one.
|
||||
await this.#sendNextR2t(pending)
|
||||
}
|
||||
}
|
||||
|
||||
async sendScsiResponse(itt: number, status: number, options: ScsiResponseOptions = {}): Promise<void> {
|
||||
const { sense, residualOverflow = 0, residualUnderflow = 0 } = options
|
||||
|
||||
const bhs = allocBhs(TargetOpcode.SCSI_RESPONSE)
|
||||
let flags = FLAG_FINAL // bit 7 is always set on a SCSI Response
|
||||
if (residualOverflow > 0) {
|
||||
flags |= SCSI_RESP_FLAG_OVERFLOW
|
||||
}
|
||||
if (residualUnderflow > 0) {
|
||||
flags |= SCSI_RESP_FLAG_UNDERFLOW
|
||||
}
|
||||
bhs[1] = flags
|
||||
bhs[2] = SCSI_RESPONSE_COMMAND_COMPLETED
|
||||
bhs[3] = status
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(this.#nextStatSN(), 24)
|
||||
bhs.writeUInt32BE(this.#expCmdSN, 28)
|
||||
bhs.writeUInt32BE(this.#maxCmdSN, 32)
|
||||
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
|
||||
|
||||
let data: Buffer | undefined
|
||||
if (sense !== undefined) {
|
||||
// Sense data in a SCSI Response is prefixed with a 2-byte length.
|
||||
data = Buffer.alloc(2 + sense.length)
|
||||
data.writeUInt16BE(sense.length, 0)
|
||||
sense.copy(data, 2)
|
||||
}
|
||||
await this.#send(assemblePdu(bhs, data))
|
||||
}
|
||||
}
|
||||
712
@vates/iscsi/src/connection.test.mts
Normal file
712
@vates/iscsi/src/connection.test.mts
Normal file
@@ -0,0 +1,712 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import type { Socket } from 'node:net'
|
||||
import { Duplex } from 'node:stream'
|
||||
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
import {
|
||||
DATA_IN_FLAG_FINAL,
|
||||
DATA_IN_FLAG_OVERFLOW,
|
||||
DATA_IN_FLAG_STATUS,
|
||||
DATA_IN_FLAG_UNDERFLOW,
|
||||
FLAG_FINAL,
|
||||
InitiatorOpcode,
|
||||
LOGIN_CSG_SHIFT,
|
||||
LOGIN_FLAG_TRANSIT,
|
||||
LoginStage,
|
||||
LoginStatusClass,
|
||||
OPCODE_IMMEDIATE,
|
||||
RejectReason,
|
||||
RESERVED_TAG,
|
||||
ScsiStatus,
|
||||
SenseKey,
|
||||
TargetOpcode,
|
||||
} from './constants.mjs'
|
||||
import { Connection, type ConnectionDeps } from './connection.mjs'
|
||||
import { serializeTextKeys } from './login.mjs'
|
||||
import { assemblePdu, IncomingPdu } from './pdu.mjs'
|
||||
import type { ScsiIdentity } from './scsi.mjs'
|
||||
|
||||
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
|
||||
const BLOCK_SIZE = 512
|
||||
const LUN_SIZE = 16 * BLOCK_SIZE
|
||||
|
||||
// Small enough that a handful of blocks splits into several PDUs / bursts, so
|
||||
// the chunking and multi-burst paths are exercised without huge buffers.
|
||||
const MAX_RECV_DATA_SEGMENT_LENGTH = 512
|
||||
const MAX_BURST_LENGTH = 512
|
||||
|
||||
// --- test doubles -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stands in for the TCP socket. `deliver()` queues a PDU for the connection's
|
||||
* read loop (the readable side); everything the connection writes is captured
|
||||
* in `sent` rather than transmitted. `Connection` only ever reads, writes, and
|
||||
* destroys its socket, plus `localAddress`/`localPort` for SendTargets — hence
|
||||
* the narrow cast in `asSocket()`.
|
||||
*/
|
||||
class FakeSocket extends Duplex {
|
||||
readonly sent: Array<Buffer> = []
|
||||
readonly localAddress = '10.0.0.1'
|
||||
readonly localPort = 3260
|
||||
|
||||
override _read(): void {}
|
||||
|
||||
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||
this.sent.push(Buffer.from(chunk))
|
||||
callback()
|
||||
}
|
||||
|
||||
/** Hand a PDU to the connection. */
|
||||
deliver(pdu: Buffer): void {
|
||||
this.push(pdu)
|
||||
}
|
||||
|
||||
/** Clean end-of-stream, which ends `serve()`'s read loop. */
|
||||
finish(): void {
|
||||
this.push(null)
|
||||
}
|
||||
|
||||
asSocket(): Socket {
|
||||
return this as unknown as Socket
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryLun implements BlockDevice {
|
||||
readonly content = Buffer.alloc(LUN_SIZE)
|
||||
/** Set to make the next `write()` fail, standing in for a backend error. */
|
||||
failNextWrite = false
|
||||
|
||||
getSize(): number {
|
||||
return this.content.length
|
||||
}
|
||||
getBlockSize(): number {
|
||||
return BLOCK_SIZE
|
||||
}
|
||||
async read(offset: number, length: number): Promise<Buffer> {
|
||||
return Buffer.from(this.content.subarray(offset, offset + length))
|
||||
}
|
||||
async write(offset: number, data: Buffer): Promise<void> {
|
||||
if (this.failNextWrite) {
|
||||
this.failNextWrite = false
|
||||
throw new Error('backend write failed')
|
||||
}
|
||||
data.copy(this.content, offset)
|
||||
}
|
||||
async flush(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
// --- initiator-side PDU builders --------------------------------------------
|
||||
|
||||
/** A single-PDU operational login that transits straight to Full Feature Phase. */
|
||||
const loginPdu = (): Buffer => {
|
||||
const bhs = Buffer.alloc(48)
|
||||
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
|
||||
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << LOGIN_CSG_SHIFT) | LoginStage.FULL_FEATURE_PHASE
|
||||
bhs.writeUInt32BE(1, 16) // ITT
|
||||
return assemblePdu(
|
||||
bhs,
|
||||
serializeTextKeys([
|
||||
['InitiatorName', 'iqn.2026-01.tech.vates:test-initiator'],
|
||||
['SessionType', 'Normal'],
|
||||
['HeaderDigest', 'None'],
|
||||
['DataDigest', 'None'],
|
||||
['MaxRecvDataSegmentLength', String(MAX_RECV_DATA_SEGMENT_LENGTH)],
|
||||
['MaxBurstLength', String(MAX_BURST_LENGTH)],
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
const scsiCommandPdu = ({
|
||||
itt,
|
||||
cmdSN,
|
||||
cdb,
|
||||
data,
|
||||
}: {
|
||||
itt: number
|
||||
cmdSN: number
|
||||
cdb: Buffer
|
||||
data?: Buffer
|
||||
}): Buffer => {
|
||||
const bhs = Buffer.alloc(48)
|
||||
bhs[0] = InitiatorOpcode.SCSI_COMMAND
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(cmdSN, 24)
|
||||
cdb.copy(bhs, 32)
|
||||
return assemblePdu(bhs, data)
|
||||
}
|
||||
|
||||
const dataOutPdu = ({
|
||||
itt,
|
||||
targetTransferTag,
|
||||
bufferOffset,
|
||||
data,
|
||||
dataSN = 0,
|
||||
}: {
|
||||
itt: number
|
||||
targetTransferTag: number
|
||||
bufferOffset: number
|
||||
data: Buffer
|
||||
dataSN?: number
|
||||
}): Buffer => {
|
||||
const bhs = Buffer.alloc(48)
|
||||
bhs[0] = InitiatorOpcode.SCSI_DATA_OUT
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(targetTransferTag, 20)
|
||||
bhs.writeUInt32BE(dataSN, 36)
|
||||
bhs.writeUInt32BE(bufferOffset, 40)
|
||||
return assemblePdu(bhs, data)
|
||||
}
|
||||
|
||||
const cdb10 = (opcode: number, lba: number, blocks: number): Buffer => {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = opcode
|
||||
cdb.writeUInt32BE(lba, 2)
|
||||
cdb.writeUInt16BE(blocks, 7)
|
||||
return cdb
|
||||
}
|
||||
const read10 = (lba: number, blocks: number) => cdb10(0x28, lba, blocks)
|
||||
const write10 = (lba: number, blocks: number) => cdb10(0x2a, lba, blocks)
|
||||
|
||||
const cdb16 = (opcode: number, lba: bigint, blocks: number): Buffer => {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = opcode
|
||||
cdb.writeBigUInt64BE(lba, 2)
|
||||
cdb.writeUInt32BE(blocks, 10)
|
||||
return cdb
|
||||
}
|
||||
const read16 = (lba: bigint, blocks: number) => cdb16(0x88, lba, blocks)
|
||||
const write16 = (lba: bigint, blocks: number) => cdb16(0x8a, lba, blocks)
|
||||
|
||||
const inquiry = (allocationLength: number): Buffer => {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = 0x12
|
||||
cdb.writeUInt16BE(allocationLength, 3)
|
||||
return cdb
|
||||
}
|
||||
|
||||
// --- harness ----------------------------------------------------------------
|
||||
|
||||
/** Re-read a PDU the target sent, so assertions can talk in field names. */
|
||||
const parseSent = (buffer: Buffer): IncomingPdu => {
|
||||
const dataSegmentLength = buffer.readUIntBE(5, 3)
|
||||
return new IncomingPdu(buffer.subarray(0, 48), Buffer.alloc(0), buffer.subarray(48, 48 + dataSegmentLength))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sense key of a SCSI Response: its data segment is a 2-byte sense length then
|
||||
* the fixed-format sense data, whose byte 2 holds the key in its low nibble.
|
||||
*/
|
||||
const senseKey = (response: IncomingPdu): number => response.data[4] & 0x0f
|
||||
|
||||
class Harness {
|
||||
readonly socket = new FakeSocket()
|
||||
readonly lun = new MemoryLun()
|
||||
readonly serving: Promise<void>
|
||||
|
||||
constructor(overrides: Partial<ConnectionDeps> = {}) {
|
||||
const deps: ConnectionDeps = {
|
||||
iqn: 'iqn.2026-01.tech.vates:target',
|
||||
identity: IDENTITY,
|
||||
lun: this.lun,
|
||||
writeTimeoutMs: 0,
|
||||
cmdWindow: 64,
|
||||
readConcurrency: 4,
|
||||
allocateTsih: () => 1,
|
||||
...overrides,
|
||||
}
|
||||
this.serving = new Connection(this.socket.asSocket(), deps).serve()
|
||||
}
|
||||
|
||||
/** Every PDU the target has sent, parsed. */
|
||||
get sent(): Array<IncomingPdu> {
|
||||
return this.socket.sent.map(parseSent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the read loop run until the target has sent `count` PDUs in total,
|
||||
* optionally delivering something first. Bounded so a response that never
|
||||
* comes fails the test instead of hanging it.
|
||||
*/
|
||||
async untilSent(count: number, deliver?: () => void): Promise<Array<IncomingPdu>> {
|
||||
deliver?.()
|
||||
for (let turn = 0; turn < 500 && this.socket.sent.length < count; turn++) {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
const sent = this.sent
|
||||
assert.ok(
|
||||
sent.length >= count,
|
||||
`expected ${count} PDUs, got ${sent.length}: ${sent.map(pdu => pdu.opcode.toString(16)).join(',')}`
|
||||
)
|
||||
return sent
|
||||
}
|
||||
|
||||
/** Log in and confirm the target accepted it. */
|
||||
async login(): Promise<void> {
|
||||
// relative to what has already been sent, so this works mid-connection too
|
||||
const baseline = this.socket.sent.length
|
||||
const response = (await this.untilSent(baseline + 1, () => this.socket.deliver(loginPdu())))[baseline]
|
||||
assert.equal(response.opcode, TargetOpcode.LOGIN_RESPONSE)
|
||||
assert.equal(response.readU8(36), LoginStatusClass.SUCCESS)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.socket.finish()
|
||||
await this.serving
|
||||
}
|
||||
}
|
||||
|
||||
/** Log in, then run `body`, always tearing the connection down afterwards. */
|
||||
const withSession = async (body: (harness: Harness) => Promise<void>, overrides?: Partial<ConnectionDeps>) => {
|
||||
const harness = new Harness(overrides)
|
||||
try {
|
||||
await harness.login()
|
||||
await body(harness)
|
||||
} finally {
|
||||
await harness.close()
|
||||
}
|
||||
}
|
||||
|
||||
const CMD_WINDOW = 64
|
||||
/** ExpCmdSN (bytes 28-31) and MaxCmdSN (32-35), the command window we advertise. */
|
||||
const commandWindow = (pdu: IncomingPdu) => ({ expCmdSN: pdu.readU32(28), maxCmdSN: pdu.readU32(32) })
|
||||
|
||||
describe('command window', () => {
|
||||
// Bytes 24-27 are CmdSN only on command PDUs. On a Data-Out (RFC 7143 §11.7)
|
||||
// and a SNACK they are reserved, so reading a window out of them yields the
|
||||
// zeroes an initiator puts there — advertising ExpCmdSN=1 mid-session. Real
|
||||
// initiators discard that as stale only while session CmdSN < 2^31; past the
|
||||
// wrap it compares as newer, is adopted, and the window collapses to a stall.
|
||||
it('is not recomputed from the reserved bytes of a Data-Out', async () => {
|
||||
await withSession(async harness => {
|
||||
// two bursts, so an R2T is emitted *after* a Data-Out has been processed
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 50, cdb: write10(0, 2) }))
|
||||
const [, firstR2t] = await harness.untilSent(2)
|
||||
const expected = { expCmdSN: 51, maxCmdSN: 51 + CMD_WINDOW }
|
||||
assert.deepEqual(commandWindow(firstR2t), expected)
|
||||
|
||||
const ttt = firstR2t.readU32(20)
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({ itt: 31, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
|
||||
)
|
||||
|
||||
const [, , secondR2t] = await harness.untilSent(3)
|
||||
assert.equal(secondR2t.opcode, TargetOpcode.R2T)
|
||||
assert.deepEqual(commandWindow(secondR2t), expected)
|
||||
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({
|
||||
itt: 31,
|
||||
targetTransferTag: ttt,
|
||||
bufferOffset: BLOCK_SIZE,
|
||||
data: Buffer.alloc(BLOCK_SIZE, 0x22),
|
||||
dataSN: 1,
|
||||
})
|
||||
)
|
||||
|
||||
const [, , , response] = await harness.untilSent(4)
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.deepEqual(commandWindow(response), expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('is not recomputed from an opcode the target does not implement', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 33, cmdSN: 70, cdb: read10(0, 1) }))
|
||||
const [, dataIn] = await harness.untilSent(2)
|
||||
const expected = { expCmdSN: 71, maxCmdSN: 71 + CMD_WINDOW }
|
||||
assert.deepEqual(commandWindow(dataIn), expected)
|
||||
|
||||
// SNACK also has bytes 24-27 reserved; it is rejected, but the Reject it
|
||||
// gets must still carry the window the session actually reached
|
||||
const bhs = Buffer.alloc(48)
|
||||
bhs[0] = InitiatorOpcode.SNACK_REQUEST
|
||||
harness.socket.deliver(assemblePdu(bhs))
|
||||
|
||||
const [, , rejected] = await harness.untilSent(3)
|
||||
assert.equal(rejected.opcode, TargetOpcode.REJECT)
|
||||
assert.equal(rejected.readU8(2), RejectReason.COMMAND_NOT_SUPPORTED)
|
||||
assert.deepEqual(commandWindow(rejected), expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('advances the window for each command PDU, but not for an immediate one', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 35, cmdSN: 5, cdb: read10(0, 1) }))
|
||||
const [, dataIn] = await harness.untilSent(2)
|
||||
assert.deepEqual(commandWindow(dataIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
|
||||
|
||||
// an immediate NOP-Out is answered but consumes no CmdSN slot
|
||||
const bhs = Buffer.alloc(48)
|
||||
bhs[0] = InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(37, 16)
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 20)
|
||||
bhs.writeUInt32BE(6, 24)
|
||||
harness.socket.deliver(assemblePdu(bhs))
|
||||
|
||||
const [, , nopIn] = await harness.untilSent(3)
|
||||
assert.equal(nopIn.opcode, TargetOpcode.NOP_IN)
|
||||
assert.deepEqual(commandWindow(nopIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('reaches full feature phase and only then accepts commands', async () => {
|
||||
const harness = new Harness()
|
||||
try {
|
||||
// a command before login is a protocol error, not a served command
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 0, cdb: read10(0, 1) }))
|
||||
const [rejected] = await harness.untilSent(1)
|
||||
assert.equal(rejected.opcode, TargetOpcode.REJECT)
|
||||
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
|
||||
|
||||
await harness.login()
|
||||
|
||||
// the same command now gets served
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 1, cdb: read10(0, 1) }))
|
||||
const sent = await harness.untilSent(3)
|
||||
assert.equal(sent[2].opcode, TargetOpcode.SCSI_DATA_IN)
|
||||
} finally {
|
||||
await harness.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('write path wiring', () => {
|
||||
it('solicits the data, commits it to the LUN, and answers GOOD', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 9, cmdSN: 1, cdb: write10(2, 1) }))
|
||||
|
||||
const [, r2t] = await harness.untilSent(2)
|
||||
assert.equal(r2t.opcode, TargetOpcode.R2T)
|
||||
assert.equal(r2t.itt, 9)
|
||||
assert.equal(r2t.readU32(36), 0) // R2TSN
|
||||
assert.equal(r2t.readU32(40), 0) // BufferOffset
|
||||
assert.equal(r2t.readU32(44), BLOCK_SIZE) // DesiredDataTransferLength
|
||||
|
||||
const payload = Buffer.alloc(BLOCK_SIZE, 0xab)
|
||||
harness.socket.deliver(dataOutPdu({ itt: 9, targetTransferTag: r2t.readU32(20), bufferOffset: 0, data: payload }))
|
||||
|
||||
const [, , response] = await harness.untilSent(3)
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.equal(response.readU8(3), ScsiStatus.GOOD)
|
||||
// ... and the bytes landed at the LBA the CDB asked for, nowhere else
|
||||
assert.deepEqual(harness.lun.content.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE), payload)
|
||||
assert.deepEqual(harness.lun.content.subarray(0, 2 * BLOCK_SIZE), Buffer.alloc(2 * BLOCK_SIZE))
|
||||
assert.deepEqual(harness.lun.content.subarray(3 * BLOCK_SIZE), Buffer.alloc(LUN_SIZE - 3 * BLOCK_SIZE))
|
||||
})
|
||||
})
|
||||
|
||||
it('solicits one burst at a time for a write larger than MaxBurstLength', async () => {
|
||||
await withSession(async harness => {
|
||||
// 2 blocks with a 1-block burst limit: two R2Ts, second only after the first lands
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 11, cmdSN: 1, cdb: write10(0, 2) }))
|
||||
|
||||
const [, first] = await harness.untilSent(2)
|
||||
assert.equal(first.readU32(36), 0) // R2TSN
|
||||
assert.equal(first.readU32(40), 0) // BufferOffset
|
||||
assert.equal(first.readU32(44), MAX_BURST_LENGTH)
|
||||
|
||||
const ttt = first.readU32(20)
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({ itt: 11, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
|
||||
)
|
||||
|
||||
const [, , second] = await harness.untilSent(3)
|
||||
assert.equal(second.opcode, TargetOpcode.R2T)
|
||||
assert.equal(second.readU32(36), 1) // R2TSN advanced
|
||||
assert.equal(second.readU32(40), BLOCK_SIZE) // next burst picks up where the first ended
|
||||
assert.equal(second.readU32(44), MAX_BURST_LENGTH)
|
||||
// nothing is committed until the whole command has landed
|
||||
assert.deepEqual(harness.lun.content.subarray(0, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
|
||||
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({
|
||||
itt: 11,
|
||||
targetTransferTag: ttt,
|
||||
bufferOffset: BLOCK_SIZE,
|
||||
data: Buffer.alloc(BLOCK_SIZE, 0x22),
|
||||
dataSN: 1,
|
||||
})
|
||||
)
|
||||
|
||||
const [, , , response] = await harness.untilSent(4)
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.equal(response.readU8(3), ScsiStatus.GOOD)
|
||||
assert.deepEqual(
|
||||
harness.lun.content.subarray(0, 2 * BLOCK_SIZE),
|
||||
Buffer.concat([Buffer.alloc(BLOCK_SIZE, 0x11), Buffer.alloc(BLOCK_SIZE, 0x22)])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unplaceable Data-Out and drops the command with it', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 13, cmdSN: 1, cdb: write10(0, 1) }))
|
||||
const [, r2t] = await harness.untilSent(2)
|
||||
const ttt = r2t.readU32(20)
|
||||
|
||||
// BufferOffset past the end of the command's data: a copy there would land
|
||||
// nothing, so the write must be refused rather than acknowledged
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({
|
||||
itt: 13,
|
||||
targetTransferTag: ttt,
|
||||
bufferOffset: BLOCK_SIZE,
|
||||
data: Buffer.alloc(BLOCK_SIZE, 0xcc),
|
||||
})
|
||||
)
|
||||
|
||||
const [, , rejected] = await harness.untilSent(3)
|
||||
assert.equal(rejected.opcode, TargetOpcode.REJECT)
|
||||
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
|
||||
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
|
||||
|
||||
// the command is gone, not merely stalled: a now-valid Data-Out for it is
|
||||
// an unknown ITT, and no SCSI Response ever completes it
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({ itt: 13, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xdd) })
|
||||
)
|
||||
const sent = await harness.untilSent(4)
|
||||
assert.equal(sent[3].opcode, TargetOpcode.REJECT)
|
||||
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
|
||||
assert.equal(
|
||||
sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_RESPONSE),
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects a Data-Out carrying another command's Target Transfer Tag", async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 15, cmdSN: 1, cdb: write10(0, 1) }))
|
||||
const [, r2t] = await harness.untilSent(2)
|
||||
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({
|
||||
itt: 15,
|
||||
targetTransferTag: r2t.readU32(20) + 1,
|
||||
bufferOffset: 0,
|
||||
data: Buffer.alloc(BLOCK_SIZE, 0xee),
|
||||
})
|
||||
)
|
||||
|
||||
const [, , rejected] = await harness.untilSent(3)
|
||||
assert.equal(rejected.opcode, TargetOpcode.REJECT)
|
||||
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a Data-Out for a command it knows nothing about', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({ itt: 404, targetTransferTag: 1, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xff) })
|
||||
)
|
||||
|
||||
const [, rejected] = await harness.untilSent(2)
|
||||
assert.equal(rejected.opcode, TargetOpcode.REJECT)
|
||||
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a failed backend write as CHECK CONDITION, not a dropped connection', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.lun.failNextWrite = true
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 17, cmdSN: 1, cdb: write10(0, 1) }))
|
||||
const [, r2t] = await harness.untilSent(2)
|
||||
|
||||
harness.socket.deliver(
|
||||
dataOutPdu({
|
||||
itt: 17,
|
||||
targetTransferTag: r2t.readU32(20),
|
||||
bufferOffset: 0,
|
||||
data: Buffer.alloc(BLOCK_SIZE, 0xab),
|
||||
})
|
||||
)
|
||||
|
||||
const [, , response] = await harness.untilSent(3)
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(senseKey(response), SenseKey.MEDIUM_ERROR)
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a command carrying immediate data instead of silently dropping it', async () => {
|
||||
await withSession(async harness => {
|
||||
// ImmediateData=No is declared at login, so this cannot be honoured — and
|
||||
// must not be ignored either, since the write would be short by these bytes
|
||||
harness.socket.deliver(
|
||||
scsiCommandPdu({ itt: 19, cmdSN: 1, cdb: write10(0, 1), data: Buffer.alloc(BLOCK_SIZE, 0x99) })
|
||||
)
|
||||
|
||||
const [, response] = await harness.untilSent(2)
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
|
||||
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
|
||||
// no R2T either: the command never started
|
||||
assert.equal(
|
||||
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data-In chunking', () => {
|
||||
/** Read `blocks` blocks of a LUN pre-filled with a recognizable pattern. */
|
||||
const readBack = async (harness: Harness, blocks: number): Promise<Array<IncomingPdu>> => {
|
||||
for (let i = 0; i < harness.lun.content.length; i++) {
|
||||
harness.lun.content[i] = (i * 7 + 3) & 0xff
|
||||
}
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 21, cmdSN: 1, cdb: read10(0, blocks) }))
|
||||
const expected = Math.ceil((blocks * BLOCK_SIZE) / MAX_RECV_DATA_SEGMENT_LENGTH)
|
||||
const sent = await harness.untilSent(1 + expected)
|
||||
return sent.slice(1)
|
||||
}
|
||||
|
||||
it('splits the payload at the initiator MaxRecvDataSegmentLength', async () => {
|
||||
await withSession(async harness => {
|
||||
const dataIn = await readBack(harness, 4)
|
||||
|
||||
assert.equal(dataIn.length, 4)
|
||||
for (const pdu of dataIn) {
|
||||
assert.equal(pdu.opcode, TargetOpcode.SCSI_DATA_IN)
|
||||
assert.equal(pdu.data.length, MAX_RECV_DATA_SEGMENT_LENGTH)
|
||||
}
|
||||
// reassembled, it is exactly what the LUN holds
|
||||
assert.deepEqual(Buffer.concat(dataIn.map(pdu => pdu.data)), harness.lun.content.subarray(0, 4 * BLOCK_SIZE))
|
||||
})
|
||||
})
|
||||
|
||||
it('numbers the PDUs with a gapless DataSN and stamps each BufferOffset', async () => {
|
||||
await withSession(async harness => {
|
||||
const dataIn = await readBack(harness, 3)
|
||||
|
||||
assert.deepEqual(
|
||||
dataIn.map(pdu => pdu.readU32(36)),
|
||||
[0, 1, 2]
|
||||
)
|
||||
assert.deepEqual(
|
||||
dataIn.map(pdu => pdu.readU32(40)),
|
||||
[0, MAX_RECV_DATA_SEGMENT_LENGTH, 2 * MAX_RECV_DATA_SEGMENT_LENGTH]
|
||||
)
|
||||
// Target Transfer Tag is unused on Data-In
|
||||
for (const pdu of dataIn) {
|
||||
assert.equal(pdu.readU32(20), RESERVED_TAG)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the status only on the final PDU, and advances StatSN once', async () => {
|
||||
await withSession(async harness => {
|
||||
const statSNAfterLogin = parseSent(harness.socket.sent[0]).readU32(24) + 1
|
||||
const dataIn = await readBack(harness, 3)
|
||||
|
||||
for (const pdu of dataIn.slice(0, -1)) {
|
||||
assert.equal(pdu.flags & DATA_IN_FLAG_FINAL, 0)
|
||||
assert.equal(pdu.flags & DATA_IN_FLAG_STATUS, 0)
|
||||
// both fields are defined as meaningless without the S bit, and are left
|
||||
// unset rather than filled with a value an initiator might act on
|
||||
assert.equal(pdu.readU8(3), 0)
|
||||
assert.equal(pdu.readU32(24), 0)
|
||||
}
|
||||
|
||||
const last = dataIn[dataIn.length - 1]
|
||||
assert.equal(last.flags & DATA_IN_FLAG_FINAL, DATA_IN_FLAG_FINAL)
|
||||
assert.equal(last.flags & DATA_IN_FLAG_STATUS, DATA_IN_FLAG_STATUS)
|
||||
assert.equal(last.readU8(3), ScsiStatus.GOOD)
|
||||
// exactly one StatSN consumed for the whole command, on its final PDU
|
||||
assert.equal(last.readU32(24), statSNAfterLogin)
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a single Data-In when the payload fits one segment', async () => {
|
||||
await withSession(async harness => {
|
||||
const dataIn = await readBack(harness, 1)
|
||||
|
||||
assert.equal(dataIn.length, 1)
|
||||
assert.equal(
|
||||
dataIn[0].flags & (DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS),
|
||||
DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
|
||||
)
|
||||
assert.equal(dataIn[0].readU32(36), 0)
|
||||
})
|
||||
})
|
||||
|
||||
it('flags residual overflow when the allocation length truncates the payload', async () => {
|
||||
await withSession(async harness => {
|
||||
// standard INQUIRY data is 36 bytes; ask for 8
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 23, cmdSN: 1, cdb: inquiry(8) }))
|
||||
|
||||
const [, dataIn] = await harness.untilSent(2)
|
||||
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
|
||||
assert.equal(dataIn.data.length, 8)
|
||||
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, DATA_IN_FLAG_OVERFLOW)
|
||||
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, 0)
|
||||
assert.equal(dataIn.readU32(44), 36 - 8) // Residual Count
|
||||
})
|
||||
})
|
||||
|
||||
it('flags residual underflow when the payload is shorter than requested', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 25, cmdSN: 1, cdb: inquiry(100) }))
|
||||
|
||||
const [, dataIn] = await harness.untilSent(2)
|
||||
assert.equal(dataIn.data.length, 36)
|
||||
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, DATA_IN_FLAG_UNDERFLOW)
|
||||
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, 0)
|
||||
assert.equal(dataIn.readU32(44), 100 - 36)
|
||||
})
|
||||
})
|
||||
|
||||
it('answers with status alone, no Data-In, when nothing is allocated for the data', async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 27, cmdSN: 1, cdb: inquiry(0) }))
|
||||
|
||||
const [, response] = await harness.untilSent(2)
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.equal(response.readU8(3), ScsiStatus.GOOD)
|
||||
assert.equal(
|
||||
harness.sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_DATA_IN),
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unservable CDBs', () => {
|
||||
// An LBA above 2^53 cannot be held exactly by a JS number. Decoding it must
|
||||
// not throw: the error would unwind out of the dispatch loop and destroy the
|
||||
// socket, turning one bogus CDB — a buggy or hostile initiator's — into the
|
||||
// loss of the whole session and every command in flight on it.
|
||||
for (const [name, cdb] of [
|
||||
['READ(16)', read16(0xffffffffffffffffn, 1)],
|
||||
['WRITE(16)', write16(0xffffffffffffffffn, 1)],
|
||||
] as const) {
|
||||
it(`answers a ${name} past the addressable range with CHECK CONDITION, keeping the session`, async () => {
|
||||
await withSession(async harness => {
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 1, cdb }))
|
||||
|
||||
const [, response] = await harness.untilSent(2)
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
|
||||
assert.equal(
|
||||
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
|
||||
false
|
||||
)
|
||||
|
||||
// the connection is still serving: a following command is answered
|
||||
harness.socket.deliver(scsiCommandPdu({ itt: 32, cmdSN: 2, cdb: inquiry(36) }))
|
||||
const [, , dataIn] = await harness.untilSent(3)
|
||||
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
200
@vates/iscsi/src/constants.mts
Normal file
200
@vates/iscsi/src/constants.mts
Normal file
@@ -0,0 +1,200 @@
|
||||
// iSCSI (RFC 7143 / RFC 3720) and SCSI (SPC-3 / SBC-3) protocol constants.
|
||||
//
|
||||
// Only the subset required by a minimal single-LUN target is defined here.
|
||||
|
||||
// --- iSCSI PDU layout -------------------------------------------------------
|
||||
|
||||
/** Length of the Basic Header Segment, in bytes. Fixed by the protocol. */
|
||||
export const BHS_LENGTH = 48
|
||||
|
||||
/** Bit set in byte 0 for initiator PDUs that must be processed immediately. */
|
||||
export const OPCODE_IMMEDIATE = 0x40
|
||||
|
||||
/** Mask extracting the 6-bit opcode from byte 0. */
|
||||
export const OPCODE_MASK = 0x3f
|
||||
|
||||
/** Final bit, byte 1, set on the last PDU of a sequence. */
|
||||
export const FLAG_FINAL = 0x80
|
||||
|
||||
/** Sentinel for "no tag" in Initiator/Target Task Tag fields. */
|
||||
export const RESERVED_TAG = 0xffffffff
|
||||
|
||||
// --- iSCSI opcodes ----------------------------------------------------------
|
||||
|
||||
/** Opcodes sent by the initiator (request PDUs). */
|
||||
export const InitiatorOpcode = {
|
||||
NOP_OUT: 0x00,
|
||||
SCSI_COMMAND: 0x01,
|
||||
SCSI_TASK_MGMT_REQUEST: 0x02,
|
||||
LOGIN_REQUEST: 0x03,
|
||||
TEXT_REQUEST: 0x04,
|
||||
SCSI_DATA_OUT: 0x05,
|
||||
LOGOUT_REQUEST: 0x06,
|
||||
SNACK_REQUEST: 0x10,
|
||||
} as const
|
||||
|
||||
/** Opcodes sent by the target (response PDUs). */
|
||||
export const TargetOpcode = {
|
||||
NOP_IN: 0x20,
|
||||
SCSI_RESPONSE: 0x21,
|
||||
SCSI_TASK_MGMT_RESPONSE: 0x22,
|
||||
LOGIN_RESPONSE: 0x23,
|
||||
TEXT_RESPONSE: 0x24,
|
||||
SCSI_DATA_IN: 0x25,
|
||||
LOGOUT_RESPONSE: 0x26,
|
||||
R2T: 0x31,
|
||||
ASYNC_MESSAGE: 0x32,
|
||||
REJECT: 0x3f,
|
||||
} as const
|
||||
|
||||
// --- Login negotiation ------------------------------------------------------
|
||||
|
||||
/** Login/text stage codes used in the CSG/NSG fields. */
|
||||
export const LoginStage = {
|
||||
SECURITY_NEGOTIATION: 0,
|
||||
OPERATIONAL_NEGOTIATION: 1,
|
||||
FULL_FEATURE_PHASE: 3,
|
||||
} as const
|
||||
|
||||
/** Login Request/Response byte 1 flags. */
|
||||
export const LOGIN_FLAG_TRANSIT = 0x80
|
||||
export const LOGIN_FLAG_CONTINUE = 0x40
|
||||
/** Mask + shift for the Current Stage (CSG) field in byte 1. */
|
||||
export const LOGIN_CSG_MASK = 0x0c
|
||||
export const LOGIN_CSG_SHIFT = 2
|
||||
/** Mask for the Next Stage (NSG) field in byte 1. */
|
||||
export const LOGIN_NSG_MASK = 0x03
|
||||
|
||||
/** iSCSI version (only version 0 exists). */
|
||||
export const ISCSI_VERSION = 0x00
|
||||
|
||||
/** Login Response Status-Class values. */
|
||||
export const LoginStatusClass = {
|
||||
SUCCESS: 0x00,
|
||||
REDIRECTION: 0x01,
|
||||
INITIATOR_ERROR: 0x02,
|
||||
TARGET_ERROR: 0x03,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Login Response Status-Detail values (byte 37), qualifying the Status-Class.
|
||||
* `AUTHENTICATION_FAILURE` pairs with `INITIATOR_ERROR` to reject a bad CHAP
|
||||
* login (RFC 7143 §11.1.5).
|
||||
*/
|
||||
export const LoginStatusDetail = {
|
||||
SUCCESS: 0x00,
|
||||
AUTHENTICATION_FAILURE: 0x01,
|
||||
} as const
|
||||
|
||||
/** `AuthMethod` login-key values we understand (RFC 7143 §12.1). */
|
||||
export const AuthMethod = {
|
||||
NONE: 'None',
|
||||
CHAP: 'CHAP',
|
||||
} as const
|
||||
|
||||
// --- Text/Data flags --------------------------------------------------------
|
||||
|
||||
export const TEXT_FLAG_FINAL = 0x80
|
||||
export const TEXT_FLAG_CONTINUE = 0x40
|
||||
|
||||
/** SCSI Data-In byte 1 flags. */
|
||||
export const DATA_IN_FLAG_FINAL = 0x80
|
||||
export const DATA_IN_FLAG_ACK = 0x40
|
||||
export const DATA_IN_FLAG_OVERFLOW = 0x04
|
||||
export const DATA_IN_FLAG_UNDERFLOW = 0x02
|
||||
export const DATA_IN_FLAG_STATUS = 0x01
|
||||
|
||||
/** SCSI Command (initiator) byte 1 flags. */
|
||||
export const SCSI_CMD_FLAG_READ = 0x40
|
||||
export const SCSI_CMD_FLAG_WRITE = 0x20
|
||||
|
||||
/** SCSI Response byte 1 residual flags. */
|
||||
export const SCSI_RESP_FLAG_OVERFLOW = 0x04
|
||||
export const SCSI_RESP_FLAG_UNDERFLOW = 0x02
|
||||
|
||||
/** SCSI Response "Response" field (byte 2). */
|
||||
export const SCSI_RESPONSE_COMMAND_COMPLETED = 0x00
|
||||
|
||||
/** Logout reason codes (byte 1, low 7 bits). */
|
||||
export const LogoutReason = {
|
||||
CLOSE_SESSION: 0,
|
||||
CLOSE_CONNECTION: 1,
|
||||
REMOVE_CONNECTION_FOR_RECOVERY: 2,
|
||||
} as const
|
||||
|
||||
/** Logout Response codes (byte 2). */
|
||||
export const LogoutResponse = {
|
||||
SUCCESS: 0,
|
||||
} as const
|
||||
|
||||
// --- SCSI status codes ------------------------------------------------------
|
||||
|
||||
export const ScsiStatus = {
|
||||
GOOD: 0x00,
|
||||
CHECK_CONDITION: 0x02,
|
||||
} as const
|
||||
|
||||
// --- SCSI CDB opcodes -------------------------------------------------------
|
||||
|
||||
export const ScsiOpcode = {
|
||||
TEST_UNIT_READY: 0x00,
|
||||
REQUEST_SENSE: 0x03,
|
||||
INQUIRY: 0x12,
|
||||
MODE_SENSE_6: 0x1a,
|
||||
READ_CAPACITY_10: 0x25,
|
||||
READ_10: 0x28,
|
||||
WRITE_10: 0x2a,
|
||||
SYNCHRONIZE_CACHE_10: 0x35,
|
||||
MODE_SENSE_10: 0x5a,
|
||||
READ_16: 0x88,
|
||||
WRITE_16: 0x8a,
|
||||
SYNCHRONIZE_CACHE_16: 0x91,
|
||||
SERVICE_ACTION_IN_16: 0x9e, // READ CAPACITY(16) lives under this opcode
|
||||
REPORT_LUNS: 0xa0,
|
||||
} as const
|
||||
|
||||
/** Service action (CDB byte 1, low 5 bits) for SERVICE_ACTION_IN_16. */
|
||||
export const SERVICE_ACTION_READ_CAPACITY_16 = 0x10
|
||||
|
||||
// --- SCSI sense keys / additional sense codes -------------------------------
|
||||
|
||||
export const SenseKey = {
|
||||
NO_SENSE: 0x00,
|
||||
RECOVERED_ERROR: 0x01,
|
||||
NOT_READY: 0x02,
|
||||
MEDIUM_ERROR: 0x03,
|
||||
HARDWARE_ERROR: 0x04,
|
||||
ILLEGAL_REQUEST: 0x05,
|
||||
UNIT_ATTENTION: 0x06,
|
||||
DATA_PROTECT: 0x07,
|
||||
} as const
|
||||
|
||||
/** Additional Sense Code / Qualifier pairs we may emit. */
|
||||
export const Asc = {
|
||||
NO_ADDITIONAL_SENSE: { asc: 0x00, ascq: 0x00 },
|
||||
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). */
|
||||
export const RejectReason = {
|
||||
PROTOCOL_ERROR: 0x04,
|
||||
COMMAND_NOT_SUPPORTED: 0x05,
|
||||
} as const
|
||||
|
||||
// --- debugging --------------------------------------------------------------
|
||||
|
||||
// Initiator opcodes (0x00-0x10) and target opcodes (0x20-0x3f) never collide,
|
||||
// so one reverse map names both directions for logs.
|
||||
const OPCODE_NAMES: Record<number, string> = {
|
||||
...Object.fromEntries(Object.entries(InitiatorOpcode).map(([name, code]) => [code, name])),
|
||||
...Object.fromEntries(Object.entries(TargetOpcode).map(([name, code]) => [code, name])),
|
||||
}
|
||||
|
||||
/** Human-readable name of a PDU opcode (for debug logs). */
|
||||
export function opcodeName(opcode: number): string {
|
||||
const code = opcode & OPCODE_MASK
|
||||
return OPCODE_NAMES[code] ?? `0x${code.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
30
@vates/iscsi/src/declarations.d.mts
Normal file
30
@vates/iscsi/src/declarations.d.mts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Ambient declarations for untyped external packages used by this module.
|
||||
// (CLAUDE.md: "Add declaration.d.ts for untyped external packages.")
|
||||
|
||||
declare module '@vates/read-chunk' {
|
||||
import type { Readable } from 'node:stream'
|
||||
|
||||
/**
|
||||
* Resolves to exactly `size` bytes read from `stream`, or `null` if the
|
||||
* stream ends before any byte of the requested chunk is available.
|
||||
*/
|
||||
export function readChunk(stream: Readable, size?: number): Promise<Buffer | null>
|
||||
|
||||
/**
|
||||
* Resolves to exactly `size` bytes read from `stream`; rejects if the stream
|
||||
* ends before `size` bytes have been read.
|
||||
*/
|
||||
export function readChunkStrict(stream: Readable, size: number): Promise<Buffer>
|
||||
}
|
||||
|
||||
declare module 'promise-toolbox' {
|
||||
/**
|
||||
* Reject the promise it is bound to if it does not settle within `delay`
|
||||
* milliseconds. Used as `pTimeout.call(promise, delay)`.
|
||||
*/
|
||||
interface PTimeout {
|
||||
<T>(this: Promise<T>, delay: number, onReject?: () => void): Promise<T>
|
||||
call<T>(promise: Promise<T>, delay: number, onReject?: () => void): Promise<T>
|
||||
}
|
||||
export const pTimeout: PTimeout
|
||||
}
|
||||
281
@vates/iscsi/src/index.mts
Normal file
281
@vates/iscsi/src/index.mts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { createServer, type AddressInfo, type Server, type Socket } from 'node:net'
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
import { Connection, type ConnectionDeps } from './connection.mjs'
|
||||
import { DEFAULT_MAX_TRANSFER_LENGTH, type ScsiIdentity } from './scsi.mjs'
|
||||
import type { ChapCredentials } from './types.mjs'
|
||||
|
||||
export { FileBlockDevice } from './backend.mjs'
|
||||
export type { BlockDevice, FileBlockDeviceOptions } from './backend.mjs'
|
||||
export type { ScsiIdentity } from './scsi.mjs'
|
||||
export type { ChapCredentials } from './types.mjs'
|
||||
export { IscsiInitiator } from './initiator.mjs'
|
||||
export type { IscsiInitiatorOptions } from './initiator.mjs'
|
||||
export { IscsiDisk } from './IscsiDisk.mjs'
|
||||
export type { IscsiDiskOptions } from './IscsiDisk.mjs'
|
||||
export { DiskBlockDevice } from './DiskBlockDevice.mjs'
|
||||
export type { DiskBlockDeviceOptions } from './DiskBlockDevice.mjs'
|
||||
export { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
|
||||
export type { CachedDiskBlockDeviceOptions } from './CachedDiskBlockDevice.mjs'
|
||||
|
||||
const log: Logger = createLogger('vates:iscsi')
|
||||
|
||||
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',
|
||||
product: 'ISCSI LUN',
|
||||
revision: '0001',
|
||||
serial: '',
|
||||
}
|
||||
|
||||
export interface IscsiTargetOptions {
|
||||
/** The target's iSCSI Qualified Name, e.g. `iqn.2024-01.tech.vates:lun0`. */
|
||||
readonly iqn: string
|
||||
/** The single LUN exposed by this target. */
|
||||
readonly lun: BlockDevice
|
||||
/** Listen address. Defaults to all interfaces. */
|
||||
readonly host?: string
|
||||
/** Listen port. Defaults to 3260. */
|
||||
readonly port?: number
|
||||
/** Overrides for the SCSI INQUIRY identity strings. */
|
||||
readonly identity?: Partial<ScsiIdentity>
|
||||
/** Drain timeout for outbound PDUs in ms (0 disables). Defaults to 30000. */
|
||||
readonly writeTimeoutMs?: number
|
||||
/** CmdSN command-window depth advertised to the initiator. Defaults to 64. */
|
||||
readonly cmdWindow?: number
|
||||
/**
|
||||
* Max number of READ commands served concurrently — the command window lets
|
||||
* the initiator keep many outstanding, but this caps how many `lun.read()`
|
||||
* calls actually run at once regardless (e.g. so a deep queue depth doesn't
|
||||
* fire dozens of simultaneous fetches against a real backend). Defaults to 16.
|
||||
*/
|
||||
readonly readConcurrency?: number
|
||||
/**
|
||||
* When set, require one-way CHAP: the target challenges each initiator and
|
||||
* rejects the login unless it proves this credential (interop with the
|
||||
* 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 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 {
|
||||
readonly #iqn: string
|
||||
readonly #lun: BlockDevice
|
||||
readonly #host?: string
|
||||
readonly #port: number
|
||||
readonly #identity: ScsiIdentity
|
||||
readonly #writeTimeoutMs: number
|
||||
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) {
|
||||
this.#iqn = options.iqn
|
||||
this.#lun = options.lun
|
||||
this.#host = options.host
|
||||
this.#port = options.port ?? DEFAULT_PORT
|
||||
this.#writeTimeoutMs = options.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS
|
||||
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.
|
||||
serial: options.iqn,
|
||||
...options.identity,
|
||||
}
|
||||
}
|
||||
|
||||
#allocateTsih(): number {
|
||||
this.#tsih = (this.#tsih % 0xffff) + 1 // non-zero, wraps within 16 bits
|
||||
return this.#tsih
|
||||
}
|
||||
|
||||
#deps(): ConnectionDeps {
|
||||
return {
|
||||
iqn: this.#iqn,
|
||||
identity: this.#identity,
|
||||
lun: this.#lun,
|
||||
writeTimeoutMs: this.#writeTimeoutMs,
|
||||
cmdWindow: this.#cmdWindow,
|
||||
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 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.#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) {
|
||||
throw new Error('iSCSI target is already listening')
|
||||
}
|
||||
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) => {
|
||||
const onError = (error: Error) => reject(error)
|
||||
server.once('error', onError)
|
||||
server.listen(this.#port, this.#host, () => {
|
||||
server.removeListener('error', onError)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
this.#server = undefined
|
||||
throw error
|
||||
}
|
||||
log.info('target listening', { iqn: this.#iqn, address: this.address() })
|
||||
}
|
||||
|
||||
/** The bound address, or undefined if not listening on an IP socket. */
|
||||
address(): AddressInfo | undefined {
|
||||
const address = this.#server?.address()
|
||||
return address !== null && typeof address === 'object' ? address : undefined
|
||||
}
|
||||
|
||||
/** Stop accepting connections, drop every connection, and close the LUN. */
|
||||
async close(): Promise<void> {
|
||||
const server = this.#server
|
||||
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 connections so server.close() can complete.
|
||||
for (const connection of connections) {
|
||||
connection.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
await this.#lun.close()
|
||||
}
|
||||
}
|
||||
261
@vates/iscsi/src/initiator.integ.mts
Normal file
261
@vates/iscsi/src/initiator.integ.mts
Normal file
@@ -0,0 +1,261 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer, type Server, type Socket } from 'node:net'
|
||||
import { once } from 'node:events'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import {
|
||||
DATA_IN_FLAG_FINAL,
|
||||
DATA_IN_FLAG_STATUS,
|
||||
FLAG_FINAL,
|
||||
InitiatorOpcode,
|
||||
ISCSI_VERSION,
|
||||
LOGIN_FLAG_TRANSIT,
|
||||
LoginStage,
|
||||
RESERVED_TAG,
|
||||
ScsiStatus,
|
||||
TargetOpcode,
|
||||
} from './constants.mjs'
|
||||
import { IscsiInitiator } from './initiator.mjs'
|
||||
import { allocBhs, assemblePdu, type IncomingPdu, readPdu } from './pdu.mjs'
|
||||
|
||||
const IQN = 'iqn.2024-01.tech.vates:nop-test'
|
||||
const BLOCK_SIZE = 512
|
||||
const BLOCK_COUNT = 2048
|
||||
|
||||
/**
|
||||
* A target that speaks just enough to get a session up — login, READ
|
||||
* CAPACITY(16), READ(16) — and can then ping the initiator on demand, which
|
||||
* the real {@link IscsiTarget} never does. Everything here is about what the
|
||||
* *initiator* does with an unsolicited NOP-In.
|
||||
*/
|
||||
class PingingTarget {
|
||||
readonly #server: Server
|
||||
#socket?: Socket
|
||||
/** PDUs received from the initiator after the session was established. */
|
||||
readonly received: Array<IncomingPdu> = []
|
||||
#statSN = 0
|
||||
|
||||
constructor() {
|
||||
this.#server = createServer(socket => {
|
||||
this.#socket = socket
|
||||
socket.setNoDelay(true)
|
||||
this.#serve(socket).catch(() => {}) // the initiator hanging up is normal
|
||||
})
|
||||
}
|
||||
|
||||
async listen(): Promise<number> {
|
||||
this.#server.listen(0, '127.0.0.1')
|
||||
await once(this.#server, 'listening')
|
||||
return (this.#server.address() as { port: number }).port
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#socket?.destroy()
|
||||
this.#server.close()
|
||||
}
|
||||
|
||||
#send(socket: Socket, buffer: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.write(buffer, error => (error ? reject(error) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
/** Ping the initiator the way a target does when the session goes quiet. */
|
||||
async ping(targetTransferTag: number, data = Buffer.alloc(0)): Promise<void> {
|
||||
const socket = this.#socket
|
||||
assert.ok(socket !== undefined, 'no initiator connected')
|
||||
const bhs = allocBhs(TargetOpcode.NOP_IN)
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: unsolicited, so none
|
||||
bhs.writeUInt32BE(targetTransferTag, 20)
|
||||
bhs.writeUInt32BE(this.#statSN, 24) // NOP-In does not advance StatSN
|
||||
await this.#send(socket, assemblePdu(bhs, data))
|
||||
}
|
||||
|
||||
/** Resolves once the initiator has sent a PDU with `opcode`. */
|
||||
async waitFor(opcode: number, timeoutMs = 2000): Promise<IncomingPdu> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
const found = this.received.find(pdu => pdu.opcode === opcode)
|
||||
if (found !== undefined) {
|
||||
return found
|
||||
}
|
||||
assert.ok(Date.now() < deadline, `initiator never sent opcode 0x${opcode.toString(16)}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
async #serve(socket: Socket): Promise<void> {
|
||||
let established = false
|
||||
for (;;) {
|
||||
const pdu = await readPdu(socket)
|
||||
if (pdu === null) {
|
||||
return
|
||||
}
|
||||
if (established) {
|
||||
this.received.push(pdu)
|
||||
}
|
||||
switch (pdu.opcode) {
|
||||
case InitiatorOpcode.LOGIN_REQUEST:
|
||||
await this.#sendLoginResponse(socket, pdu)
|
||||
established = true
|
||||
break
|
||||
case InitiatorOpcode.SCSI_COMMAND:
|
||||
await this.#sendReadData(socket, pdu)
|
||||
break
|
||||
case InitiatorOpcode.LOGOUT_REQUEST: {
|
||||
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(pdu.itt, 16)
|
||||
bhs.writeUInt32BE(this.#statSN++, 24)
|
||||
await this.#send(socket, assemblePdu(bhs))
|
||||
return
|
||||
}
|
||||
default: // NOP-Out replies land in `received`; nothing to answer
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #sendLoginResponse(socket: Socket, request: IncomingPdu): Promise<void> {
|
||||
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
|
||||
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << 2) | LoginStage.FULL_FEATURE_PHASE
|
||||
bhs[2] = ISCSI_VERSION
|
||||
bhs[3] = ISCSI_VERSION
|
||||
request.bhs.copy(bhs, 8, 8, 14) // echo ISID
|
||||
bhs.writeUInt16BE(1, 14) // TSIH
|
||||
bhs.writeUInt32BE(request.itt, 16)
|
||||
bhs.writeUInt32BE(this.#statSN++, 24)
|
||||
await this.#send(socket, assemblePdu(bhs))
|
||||
}
|
||||
|
||||
/** Answer any read-type command with a single Data-In carrying GOOD status. */
|
||||
async #sendReadData(socket: Socket, command: IncomingPdu): Promise<void> {
|
||||
const cdb = command.bhs.subarray(32, 48)
|
||||
const expectedLength = command.readU32(20)
|
||||
let payload
|
||||
if (cdb[0] === 0x9e) {
|
||||
// SERVICE ACTION IN(16) / READ CAPACITY(16)
|
||||
payload = Buffer.alloc(expectedLength)
|
||||
payload.writeBigUInt64BE(BigInt(BLOCK_COUNT - 1), 0)
|
||||
payload.writeUInt32BE(BLOCK_SIZE, 8)
|
||||
} else {
|
||||
payload = Buffer.alloc(expectedLength, 0x5a)
|
||||
}
|
||||
|
||||
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
|
||||
bhs[1] = DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
|
||||
bhs[3] = ScsiStatus.GOOD
|
||||
bhs.writeUInt32BE(command.itt, 16)
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 20)
|
||||
bhs.writeUInt32BE(this.#statSN++, 24)
|
||||
await this.#send(socket, assemblePdu(bhs, payload))
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `body` against a connected initiator, always tearing both ends down. */
|
||||
const withSession = async (body: (initiator: IscsiInitiator, target: PingingTarget) => Promise<void>) => {
|
||||
const target = new PingingTarget()
|
||||
const port = await target.listen()
|
||||
const initiator = new IscsiInitiator({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
targetIqn: IQN,
|
||||
messageTimeoutMs: 5000,
|
||||
})
|
||||
await initiator.connect()
|
||||
try {
|
||||
await body(initiator, target)
|
||||
} finally {
|
||||
await initiator.close()
|
||||
await target.close()
|
||||
}
|
||||
}
|
||||
|
||||
describe('NOP-In keepalives', () => {
|
||||
// The read loop used to stop as soon as no read was outstanding, and it is
|
||||
// the only reader of the socket — so a ping arriving while idle sat unread in
|
||||
// a paused stream. Targets drop a session whose pings go unanswered (30s on
|
||||
// LIO by default), and nothing here reconnects, so an idle mount died.
|
||||
it('are answered while the session is idle', async () => {
|
||||
await withSession(async (initiator, target) => {
|
||||
// no read in flight, and none will be
|
||||
await target.ping(7)
|
||||
|
||||
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
|
||||
assert.equal(reply.readU32(20), 7) // echoes the Target Transfer Tag
|
||||
assert.equal(reply.itt, RESERVED_TAG) // unsolicited reply, so no ITT
|
||||
assert.notEqual(reply.bhs[0] & 0x40, 0) // immediate
|
||||
})
|
||||
})
|
||||
|
||||
it('echo the ping payload back', async () => {
|
||||
await withSession(async (initiator, target) => {
|
||||
const ping = Buffer.from('are you there')
|
||||
await target.ping(9, ping)
|
||||
|
||||
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
|
||||
assert.deepEqual(reply.data, ping)
|
||||
})
|
||||
})
|
||||
|
||||
it('are answered between reads, without disturbing them', async () => {
|
||||
await withSession(async (initiator, target) => {
|
||||
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
|
||||
|
||||
await target.ping(11)
|
||||
await target.waitFor(InitiatorOpcode.NOP_OUT)
|
||||
|
||||
// the session is still usable afterwards
|
||||
const data = await initiator.read(BLOCK_SIZE, BLOCK_SIZE)
|
||||
assert.deepEqual(data, Buffer.alloc(BLOCK_SIZE, 0x5a))
|
||||
})
|
||||
})
|
||||
|
||||
it('are ignored when they are a reply to our own ping', async () => {
|
||||
await withSession(async (initiator, target) => {
|
||||
// a NOP-In with no Target Transfer Tag is a reply, not a ping: answering
|
||||
// it would start an endless exchange
|
||||
await target.ping(RESERVED_TAG)
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
assert.equal(
|
||||
target.received.some(pdu => pdu.opcode === InitiatorOpcode.NOP_OUT),
|
||||
false
|
||||
)
|
||||
// and the session still works
|
||||
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('session teardown', () => {
|
||||
it('logs out cleanly even though the read loop owns the socket', async () => {
|
||||
const target = new PingingTarget()
|
||||
const port = await target.listen()
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
|
||||
await initiator.connect()
|
||||
|
||||
await initiator.close()
|
||||
|
||||
// the Logout Request reached the target, i.e. `close()` did not give up on
|
||||
// its own response for lack of a reader
|
||||
await target.waitFor(InitiatorOpcode.LOGOUT_REQUEST)
|
||||
await target.close()
|
||||
})
|
||||
|
||||
it('fails a read with the reason the session died, rather than timing out', async () => {
|
||||
const target = new PingingTarget()
|
||||
const port = await target.listen()
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
|
||||
await initiator.connect()
|
||||
|
||||
await target.close() // target vanishes while the initiator is idle
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
const started = Date.now()
|
||||
await assert.rejects(initiator.read(0, BLOCK_SIZE), /connection closed/)
|
||||
// far below messageTimeoutMs: the dead session is known, not discovered
|
||||
assert.ok(Date.now() - started < 1000)
|
||||
})
|
||||
})
|
||||
501
@vates/iscsi/src/initiator.mts
Normal file
501
@vates/iscsi/src/initiator.mts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { once } from 'node:events'
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
import { pTimeout } from 'promise-toolbox'
|
||||
|
||||
import {
|
||||
AuthMethod,
|
||||
DATA_IN_FLAG_STATUS,
|
||||
FLAG_FINAL,
|
||||
InitiatorOpcode,
|
||||
LOGIN_CSG_SHIFT,
|
||||
LOGIN_FLAG_TRANSIT,
|
||||
LOGIN_NSG_MASK,
|
||||
LoginStage,
|
||||
LoginStatusClass,
|
||||
OPCODE_IMMEDIATE,
|
||||
RESERVED_TAG,
|
||||
SCSI_CMD_FLAG_READ,
|
||||
ScsiOpcode,
|
||||
ScsiStatus,
|
||||
SERVICE_ACTION_READ_CAPACITY_16,
|
||||
TargetOpcode,
|
||||
} from './constants.mjs'
|
||||
import { CHAP_ALGORITHM_MD5, computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
|
||||
import { parseTextKeys, serializeTextKeys } from './login.mjs'
|
||||
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
|
||||
import type { ChapCredentials } from './types.mjs'
|
||||
|
||||
const log: Logger = createLogger('vates:iscsi:initiator')
|
||||
|
||||
const DEFAULT_PORT = 3260
|
||||
const DEFAULT_MESSAGE_TIMEOUT_MS = 60_000
|
||||
const DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
|
||||
const READ_CAPACITY_16_LENGTH = 32
|
||||
|
||||
export interface IscsiInitiatorOptions {
|
||||
/** Portal address to connect to. */
|
||||
readonly host: string
|
||||
/** Portal port. Defaults to 3260. */
|
||||
readonly port?: number
|
||||
/** Target IQN (`TargetName`) to log into. */
|
||||
readonly targetIqn: string
|
||||
/** Our initiator IQN (`InitiatorName`). Defaults to a generated one. */
|
||||
readonly initiatorIqn?: string
|
||||
/**
|
||||
* One-way CHAP responder credential. When the target requests CHAP we answer
|
||||
* its challenge with this user/secret. Omit for an unauthenticated login.
|
||||
*/
|
||||
readonly chap?: ChapCredentials
|
||||
/** Per-message timeout for login/read responses, in ms. Defaults to 60000. */
|
||||
readonly messageTimeoutMs?: number
|
||||
}
|
||||
|
||||
/** An outstanding SCSI read, keyed by its Initiator Task Tag. */
|
||||
interface Pending {
|
||||
/** Preallocated buffer sized to the expected transfer length. */
|
||||
readonly buffer: Buffer
|
||||
resolve(data: Buffer): void
|
||||
reject(error: Error): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal userspace iSCSI initiator (client), mirroring {@link IscsiTarget}'s
|
||||
* framing and negotiation (read-only, single connection, one LUN, digests
|
||||
* off, `ErrorRecoveryLevel=0`).
|
||||
*
|
||||
* A backlog `Map` keyed by ITT, drained by a single read loop, demultiplexes
|
||||
* responses so several reads can be outstanding at once (e.g. under a
|
||||
* `ReadAhead` disk wrapper).
|
||||
*/
|
||||
export class IscsiInitiator {
|
||||
readonly #host: string
|
||||
readonly #port: number
|
||||
readonly #targetIqn: string
|
||||
readonly #initiatorIqn: string
|
||||
readonly #chap?: ChapCredentials
|
||||
readonly #messageTimeoutMs: number
|
||||
|
||||
#socket?: Socket
|
||||
#itt = 0
|
||||
#cmdSN = 0
|
||||
#expStatSN = 0
|
||||
|
||||
#blockSize = 0
|
||||
#capacityBytes = 0
|
||||
|
||||
// Outstanding reads, keyed by ITT. A single #readLoop() (guarded by #reading)
|
||||
// is the only consumer of readPdu, so it is safe to have many entries here.
|
||||
readonly #backlog = new Map<number, Pending>()
|
||||
#reading = false
|
||||
// set once `close()` starts, so the read loop's own end is not reported as a
|
||||
// fault and it stops instead of racing the socket teardown
|
||||
#closing = false
|
||||
// why the read loop stopped, when it was not us closing
|
||||
#failure?: Error
|
||||
// resolved by the read loop when the target's Logout Response arrives
|
||||
#logoutAnswered?: () => void
|
||||
|
||||
constructor(options: IscsiInitiatorOptions) {
|
||||
this.#host = options.host
|
||||
this.#port = options.port ?? DEFAULT_PORT
|
||||
this.#targetIqn = options.targetIqn
|
||||
this.#initiatorIqn = options.initiatorIqn ?? 'iqn.2024-01.tech.vates:initiator'
|
||||
this.#chap = options.chap
|
||||
this.#messageTimeoutMs = options.messageTimeoutMs ?? DEFAULT_MESSAGE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/** LUN logical block size in bytes (valid after {@link connect}). */
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
/** LUN capacity in bytes (valid after {@link connect}). */
|
||||
getSize(): number {
|
||||
return this.#capacityBytes
|
||||
}
|
||||
|
||||
// --- lifecycle ------------------------------------------------------------
|
||||
|
||||
/** Open the TCP connection, log in, and read the LUN capacity. */
|
||||
async connect(): Promise<void> {
|
||||
const socket = connect(this.#port, this.#host)
|
||||
this.#socket = socket
|
||||
try {
|
||||
await once(socket, 'connect')
|
||||
socket.setNoDelay(true)
|
||||
// A socket error with no read in flight would otherwise go unnoticed.
|
||||
socket.on('error', error => this.#rejectAll(error instanceof Error ? error : new Error(String(error))))
|
||||
// `#login` reads its own responses; from here on the read loop owns the
|
||||
// socket, and it must be running before anything can answer a NOP-In
|
||||
await this.#login()
|
||||
void this.#pump()
|
||||
await this.#readCapacity()
|
||||
log.info('session established', { target: this.#targetIqn, capacity: this.#capacityBytes })
|
||||
} catch (error) {
|
||||
socket.destroy()
|
||||
this.#socket = undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
#requireSocket(): Socket {
|
||||
const socket = this.#socket
|
||||
if (socket === undefined) {
|
||||
throw new Error('IscsiInitiator.connect() must be called first')
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
#send(buffer: Buffer): Promise<void> {
|
||||
return writePdu(this.#requireSocket(), buffer, this.#messageTimeoutMs)
|
||||
}
|
||||
|
||||
#nextItt(): number {
|
||||
this.#itt = (this.#itt + 1) >>> 0
|
||||
return this.#itt
|
||||
}
|
||||
|
||||
/** Best-effort Logout, then close the socket. */
|
||||
async close(): Promise<void> {
|
||||
const socket = this.#socket
|
||||
if (socket === undefined) {
|
||||
return
|
||||
}
|
||||
// stops the read loop and keeps its own end from being logged as a fault
|
||||
this.#closing = true
|
||||
try {
|
||||
const bhs = allocBhs(InitiatorOpcode.LOGOUT_REQUEST | OPCODE_IMMEDIATE)
|
||||
bhs[1] = FLAG_FINAL // reason 0: close the session
|
||||
bhs.writeUInt32BE(this.#nextItt(), 16)
|
||||
bhs.writeUInt32BE(this.#cmdSN, 24)
|
||||
bhs.writeUInt32BE(this.#expStatSN, 28)
|
||||
// the read loop owns the socket, so the response comes through it: reading
|
||||
// here too would put two consumers on one stream and split PDUs between them
|
||||
const answered = new Promise<void>(resolve => {
|
||||
this.#logoutAnswered = resolve
|
||||
})
|
||||
await this.#send(assemblePdu(bhs))
|
||||
await pTimeout.call(answered, this.#messageTimeoutMs)
|
||||
} catch (error) {
|
||||
log.debug('logout failed, closing anyway', { error })
|
||||
} finally {
|
||||
this.#logoutAnswered = undefined
|
||||
this.#socket = undefined
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// --- login ----------------------------------------------------------------
|
||||
|
||||
/** Build the flags byte (T/CSG/NSG) of a Login Request. */
|
||||
#loginFlags(transit: boolean, csg: number, nsg: number): number {
|
||||
return (transit ? LOGIN_FLAG_TRANSIT : 0) | (csg << LOGIN_CSG_SHIFT) | (nsg & LOGIN_NSG_MASK)
|
||||
}
|
||||
|
||||
async #sendLogin(itt: number, flags: number, keys: Array<[string, string]>): Promise<IncomingPdu> {
|
||||
const bhs = allocBhs(InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE)
|
||||
bhs[1] = flags
|
||||
Buffer.from([0x80, 0, 0, 0, 0, 1]).copy(bhs, 8) // ISID (fixed; single session)
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(this.#cmdSN, 24) // login is immediate: does not advance CmdSN
|
||||
bhs.writeUInt32BE(this.#expStatSN, 28)
|
||||
log.debug('login request', { flags: `0x${flags.toString(16)}`, keys: Object.fromEntries(keys) })
|
||||
await this.#send(assemblePdu(bhs, serializeTextKeys(keys)))
|
||||
|
||||
const pdu = await pTimeout.call(readPdu(this.#requireSocket()), this.#messageTimeoutMs)
|
||||
if (pdu === null) {
|
||||
throw new Error('connection closed during login')
|
||||
}
|
||||
if (pdu.opcode !== TargetOpcode.LOGIN_RESPONSE) {
|
||||
throw new Error(`expected Login Response, got opcode 0x${pdu.opcode.toString(16)}`)
|
||||
}
|
||||
const statusClass = pdu.readU8(36)
|
||||
log.debug('login response', {
|
||||
statusClass,
|
||||
statusDetail: pdu.readU8(37),
|
||||
transit: (pdu.readU8(1) & LOGIN_FLAG_TRANSIT) !== 0,
|
||||
keys: Object.fromEntries(parseTextKeys(pdu.data)),
|
||||
})
|
||||
if (statusClass !== LoginStatusClass.SUCCESS) {
|
||||
throw new Error(
|
||||
`login rejected (status-class 0x${statusClass.toString(16)}, detail 0x${pdu.readU8(37).toString(16)})`
|
||||
)
|
||||
}
|
||||
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
|
||||
return pdu
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the login: an optional CHAP security stage (we are the responder),
|
||||
* then the operational stage into the full-feature phase. All PDUs of one
|
||||
* login share a single ITT.
|
||||
*/
|
||||
async #login(): Promise<void> {
|
||||
const itt = this.#nextItt()
|
||||
const names: Array<[string, string]> = [
|
||||
['InitiatorName', this.#initiatorIqn],
|
||||
['TargetName', this.#targetIqn],
|
||||
['SessionType', 'Normal'],
|
||||
]
|
||||
|
||||
if (this.#chap !== undefined) {
|
||||
// Round 1: offer CHAP (falling back to None) in the security stage.
|
||||
const r1 = await this.#sendLogin(
|
||||
itt,
|
||||
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
|
||||
[...names, ['AuthMethod', `${AuthMethod.CHAP},${AuthMethod.NONE}`]]
|
||||
)
|
||||
const method = parseTextKeys(r1.data).get('AuthMethod')
|
||||
log.debug('chap: target selected AuthMethod', { method })
|
||||
if (method === AuthMethod.CHAP) {
|
||||
// Round 2: propose MD5.
|
||||
const r2 = await this.#sendLogin(
|
||||
itt,
|
||||
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
|
||||
[['CHAP_A', String(CHAP_ALGORITHM_MD5)]]
|
||||
)
|
||||
// Round 3: answer the target's challenge, transiting to operational.
|
||||
// (Mutual CHAP is not supported: we never challenge the target; a target
|
||||
// that *requires* mutual auth will reject this login, surfaced above.)
|
||||
const challenge = parseTextKeys(r2.data)
|
||||
const id = Number.parseInt(challenge.get('CHAP_I') ?? '', 10)
|
||||
const challengeValue = challenge.get('CHAP_C')
|
||||
if (!Number.isInteger(id) || challengeValue === undefined) {
|
||||
throw new Error('malformed CHAP challenge from target')
|
||||
}
|
||||
const response = computeResponse(id, this.#chap.secret, decodeChapValue(challengeValue))
|
||||
log.debug('chap: answering challenge', { id, user: this.#chap.user })
|
||||
await this.#sendLogin(
|
||||
itt,
|
||||
this.#loginFlags(true, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
|
||||
[
|
||||
['CHAP_N', this.#chap.user],
|
||||
['CHAP_R', encodeChapValue(response)],
|
||||
]
|
||||
)
|
||||
await this.#operationalLogin(itt, [])
|
||||
return
|
||||
}
|
||||
if (method !== AuthMethod.NONE && method !== undefined) {
|
||||
throw new Error(`target selected unsupported AuthMethod=${method}`)
|
||||
}
|
||||
// Target did not require CHAP: continue straight to operational.
|
||||
await this.#operationalLogin(itt, [])
|
||||
return
|
||||
}
|
||||
|
||||
// No CHAP: a single operational-stage PDU straight to the full-feature phase.
|
||||
await this.#operationalLogin(itt, names)
|
||||
}
|
||||
|
||||
/** Operational-stage login PDU, transiting to the full-feature phase. */
|
||||
async #operationalLogin(itt: number, extraKeys: Array<[string, string]>): Promise<void> {
|
||||
const pdu = await this.#sendLogin(
|
||||
itt,
|
||||
this.#loginFlags(true, LoginStage.OPERATIONAL_NEGOTIATION, LoginStage.FULL_FEATURE_PHASE),
|
||||
[
|
||||
...extraKeys,
|
||||
['HeaderDigest', 'None'],
|
||||
['DataDigest', 'None'],
|
||||
['InitialR2T', 'Yes'],
|
||||
['ImmediateData', 'No'],
|
||||
['ErrorRecoveryLevel', '0'],
|
||||
['MaxRecvDataSegmentLength', String(DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH)],
|
||||
]
|
||||
)
|
||||
if ((pdu.readU8(1) & LOGIN_FLAG_TRANSIT) === 0) {
|
||||
throw new Error('target did not transit to the full-feature phase')
|
||||
}
|
||||
}
|
||||
|
||||
// --- read path ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read exactly `length` bytes from byte `offset` of the LUN. `offset` and
|
||||
* `length` must be block-aligned (multiples of {@link getBlockSize}).
|
||||
*/
|
||||
read(offset: number, length: number): Promise<Buffer> {
|
||||
const blockSize = this.#blockSize
|
||||
if (offset % blockSize !== 0 || length % blockSize !== 0) {
|
||||
return Promise.reject(new Error(`unaligned read (offset ${offset}, length ${length}, block ${blockSize})`))
|
||||
}
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = ScsiOpcode.READ_16
|
||||
cdb.writeBigUInt64BE(BigInt(offset / blockSize), 2) // LBA
|
||||
cdb.writeUInt32BE(length / blockSize, 10) // transfer length, in blocks
|
||||
return this.#scsiRead(cdb, length)
|
||||
}
|
||||
|
||||
/** Issue READ CAPACITY(16) and record the LUN block size and capacity. */
|
||||
async #readCapacity(): Promise<void> {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = ScsiOpcode.SERVICE_ACTION_IN_16
|
||||
cdb[1] = SERVICE_ACTION_READ_CAPACITY_16
|
||||
cdb.writeUInt32BE(READ_CAPACITY_16_LENGTH, 10) // allocation length
|
||||
// Block size is unknown here, so bypass the alignment guard in read().
|
||||
const data = await this.#scsiRead(cdb, READ_CAPACITY_16_LENGTH)
|
||||
const maxLba = data.readBigUInt64BE(0)
|
||||
this.#blockSize = data.readUInt32BE(8)
|
||||
this.#capacityBytes = Number((maxLba + 1n) * BigInt(this.#blockSize))
|
||||
log.debug('read capacity', { blockSize: this.#blockSize, capacityBytes: this.#capacityBytes })
|
||||
}
|
||||
|
||||
/** Issue a read-type SCSI command and resolve with its assembled Data-In. */
|
||||
#scsiRead(cdb: Buffer, expectedLength: number): Promise<Buffer> {
|
||||
const failure = this.#failure
|
||||
if (failure !== undefined) {
|
||||
// the session is already gone: fail with why, rather than writing into a
|
||||
// half-closed socket and waiting out the message timeout
|
||||
return Promise.reject(failure)
|
||||
}
|
||||
const socket = this.#requireSocket()
|
||||
const itt = this.#nextItt()
|
||||
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
|
||||
bhs[1] = FLAG_FINAL | SCSI_CMD_FLAG_READ
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(expectedLength, 20) // Expected Data Transfer Length
|
||||
bhs.writeUInt32BE(this.#cmdSN, 24)
|
||||
this.#cmdSN = (this.#cmdSN + 1) >>> 0
|
||||
bhs.writeUInt32BE(this.#expStatSN, 28)
|
||||
cdb.copy(bhs, 32)
|
||||
log.debug('scsi read', { itt, cdb: `0x${cdb[0].toString(16)}`, expectedLength, cmdSN: (this.#cmdSN - 1) >>> 0 })
|
||||
|
||||
// Register the pending read BEFORE writing, so a fast response cannot arrive
|
||||
// before the backlog entry exists. No await between allocate → register →
|
||||
// send → pump, which keeps concurrent reads correct.
|
||||
const promise = new Promise<Buffer>((resolve, reject) => {
|
||||
this.#backlog.set(itt, { buffer: Buffer.alloc(expectedLength), resolve, reject })
|
||||
})
|
||||
writePdu(socket, assemblePdu(bhs), this.#messageTimeoutMs).catch(error => {
|
||||
const pending = this.#backlog.get(itt)
|
||||
if (pending !== undefined) {
|
||||
this.#backlog.delete(itt)
|
||||
pending.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
void this.#pump()
|
||||
return pTimeout.call(promise, this.#messageTimeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain inbound PDUs for as long as the session lives. Only ever one runs at
|
||||
* a time, and it is the only reader of the socket after login.
|
||||
*
|
||||
* It deliberately does not stop when the backlog empties. A target sends its
|
||||
* NOP-In keepalives precisely when nothing else is going on, and it is the
|
||||
* only reader — so a loop that parked between reads would leave those pings
|
||||
* sitting unread in a paused stream. Targets drop a session whose pings go
|
||||
* unanswered (LIO's `nopin_response_timeout` is 30s by default, arrays are
|
||||
* similar), and nothing here reconnects, so the session would die during any
|
||||
* idle period: right after `connect()`, between reads, or while the consumer
|
||||
* of a mounted disk stalls.
|
||||
*/
|
||||
async #pump(): Promise<void> {
|
||||
if (this.#reading) {
|
||||
return
|
||||
}
|
||||
this.#reading = true
|
||||
try {
|
||||
while (!this.#closing) {
|
||||
const socket = this.#socket
|
||||
if (socket === undefined) {
|
||||
throw new Error('connection closed')
|
||||
}
|
||||
const pdu = await readPdu(socket)
|
||||
if (pdu === null) {
|
||||
throw new Error('connection closed by target')
|
||||
}
|
||||
this.#route(pdu)
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
if (!this.#closing) {
|
||||
// The loop now runs while idle, so it is usually the first to notice the
|
||||
// session die — with nothing in the backlog to reject and nobody to
|
||||
// return an error to. Log it where it happens, and keep the reason so
|
||||
// later reads fail with the original cause instead of whatever the dead
|
||||
// socket produces.
|
||||
this.#failure = failure
|
||||
log.warn('read loop stopped, session is dead', { error: failure })
|
||||
this.#rejectAll(failure)
|
||||
}
|
||||
} finally {
|
||||
this.#reading = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Route one inbound PDU to its pending read (by ITT), completing it if done. */
|
||||
#route(pdu: IncomingPdu): void {
|
||||
switch (pdu.opcode) {
|
||||
case TargetOpcode.SCSI_DATA_IN: {
|
||||
const pending = this.#backlog.get(pdu.itt)
|
||||
if (pending === undefined) {
|
||||
return // stale/duplicate; ignore
|
||||
}
|
||||
pdu.data.copy(pending.buffer, pdu.readU32(40)) // copy at Buffer Offset
|
||||
const hasStatus = (pdu.flags & DATA_IN_FLAG_STATUS) !== 0
|
||||
log.debug('data-in', { itt: pdu.itt, offset: pdu.readU32(40), length: pdu.data.length, final: hasStatus })
|
||||
if (hasStatus) {
|
||||
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
|
||||
this.#complete(pdu.itt, pending, pdu.readU8(3))
|
||||
}
|
||||
return
|
||||
}
|
||||
case TargetOpcode.SCSI_RESPONSE: {
|
||||
const pending = this.#backlog.get(pdu.itt)
|
||||
if (pending !== undefined) {
|
||||
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
|
||||
this.#complete(pdu.itt, pending, pdu.readU8(3))
|
||||
}
|
||||
return
|
||||
}
|
||||
case TargetOpcode.NOP_IN: {
|
||||
// A target-initiated NOP ping (TTT set) must be answered to keep the
|
||||
// session alive; a NOP reply to our own ping (none here) is ignored.
|
||||
const ttt = pdu.readU32(20)
|
||||
if (ttt !== RESERVED_TAG) {
|
||||
void this.#replyNop(ttt, pdu.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
case TargetOpcode.LOGOUT_RESPONSE:
|
||||
// awaited by `close()`, which cannot read the socket itself
|
||||
this.#logoutAnswered?.()
|
||||
return
|
||||
default:
|
||||
// R2T/Async/Reject are not expected on the read-only path; ignore.
|
||||
log.debug('ignoring unexpected PDU', { opcode: pdu.opcode })
|
||||
}
|
||||
}
|
||||
|
||||
#complete(itt: number, pending: Pending, status: number): void {
|
||||
this.#backlog.delete(itt)
|
||||
log.debug('read complete', { itt, status, pending: this.#backlog.size })
|
||||
if (status === ScsiStatus.GOOD) {
|
||||
pending.resolve(pending.buffer)
|
||||
} else {
|
||||
pending.reject(new Error(`SCSI command failed with status 0x${status.toString(16)}`))
|
||||
}
|
||||
}
|
||||
|
||||
async #replyNop(targetTransferTag: number, ping: Buffer): Promise<void> {
|
||||
const bhs = allocBhs(InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE)
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: none (this is an unsolicited reply)
|
||||
bhs.writeUInt32BE(targetTransferTag, 20)
|
||||
bhs.writeUInt32BE(this.#cmdSN, 24)
|
||||
bhs.writeUInt32BE(this.#expStatSN, 28)
|
||||
await this.#send(assemblePdu(bhs, ping)).catch(error => log.debug('NOP reply failed', { error }))
|
||||
}
|
||||
|
||||
#rejectAll(error: Error): void {
|
||||
if (this.#backlog.size > 0) {
|
||||
log.debug('rejecting all pending reads', { count: this.#backlog.size, error: error.message })
|
||||
}
|
||||
for (const [itt, pending] of this.#backlog) {
|
||||
this.#backlog.delete(itt)
|
||||
pending.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
372
@vates/iscsi/src/login.mts
Normal file
372
@vates/iscsi/src/login.mts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { isIP } from 'node:net'
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
|
||||
import {
|
||||
AuthMethod,
|
||||
LOGIN_CSG_MASK,
|
||||
LOGIN_CSG_SHIFT,
|
||||
LOGIN_FLAG_CONTINUE,
|
||||
LOGIN_FLAG_TRANSIT,
|
||||
LOGIN_NSG_MASK,
|
||||
LoginStage,
|
||||
LoginStatusClass,
|
||||
LoginStatusDetail,
|
||||
} from './constants.mjs'
|
||||
import {
|
||||
computeResponse,
|
||||
encodeChapValue,
|
||||
decodeChapValue,
|
||||
generateChallenge,
|
||||
generateId,
|
||||
parseAlgorithmList,
|
||||
selectAlgorithm,
|
||||
verifyResponse,
|
||||
} from './chap.mjs'
|
||||
import type { IncomingPdu } from './pdu.mjs'
|
||||
import type { ChapCredentials, NegotiatedParams, SessionType } from './types.mjs'
|
||||
|
||||
const EMPTY = Buffer.alloc(0)
|
||||
|
||||
// Target-side login negotiation trace. Enable the `vates:iscsi:login` debug
|
||||
// namespace to see every negotiated key and CHAP state change — the usual root
|
||||
// cause when a particular initiator fails to attach.
|
||||
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.
|
||||
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> {
|
||||
const keys = new Map<string, string>()
|
||||
for (const pair of data.toString('utf8').split('\0')) {
|
||||
if (pair.length === 0) {
|
||||
continue
|
||||
}
|
||||
const eq = pair.indexOf('=')
|
||||
if (eq !== -1) {
|
||||
keys.set(pair.slice(0, eq), pair.slice(eq + 1))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Serialize key=value pairs into a NUL-delimited (and NUL-terminated) buffer. */
|
||||
export function serializeTextKeys(entries: ReadonlyArray<readonly [string, string]>): Buffer {
|
||||
if (entries.length === 0) {
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
return Buffer.from(entries.map(([key, value]) => `${key}=${value}\0`).join(''), 'utf8')
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an `address:port,portal-group-tag` value for a SendTargets
|
||||
* `TargetAddress`, per the iSCSI grammar:
|
||||
* - unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4`, produced by a dual-stack socket
|
||||
* accepting an IPv4 connection) to plain IPv4, so initiators get a clean,
|
||||
* matchable portal;
|
||||
* - bracket real IPv6 literals (`[2001:db8::1]:3260,1`).
|
||||
*/
|
||||
export function formatPortal(address: string, port: number): string {
|
||||
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address)
|
||||
const normalized = mapped !== null ? mapped[1] : address
|
||||
const bracketed = isIP(normalized) === 6 ? `[${normalized}]` : normalized
|
||||
return `${bracketed}:${port},1`
|
||||
}
|
||||
|
||||
/** Build the SendTargets discovery Text Response data segment. */
|
||||
export function buildSendTargetsResponse(iqn: string, address: string, port: number): Buffer {
|
||||
return serializeTextKeys([
|
||||
['TargetName', iqn],
|
||||
['TargetAddress', formatPortal(address, port)],
|
||||
])
|
||||
}
|
||||
|
||||
/** Outcome of processing a single Login Request PDU. */
|
||||
export interface LoginStepResult {
|
||||
/** Byte 1 (T/C/CSG/NSG) of the Login Response. */
|
||||
flagsByte: number
|
||||
/** Response key=value text segment. */
|
||||
data: Buffer
|
||||
/** Login Response Status-Class (byte 36). Defaults to SUCCESS when omitted. */
|
||||
statusClass?: number
|
||||
/** Login Response Status-Detail (byte 37). Defaults to 0 when omitted. */
|
||||
statusDetail?: number
|
||||
}
|
||||
|
||||
/** A rejected login: the Status-Class/Detail bytes the response must carry. */
|
||||
interface AuthFailure {
|
||||
statusClass: number
|
||||
statusDetail: number
|
||||
}
|
||||
|
||||
/** CHAP authentication failure (RFC 7143 §11.1.5): Initiator Error / auth failure. */
|
||||
const AUTH_FAILURE: AuthFailure = {
|
||||
statusClass: LoginStatusClass.INITIATOR_ERROR,
|
||||
statusDetail: LoginStatusDetail.AUTHENTICATION_FAILURE,
|
||||
}
|
||||
|
||||
/** Progress of the target-side one-way CHAP exchange (RFC 7143 §11.1.4). */
|
||||
type ChapState = 'none' | 'awaitAlgorithm' | 'awaitResponse' | 'authenticated' | 'failed'
|
||||
|
||||
/**
|
||||
* Negotiates one connection's login: the target is authoritative, so every
|
||||
* key is answered with the pinned value, and RFC 7143's boolean rules
|
||||
* guarantee the session collapses to the one supported path regardless of
|
||||
* what the initiator offers.
|
||||
*
|
||||
* With {@link ChapCredentials}, the target challenges the initiator during
|
||||
* security negotiation and refuses to reach the operational stage until it
|
||||
* proves the secret; without one, it uses the legacy `AuthMethod=None` path.
|
||||
*/
|
||||
export class LoginNegotiator {
|
||||
#firstResponseSent = false
|
||||
#complete = false
|
||||
|
||||
readonly #chap?: ChapCredentials
|
||||
#chapState: ChapState = 'none'
|
||||
#chapId = 0
|
||||
#chapChallenge: Buffer = EMPTY
|
||||
|
||||
sessionType: SessionType = 'Normal'
|
||||
initiatorName?: string
|
||||
targetName?: string
|
||||
readonly params: NegotiatedParams = {
|
||||
initiatorMaxRecvDataSegmentLength: DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH,
|
||||
maxBurstLength: OUR_MAX_BURST_LENGTH,
|
||||
}
|
||||
|
||||
constructor(chap?: ChapCredentials) {
|
||||
this.#chap = chap
|
||||
}
|
||||
|
||||
/** True once the session has transited to the full-feature phase. */
|
||||
get complete(): boolean {
|
||||
return this.#complete
|
||||
}
|
||||
|
||||
/** True once CHAP authentication has definitively failed for this connection. */
|
||||
get failed(): boolean {
|
||||
return this.#chapState === 'failed'
|
||||
}
|
||||
|
||||
/** CHAP is enforced only when configured and only for Normal (data) sessions. */
|
||||
get #chapRequired(): boolean {
|
||||
return this.#chap !== undefined && this.sessionType === 'Normal'
|
||||
}
|
||||
|
||||
process(pdu: IncomingPdu): LoginStepResult {
|
||||
const flags = pdu.flags
|
||||
const csg = (flags & LOGIN_CSG_MASK) >> LOGIN_CSG_SHIFT
|
||||
const nsg = flags & LOGIN_NSG_MASK
|
||||
const transit = (flags & LOGIN_FLAG_TRANSIT) !== 0
|
||||
const cont = (flags & LOGIN_FLAG_CONTINUE) !== 0
|
||||
const keys = parseTextKeys(pdu.data)
|
||||
log.debug('login request', { csg, nsg, transit, cont, keys: Object.fromEntries(keys) })
|
||||
|
||||
const sessionType = keys.get('SessionType')
|
||||
if (sessionType !== undefined) {
|
||||
this.sessionType = sessionType === 'Discovery' ? 'Discovery' : 'Normal'
|
||||
}
|
||||
this.initiatorName ??= keys.get('InitiatorName')
|
||||
this.targetName ??= keys.get('TargetName')
|
||||
|
||||
const responseKeys: Array<[string, string]> = []
|
||||
// A normal session's first Login Response must carry the portal group tag.
|
||||
if (!this.#firstResponseSent && this.sessionType === 'Normal') {
|
||||
responseKeys.push(['TargetPortalGroupTag', '1'])
|
||||
}
|
||||
|
||||
let failure: AuthFailure | undefined
|
||||
if (csg === LoginStage.SECURITY_NEGOTIATION) {
|
||||
// Act only on a complete key set (C bit clear); while the initiator is
|
||||
// still sending keys we accumulate without answering the exchange.
|
||||
if (!cont) {
|
||||
failure = this.#handleSecurity(keys, responseKeys)
|
||||
}
|
||||
} else if (csg === LoginStage.OPERATIONAL_NEGOTIATION) {
|
||||
if (this.#chapRequired && this.#chapState !== 'authenticated') {
|
||||
// Normal session jumped to operational negotiation without completing the
|
||||
// CHAP security stage we require.
|
||||
this.#chapState = 'failed'
|
||||
failure = AUTH_FAILURE
|
||||
} else {
|
||||
this.#negotiateOperational(keys, responseKeys)
|
||||
}
|
||||
}
|
||||
|
||||
let flagsByte = csg << LOGIN_CSG_SHIFT
|
||||
// Transit only when the initiator asked to, is not still sending keys, and
|
||||
// (if CHAP is required) authentication has succeeded. This single gate blocks
|
||||
// a premature Transit bit and any transit before/without CHAP.
|
||||
const authReady = !this.#chapRequired || this.#chapState === 'authenticated'
|
||||
if (transit && !cont && authReady) {
|
||||
flagsByte |= LOGIN_FLAG_TRANSIT | nsg
|
||||
if (nsg === LoginStage.FULL_FEATURE_PHASE) {
|
||||
this.#complete = true
|
||||
}
|
||||
}
|
||||
|
||||
this.#firstResponseSent = true
|
||||
log.debug('login response', {
|
||||
transit: (flagsByte & LOGIN_FLAG_TRANSIT) !== 0,
|
||||
nsg: flagsByte & LOGIN_NSG_MASK,
|
||||
keys: Object.fromEntries(responseKeys),
|
||||
statusClass: failure?.statusClass ?? 0,
|
||||
statusDetail: failure?.statusDetail ?? 0,
|
||||
chapState: this.#chapState,
|
||||
})
|
||||
return {
|
||||
flagsByte,
|
||||
data: serializeTextKeys(responseKeys),
|
||||
statusClass: failure?.statusClass,
|
||||
statusDetail: failure?.statusDetail,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one round of the target-side one-way CHAP exchange, mutating
|
||||
* {@link ChapState} and appending any response keys. Returns an
|
||||
* {@link AuthFailure} when authentication is rejected, otherwise `undefined`
|
||||
* (success or "still in progress, awaiting the next PDU").
|
||||
*/
|
||||
#handleSecurity(keys: Map<string, string>, responseKeys: Array<[string, string]>): AuthFailure | undefined {
|
||||
// CHAP applies only to Normal (data) sessions. Discovery sessions can only
|
||||
// run SendTargets — they never reach the LUN — so they negotiate
|
||||
// AuthMethod=None, keeping the standard unauthenticated `iscsiadm -m
|
||||
// discovery` flow working. Without CHAP configured, this is the only path.
|
||||
const chap = this.#chap
|
||||
if (chap === undefined || this.sessionType !== 'Normal') {
|
||||
if (keys.has('AuthMethod')) {
|
||||
responseKeys.push(['AuthMethod', AuthMethod.NONE])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (this.#chapState) {
|
||||
case 'none': {
|
||||
const offer = keys.get('AuthMethod')
|
||||
if (offer === undefined) {
|
||||
return undefined // still waiting for the AuthMethod offer
|
||||
}
|
||||
if (!offer.split(',').includes(AuthMethod.CHAP)) {
|
||||
// Initiator offered only None (or an unknown method) while we require CHAP.
|
||||
log.debug('chap: initiator did not offer CHAP', { offered: offer })
|
||||
this.#chapState = 'failed'
|
||||
return AUTH_FAILURE
|
||||
}
|
||||
responseKeys.push(['AuthMethod', AuthMethod.CHAP])
|
||||
this.#chapState = 'awaitAlgorithm'
|
||||
return undefined
|
||||
}
|
||||
case 'awaitAlgorithm': {
|
||||
const offered = keys.get('CHAP_A')
|
||||
if (offered === undefined) {
|
||||
return undefined // still waiting for CHAP_A
|
||||
}
|
||||
const algorithm = selectAlgorithm(parseAlgorithmList(offered))
|
||||
if (algorithm === undefined) {
|
||||
log.debug('chap: no supported algorithm offered', { offered })
|
||||
this.#chapState = 'failed'
|
||||
return AUTH_FAILURE
|
||||
}
|
||||
this.#chapId = generateId()
|
||||
this.#chapChallenge = generateChallenge()
|
||||
responseKeys.push(['CHAP_A', String(algorithm)])
|
||||
responseKeys.push(['CHAP_I', String(this.#chapId)])
|
||||
responseKeys.push(['CHAP_C', encodeChapValue(this.#chapChallenge)])
|
||||
log.debug('chap: issued challenge', { algorithm, id: this.#chapId })
|
||||
this.#chapState = 'awaitResponse'
|
||||
return undefined
|
||||
}
|
||||
case 'awaitResponse': {
|
||||
const name = keys.get('CHAP_N')
|
||||
const response = keys.get('CHAP_R')
|
||||
if (name === undefined || response === undefined) {
|
||||
return undefined // still waiting for CHAP_N / CHAP_R
|
||||
}
|
||||
let got: Buffer
|
||||
try {
|
||||
got = decodeChapValue(response)
|
||||
} catch {
|
||||
this.#chapState = 'failed'
|
||||
return AUTH_FAILURE
|
||||
}
|
||||
const expected = computeResponse(this.#chapId, chap.secret, this.#chapChallenge)
|
||||
if (name === chap.user && verifyResponse(expected, got)) {
|
||||
log.debug('chap: authenticated', { user: name })
|
||||
this.#chapState = 'authenticated'
|
||||
return undefined
|
||||
}
|
||||
log.debug('chap: verification failed', { user: name, userMatch: name === chap.user })
|
||||
this.#chapState = 'failed'
|
||||
return AUTH_FAILURE
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
#negotiateOperational(keys: Map<string, string>, responseKeys: Array<[string, string]>): void {
|
||||
// Only answer what the initiator actually offered: for every key below, the
|
||||
// RFC 7143 default of an unoffered key is already the value we want, so
|
||||
// staying silent and letting the default stand is equivalent.
|
||||
const answer = (key: string, value: string) => {
|
||||
if (keys.has(key)) {
|
||||
responseKeys.push([key, value])
|
||||
}
|
||||
}
|
||||
answer('HeaderDigest', 'None')
|
||||
answer('DataDigest', 'None')
|
||||
answer('MaxConnections', '1')
|
||||
answer('InitialR2T', 'Yes')
|
||||
answer('ErrorRecoveryLevel', '0')
|
||||
|
||||
// ImmediateData is the one exception: its RFC 7143 default is *Yes*, so an
|
||||
// initiator that never offers the key may legally put write data in the SCSI
|
||||
// Command PDU itself — which this target does not implement and would
|
||||
// otherwise silently drop, acknowledging a write it never applied. Declare
|
||||
// No whether or not it was offered. Costs nothing: there is no immediate
|
||||
// data path to lose, and every mainstream initiator offers the key anyway,
|
||||
// so for them this is byte-identical to before.
|
||||
responseKeys.push(['ImmediateData', 'No'])
|
||||
answer('DataPDUInOrder', 'Yes')
|
||||
answer('DataSequenceInOrder', 'Yes')
|
||||
answer('DefaultTime2Wait', '2')
|
||||
answer('DefaultTime2Retain', '0')
|
||||
answer('MaxOutstandingR2T', '1')
|
||||
|
||||
// MaxRecvDataSegmentLength is declarative per direction: capture the
|
||||
// initiator's (caps our Data-In) and always declare ours (caps its Data-Out).
|
||||
this.params.initiatorMaxRecvDataSegmentLength = parsePositiveInt(
|
||||
keys.get('MaxRecvDataSegmentLength'),
|
||||
this.params.initiatorMaxRecvDataSegmentLength
|
||||
)
|
||||
responseKeys.push(['MaxRecvDataSegmentLength', String(OUR_MAX_RECV_DATA_SEGMENT_LENGTH)])
|
||||
|
||||
// MaxBurstLength / FirstBurstLength negotiate to the minimum of both offers.
|
||||
const maxBurst = Math.min(parsePositiveInt(keys.get('MaxBurstLength'), OUR_MAX_BURST_LENGTH), OUR_MAX_BURST_LENGTH)
|
||||
this.params.maxBurstLength = maxBurst
|
||||
if (keys.has('MaxBurstLength')) {
|
||||
responseKeys.push(['MaxBurstLength', String(maxBurst)])
|
||||
}
|
||||
if (keys.has('FirstBurstLength')) {
|
||||
const firstBurst = Math.min(parsePositiveInt(keys.get('FirstBurstLength'), maxBurst), maxBurst)
|
||||
responseKeys.push(['FirstBurstLength', String(firstBurst)])
|
||||
}
|
||||
}
|
||||
}
|
||||
313
@vates/iscsi/src/login.test.mts
Normal file
313
@vates/iscsi/src/login.test.mts
Normal file
@@ -0,0 +1,313 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import {
|
||||
InitiatorOpcode,
|
||||
LOGIN_CSG_SHIFT,
|
||||
LOGIN_FLAG_CONTINUE,
|
||||
LOGIN_FLAG_TRANSIT,
|
||||
LOGIN_NSG_MASK,
|
||||
LoginStage,
|
||||
LoginStatusClass,
|
||||
LoginStatusDetail,
|
||||
OPCODE_IMMEDIATE,
|
||||
} from './constants.mjs'
|
||||
import { buildSendTargetsResponse, formatPortal, LoginNegotiator, parseTextKeys, serializeTextKeys } from './login.mjs'
|
||||
import { computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
|
||||
import { IncomingPdu } from './pdu.mjs'
|
||||
|
||||
function loginFlags(options: { transit?: boolean; cont?: boolean; csg: number; nsg: number }): number {
|
||||
return (
|
||||
(options.transit ? LOGIN_FLAG_TRANSIT : 0) |
|
||||
(options.cont ? LOGIN_FLAG_CONTINUE : 0) |
|
||||
(options.csg << LOGIN_CSG_SHIFT) |
|
||||
(options.nsg & LOGIN_NSG_MASK)
|
||||
)
|
||||
}
|
||||
|
||||
function makeLoginPdu(flags: number, keys: Record<string, string>): IncomingPdu {
|
||||
const bhs = Buffer.alloc(48)
|
||||
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
|
||||
bhs[1] = flags
|
||||
const data = serializeTextKeys(Object.entries(keys))
|
||||
return new IncomingPdu(bhs, Buffer.alloc(0), data)
|
||||
}
|
||||
|
||||
describe('text key serialization', () => {
|
||||
it('round-trips NUL-delimited key=value pairs', () => {
|
||||
const buffer = serializeTextKeys([
|
||||
['A', '1'],
|
||||
['HeaderDigest', 'None'],
|
||||
])
|
||||
assert.equal(buffer.toString('binary'), 'A=1\0HeaderDigest=None\0')
|
||||
const keys = parseTextKeys(buffer)
|
||||
assert.equal(keys.get('A'), '1')
|
||||
assert.equal(keys.get('HeaderDigest'), 'None')
|
||||
})
|
||||
|
||||
it('ignores empty fragments and malformed pairs', () => {
|
||||
const keys = parseTextKeys(Buffer.from('\0Key=Value\0bogus\0', 'binary'))
|
||||
assert.equal(keys.size, 1)
|
||||
assert.equal(keys.get('Key'), 'Value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoginNegotiator', () => {
|
||||
it('completes a single-PDU operational login and pins the negotiated values', () => {
|
||||
const negotiator = new LoginNegotiator()
|
||||
const pdu = makeLoginPdu(
|
||||
loginFlags({
|
||||
transit: true,
|
||||
csg: LoginStage.OPERATIONAL_NEGOTIATION,
|
||||
nsg: LoginStage.FULL_FEATURE_PHASE,
|
||||
}),
|
||||
{
|
||||
InitiatorName: 'iqn.1994-05.com.example:initiator',
|
||||
SessionType: 'Normal',
|
||||
HeaderDigest: 'None,CRC32C',
|
||||
DataDigest: 'None,CRC32C',
|
||||
InitialR2T: 'No',
|
||||
ImmediateData: 'Yes',
|
||||
MaxRecvDataSegmentLength: '262144',
|
||||
MaxBurstLength: '16776192',
|
||||
}
|
||||
)
|
||||
const { flagsByte, data } = negotiator.process(pdu)
|
||||
|
||||
assert.equal(negotiator.complete, true)
|
||||
assert.ok((flagsByte & LOGIN_FLAG_TRANSIT) !== 0)
|
||||
assert.equal(flagsByte & LOGIN_NSG_MASK, LoginStage.FULL_FEATURE_PHASE)
|
||||
|
||||
const keys = parseTextKeys(data)
|
||||
assert.equal(keys.get('TargetPortalGroupTag'), '1')
|
||||
assert.equal(keys.get('HeaderDigest'), 'None')
|
||||
assert.equal(keys.get('DataDigest'), 'None')
|
||||
// Pinned to the single write code path regardless of the initiator's offer.
|
||||
assert.equal(keys.get('InitialR2T'), 'Yes')
|
||||
assert.equal(keys.get('ImmediateData'), 'No')
|
||||
// MaxBurstLength negotiates down to our cap.
|
||||
assert.equal(keys.get('MaxBurstLength'), '262144')
|
||||
|
||||
assert.equal(negotiator.sessionType, 'Normal')
|
||||
assert.equal(negotiator.initiatorName, 'iqn.1994-05.com.example:initiator')
|
||||
assert.equal(negotiator.params.initiatorMaxRecvDataSegmentLength, 262144)
|
||||
assert.equal(negotiator.params.maxBurstLength, 262144)
|
||||
})
|
||||
|
||||
it('handles a two-stage security then operational login', () => {
|
||||
const negotiator = new LoginNegotiator()
|
||||
|
||||
const security = negotiator.process(
|
||||
makeLoginPdu(
|
||||
loginFlags({
|
||||
transit: true,
|
||||
csg: LoginStage.SECURITY_NEGOTIATION,
|
||||
nsg: LoginStage.OPERATIONAL_NEGOTIATION,
|
||||
}),
|
||||
{ InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: 'None' }
|
||||
)
|
||||
)
|
||||
assert.equal(negotiator.complete, false)
|
||||
assert.equal(parseTextKeys(security.data).get('AuthMethod'), 'None')
|
||||
|
||||
const operational = negotiator.process(
|
||||
makeLoginPdu(
|
||||
loginFlags({
|
||||
transit: true,
|
||||
csg: LoginStage.OPERATIONAL_NEGOTIATION,
|
||||
nsg: LoginStage.FULL_FEATURE_PHASE,
|
||||
}),
|
||||
{ HeaderDigest: 'None', DataDigest: 'None' }
|
||||
)
|
||||
)
|
||||
assert.equal(negotiator.complete, true)
|
||||
// The portal group tag is only emitted in the first response.
|
||||
assert.equal(parseTextKeys(operational.data).get('TargetPortalGroupTag'), undefined)
|
||||
})
|
||||
|
||||
it('omits the portal group tag for a discovery session', () => {
|
||||
const negotiator = new LoginNegotiator()
|
||||
const { data } = negotiator.process(
|
||||
makeLoginPdu(
|
||||
loginFlags({
|
||||
transit: true,
|
||||
csg: LoginStage.OPERATIONAL_NEGOTIATION,
|
||||
nsg: LoginStage.FULL_FEATURE_PHASE,
|
||||
}),
|
||||
{ SessionType: 'Discovery' }
|
||||
)
|
||||
)
|
||||
assert.equal(negotiator.sessionType, 'Discovery')
|
||||
assert.equal(parseTextKeys(data).get('TargetPortalGroupTag'), undefined)
|
||||
})
|
||||
|
||||
it('does not transit while the initiator is still sending keys (C bit)', () => {
|
||||
const negotiator = new LoginNegotiator()
|
||||
const { flagsByte } = negotiator.process(
|
||||
makeLoginPdu(
|
||||
loginFlags({
|
||||
transit: true,
|
||||
cont: true,
|
||||
csg: LoginStage.OPERATIONAL_NEGOTIATION,
|
||||
nsg: LoginStage.FULL_FEATURE_PHASE,
|
||||
}),
|
||||
{ HeaderDigest: 'None' }
|
||||
)
|
||||
)
|
||||
assert.equal(negotiator.complete, false)
|
||||
assert.equal(flagsByte & LOGIN_FLAG_TRANSIT, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoginNegotiator CHAP authenticator', () => {
|
||||
const CHAP = { user: 'alice', secret: 's3cr3t' }
|
||||
|
||||
const securityFlags = (transit = false) =>
|
||||
loginFlags({ transit, csg: LoginStage.SECURITY_NEGOTIATION, nsg: LoginStage.OPERATIONAL_NEGOTIATION })
|
||||
|
||||
/** Round 1: offer AuthMethod. */
|
||||
function offerAuth(negotiator: LoginNegotiator, methods = 'CHAP,None', transit = false) {
|
||||
return negotiator.process(
|
||||
makeLoginPdu(securityFlags(transit), { InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: methods })
|
||||
)
|
||||
}
|
||||
|
||||
/** Round 2: offer CHAP_A, return the target's parsed challenge keys. */
|
||||
function offerAlgorithm(negotiator: LoginNegotiator, list = '5') {
|
||||
const result = negotiator.process(makeLoginPdu(securityFlags(false), { CHAP_A: list }))
|
||||
return { result, keys: parseTextKeys(result.data) }
|
||||
}
|
||||
|
||||
/** Round 3: answer with CHAP_N/CHAP_R for the given challenge. */
|
||||
function answerChallenge(
|
||||
negotiator: LoginNegotiator,
|
||||
challengeKeys: Map<string, string>,
|
||||
{ user = CHAP.user, secret = CHAP.secret } = {}
|
||||
) {
|
||||
const id = Number.parseInt(challengeKeys.get('CHAP_I') ?? '', 10)
|
||||
const challenge = decodeChapValue(challengeKeys.get('CHAP_C') ?? '')
|
||||
const response = encodeChapValue(computeResponse(id, secret, challenge))
|
||||
return negotiator.process(makeLoginPdu(securityFlags(true), { CHAP_N: user, CHAP_R: response }))
|
||||
}
|
||||
|
||||
it('challenges then transits only after a correct response', () => {
|
||||
const negotiator = new LoginNegotiator(CHAP)
|
||||
|
||||
// Round 1: we answer AuthMethod=CHAP and do NOT transit.
|
||||
const r1 = offerAuth(negotiator)
|
||||
assert.equal(parseTextKeys(r1.data).get('AuthMethod'), 'CHAP')
|
||||
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
|
||||
assert.equal(r1.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
|
||||
|
||||
// Round 2: we issue CHAP_A=5, an identifier and a challenge, still no transit.
|
||||
const { result: r2, keys: challenge } = offerAlgorithm(negotiator)
|
||||
assert.equal(challenge.get('CHAP_A'), '5')
|
||||
assert.ok(challenge.has('CHAP_I'))
|
||||
assert.match(challenge.get('CHAP_C') ?? '', /^0x[0-9a-f]+$/)
|
||||
assert.equal(r2.flagsByte & LOGIN_FLAG_TRANSIT, 0)
|
||||
|
||||
// Round 3: a correct response transits to the operational stage.
|
||||
const r3 = answerChallenge(negotiator, challenge)
|
||||
assert.equal(r3.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
|
||||
assert.ok((r3.flagsByte & LOGIN_FLAG_TRANSIT) !== 0, 'transit granted after auth')
|
||||
assert.equal(r3.flagsByte & LOGIN_NSG_MASK, LoginStage.OPERATIONAL_NEGOTIATION)
|
||||
assert.equal(negotiator.failed, false)
|
||||
|
||||
// The operational stage then completes as usual.
|
||||
const op = negotiator.process(
|
||||
makeLoginPdu(
|
||||
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
|
||||
{ HeaderDigest: 'None', DataDigest: 'None' }
|
||||
)
|
||||
)
|
||||
assert.equal(negotiator.complete, true)
|
||||
assert.equal(parseTextKeys(op.data).get('HeaderDigest'), 'None')
|
||||
})
|
||||
|
||||
it('rejects a wrong secret with Initiator Error / authentication failure', () => {
|
||||
const negotiator = new LoginNegotiator(CHAP)
|
||||
offerAuth(negotiator)
|
||||
const { keys: challenge } = offerAlgorithm(negotiator)
|
||||
const r3 = answerChallenge(negotiator, challenge, { secret: 'wrong' })
|
||||
|
||||
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
|
||||
assert.equal(r3.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
|
||||
assert.equal(r3.flagsByte & LOGIN_FLAG_TRANSIT, 0)
|
||||
assert.equal(negotiator.complete, false)
|
||||
assert.equal(negotiator.failed, true)
|
||||
})
|
||||
|
||||
it('rejects a wrong username even with the right secret', () => {
|
||||
const negotiator = new LoginNegotiator(CHAP)
|
||||
offerAuth(negotiator)
|
||||
const { keys: challenge } = offerAlgorithm(negotiator)
|
||||
const r3 = answerChallenge(negotiator, challenge, { user: 'mallory' })
|
||||
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
|
||||
assert.equal(negotiator.failed, true)
|
||||
})
|
||||
|
||||
it('fails when the initiator offers only AuthMethod=None', () => {
|
||||
const negotiator = new LoginNegotiator(CHAP)
|
||||
const r1 = offerAuth(negotiator, 'None')
|
||||
assert.equal(r1.statusClass, LoginStatusClass.INITIATOR_ERROR)
|
||||
assert.equal(r1.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
|
||||
assert.equal(negotiator.failed, true)
|
||||
})
|
||||
|
||||
it('fails when CHAP_A does not offer MD5', () => {
|
||||
const negotiator = new LoginNegotiator(CHAP)
|
||||
offerAuth(negotiator)
|
||||
const { result } = offerAlgorithm(negotiator, '6,7')
|
||||
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
|
||||
assert.equal(negotiator.failed, true)
|
||||
})
|
||||
|
||||
it('does not transit if the initiator sets the T bit before authenticating', () => {
|
||||
const negotiator = new LoginNegotiator(CHAP)
|
||||
// Round 1 with the Transit bit set: must be ignored until CHAP completes.
|
||||
const r1 = offerAuth(negotiator, 'CHAP,None', true)
|
||||
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
|
||||
assert.equal(negotiator.complete, false)
|
||||
})
|
||||
|
||||
it('fails if the initiator skips security and jumps to operational negotiation', () => {
|
||||
const negotiator = new LoginNegotiator(CHAP)
|
||||
const result = negotiator.process(
|
||||
makeLoginPdu(
|
||||
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
|
||||
{ HeaderDigest: 'None' }
|
||||
)
|
||||
)
|
||||
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
|
||||
assert.equal(result.flagsByte & LOGIN_FLAG_TRANSIT, 0)
|
||||
assert.equal(negotiator.complete, false)
|
||||
assert.equal(negotiator.failed, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPortal', () => {
|
||||
it('keeps plain IPv4 addresses', () => {
|
||||
assert.equal(formatPortal('10.0.0.1', 3260), '10.0.0.1:3260,1')
|
||||
})
|
||||
|
||||
it('unwraps IPv4-mapped IPv6 (dual-stack socket artifact)', () => {
|
||||
assert.equal(formatPortal('::ffff:192.168.1.8', 3260), '192.168.1.8:3260,1')
|
||||
})
|
||||
|
||||
it('brackets real IPv6 literals', () => {
|
||||
assert.equal(formatPortal('2a01:e0a:e6a:9500::1', 3260), '[2a01:e0a:e6a:9500::1]:3260,1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSendTargetsResponse', () => {
|
||||
it('advertises the target name and address', () => {
|
||||
const keys = parseTextKeys(buildSendTargetsResponse('iqn.2024-01.tech.vates:lun0', '10.0.0.1', 3260))
|
||||
assert.equal(keys.get('TargetName'), 'iqn.2024-01.tech.vates:lun0')
|
||||
assert.equal(keys.get('TargetAddress'), '10.0.0.1:3260,1')
|
||||
})
|
||||
|
||||
it('advertises a clean IPv4 portal for an IPv4-mapped local address', () => {
|
||||
const keys = parseTextKeys(buildSendTargetsResponse('iqn.x:y', '::ffff:192.168.1.8', 3260))
|
||||
assert.equal(keys.get('TargetAddress'), '192.168.1.8:3260,1')
|
||||
})
|
||||
})
|
||||
1128
@vates/iscsi/src/loopback.integ.mts
Normal file
1128
@vates/iscsi/src/loopback.integ.mts
Normal file
File diff suppressed because it is too large
Load Diff
174
@vates/iscsi/src/pdu.mts
Normal file
174
@vates/iscsi/src/pdu.mts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { readChunk, readChunkStrict } from '@vates/read-chunk'
|
||||
import { pTimeout } from 'promise-toolbox'
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
|
||||
import { BHS_LENGTH, opcodeName, OPCODE_IMMEDIATE, OPCODE_MASK, FLAG_FINAL } from './constants.mjs'
|
||||
|
||||
const EMPTY = Buffer.alloc(0)
|
||||
|
||||
// Framing-level wire trace, shared by target and initiator. Enable with the
|
||||
// `vates:iscsi:pdu` debug namespace to see every PDU that crosses the socket —
|
||||
// the first thing to look at when a new initiator/target combination misbehaves.
|
||||
const log: Logger = createLogger('vates:iscsi:pdu')
|
||||
|
||||
/** Round `n` up to the next multiple of 4 (iSCSI segments are 4-byte padded). */
|
||||
export function roundUp4(n: number): number {
|
||||
return (n + 3) & ~3
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully-received inbound PDU: the 48-byte Basic Header Segment, any Additional
|
||||
* Header Segment, and the (unpadded) data segment. Field accessors read the BHS
|
||||
* at the offsets defined by RFC 7143; callers pick the ones relevant to the
|
||||
* opcode they are handling.
|
||||
*/
|
||||
export class IncomingPdu {
|
||||
constructor(
|
||||
readonly bhs: Buffer,
|
||||
readonly ahs: Buffer,
|
||||
readonly data: Buffer
|
||||
) {}
|
||||
|
||||
/** 6-bit opcode (immediate bit masked off). */
|
||||
get opcode(): number {
|
||||
return this.bhs[0] & OPCODE_MASK
|
||||
}
|
||||
|
||||
/** Whether the immediate bit is set (initiator requests immediate processing). */
|
||||
get immediate(): boolean {
|
||||
return (this.bhs[0] & OPCODE_IMMEDIATE) !== 0
|
||||
}
|
||||
|
||||
/** Raw opcode-specific flags byte (byte 1). */
|
||||
get flags(): number {
|
||||
return this.bhs[1]
|
||||
}
|
||||
|
||||
get finalBit(): boolean {
|
||||
return (this.bhs[1] & FLAG_FINAL) !== 0
|
||||
}
|
||||
|
||||
readU8(offset: number): number {
|
||||
return this.bhs.readUInt8(offset)
|
||||
}
|
||||
|
||||
readU16(offset: number): number {
|
||||
return this.bhs.readUInt16BE(offset)
|
||||
}
|
||||
|
||||
readU32(offset: number): number {
|
||||
return this.bhs.readUInt32BE(offset)
|
||||
}
|
||||
|
||||
/** Initiator Task Tag (bytes 16-19), present in every initiator PDU we handle. */
|
||||
get itt(): number {
|
||||
return this.bhs.readUInt32BE(16)
|
||||
}
|
||||
|
||||
/** CmdSN (bytes 24-27) — meaningful on command-carrying PDUs. */
|
||||
get cmdSN(): number {
|
||||
return this.bhs.readUInt32BE(24)
|
||||
}
|
||||
|
||||
/** ExpStatSN (bytes 28-31). */
|
||||
get expStatSN(): number {
|
||||
return this.bhs.readUInt32BE(28)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read exactly one PDU from `stream`, reassembling across TCP boundaries.
|
||||
* Resolves to `null` on a clean EOF between PDUs; throws on a truncated PDU
|
||||
* or read error. No header/data digest bytes: digests are pinned off.
|
||||
*
|
||||
* 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, maxDataSegmentLength = Infinity): Promise<IncomingPdu | null> {
|
||||
const bhs = await readChunk(stream, BHS_LENGTH)
|
||||
if (bhs === null) {
|
||||
return null // clean EOF between PDUs
|
||||
}
|
||||
if (bhs.length !== BHS_LENGTH) {
|
||||
throw new Error(`truncated iSCSI BHS (got ${bhs.length} of ${BHS_LENGTH} bytes)`)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
let ahs = EMPTY
|
||||
let data = EMPTY
|
||||
if (ahsBytes + dataBytesPadded > 0) {
|
||||
const rest = await readChunkStrict(stream, ahsBytes + dataBytesPadded)
|
||||
if (ahsBytes > 0) {
|
||||
ahs = rest.subarray(0, ahsBytes)
|
||||
}
|
||||
data = rest.subarray(ahsBytes, ahsBytes + dataSegmentLength) // strip 4-byte padding
|
||||
}
|
||||
|
||||
const pdu = new IncomingPdu(bhs, ahs, data)
|
||||
log.debug('rx', {
|
||||
opcode: opcodeName(bhs[0]),
|
||||
itt: pdu.itt,
|
||||
flags: `0x${bhs[1].toString(16).padStart(2, '0')}`,
|
||||
ahs: ahsBytes,
|
||||
data: dataSegmentLength,
|
||||
})
|
||||
return pdu
|
||||
}
|
||||
|
||||
/** Allocate a zeroed 48-byte BHS with byte 0 set to `opcode`. */
|
||||
export function allocBhs(opcode: number): Buffer {
|
||||
const bhs = Buffer.alloc(BHS_LENGTH)
|
||||
bhs[0] = opcode
|
||||
return bhs
|
||||
}
|
||||
|
||||
/** Write the 24-bit DataSegmentLength field (bytes 5-7) of a BHS. */
|
||||
export function setDataSegmentLength(bhs: Buffer, length: number): void {
|
||||
bhs.writeUIntBE(length, 5, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a complete PDU buffer from a 48-byte BHS and an optional data
|
||||
* segment, setting DataSegmentLength and applying 4-byte padding.
|
||||
*/
|
||||
export function assemblePdu(bhs: Buffer, data?: Buffer): Buffer {
|
||||
const length = data?.length ?? 0
|
||||
setDataSegmentLength(bhs, length)
|
||||
if (length === 0) {
|
||||
return bhs
|
||||
}
|
||||
const padding = roundUp4(length) - length
|
||||
return padding === 0 ? Buffer.concat([bhs, data!]) : Buffer.concat([bhs, data!, Buffer.alloc(padding)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a PDU to the socket, resolving once the kernel has accepted it (honoring
|
||||
* back-pressure via the write callback). Rejects if the write errors or does not
|
||||
* drain within `timeoutMs` (0 disables the timeout).
|
||||
*/
|
||||
export function writePdu(socket: Socket, buffer: Buffer, timeoutMs: number): Promise<void> {
|
||||
log.debug('tx', {
|
||||
opcode: opcodeName(buffer[0]),
|
||||
itt: buffer.readUInt32BE(16),
|
||||
flags: `0x${buffer[1].toString(16).padStart(2, '0')}`,
|
||||
length: buffer.length,
|
||||
})
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
socket.write(buffer, error => (error ? reject(error) : resolve()))
|
||||
})
|
||||
return timeoutMs > 0 ? pTimeout.call(promise, timeoutMs) : promise
|
||||
}
|
||||
118
@vates/iscsi/src/pdu.test.mts
Normal file
118
@vates/iscsi/src/pdu.test.mts
Normal file
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { InitiatorOpcode } from './constants.mjs'
|
||||
import { allocBhs, assemblePdu, IncomingPdu, readPdu, roundUp4 } from './pdu.mjs'
|
||||
|
||||
function buildScsiCommandPdu(itt: number, cmdSN: number, cdb: Buffer, data?: Buffer): Buffer {
|
||||
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
|
||||
bhs[1] = 0x80 // Final
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(0, 20) // EDTL
|
||||
bhs.writeUInt32BE(cmdSN, 24)
|
||||
cdb.copy(bhs, 32)
|
||||
return assemblePdu(bhs, data)
|
||||
}
|
||||
|
||||
async function readOne(buffer: Buffer): Promise<IncomingPdu> {
|
||||
const stream = new PassThrough()
|
||||
stream.end(buffer)
|
||||
const pdu = await readPdu(stream)
|
||||
assert.ok(pdu !== null)
|
||||
return pdu
|
||||
}
|
||||
|
||||
describe('roundUp4', () => {
|
||||
it('rounds up to the next multiple of 4', () => {
|
||||
assert.equal(roundUp4(0), 0)
|
||||
assert.equal(roundUp4(1), 4)
|
||||
assert.equal(roundUp4(4), 4)
|
||||
assert.equal(roundUp4(5), 8)
|
||||
assert.equal(roundUp4(15), 16)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assemblePdu / readPdu round-trip', () => {
|
||||
it('preserves opcode and header fields', async () => {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = 0x28 // READ(10)
|
||||
const pdu = await readOne(buildScsiCommandPdu(0xdeadbeef, 42, cdb))
|
||||
assert.equal(pdu.opcode, InitiatorOpcode.SCSI_COMMAND)
|
||||
assert.equal(pdu.immediate, false)
|
||||
assert.equal(pdu.finalBit, true)
|
||||
assert.equal(pdu.itt, 0xdeadbeef)
|
||||
assert.equal(pdu.cmdSN, 42)
|
||||
assert.equal(pdu.bhs.subarray(32, 48)[0], 0x28)
|
||||
})
|
||||
|
||||
it('carries a data segment and strips its 4-byte padding', async () => {
|
||||
const data = Buffer.from([1, 2, 3, 4, 5]) // 5 bytes -> padded to 8 on the wire
|
||||
const wire = buildScsiCommandPdu(1, 1, Buffer.alloc(16), data)
|
||||
assert.equal(wire.length, 48 + 8, 'data segment is padded to a 4-byte boundary')
|
||||
const pdu = await readOne(wire)
|
||||
assert.deepEqual(pdu.data, data, 'padding is stripped from the parsed data')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
const stream = new PassThrough()
|
||||
const pduPromise = readPdu(stream)
|
||||
// Feed the bytes in awkward fragments.
|
||||
stream.write(wire.subarray(0, 10))
|
||||
stream.write(wire.subarray(10, 48))
|
||||
stream.write(wire.subarray(48, 55))
|
||||
stream.end(wire.subarray(55))
|
||||
const pdu = await pduPromise
|
||||
assert.ok(pdu !== null)
|
||||
assert.equal(pdu.itt, 7)
|
||||
assert.deepEqual(pdu.data, data)
|
||||
})
|
||||
|
||||
it('reads two PDUs coalesced into one chunk', async () => {
|
||||
const first = buildScsiCommandPdu(1, 1, Buffer.alloc(16), Buffer.from([0xaa]))
|
||||
const second = buildScsiCommandPdu(2, 2, Buffer.alloc(16), Buffer.from([0xbb, 0xcc]))
|
||||
const stream = new PassThrough()
|
||||
stream.end(Buffer.concat([first, second]))
|
||||
|
||||
const a = await readPdu(stream)
|
||||
const b = await readPdu(stream)
|
||||
assert.equal(a?.itt, 1)
|
||||
assert.deepEqual(a?.data, Buffer.from([0xaa]))
|
||||
assert.equal(b?.itt, 2)
|
||||
assert.deepEqual(b?.data, Buffer.from([0xbb, 0xcc]))
|
||||
})
|
||||
|
||||
it('returns null on a clean end-of-stream', async () => {
|
||||
const stream = new PassThrough()
|
||||
stream.end()
|
||||
assert.equal(await readPdu(stream), null)
|
||||
})
|
||||
|
||||
it('throws on a truncated BHS', async () => {
|
||||
const stream = new PassThrough()
|
||||
stream.end(Buffer.alloc(20)) // fewer than 48 bytes, then EOF
|
||||
await assert.rejects(readPdu(stream), /truncated/)
|
||||
})
|
||||
})
|
||||
293
@vates/iscsi/src/scsi.mts
Normal file
293
@vates/iscsi/src/scsi.mts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { createLogger, type Logger } from '@xen-orchestra/log'
|
||||
|
||||
import { Asc, ScsiStatus, SenseKey } from './constants.mjs'
|
||||
import { decodeCdb, type CommandContext, type SenseInfo } from './types.mjs'
|
||||
|
||||
// SCSI command trace. Enable `vates:iscsi:scsi` to see every decoded CDB the
|
||||
// target services — useful when an initiator issues a command we mishandle.
|
||||
const log: Logger = createLogger('vates:iscsi:scsi')
|
||||
|
||||
/** SCSI INQUIRY identity strings advertised by the LUN. */
|
||||
export interface ScsiIdentity {
|
||||
/** T10 vendor identification (≤ 8 ASCII chars). */
|
||||
vendor: string
|
||||
/** Product identification (≤ 16 ASCII chars). */
|
||||
product: string
|
||||
/** Product revision level (≤ 4 ASCII chars). */
|
||||
revision: string
|
||||
/** Unit serial number; also seeds the VPD 0x83 device identifier. */
|
||||
serial: string
|
||||
}
|
||||
|
||||
// --- low-level encoders -----------------------------------------------------
|
||||
|
||||
function writeAscii(buffer: Buffer, offset: number, length: number, value: string): void {
|
||||
buffer.fill(0x20, offset, offset + length) // space pad
|
||||
buffer.write(value.slice(0, length), offset, 'ascii')
|
||||
}
|
||||
|
||||
/** Standard INQUIRY data (36 bytes) for a direct-access block device. */
|
||||
export function buildStandardInquiry(identity: ScsiIdentity): Buffer {
|
||||
const buffer = Buffer.alloc(36)
|
||||
buffer[0] = 0x00 // peripheral qualifier 0, device type 0x00 (direct-access block device)
|
||||
buffer[1] = 0x00 // not removable
|
||||
buffer[2] = 0x05 // version: SPC-3
|
||||
buffer[3] = 0x02 // response data format 2
|
||||
buffer[4] = 31 // additional length (total 36 - 5)
|
||||
writeAscii(buffer, 8, 8, identity.vendor)
|
||||
writeAscii(buffer, 16, 16, identity.product)
|
||||
writeAscii(buffer, 32, 4, identity.revision)
|
||||
return buffer
|
||||
}
|
||||
|
||||
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,
|
||||
maxTransferBlocks = DEFAULT_MAX_TRANSFER_LENGTH / 512
|
||||
): Buffer | undefined {
|
||||
switch (pageCode) {
|
||||
case 0x00: {
|
||||
// Supported VPD pages.
|
||||
const buffer = Buffer.alloc(4 + SUPPORTED_VPD_PAGES.length)
|
||||
buffer[1] = 0x00
|
||||
buffer.writeUInt16BE(SUPPORTED_VPD_PAGES.length, 2)
|
||||
SUPPORTED_VPD_PAGES.forEach((page, i) => {
|
||||
buffer[4 + i] = page
|
||||
})
|
||||
return buffer
|
||||
}
|
||||
case 0x80: {
|
||||
// Unit serial number.
|
||||
const serial = Buffer.from(identity.serial, 'ascii')
|
||||
const buffer = Buffer.alloc(4 + serial.length)
|
||||
buffer[1] = 0x80
|
||||
buffer.writeUInt16BE(serial.length, 2)
|
||||
serial.copy(buffer, 4)
|
||||
return buffer
|
||||
}
|
||||
case 0x83: {
|
||||
// Device identification: one T10-vendor-ID-based designator (ASCII).
|
||||
const designator = Buffer.alloc(8 + identity.serial.length)
|
||||
writeAscii(designator, 0, 8, identity.vendor)
|
||||
designator.write(identity.serial, 8, 'ascii')
|
||||
const descriptor = Buffer.alloc(4 + designator.length)
|
||||
descriptor[0] = 0x02 // code set: ASCII
|
||||
descriptor[1] = 0x01 // association: logical unit, designator type: T10 vendor ID based
|
||||
descriptor[3] = designator.length
|
||||
designator.copy(descriptor, 4)
|
||||
const buffer = Buffer.alloc(4 + descriptor.length)
|
||||
buffer[1] = 0x83
|
||||
buffer.writeUInt16BE(descriptor.length, 2)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** READ CAPACITY(10) parameter data (8 bytes). */
|
||||
export function buildReadCapacity10(blockCount: number, blockSize: number): Buffer {
|
||||
const buffer = Buffer.alloc(8)
|
||||
const maxLba = blockCount - 1
|
||||
// If the capacity overflows 32 bits, return 0xffffffff to force the initiator
|
||||
// to issue READ CAPACITY(16).
|
||||
buffer.writeUInt32BE(maxLba > 0xffffffff ? 0xffffffff : maxLba, 0)
|
||||
buffer.writeUInt32BE(blockSize, 4)
|
||||
return buffer
|
||||
}
|
||||
|
||||
/** READ CAPACITY(16) parameter data (32 bytes). */
|
||||
export function buildReadCapacity16(blockCount: number, blockSize: number): Buffer {
|
||||
const buffer = Buffer.alloc(32)
|
||||
buffer.writeBigUInt64BE(BigInt(blockCount - 1), 0)
|
||||
buffer.writeUInt32BE(blockSize, 8)
|
||||
return buffer
|
||||
}
|
||||
|
||||
/** REPORT LUNS parameter data advertising a single LUN 0 (16 bytes). */
|
||||
export function buildReportLuns(): Buffer {
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer.writeUInt32BE(8, 0) // LUN list length: one 8-byte LUN entry
|
||||
return buffer // bytes 8-15 are LUN 0 (all zero)
|
||||
}
|
||||
|
||||
/** Fixed-format sense data (18 bytes). */
|
||||
export function buildFixedSense({ key, asc, ascq }: SenseInfo): Buffer {
|
||||
const buffer = Buffer.alloc(18)
|
||||
buffer[0] = 0x70 // response code: current error, fixed format
|
||||
buffer[2] = key
|
||||
buffer[7] = 10 // additional sense length (18 - 8)
|
||||
buffer[12] = asc
|
||||
buffer[13] = ascq
|
||||
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, 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
|
||||
}
|
||||
|
||||
const ILLEGAL_REQUEST_INVALID_OPCODE: SenseInfo = {
|
||||
key: SenseKey.ILLEGAL_REQUEST,
|
||||
...Asc.INVALID_COMMAND_OPERATION_CODE,
|
||||
}
|
||||
const ILLEGAL_REQUEST_INVALID_FIELD: SenseInfo = {
|
||||
key: SenseKey.ILLEGAL_REQUEST,
|
||||
...Asc.INVALID_FIELD_IN_CDB,
|
||||
}
|
||||
const ILLEGAL_REQUEST_LBA_OUT_OF_RANGE: SenseInfo = {
|
||||
key: SenseKey.ILLEGAL_REQUEST,
|
||||
...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) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and execute one SCSI Command, driving completion through `ctx`.
|
||||
*
|
||||
* `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,
|
||||
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 })
|
||||
|
||||
switch (request.kind) {
|
||||
case 'inquiry': {
|
||||
if (!request.evpd) {
|
||||
if (request.pageCode !== 0) {
|
||||
// EVPD clear but a page code was given: invalid per SPC.
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
|
||||
}
|
||||
return ctx.sendReadData(itt, buildStandardInquiry(identity), request.allocationLength)
|
||||
}
|
||||
const page = buildVpd(request.pageCode, identity, maxTransferBlocks)
|
||||
if (page === undefined) {
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
|
||||
}
|
||||
return ctx.sendReadData(itt, page, request.allocationLength)
|
||||
}
|
||||
|
||||
case 'reportLuns':
|
||||
return ctx.sendReadData(itt, buildReportLuns(), request.allocationLength)
|
||||
|
||||
case 'readCapacity10':
|
||||
return ctx.sendReadData(itt, buildReadCapacity10(blockCount, blockSize), 8)
|
||||
|
||||
case 'readCapacity16':
|
||||
return ctx.sendReadData(itt, buildReadCapacity16(blockCount, blockSize), request.allocationLength)
|
||||
|
||||
case 'read': {
|
||||
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)
|
||||
}
|
||||
let data: Buffer
|
||||
try {
|
||||
data = await lun.read(request.lba * blockSize, byteLength)
|
||||
} catch (error) {
|
||||
// A single bad block (backend hiccup, corrupt source) must fail only
|
||||
// this command, not the whole connection — mirrors how a failed
|
||||
// lun.write() is handled in connection.mts's #handleDataOut.
|
||||
log.warn('LUN read failed', error instanceof Error ? error : new Error(String(error)))
|
||||
return checkCondition(ctx, itt, MEDIUM_ERROR_NO_ADDITIONAL_SENSE)
|
||||
}
|
||||
return ctx.sendReadData(itt, data, byteLength)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Hand off to the connection: it solicits the data via R2T and sends the
|
||||
// SCSI Response once the (possibly interleaved) Data-Out PDUs complete.
|
||||
return ctx.beginWrite(itt, request.lba * blockSize, byteLength)
|
||||
}
|
||||
|
||||
case 'testUnitReady':
|
||||
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
|
||||
|
||||
case 'requestSense': {
|
||||
// No deferred error model in v1: always report NO SENSE.
|
||||
const sense = buildFixedSense({ key: SenseKey.NO_SENSE, ...Asc.NO_ADDITIONAL_SENSE })
|
||||
return ctx.sendReadData(itt, sense, request.allocationLength)
|
||||
}
|
||||
|
||||
case 'modeSense':
|
||||
return ctx.sendReadData(itt, buildModeSense(request.long, lun.readOnly === true), request.allocationLength)
|
||||
|
||||
case 'syncCache':
|
||||
await lun.flush()
|
||||
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
|
||||
|
||||
case 'unsupported':
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_OPCODE)
|
||||
}
|
||||
}
|
||||
302
@vates/iscsi/src/scsi.test.mts
Normal file
302
@vates/iscsi/src/scsi.test.mts
Normal file
@@ -0,0 +1,302 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
import { ScsiStatus, SenseKey } from './constants.mjs'
|
||||
import {
|
||||
buildReadCapacity10,
|
||||
buildReadCapacity16,
|
||||
buildReportLuns,
|
||||
buildStandardInquiry,
|
||||
buildVpd,
|
||||
handleScsiCommand,
|
||||
type ScsiIdentity,
|
||||
} from './scsi.mjs'
|
||||
import { decodeCdb, type CommandContext, type ScsiResponseOptions } from './types.mjs'
|
||||
|
||||
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
|
||||
|
||||
// --- a trivial in-memory LUN ------------------------------------------------
|
||||
|
||||
class MemoryBlockDevice implements BlockDevice {
|
||||
readonly #buffer: Buffer
|
||||
readonly #blockSize: number
|
||||
|
||||
constructor(sizeBytes: number, blockSize = 512) {
|
||||
this.#buffer = Buffer.alloc(sizeBytes)
|
||||
this.#blockSize = blockSize
|
||||
}
|
||||
|
||||
get buffer(): Buffer {
|
||||
return this.#buffer
|
||||
}
|
||||
|
||||
getSize(): number {
|
||||
return this.#buffer.length
|
||||
}
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
}
|
||||
async read(offset: number, length: number): Promise<Buffer> {
|
||||
return Buffer.from(this.#buffer.subarray(offset, offset + length))
|
||||
}
|
||||
async write(offset: number, data: Buffer): Promise<void> {
|
||||
data.copy(this.#buffer, offset)
|
||||
}
|
||||
async flush(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class FakeContext implements CommandContext {
|
||||
readonly reads: Array<{ data: Buffer; allocationLength: number }> = []
|
||||
readonly responses: Array<{ status: number; options?: ScsiResponseOptions }> = []
|
||||
readonly writes: Array<{ itt: number; lunOffset: number; totalLength: number }> = []
|
||||
|
||||
constructor(readonly lun: BlockDevice) {}
|
||||
|
||||
async sendReadData(_itt: number, data: Buffer, allocationLength: number): Promise<void> {
|
||||
this.reads.push({ data, allocationLength })
|
||||
}
|
||||
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
|
||||
this.writes.push({ itt, lunOffset, totalLength })
|
||||
}
|
||||
async sendScsiResponse(_itt: number, status: number, options?: ScsiResponseOptions): Promise<void> {
|
||||
this.responses.push({ status, options })
|
||||
}
|
||||
}
|
||||
|
||||
function cdb(...bytes: number[]): Buffer {
|
||||
const buffer = Buffer.alloc(16)
|
||||
Buffer.from(bytes).copy(buffer)
|
||||
return buffer
|
||||
}
|
||||
|
||||
describe('decodeCdb', () => {
|
||||
it('decodes READ(10) LBA and transfer length', () => {
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x28
|
||||
buffer.writeUInt32BE(0x01020304, 2)
|
||||
buffer.writeUInt16BE(8, 7)
|
||||
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: 0x01020304, blocks: 8 })
|
||||
})
|
||||
|
||||
it('decodes WRITE(16) LBA and transfer length', () => {
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x8a
|
||||
buffer.writeBigUInt64BE(123456789n, 2)
|
||||
buffer.writeUInt32BE(16, 10)
|
||||
assert.deepEqual(decodeCdb(buffer), { kind: 'write', lba: 123456789, blocks: 16 })
|
||||
})
|
||||
|
||||
it('decodes READ CAPACITY(16) only for the right service action', () => {
|
||||
assert.equal(decodeCdb(cdb(0x9e, 0x10)).kind, 'readCapacity16')
|
||||
assert.equal(decodeCdb(cdb(0x9e, 0x12)).kind, 'unsupported')
|
||||
})
|
||||
|
||||
it('clamps an out-of-range 64-bit LBA instead of throwing', () => {
|
||||
// Past 2^53 an LBA no longer fits a JS number — and is past any capacity we
|
||||
// can serve, so it must come back as an unservable LBA the range check
|
||||
// rejects, not as an error that would tear the connection down.
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x88
|
||||
buffer.writeBigUInt64BE(0xffffffffffffffffn, 2)
|
||||
buffer.writeUInt32BE(1, 10)
|
||||
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: Number.MAX_SAFE_INTEGER, blocks: 1 })
|
||||
})
|
||||
|
||||
it('maps unknown opcodes to unsupported', () => {
|
||||
assert.deepEqual(decodeCdb(cdb(0xff)), { kind: 'unsupported', opcode: 0xff })
|
||||
})
|
||||
})
|
||||
|
||||
describe('response encoders', () => {
|
||||
it('builds 36-byte standard INQUIRY data for a block device', () => {
|
||||
const data = buildStandardInquiry(IDENTITY)
|
||||
assert.equal(data.length, 36)
|
||||
assert.equal(data[0], 0x00) // direct-access block device
|
||||
assert.equal(data[4], 31) // additional length
|
||||
assert.equal(data.subarray(8, 16).toString('ascii').trim(), 'VATES')
|
||||
assert.equal(data.subarray(16, 32).toString('ascii').trim(), 'ISCSI LUN')
|
||||
})
|
||||
|
||||
it('builds READ CAPACITY(10) as (maxLba, blockSize)', () => {
|
||||
const data = buildReadCapacity10(2048, 512)
|
||||
assert.equal(data.readUInt32BE(0), 2047)
|
||||
assert.equal(data.readUInt32BE(4), 512)
|
||||
})
|
||||
|
||||
it('clamps READ CAPACITY(10) max LBA to force RC16 on large disks', () => {
|
||||
const data = buildReadCapacity10(0x1_0000_0001, 512)
|
||||
assert.equal(data.readUInt32BE(0), 0xffffffff)
|
||||
})
|
||||
|
||||
it('builds READ CAPACITY(16) with a 64-bit max LBA', () => {
|
||||
const data = buildReadCapacity16(0x1_0000_0001, 512)
|
||||
assert.equal(data.readBigUInt64BE(0), 0x1_0000_0000n)
|
||||
assert.equal(data.readUInt32BE(8), 512)
|
||||
})
|
||||
|
||||
it('reports a single LUN 0', () => {
|
||||
const data = buildReportLuns()
|
||||
assert.equal(data.readUInt32BE(0), 8) // one 8-byte LUN entry
|
||||
assert.deepEqual(data.subarray(8, 16), Buffer.alloc(8))
|
||||
})
|
||||
|
||||
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, 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', () => {
|
||||
it('INQUIRY returns standard data trimmed to the allocation length', async () => {
|
||||
const ctx = new FakeContext(new MemoryBlockDevice(1024 * 1024))
|
||||
await handleScsiCommand(cdb(0x12, 0x00, 0x00, 0x00, 36), 1, ctx, IDENTITY)
|
||||
assert.equal(ctx.reads.length, 1)
|
||||
assert.equal(ctx.reads[0].data.length, 36)
|
||||
assert.equal(ctx.reads[0].allocationLength, 36)
|
||||
})
|
||||
|
||||
it('READ(10) returns the backing bytes for the requested blocks', async () => {
|
||||
const lun = new MemoryBlockDevice(4096)
|
||||
lun.buffer.fill(0xab, 512, 1024) // block 1
|
||||
const ctx = new FakeContext(lun)
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x28
|
||||
buffer.writeUInt32BE(1, 2) // LBA 1
|
||||
buffer.writeUInt16BE(1, 7) // 1 block
|
||||
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
|
||||
assert.equal(ctx.reads.length, 1)
|
||||
assert.deepEqual(ctx.reads[0].data, Buffer.alloc(512, 0xab))
|
||||
})
|
||||
|
||||
it('WRITE(10) begins a solicited write at the right LUN offset and length', async () => {
|
||||
const ctx = new FakeContext(new MemoryBlockDevice(4096))
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x2a
|
||||
buffer.writeUInt32BE(2, 2) // LBA 2
|
||||
buffer.writeUInt16BE(3, 7) // 3 blocks
|
||||
await handleScsiCommand(buffer, 7, ctx, IDENTITY)
|
||||
// The data transfer + GOOD response are completed later by the connection.
|
||||
assert.deepEqual(ctx.writes, [{ itt: 7, lunOffset: 2 * 512, totalLength: 3 * 512 }])
|
||||
assert.equal(ctx.responses.length, 0)
|
||||
})
|
||||
|
||||
it('WRITE(10) of zero blocks returns GOOD immediately with no transfer', async () => {
|
||||
const ctx = new FakeContext(new MemoryBlockDevice(4096))
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x2a // LBA 0, 0 blocks
|
||||
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
|
||||
assert.equal(ctx.writes.length, 0)
|
||||
assert.deepEqual(ctx.responses, [{ status: ScsiStatus.GOOD, options: undefined }])
|
||||
})
|
||||
|
||||
it('rejects an out-of-range READ with CHECK CONDITION', async () => {
|
||||
const ctx = new FakeContext(new MemoryBlockDevice(4096)) // 8 blocks
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x28
|
||||
buffer.writeUInt32BE(7, 2) // LBA 7
|
||||
buffer.writeUInt16BE(4, 7) // 4 blocks -> past end
|
||||
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
|
||||
assert.equal(ctx.responses.length, 1)
|
||||
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
|
||||
})
|
||||
|
||||
it('reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an uncaught throw', async () => {
|
||||
const lun: BlockDevice = {
|
||||
getSize: () => 4096,
|
||||
getBlockSize: () => 512,
|
||||
read: async () => {
|
||||
throw new Error('backend read failed')
|
||||
},
|
||||
write: async () => {},
|
||||
flush: async () => {},
|
||||
close: async () => {},
|
||||
}
|
||||
const ctx = new FakeContext(lun)
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer[0] = 0x28
|
||||
buffer.writeUInt32BE(1, 2) // LBA 1
|
||||
buffer.writeUInt16BE(1, 7) // 1 block
|
||||
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
|
||||
assert.equal(ctx.responses.length, 1)
|
||||
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
|
||||
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.MEDIUM_ERROR)
|
||||
assert.equal(ctx.reads.length, 0) // sendReadData was never reached
|
||||
})
|
||||
|
||||
it('returns CHECK CONDITION for an unsupported opcode', async () => {
|
||||
const ctx = new FakeContext(new MemoryBlockDevice(4096))
|
||||
await handleScsiCommand(cdb(0xff), 1, ctx, IDENTITY)
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
33
@vates/iscsi/src/semaphore.mts
Normal file
33
@vates/iscsi/src/semaphore.mts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* A minimal counting semaphore: bounds how many `run()`-wrapped tasks execute
|
||||
* at once, queueing the rest in FIFO order until a slot frees up.
|
||||
*/
|
||||
export class Semaphore {
|
||||
#available: number
|
||||
readonly #waiters: Array<() => void> = []
|
||||
|
||||
constructor(concurrency: number) {
|
||||
if (!Number.isInteger(concurrency) || concurrency <= 0) {
|
||||
throw new Error(`concurrency must be a positive integer, got ${concurrency}`)
|
||||
}
|
||||
this.#available = concurrency
|
||||
}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.#available > 0) {
|
||||
this.#available--
|
||||
} else {
|
||||
await new Promise<void>(resolve => this.#waiters.push(resolve))
|
||||
}
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
const next = this.#waiters.shift()
|
||||
if (next !== undefined) {
|
||||
next()
|
||||
} else {
|
||||
this.#available++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
@vates/iscsi/src/semaphore.test.mts
Normal file
70
@vates/iscsi/src/semaphore.test.mts
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { Semaphore } from './semaphore.mjs'
|
||||
|
||||
describe('Semaphore', () => {
|
||||
it('rejects a non-positive-integer concurrency', () => {
|
||||
assert.throws(() => new Semaphore(0), /positive integer/)
|
||||
assert.throws(() => new Semaphore(-1), /positive integer/)
|
||||
assert.throws(() => new Semaphore(1.5), /positive integer/)
|
||||
})
|
||||
|
||||
it('runs tasks immediately while under the limit', async () => {
|
||||
const semaphore = new Semaphore(2)
|
||||
const started: Array<number> = []
|
||||
await Promise.all(
|
||||
[0, 1].map(i =>
|
||||
semaphore.run(async () => {
|
||||
started.push(i)
|
||||
})
|
||||
)
|
||||
)
|
||||
assert.deepEqual(started.sort(), [0, 1])
|
||||
})
|
||||
|
||||
it('caps concurrency and admits the next task as soon as a slot frees', async () => {
|
||||
const semaphore = new Semaphore(2)
|
||||
let inFlight = 0
|
||||
let maxInFlight = 0
|
||||
const releases: Array<() => void> = []
|
||||
|
||||
const run = () =>
|
||||
semaphore.run(async () => {
|
||||
inFlight++
|
||||
maxInFlight = Math.max(maxInFlight, inFlight)
|
||||
await new Promise<void>(resolve => releases.push(resolve))
|
||||
inFlight--
|
||||
})
|
||||
|
||||
const tasks = [run(), run(), run(), run()]
|
||||
// let the first batch (2, the concurrency limit) start
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(inFlight, 2)
|
||||
assert.equal(releases.length, 2)
|
||||
|
||||
while (releases.length > 0) {
|
||||
releases.shift()!()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
|
||||
assert.equal(maxInFlight, 2)
|
||||
})
|
||||
|
||||
it('propagates a task failure without leaking its slot', async () => {
|
||||
const semaphore = new Semaphore(1)
|
||||
await assert.rejects(
|
||||
semaphore.run(async () => {
|
||||
throw new Error('boom')
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
// the slot must have been released despite the failure
|
||||
let ran = false
|
||||
await semaphore.run(async () => {
|
||||
ran = true
|
||||
})
|
||||
assert.equal(ran, true)
|
||||
})
|
||||
})
|
||||
24
@vates/iscsi/src/sequencing.test.mts
Normal file
24
@vates/iscsi/src/sequencing.test.mts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { addSerial, incrementSerial, SERIAL_MODULO } from './connection.mjs'
|
||||
|
||||
describe('iSCSI serial-number arithmetic', () => {
|
||||
it('increments within range', () => {
|
||||
assert.equal(incrementSerial(0), 1)
|
||||
assert.equal(incrementSerial(41), 42)
|
||||
})
|
||||
|
||||
it('wraps at 2^32', () => {
|
||||
assert.equal(SERIAL_MODULO, 0x1_0000_0000)
|
||||
assert.equal(incrementSerial(0xffffffff), 0)
|
||||
assert.equal(addSerial(0xfffffffe, 5), 3)
|
||||
assert.equal(addSerial(0xffffffff, 1), 0)
|
||||
})
|
||||
|
||||
it('advertises a command window above the next expected CmdSN, wrapping cleanly', () => {
|
||||
const expCmdSN = 0xfffffff0
|
||||
const window = 64
|
||||
assert.equal(addSerial(expCmdSN, window), (0xfffffff0 + 64) % SERIAL_MODULO)
|
||||
})
|
||||
})
|
||||
244
@vates/iscsi/src/target.integ.mts
Normal file
244
@vates/iscsi/src/target.integ.mts
Normal file
@@ -0,0 +1,244 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, open as openFile, readFile, rm, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { promisify } from 'node:util'
|
||||
import { after, before, describe, it } from 'node:test'
|
||||
|
||||
import { FileBlockDevice, IscsiTarget } from './index.mjs'
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
|
||||
const IQN = 'iqn.2024-01.tech.vates:integ'
|
||||
const LUN_SIZE = 8 * 1024 * 1024 // 8 MiB
|
||||
const BLOCK_SIZE = 512
|
||||
|
||||
/**
|
||||
* Determine why the open-iscsi interop test cannot run here (or `false` if it
|
||||
* can). It needs root (to manage iSCSI sessions and flush block buffers) and the
|
||||
* open-iscsi initiator — the exact software XCP-ng uses. As a lighter, root-free
|
||||
* local alternative, the `libiscsi` userspace tools (`iscsi-inq`, `iscsi-ls`)
|
||||
* can smoke-test discovery/INQUIRY without touching the kernel initiator.
|
||||
*/
|
||||
function skipReason(): string | false {
|
||||
if (process.getuid === undefined || process.getuid() !== 0) {
|
||||
return 'requires root to manage iSCSI sessions'
|
||||
}
|
||||
try {
|
||||
execFileSync('iscsiadm', ['--version'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
return 'requires open-iscsi (iscsiadm not found)'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function iscsiadm(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileP('iscsiadm', args)
|
||||
return stdout
|
||||
}
|
||||
|
||||
/** Parse `iscsiadm -m session -P 3` for the attached SCSI disk's name (e.g. `sdb`) of our target. */
|
||||
function parseAttachedDisk(sessionOutput: string): string | undefined {
|
||||
const lines = sessionOutput.split('\n')
|
||||
const targetLine = lines.findIndex(line => line.includes(IQN))
|
||||
if (targetLine === -1) {
|
||||
return undefined
|
||||
}
|
||||
for (let i = targetLine; i < lines.length; i++) {
|
||||
const match = lines[i].match(/Attached scsi disk (sd\w+)/)
|
||||
if (match !== null) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDevice(): Promise<string> {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
const session = await iscsiadm(['-m', 'session', '-P', '3']).catch(() => '')
|
||||
const name = parseAttachedDisk(session)
|
||||
if (name !== undefined && (await isDeviceReady(name))) {
|
||||
return `/dev/${name}`
|
||||
}
|
||||
await delay(250)
|
||||
}
|
||||
throw new Error('iSCSI block device did not appear')
|
||||
}
|
||||
|
||||
describe('open-iscsi interop', { skip: skipReason() }, () => {
|
||||
let dir: string
|
||||
let backingPath: string
|
||||
let target: IscsiTarget
|
||||
let portal: string
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-integ-'))
|
||||
backingPath = join(dir, 'lun.img')
|
||||
await writeFile(backingPath, Buffer.alloc(0))
|
||||
await truncate(backingPath, LUN_SIZE)
|
||||
|
||||
target = new IscsiTarget({
|
||||
iqn: IQN,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
|
||||
})
|
||||
await target.listen()
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
portal = `127.0.0.1:${address.port}`
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Best-effort teardown: logout + drop the node record so the host is clean.
|
||||
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--logout']).catch(() => {})
|
||||
await iscsiadm(['-m', 'node', '-o', 'delete', '-T', IQN, '-p', portal]).catch(() => {})
|
||||
await target?.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers, logs in, writes and reads back a LUN', async () => {
|
||||
const discovery = await iscsiadm(['-m', 'discovery', '-t', 'sendtargets', '-p', portal])
|
||||
assert.match(discovery, new RegExp(IQN), 'SendTargets discovery should list our IQN')
|
||||
|
||||
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login'])
|
||||
const device = await waitForDevice()
|
||||
|
||||
// Write a recognizable pattern at block 10 through the kernel initiator.
|
||||
const lba = 10
|
||||
const offset = lba * BLOCK_SIZE
|
||||
const pattern = Buffer.alloc(BLOCK_SIZE)
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
pattern[i] = (i * 3 + 5) & 0xff
|
||||
}
|
||||
const writeHandle = await openFile(device, 'r+')
|
||||
try {
|
||||
await writeHandle.write(pattern, 0, pattern.length, offset)
|
||||
await writeHandle.sync()
|
||||
} finally {
|
||||
await writeHandle.close()
|
||||
}
|
||||
|
||||
// The WRITE path must have landed the bytes in the backing file.
|
||||
const onDisk = await readFile(backingPath)
|
||||
assert.deepEqual(onDisk.subarray(offset, offset + BLOCK_SIZE), pattern, 'backing file updated by WRITE')
|
||||
|
||||
// Drop the page cache so the read traverses the target (READ path).
|
||||
await execFileP('blockdev', ['--flushbufs', device]).catch(() => {})
|
||||
const readHandle = await openFile(device, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(BLOCK_SIZE)
|
||||
await readHandle.read(buffer, 0, BLOCK_SIZE, offset)
|
||||
assert.deepEqual(buffer, pattern, 'data read back through the target matches')
|
||||
} finally {
|
||||
await readHandle.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The target as CHAP authenticator against the real open-iscsi initiator (the
|
||||
// XCP-ng interop role). Credentials go into the node record's node.session.auth
|
||||
// keys, exactly as XCP-ng's `device-config:chapuser`/`chappassword` do.
|
||||
describe('open-iscsi CHAP interop', { skip: skipReason() }, () => {
|
||||
// open-iscsi requires the initiator (outgoing) secret to be 12-16 characters.
|
||||
const CHAP = { user: 'alice', secret: 'supersecret1234' }
|
||||
let dir: string
|
||||
let backingPath: string
|
||||
let target: IscsiTarget
|
||||
let portal: string
|
||||
|
||||
async function configureAuth(user: string, secret: string): Promise<void> {
|
||||
const set = (name: string, value: string) =>
|
||||
iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '-o', 'update', '-n', name, '-v', value])
|
||||
await set('node.session.auth.authmethod', 'CHAP')
|
||||
await set('node.session.auth.username', user)
|
||||
await set('node.session.auth.password', secret)
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-chap-integ-'))
|
||||
backingPath = join(dir, 'lun.img')
|
||||
await writeFile(backingPath, Buffer.alloc(0))
|
||||
await truncate(backingPath, LUN_SIZE)
|
||||
|
||||
target = new IscsiTarget({
|
||||
iqn: IQN,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
|
||||
chap: CHAP,
|
||||
})
|
||||
await target.listen()
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
portal = `127.0.0.1:${address.port}`
|
||||
// Discovery creates the node record we then attach auth to.
|
||||
await iscsiadm(['-m', 'discovery', '-t', 'sendtargets', '-p', portal])
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--logout']).catch(() => {})
|
||||
await iscsiadm(['-m', 'node', '-o', 'delete', '-T', IQN, '-p', portal]).catch(() => {})
|
||||
await target?.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects login with a wrong secret', async () => {
|
||||
await configureAuth(CHAP.user, 'wrongsecret12345')
|
||||
await assert.rejects(
|
||||
iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login']),
|
||||
'open-iscsi login must fail when the CHAP secret is wrong'
|
||||
)
|
||||
})
|
||||
|
||||
it('authenticates with the right secret and does IO', async () => {
|
||||
await configureAuth(CHAP.user, CHAP.secret)
|
||||
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login'])
|
||||
const device = await waitForDevice()
|
||||
|
||||
const lba = 7
|
||||
const offset = lba * BLOCK_SIZE
|
||||
const pattern = Buffer.alloc(BLOCK_SIZE)
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
pattern[i] = (i * 5 + 9) & 0xff
|
||||
}
|
||||
const writeHandle = await openFile(device, 'r+')
|
||||
try {
|
||||
await writeHandle.write(pattern, 0, pattern.length, offset)
|
||||
await writeHandle.sync()
|
||||
} finally {
|
||||
await writeHandle.close()
|
||||
}
|
||||
|
||||
await execFileP('blockdev', ['--flushbufs', device]).catch(() => {})
|
||||
const readHandle = await openFile(device, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(BLOCK_SIZE)
|
||||
await readHandle.read(buffer, 0, BLOCK_SIZE, offset)
|
||||
assert.deepEqual(buffer, pattern, 'data read back over an authenticated session matches')
|
||||
} finally {
|
||||
await readHandle.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
149
@vates/iscsi/src/types.mts
Normal file
149
@vates/iscsi/src/types.mts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
import { ScsiOpcode, SERVICE_ACTION_READ_CAPACITY_16 } from './constants.mjs'
|
||||
|
||||
// --- Decoded SCSI requests --------------------------------------------------
|
||||
|
||||
/** A SCSI command we recognize, decoded from its CDB. */
|
||||
export type ScsiRequest =
|
||||
| { kind: 'inquiry'; evpd: boolean; pageCode: number; allocationLength: number }
|
||||
| { kind: 'reportLuns'; allocationLength: number }
|
||||
| { kind: 'readCapacity10' }
|
||||
| { kind: 'readCapacity16'; allocationLength: number }
|
||||
| { kind: 'read'; lba: number; blocks: number }
|
||||
| { kind: 'write'; lba: number; blocks: number }
|
||||
| { kind: 'testUnitReady' }
|
||||
| { kind: 'requestSense'; allocationLength: number }
|
||||
| { kind: 'modeSense'; long: boolean; pageCode: number; allocationLength: number }
|
||||
| { kind: 'syncCache' }
|
||||
| { kind: 'unsupported'; opcode: number }
|
||||
|
||||
/** Largest LBA representable as a safe JS integer; guards 64-bit CDB decoding. */
|
||||
const MAX_SAFE_LBA = Number.MAX_SAFE_INTEGER
|
||||
|
||||
/**
|
||||
* Read a 64-bit LBA, clamped to what a JS number holds exactly.
|
||||
*
|
||||
* An LBA above that is past the capacity of any device we can serve, so the
|
||||
* clamp cannot collide with an addressable block: the caller's
|
||||
* `lba + blocks > blockCount` check rejects it with LBA OUT OF RANGE, which is
|
||||
* the answer SCSI wants anyway. Throwing here instead would take the whole
|
||||
* connection down (the error unwinds to `serve()`, which destroys the socket)
|
||||
* over a single bogus CDB.
|
||||
*/
|
||||
function readLba64(cdb: Buffer, offset: number): number {
|
||||
const lba = cdb.readBigUInt64BE(offset)
|
||||
return lba > BigInt(MAX_SAFE_LBA) ? MAX_SAFE_LBA : Number(lba)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a SCSI CDB into a {@link ScsiRequest}. Unknown opcodes (and READ
|
||||
* CAPACITY(16)'s sibling service actions we do not implement) map to
|
||||
* `{ kind: 'unsupported' }` so the caller can return CHECK CONDITION. Never
|
||||
* throws: every 16-byte CDB decodes to something the caller can answer.
|
||||
*/
|
||||
export function decodeCdb(cdb: Buffer): ScsiRequest {
|
||||
const opcode = cdb[0]
|
||||
switch (opcode) {
|
||||
case ScsiOpcode.INQUIRY:
|
||||
return {
|
||||
kind: 'inquiry',
|
||||
evpd: (cdb[1] & 0x01) !== 0,
|
||||
pageCode: cdb[2],
|
||||
allocationLength: cdb.readUInt16BE(3),
|
||||
}
|
||||
case ScsiOpcode.REPORT_LUNS:
|
||||
return { kind: 'reportLuns', allocationLength: cdb.readUInt32BE(6) }
|
||||
case ScsiOpcode.READ_CAPACITY_10:
|
||||
return { kind: 'readCapacity10' }
|
||||
case ScsiOpcode.SERVICE_ACTION_IN_16:
|
||||
if ((cdb[1] & 0x1f) === SERVICE_ACTION_READ_CAPACITY_16) {
|
||||
return { kind: 'readCapacity16', allocationLength: cdb.readUInt32BE(10) }
|
||||
}
|
||||
return { kind: 'unsupported', opcode }
|
||||
case ScsiOpcode.READ_10:
|
||||
return { kind: 'read', lba: cdb.readUInt32BE(2), blocks: cdb.readUInt16BE(7) }
|
||||
case ScsiOpcode.WRITE_10:
|
||||
return { kind: 'write', lba: cdb.readUInt32BE(2), blocks: cdb.readUInt16BE(7) }
|
||||
case ScsiOpcode.READ_16:
|
||||
return { kind: 'read', lba: readLba64(cdb, 2), blocks: cdb.readUInt32BE(10) }
|
||||
case ScsiOpcode.WRITE_16:
|
||||
return { kind: 'write', lba: readLba64(cdb, 2), blocks: cdb.readUInt32BE(10) }
|
||||
case ScsiOpcode.TEST_UNIT_READY:
|
||||
return { kind: 'testUnitReady' }
|
||||
case ScsiOpcode.REQUEST_SENSE:
|
||||
return { kind: 'requestSense', allocationLength: cdb[4] }
|
||||
case ScsiOpcode.MODE_SENSE_6:
|
||||
return { kind: 'modeSense', long: false, pageCode: cdb[2] & 0x3f, allocationLength: cdb[4] }
|
||||
case ScsiOpcode.MODE_SENSE_10:
|
||||
return { kind: 'modeSense', long: true, pageCode: cdb[2] & 0x3f, allocationLength: cdb.readUInt16BE(7) }
|
||||
case ScsiOpcode.SYNCHRONIZE_CACHE_10:
|
||||
case ScsiOpcode.SYNCHRONIZE_CACHE_16:
|
||||
return { kind: 'syncCache' }
|
||||
default:
|
||||
return { kind: 'unsupported', opcode }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session / connection state shared across modules -----------------------
|
||||
|
||||
/** Operational parameters fixed during login that the SCSI layer depends on. */
|
||||
export interface NegotiatedParams {
|
||||
/** Initiator's MaxRecvDataSegmentLength — the cap on each Data-In PDU we send. */
|
||||
initiatorMaxRecvDataSegmentLength: number
|
||||
/** Max bytes solicited per R2T. */
|
||||
maxBurstLength: number
|
||||
}
|
||||
|
||||
export type SessionType = 'Discovery' | 'Normal'
|
||||
|
||||
/**
|
||||
* A single one-way CHAP credential. On the target it is the incoming credential
|
||||
* an initiator must present; on the initiator it is the credential presented to
|
||||
* the target when challenged. Mutual (two-way) CHAP is not yet supported.
|
||||
*/
|
||||
export interface ChapCredentials {
|
||||
/** CHAP username (`CHAP_N`). */
|
||||
readonly user: string
|
||||
/** CHAP shared secret. */
|
||||
readonly secret: string
|
||||
}
|
||||
|
||||
/** Sense data to attach to a CHECK CONDITION response. */
|
||||
export interface SenseInfo {
|
||||
key: number
|
||||
asc: number
|
||||
ascq: number
|
||||
}
|
||||
|
||||
export interface ScsiResponseOptions {
|
||||
/** Fixed-format sense data buffer (already serialized). */
|
||||
sense?: Buffer
|
||||
/** Residual byte counts for under/overflow reporting. */
|
||||
residualOverflow?: number
|
||||
residualUnderflow?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The capabilities the SCSI command handler needs from its connection: the LUN
|
||||
* backend plus the three ways a command can complete (stream read data, begin a
|
||||
* solicited write, or just return status). The connection implements this;
|
||||
* keeping it an interface avoids a dependency cycle between `scsi` and
|
||||
* `connection`.
|
||||
*/
|
||||
export interface CommandContext {
|
||||
readonly lun: BlockDevice
|
||||
/**
|
||||
* Send `data` as Data-In PDU(s) with GOOD status piggybacked on the last,
|
||||
* trimmed/residual-reported against `allocationLength`.
|
||||
*/
|
||||
sendReadData(itt: number, data: Buffer, allocationLength: number): Promise<void>
|
||||
/**
|
||||
* Begin a solicited write of `totalLength` bytes landing at `lunOffset`: the
|
||||
* connection registers the transfer and sends the first R2T, then returns. The
|
||||
* write executes and its SCSI Response is sent once the Data-Out PDUs arrive
|
||||
* (which may be interleaved with other commands).
|
||||
*/
|
||||
beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void>
|
||||
/** Send a standalone SCSI Response carrying `status` (+ optional sense/residual). */
|
||||
sendScsiResponse(itt: number, status: number, options?: ScsiResponseOptions): Promise<void>
|
||||
}
|
||||
151
@vates/iscsi/src/writePath.mts
Normal file
151
@vates/iscsi/src/writePath.mts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { FLAG_FINAL, TargetOpcode } from './constants.mjs'
|
||||
import { allocBhs, assemblePdu } from './pdu.mjs'
|
||||
|
||||
/** Sequence numbers stamped into an outbound R2T (which does not advance StatSN). */
|
||||
export interface SequenceSnapshot {
|
||||
statSN: number
|
||||
expCmdSN: number
|
||||
maxCmdSN: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Ready To Transfer (R2T) PDU soliciting `desiredLength` bytes of write
|
||||
* data starting at `bufferOffset`.
|
||||
*/
|
||||
export function buildR2t(
|
||||
params: {
|
||||
itt: number
|
||||
r2tSN: number
|
||||
targetTransferTag: number
|
||||
bufferOffset: number
|
||||
desiredLength: number
|
||||
},
|
||||
sequence: SequenceSnapshot
|
||||
): Buffer {
|
||||
const bhs = allocBhs(TargetOpcode.R2T)
|
||||
bhs[1] = FLAG_FINAL // R2T always has the Final bit set
|
||||
bhs.writeUInt32BE(params.itt, 16)
|
||||
bhs.writeUInt32BE(params.targetTransferTag, 20)
|
||||
bhs.writeUInt32BE(sequence.statSN, 24)
|
||||
bhs.writeUInt32BE(sequence.expCmdSN, 28)
|
||||
bhs.writeUInt32BE(sequence.maxCmdSN, 32)
|
||||
bhs.writeUInt32BE(params.r2tSN, 36)
|
||||
bhs.writeUInt32BE(params.bufferOffset, 40)
|
||||
bhs.writeUInt32BE(params.desiredLength, 44)
|
||||
return assemblePdu(bhs)
|
||||
}
|
||||
|
||||
/**
|
||||
* State of one in-progress WRITE: data is solicited one burst at a time (we pin
|
||||
* MaxOutstandingR2T=1) and accumulated as Data-Out PDUs arrive, interleaved with
|
||||
* any other commands on the connection.
|
||||
*/
|
||||
export interface PendingWrite {
|
||||
/** Initiator Task Tag of the WRITE command; Data-Out PDUs are routed by it. */
|
||||
readonly itt: number
|
||||
/** Target Transfer Tag echoed in this command's R2Ts / Data-Out PDUs. */
|
||||
readonly targetTransferTag: number
|
||||
/** Byte offset within the LUN where the data must land. */
|
||||
readonly lunOffset: number
|
||||
/** Assembled write payload. */
|
||||
readonly buffer: Buffer
|
||||
/** Total bytes expected. */
|
||||
readonly totalLength: number
|
||||
/**
|
||||
* Bytes of {@link buffer} filled, counted from offset 0 and contiguous by
|
||||
* construction — see {@link receiveDataOut}. Never a plain count of bytes
|
||||
* received off the wire, which is not the same thing.
|
||||
*/
|
||||
contiguousReceived: number
|
||||
/** Bytes solicited so far (one burst ahead of `contiguousReceived`). */
|
||||
solicited: number
|
||||
/** Next R2TSN to emit. */
|
||||
r2tSN: number
|
||||
}
|
||||
|
||||
/** One inbound Data-Out PDU, reduced to what the write path needs from it. */
|
||||
export interface DataOutSegment {
|
||||
/** Target Transfer Tag echoed by the initiator (BHS bytes 20-23). */
|
||||
readonly targetTransferTag: number
|
||||
/** Where these bytes belong, relative to the start of the command's data (BHS bytes 40-43). */
|
||||
readonly bufferOffset: number
|
||||
readonly data: Buffer
|
||||
}
|
||||
|
||||
/** The PDU was placed in the staging buffer. */
|
||||
export interface PlacedDataOut {
|
||||
readonly ok: true
|
||||
/** Every byte of the command has landed: the write can be committed to the LUN. */
|
||||
readonly complete: boolean
|
||||
/** The solicited burst is fully received: the next one can be solicited. */
|
||||
readonly burstComplete: boolean
|
||||
}
|
||||
|
||||
/** The PDU cannot be placed; the command must be aborted rather than committed. */
|
||||
export interface RefusedDataOut {
|
||||
readonly ok: false
|
||||
/** Why, for the log line accompanying the Reject. */
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export type DataOutOutcome = PlacedDataOut | RefusedDataOut
|
||||
|
||||
/**
|
||||
* Validate one Data-Out PDU against its pending write and, only if it belongs
|
||||
* exactly where the buffer is still unfilled, copy it in. Validation and copy
|
||||
* live together on purpose: the checks are what make the copy in-bounds, so a
|
||||
* caller must not be able to do one without the other.
|
||||
*
|
||||
* A Data-Out must start exactly where the previous one ended. That strictness
|
||||
* is what makes `contiguousReceived` mean "bytes of the buffer filled" — and
|
||||
* only that meaning makes the completion test sound. Counting bytes as they
|
||||
* arrive off the wire does not work, because `Buffer.copy` silently clamps:
|
||||
*
|
||||
* - 512 bytes at BufferOffset 512 of a 512-byte write copies *nothing*,
|
||||
* - 512 bytes at BufferOffset 256 of a 512-byte write copies only 256,
|
||||
* - the same 256 bytes sent twice copies 256 bytes over themselves.
|
||||
*
|
||||
* Each of those reaches `totalLength` bytes received with part of the buffer
|
||||
* never written, so the target would commit a partly-applied write and answer
|
||||
* GOOD — an acknowledged write silently losing data, which is worse than
|
||||
* failing it. Before this was a contiguity check the staging buffer was also
|
||||
* `allocUnsafe`, so the unwritten part leaked recycled heap to the initiator.
|
||||
*
|
||||
* Requiring contiguity is legitimate only because of what login declares in
|
||||
* `#negotiateOperational`: `InitialR2T=Yes` (no unsolicited data, so every
|
||||
* Data-Out is covered by an R2T and its TTT must match), `DataPDUInOrder=Yes`
|
||||
* (RFC 7143: within a sequence, Data-Out PDUs MUST be in increasing
|
||||
* BufferOffset order) and `MaxOutstandingR2T=1` (only one burst is ever
|
||||
* solicited, so no other range can legitimately be in flight). Each of those
|
||||
* is the winning side of its negotiation rule, so an initiator cannot talk us
|
||||
* out of them. Should any of the three ever change, this must become per-burst
|
||||
* range accounting instead of a single high-water mark.
|
||||
*/
|
||||
export function receiveDataOut(pending: PendingWrite, segment: DataOutSegment): DataOutOutcome {
|
||||
const { targetTransferTag, bufferOffset, data } = segment
|
||||
if (targetTransferTag !== pending.targetTransferTag) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Target Transfer Tag ${targetTransferTag} is not this command's ${pending.targetTransferTag}`,
|
||||
}
|
||||
}
|
||||
if (bufferOffset !== pending.contiguousReceived) {
|
||||
return { ok: false, reason: `BufferOffset ${bufferOffset} is not the expected ${pending.contiguousReceived}` }
|
||||
}
|
||||
// `solicited` only ever grows to at most `totalLength`, so bounding by it also
|
||||
// keeps the copy inside the buffer — no clamping, no silent short copy.
|
||||
if (bufferOffset + data.length > pending.solicited) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${data.length} bytes at ${bufferOffset} exceed the ${pending.solicited} bytes solicited`,
|
||||
}
|
||||
}
|
||||
|
||||
data.copy(pending.buffer, bufferOffset)
|
||||
pending.contiguousReceived += data.length
|
||||
return {
|
||||
ok: true,
|
||||
complete: pending.contiguousReceived === pending.totalLength,
|
||||
burstComplete: pending.contiguousReceived === pending.solicited,
|
||||
}
|
||||
}
|
||||
168
@vates/iscsi/src/writePath.test.mts
Normal file
168
@vates/iscsi/src/writePath.test.mts
Normal file
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { FLAG_FINAL, TargetOpcode } from './constants.mjs'
|
||||
import { buildR2t, receiveDataOut, type PendingWrite } from './writePath.mjs'
|
||||
|
||||
const TTT = 7
|
||||
const BLOCK = 512
|
||||
|
||||
/**
|
||||
* A pending WRITE with its first burst already solicited, as `beginWrite`
|
||||
* leaves it. `solicited` defaults to the whole command (one burst), which is
|
||||
* what a write below MaxBurstLength gets.
|
||||
*/
|
||||
const pending = ({
|
||||
totalLength = BLOCK,
|
||||
solicited = totalLength,
|
||||
}: { totalLength?: number; solicited?: number } = {}): PendingWrite => ({
|
||||
itt: 1,
|
||||
targetTransferTag: TTT,
|
||||
lunOffset: 0,
|
||||
buffer: Buffer.alloc(totalLength),
|
||||
totalLength,
|
||||
contiguousReceived: 0,
|
||||
solicited,
|
||||
r2tSN: 1,
|
||||
})
|
||||
|
||||
const dataOut = (bufferOffset: number, length: number, fill = 0xab) => ({
|
||||
targetTransferTag: TTT,
|
||||
bufferOffset,
|
||||
data: Buffer.alloc(length, fill),
|
||||
})
|
||||
|
||||
describe('buildR2t', () => {
|
||||
it('solicits the requested range, always with the final bit set', () => {
|
||||
const r2t = buildR2t(
|
||||
{ itt: 42, r2tSN: 3, targetTransferTag: TTT, bufferOffset: 1024, desiredLength: 512 },
|
||||
{ statSN: 9, expCmdSN: 10, maxCmdSN: 74 }
|
||||
)
|
||||
|
||||
assert.equal(r2t[0], TargetOpcode.R2T)
|
||||
assert.equal(r2t[1], FLAG_FINAL)
|
||||
assert.equal(r2t.readUInt32BE(16), 42)
|
||||
assert.equal(r2t.readUInt32BE(20), TTT)
|
||||
assert.equal(r2t.readUInt32BE(36), 3)
|
||||
assert.equal(r2t.readUInt32BE(40), 1024)
|
||||
assert.equal(r2t.readUInt32BE(44), 512)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receiveDataOut', () => {
|
||||
it('places a burst delivered in one PDU and reports it complete', () => {
|
||||
const write = pending()
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(0, BLOCK))
|
||||
|
||||
assert.deepEqual(outcome, { ok: true, complete: true, burstComplete: true })
|
||||
assert.equal(write.contiguousReceived, BLOCK)
|
||||
assert.deepEqual(write.buffer, Buffer.alloc(BLOCK, 0xab))
|
||||
})
|
||||
|
||||
it('places consecutive PDUs of one burst, completing only on the last', () => {
|
||||
const write = pending({ totalLength: 3 * BLOCK })
|
||||
|
||||
const first = receiveDataOut(write, dataOut(0, BLOCK, 0x11))
|
||||
const second = receiveDataOut(write, dataOut(BLOCK, BLOCK, 0x22))
|
||||
const third = receiveDataOut(write, dataOut(2 * BLOCK, BLOCK, 0x33))
|
||||
|
||||
assert.deepEqual(first, { ok: true, complete: false, burstComplete: false })
|
||||
assert.deepEqual(second, { ok: true, complete: false, burstComplete: false })
|
||||
assert.deepEqual(third, { ok: true, complete: true, burstComplete: true })
|
||||
assert.deepEqual(
|
||||
write.buffer,
|
||||
Buffer.concat([Buffer.alloc(BLOCK, 0x11), Buffer.alloc(BLOCK, 0x22), Buffer.alloc(BLOCK, 0x33)])
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the burst complete, but not the command, when more remains to solicit', () => {
|
||||
const write = pending({ totalLength: 2 * BLOCK, solicited: BLOCK })
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(0, BLOCK))
|
||||
|
||||
assert.deepEqual(outcome, { ok: true, complete: false, burstComplete: true })
|
||||
})
|
||||
|
||||
// The three ways a plain count of received bytes reached `totalLength` with
|
||||
// part of the staging buffer never written — each one an acknowledged write
|
||||
// that silently lost data, since `Buffer.copy` clamps instead of throwing.
|
||||
describe('refuses a PDU that would leave a hole in the buffer', () => {
|
||||
it('past the end of the buffer, where the copy would land nothing', () => {
|
||||
const write = pending()
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(BLOCK, BLOCK))
|
||||
|
||||
assert.equal(outcome.ok, false)
|
||||
assert.equal(write.contiguousReceived, 0)
|
||||
assert.deepEqual(write.buffer, Buffer.alloc(BLOCK))
|
||||
})
|
||||
|
||||
it('straddling the end, where the copy would be silently short', () => {
|
||||
const write = pending()
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(BLOCK / 2, BLOCK))
|
||||
|
||||
assert.equal(outcome.ok, false)
|
||||
assert.equal(write.contiguousReceived, 0)
|
||||
})
|
||||
|
||||
it('repeating a range already received, which would never fill the rest', () => {
|
||||
const write = pending()
|
||||
assert.equal(receiveDataOut(write, dataOut(0, BLOCK / 2, 0x11)).ok, true)
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(0, BLOCK / 2, 0x22))
|
||||
|
||||
assert.equal(outcome.ok, false)
|
||||
// the first PDU's bytes are still there, and the rest is still unfilled
|
||||
assert.equal(write.contiguousReceived, BLOCK / 2)
|
||||
assert.deepEqual(write.buffer, Buffer.concat([Buffer.alloc(BLOCK / 2, 0x11), Buffer.alloc(BLOCK / 2)]))
|
||||
})
|
||||
|
||||
it('skipping ahead, leaving a gap before it', () => {
|
||||
const write = pending({ totalLength: 2 * BLOCK })
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(BLOCK, BLOCK))
|
||||
|
||||
assert.equal(outcome.ok, false)
|
||||
assert.equal(write.contiguousReceived, 0)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses bytes beyond the current burst, even when the buffer could hold them', () => {
|
||||
// 2 blocks solicited of a 4-block write: the last 2 are only legitimate
|
||||
// after the next R2T, so accepting them now would break burst accounting
|
||||
const write = pending({ totalLength: 4 * BLOCK, solicited: 2 * BLOCK })
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(0, 3 * BLOCK))
|
||||
|
||||
assert.equal(outcome.ok, false)
|
||||
assert.equal(write.contiguousReceived, 0)
|
||||
})
|
||||
|
||||
it("refuses a PDU carrying another command's Target Transfer Tag", () => {
|
||||
const write = pending()
|
||||
|
||||
const outcome = receiveDataOut(write, { ...dataOut(0, BLOCK), targetTransferTag: TTT + 1 })
|
||||
|
||||
assert.equal(outcome.ok, false)
|
||||
assert.equal(write.contiguousReceived, 0)
|
||||
})
|
||||
|
||||
it('explains why it refused, for the log line next to the Reject', () => {
|
||||
const outcome = receiveDataOut(pending(), dataOut(BLOCK, BLOCK))
|
||||
|
||||
assert.equal(outcome.ok, false)
|
||||
assert.match(outcome.ok === false ? outcome.reason : '', /BufferOffset/)
|
||||
})
|
||||
|
||||
it('accepts a zero-length PDU without disturbing the coverage it has', () => {
|
||||
const write = pending()
|
||||
receiveDataOut(write, dataOut(0, BLOCK / 2, 0x11))
|
||||
|
||||
const outcome = receiveDataOut(write, dataOut(BLOCK / 2, 0))
|
||||
|
||||
assert.deepEqual(outcome, { ok: true, complete: false, burstComplete: false })
|
||||
assert.equal(write.contiguousReceived, BLOCK / 2)
|
||||
})
|
||||
})
|
||||
14
@vates/iscsi/tsconfig.json
Normal file
14
@vates/iscsi/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "ESNext",
|
||||
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true, // Skip type-checking dependency .d.ts (e.g. @xen-orchestra/disk-transform), as it does itself
|
||||
"declaration": true, // Generates `.d.ts` files for type safety
|
||||
"outDir": "dist" // Outputs compiled code to the `dist` directory
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
### `new NdbClient({address, exportname, secure = true, port = 10809})`
|
||||
This package provides a NBD client, in two flavors sharing the same protocol
|
||||
implementation (`AbstractNbdClient`): over the network, or over the standard
|
||||
streams of a NBD server run as a child process.
|
||||
|
||||
create a new nbd client
|
||||
### `new NbdClient({address, exportname, cert, port = 10809})`
|
||||
|
||||
Create a new nbd client connecting to a server over TCP.
|
||||
|
||||
`NbdClient` is also exported as `NbdTcpClient`.
|
||||
|
||||
```js
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
@@ -14,3 +20,55 @@ await client.connect()
|
||||
const block = await client.readBlock(blockIndex, BlockSize)
|
||||
await client.disconnect()
|
||||
```
|
||||
|
||||
### `new NbdStdioClient({command, args, exportname})`
|
||||
|
||||
Create a new nbd client talking to a server through its standard streams, like
|
||||
`nbdkit --single` or any server reading its queries on stdin and writing its
|
||||
answers on stdout.
|
||||
|
||||
The process is spawned by `connect()` and stopped by `disconnect()`.
|
||||
|
||||
```js
|
||||
import { NbdStdioClient } from '@vates/nbd-client'
|
||||
const client = new NbdStdioClient({
|
||||
command: 'nbdkit',
|
||||
args: ['--single', '--exit-with-parent', '--read-only', 'file', '/path/to/disk.raw'],
|
||||
exportname: '', // optional, the default export of the server
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
const block = await client.readBlock(blockIndex, BlockSize)
|
||||
await client.disconnect()
|
||||
```
|
||||
|
||||
There is no TLS here: the transport is a pair of pipes, there is nothing to
|
||||
encrypt.
|
||||
|
||||
`readBlock()` retries on error, and a retry reconnects: with this client that
|
||||
means killing the server and spawning a new one, so the command must be
|
||||
restartable and must serve the same content on each run.
|
||||
|
||||
`getMap()` is not implemented, since `nbdinfo` needs either an URI or a server
|
||||
supporting systemd socket activation: compute the map on the caller side and
|
||||
pass it explicitly, for example `new NbdDisk(infos, blockSize, { dataMap })`.
|
||||
|
||||
The tail of the server stderr is available as `client.stderr` and attached to
|
||||
the errors raised when the server dies unexpectedly.
|
||||
|
||||
### Writing another client
|
||||
|
||||
Subclass `AbstractNbdClient` and implement the transport:
|
||||
|
||||
- `_openTransport()`: **required**, returns `{ readable, writable }`, called on
|
||||
every (re)connection.
|
||||
- `_secureTransport(transport)`: optional, called during the handshake, the only
|
||||
place where the transport can be upgraded (`NBD_OPT_STARTTLS`).
|
||||
- `_closeTransport(transport, lastMessage)`: optional, must hand over
|
||||
`lastMessage` (`NBD_CMD_DISC`) before closing.
|
||||
- `_destroyTransport(transport)`: optional, forceful cleanup, must not throw.
|
||||
- `getMap(signal)`: optional, the base implementation rejects with the code
|
||||
`NBD_MAP_UNSUPPORTED`.
|
||||
|
||||
Any extra property set on the transport object is passed back to
|
||||
`_closeTransport()` and `_destroyTransport()`.
|
||||
|
||||
586
@vates/nbd-client/AbstractNbdClient.mjs
Normal file
586
@vates/nbd-client/AbstractNbdClient.mjs
Normal file
@@ -0,0 +1,586 @@
|
||||
import assert from 'node:assert'
|
||||
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
|
||||
import { readChunkStrict } from '@vates/read-chunk'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import {
|
||||
INIT_PASSWD,
|
||||
NBD_CMD_DISC,
|
||||
NBD_CMD_READ,
|
||||
NBD_DEFAULT_BLOCK_SIZE,
|
||||
NBD_FLAG_FIXED_NEWSTYLE,
|
||||
NBD_FLAG_HAS_FLAGS,
|
||||
NBD_OPT_EXPORT_NAME,
|
||||
NBD_OPT_REPLY_MAGIC,
|
||||
NBD_REPLY_ACK,
|
||||
NBD_REPLY_MAGIC,
|
||||
NBD_REQUEST_MAGIC,
|
||||
OPTS_MAGIC,
|
||||
} from './constants.mjs'
|
||||
|
||||
const { debug, warn } = createLogger('vates:nbd-client')
|
||||
|
||||
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
|
||||
|
||||
/**
|
||||
* A pair of streams carrying the NBD protocol.
|
||||
*
|
||||
* For a TCP client both are the same socket, for a client talking to a server
|
||||
* through its standard streams they are the two ends of two different pipes.
|
||||
*
|
||||
* @typedef {object} NbdTransport
|
||||
* @property {import('node:stream').Readable} readable - where the server answers are read from
|
||||
* @property {import('node:stream').Writable} writable - where the client queries are written to
|
||||
*/
|
||||
|
||||
/**
|
||||
* Transport agnostic NBD client: it implements the handshake and the
|
||||
* transmission phases, and delegates the opening/closing of the underlying
|
||||
* streams to its subclasses.
|
||||
*
|
||||
* Subclasses MUST implement `_openTransport()` and MAY override
|
||||
* `_secureTransport()`, `_closeTransport()`, `_destroyTransport()` and `getMap()`.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class AbstractNbdClient {
|
||||
#exportName
|
||||
#exportSize
|
||||
|
||||
/** @type {NbdTransport|undefined} */
|
||||
#transport
|
||||
|
||||
// incremented on each connection attempt: an attempt which is not the last
|
||||
// one anymore has been superseded (its caller timed out on it) and must not
|
||||
// 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
|
||||
#connectTimeout
|
||||
#messageTimeout
|
||||
|
||||
// AFAIK, there is no guaranty the server answers in the same order as the queries
|
||||
// so we handle a backlog of command waiting for response and handle concurrency manually
|
||||
|
||||
#waitingForResponse // there is already a listener waiting for a response
|
||||
#nextCommandQueryId = BigInt(0)
|
||||
// map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
|
||||
#commandQueryBacklog = new Map()
|
||||
|
||||
#reconnectingPromise
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {string} [settings.exportname] - the NBD export to open, empty for the default one
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.connectTimeout]
|
||||
* @param {number} [options.messageTimeout]
|
||||
* @param {number} [options.waitBeforeReconnect]
|
||||
* @param {number} [options.readBlockRetries]
|
||||
* @param {number} [options.reconnectRetry]
|
||||
*/
|
||||
constructor(
|
||||
{ exportname = '' },
|
||||
{
|
||||
connectTimeout = 6e4,
|
||||
messageTimeout = 6e4,
|
||||
waitBeforeReconnect = 1e3,
|
||||
readBlockRetries = 5,
|
||||
reconnectRetry = 5,
|
||||
} = {}
|
||||
) {
|
||||
this.#exportName = exportname
|
||||
this.#waitBeforeReconnect = waitBeforeReconnect
|
||||
this.#readBlockRetries = readBlockRetries
|
||||
this.#reconnectRetry = reconnectRetry
|
||||
this.#connectTimeout = connectTimeout
|
||||
this.#messageTimeout = messageTimeout
|
||||
}
|
||||
|
||||
get exportSize() {
|
||||
return this.#exportSize
|
||||
}
|
||||
|
||||
get exportName() {
|
||||
return this.#exportName
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.#connectedGeneration !== undefined
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* transport, to be implemented by the subclasses
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Open the streams used to talk to the NBD server.
|
||||
*
|
||||
* Called on every (re)connection: it must be able to open a brand new
|
||||
* transport each time it is called.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {Promise<NbdTransport>}
|
||||
*/
|
||||
async _openTransport() {
|
||||
throw new Error(`${this.constructor.name} must implement _openTransport()`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook called during the handshake, after the client flags have been sent and
|
||||
* before the export is selected: this is the only moment where the transport
|
||||
* can be upgraded (NBD_OPT_STARTTLS).
|
||||
*
|
||||
* Returning another transport than the one received replaces it, returning
|
||||
* the same one is a no-op.
|
||||
*
|
||||
* @param {NbdTransport} transport
|
||||
* @returns {Promise<NbdTransport>}
|
||||
*/
|
||||
async _secureTransport(transport) {
|
||||
return transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close the transport.
|
||||
*
|
||||
* `lastMessage` is a ready to send NBD_CMD_DISC: implementations must hand it
|
||||
* over to the server (it is the only clean way to tell it we're done) before
|
||||
* closing their side.
|
||||
*
|
||||
* Failing or timing out here is not fatal: `_destroyTransport()` will be
|
||||
* called as a fallback.
|
||||
*
|
||||
* @param {NbdTransport} transport
|
||||
* @param {Buffer} lastMessage
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _closeTransport(transport, lastMessage) {
|
||||
await pFromCallback(cb => transport.writable.end(lastMessage, cb))
|
||||
}
|
||||
|
||||
/**
|
||||
* Forcefully release the transport and everything backing it.
|
||||
*
|
||||
* Must be idempotent, and must not throw.
|
||||
*
|
||||
* @param {NbdTransport} transport
|
||||
* @returns {void}
|
||||
*/
|
||||
_destroyTransport(transport) {
|
||||
transport.readable.destroy()
|
||||
if (transport.writable !== transport.readable) {
|
||||
transport.writable.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the map of the file with holes, zeros and data, useful to handle
|
||||
* efficiently sparse sources.
|
||||
*
|
||||
* The base implementation is unable to compute it: subclasses able to do so
|
||||
* must override this method.
|
||||
*
|
||||
* To implement this here: use structured replies if the server supports them,
|
||||
* and then ask for BLOCK_STATUS.
|
||||
*
|
||||
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
|
||||
* A promise that resolves to an array where each object represents a segment:
|
||||
* - `offset` — The byte offset from the start.
|
||||
* - `length` — The size of the segment in bytes.
|
||||
* - `type` — A numeric code indicating the segment type ( 0 means data).
|
||||
*/
|
||||
async getMap() {
|
||||
const error = new Error(`${this.constructor.name} does not support getMap()`)
|
||||
error.code = 'NBD_MAP_UNSUPPORTED'
|
||||
throw error
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* connection
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#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 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)
|
||||
}
|
||||
|
||||
// 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', onError)
|
||||
}
|
||||
}
|
||||
|
||||
async #connect() {
|
||||
const generation = ++this.#connectGeneration
|
||||
// the transport is kept local until the client is connected: connect() does
|
||||
// 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, generation)
|
||||
try {
|
||||
// the transport can be replaced during the handshake (TLS upgrade)
|
||||
transport = await this.#handshake(transport, generation)
|
||||
|
||||
if (generation !== this.#connectGeneration) {
|
||||
const error = new Error('this connection has been superseded by a newer one')
|
||||
error.code = 'NBD_CONNECT_SUPERSEDED'
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
// don't leak the transport (a socket, a child process, ...): disconnect()
|
||||
// is a no-op as long as we're not connected, so nobody else will close it
|
||||
this.#destroyTransport(transport)
|
||||
throw error
|
||||
}
|
||||
this.#transport = transport
|
||||
this.#connectedGeneration = generation
|
||||
// reset internal state if we reconnected a nbd client
|
||||
this.#commandQueryBacklog = new Map()
|
||||
this.#waitingForResponse = false
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const promise = this.#connect()
|
||||
try {
|
||||
return await pTimeout.call(promise, this.#connectTimeout)
|
||||
} catch (error) {
|
||||
// pTimeout does not cancel: #connect() may still be running and end up
|
||||
// with an opened transport nobody owns anymore
|
||||
promise.then(
|
||||
() =>
|
||||
this.disconnect().catch(cleanupError => warn('error while cleaning up a late connection', { cleanupError })),
|
||||
() => {} // the failure is already reported through `error`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.#connectedGeneration === undefined) {
|
||||
return
|
||||
}
|
||||
this.#connectedGeneration = undefined
|
||||
const transport = this.#transport
|
||||
this.#transport = undefined
|
||||
if (transport === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const queryId = this.#nextCommandQueryId
|
||||
this.#nextCommandQueryId++
|
||||
|
||||
const buffer = Buffer.alloc(28)
|
||||
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
|
||||
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
|
||||
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
|
||||
buffer.writeBigUInt64BE(queryId, 8)
|
||||
buffer.writeBigUInt64BE(0n, 16)
|
||||
buffer.writeInt32BE(0, 24)
|
||||
|
||||
try {
|
||||
await pTimeout.call(this._closeTransport(transport, buffer), this.#messageTimeout)
|
||||
} catch (error) {
|
||||
// expected when the other end is already gone, nothing actionable: the
|
||||
// transport is destroyed instead of being closed gracefully
|
||||
debug('error while closing the nbd transport, destroying it', { error })
|
||||
this.#destroyTransport(transport)
|
||||
}
|
||||
}
|
||||
|
||||
#destroyTransport(transport) {
|
||||
try {
|
||||
this._destroyTransport(transport)
|
||||
} catch (error) {
|
||||
warn('error while destroying the nbd transport', { error })
|
||||
}
|
||||
}
|
||||
|
||||
#clearReconnectPromise = () => {
|
||||
this.#reconnectingPromise = undefined
|
||||
}
|
||||
|
||||
async #reconnect() {
|
||||
await this.disconnect().catch(() => {})
|
||||
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
// we need to ensure reconnections do not occur in parallel
|
||||
if (this.#reconnectingPromise === undefined) {
|
||||
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
|
||||
tries: this.#reconnectRetry,
|
||||
})
|
||||
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
|
||||
}
|
||||
|
||||
return this.#reconnectingPromise
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* handshake
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Send an option to the server and check it acknowledged it.
|
||||
*
|
||||
* We can use individual read/write from the transport here since there is no
|
||||
* concurrency during the handshake.
|
||||
*
|
||||
* The transport is passed explicitly since the client is not connected yet:
|
||||
* it is only published once the handshake succeeded.
|
||||
*
|
||||
* protected: subclasses need it to negotiate their own options (NBD_OPT_STARTTLS)
|
||||
*
|
||||
* @param {NbdTransport} transport
|
||||
* @param {number} option
|
||||
* @param {Buffer} [buffer] - the payload of the option
|
||||
*/
|
||||
async _sendOption(transport, option, buffer = Buffer.alloc(0)) {
|
||||
await this.#write(transport, OPTS_MAGIC)
|
||||
await this.#writeInt32(transport, option)
|
||||
await this.#writeInt32(transport, buffer.length)
|
||||
await this.#write(transport, buffer)
|
||||
assert.strictEqual(await this.#readInt64(transport), NBD_OPT_REPLY_MAGIC) // magic number everywhere
|
||||
assert.strictEqual(await this.#readInt32(transport), option) // the option passed
|
||||
assert.strictEqual(await this.#readInt32(transport), NBD_REPLY_ACK) // ACK
|
||||
const length = await this.#readInt32(transport)
|
||||
assert.strictEqual(length, 0) // length
|
||||
}
|
||||
|
||||
// we can use individual read/write from the transport here since there is only one handshake at once, no concurrency
|
||||
//
|
||||
// it works on the transport it is given and returns the one to use for the
|
||||
// transmission phase, which is not necessarily the same (TLS upgrade)
|
||||
//
|
||||
/**
|
||||
* @param {NbdTransport} transport
|
||||
* @param {number} generation - the connection attempt owning the transport
|
||||
* @returns {Promise<NbdTransport>}
|
||||
*/
|
||||
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)
|
||||
const flags = flagsBuffer.readInt16BE(0)
|
||||
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
|
||||
await this.#writeInt32(transport, NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
|
||||
|
||||
// let the subclass upgrade the transport if it needs to (TLS)
|
||||
const secured = await this._secureTransport(transport)
|
||||
if (secured !== transport) {
|
||||
this.#watchTransport(secured, generation)
|
||||
transport = secured
|
||||
}
|
||||
|
||||
// send export name we want to access.
|
||||
// it's implicitly closing the negotiation phase.
|
||||
await this.#write(transport, OPTS_MAGIC)
|
||||
await this.#writeInt32(transport, NBD_OPT_EXPORT_NAME)
|
||||
const exportNameBuffer = Buffer.from(this.#exportName)
|
||||
await this.#writeInt32(transport, exportNameBuffer.length)
|
||||
await this.#write(transport, exportNameBuffer)
|
||||
|
||||
// 8 (export size ) + 2 (flags) + 124 zero = 134
|
||||
// must read all to ensure nothing stays in the buffer
|
||||
const answer = await this.#read(transport, 134)
|
||||
this.#exportSize = answer.readBigUInt64BE(0)
|
||||
const transmissionFlags = answer.readInt16BE(8)
|
||||
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
|
||||
|
||||
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* raw read/write
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#getTransport() {
|
||||
const transport = this.#transport
|
||||
if (transport === undefined) {
|
||||
const error = new Error('the nbd client is not connected')
|
||||
error.code = 'NBD_NOT_CONNECTED'
|
||||
throw error
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
#read(transport, length) {
|
||||
const promise = readChunkStrict(transport.readable, length)
|
||||
return pTimeout.call(promise, this.#messageTimeout)
|
||||
}
|
||||
|
||||
#write(transport, buffer) {
|
||||
let timeout
|
||||
const messageTimeout = this.#messageTimeout
|
||||
const { writable } = transport
|
||||
return Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('timeout'))
|
||||
}, messageTimeout)
|
||||
}),
|
||||
new Promise(resolve =>
|
||||
writable.write(buffer, () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
async #readInt32(transport) {
|
||||
const buffer = await this.#read(transport, 4)
|
||||
return buffer.readInt32BE(0)
|
||||
}
|
||||
|
||||
async #readInt64(transport) {
|
||||
const buffer = await this.#read(transport, 8)
|
||||
return buffer.readBigUInt64BE(0)
|
||||
}
|
||||
|
||||
#writeInt32(transport, int) {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeInt32BE(int)
|
||||
return this.#write(transport, buffer)
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* transmission
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// when one read fail ,stop everything
|
||||
// (sync: it must be usable from an event handler without leaving a floating promise)
|
||||
#rejectAll(error) {
|
||||
this.#commandQueryBacklog.forEach(({ reject }) => {
|
||||
reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
async #readBlockResponse() {
|
||||
// ensure at most one read occur in parallel
|
||||
if (this.#waitingForResponse) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const transport = this.#getTransport()
|
||||
const buffer = await this.#read(transport, 16)
|
||||
const magic = buffer.readInt32BE(0)
|
||||
|
||||
if (magic !== NBD_REPLY_MAGIC) {
|
||||
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
|
||||
}
|
||||
|
||||
const error = buffer.readInt32BE(4)
|
||||
if (error !== 0) {
|
||||
// @todo use error code from constants.mjs
|
||||
throw new Error(`GOT ERROR CODE : ${error}`)
|
||||
}
|
||||
|
||||
const blockQueryId = buffer.readBigUInt64BE(8)
|
||||
const query = this.#commandQueryBacklog.get(blockQueryId)
|
||||
if (!query) {
|
||||
throw new Error(` no query associated with id ${blockQueryId}`)
|
||||
}
|
||||
this.#commandQueryBacklog.delete(blockQueryId)
|
||||
const data = await this.#read(transport, query.size)
|
||||
query.resolve(data)
|
||||
this.#waitingForResponse = false
|
||||
if (this.#commandQueryBacklog.size > 0) {
|
||||
// it doesn't throw directly but will throw all relevant promise on failure
|
||||
this.#readBlockResponse()
|
||||
}
|
||||
} catch (error) {
|
||||
// reject all the promises
|
||||
// we don't need to call readBlockResponse on failure
|
||||
// since we will empty the backlog
|
||||
this.#rejectAll(error)
|
||||
}
|
||||
}
|
||||
|
||||
async #readBlock(index, size) {
|
||||
// we don't want to add anything in backlog while reconnecting
|
||||
if (this.#reconnectingPromise) {
|
||||
await this.#reconnectingPromise
|
||||
}
|
||||
|
||||
const queryId = this.#nextCommandQueryId
|
||||
this.#nextCommandQueryId++
|
||||
|
||||
// create and send command at once to ensure there is no concurrency issue
|
||||
const buffer = Buffer.alloc(28)
|
||||
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
|
||||
buffer.writeInt16BE(0, 4) // no command flags for a simple block read
|
||||
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
|
||||
buffer.writeBigUInt64BE(queryId, 8)
|
||||
// byte offset in the raw disk
|
||||
const offset = BigInt(index) * BigInt(size)
|
||||
const remaining = this.#exportSize - offset
|
||||
if (remaining < BigInt(size)) {
|
||||
size = Number(remaining)
|
||||
}
|
||||
|
||||
buffer.writeBigUInt64BE(offset, 16)
|
||||
buffer.writeInt32BE(size, 24)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
function decoratedReject(error) {
|
||||
error.index = index
|
||||
error.size = size
|
||||
reject(error)
|
||||
}
|
||||
|
||||
// this will handle one block response, but it can be another block
|
||||
// since server does not guaranty to handle query in order
|
||||
this.#commandQueryBacklog.set(queryId, {
|
||||
size,
|
||||
resolve,
|
||||
reject: decoratedReject,
|
||||
})
|
||||
// really send the command to the server
|
||||
this.#write(this.#getTransport(), buffer).catch(decoratedReject)
|
||||
|
||||
// #readBlockResponse never throws directly
|
||||
// but if it fails it will reject all the promises in the backlog
|
||||
this.#readBlockResponse()
|
||||
})
|
||||
}
|
||||
|
||||
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
|
||||
return pRetry(() => this.#readBlock(index, size), {
|
||||
tries: this.#readBlockRetries,
|
||||
when: error => error.code !== 'ERR_ABORTED',
|
||||
onRetry: async err => {
|
||||
warn('will retry reading block ', index, err)
|
||||
await this.reconnect()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
199
@vates/nbd-client/AbstractNbdClient.test.mjs
Normal file
199
@vates/nbd-client/AbstractNbdClient.test.mjs
Normal file
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { setTimeout as pSleep } from 'node:timers/promises'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import AbstractNbdClient from './AbstractNbdClient.mjs'
|
||||
import { serveNbd } from './tests/fake-nbd-server.mjs'
|
||||
|
||||
const BLOCK_SIZE = 512
|
||||
// non aligned size, to check the truncated last block
|
||||
const DATA = Buffer.alloc(BLOCK_SIZE * 3 + 100)
|
||||
for (let i = 0; i < DATA.length; i += 4) {
|
||||
DATA.writeUInt32BE(i, i)
|
||||
}
|
||||
|
||||
// exercises AbstractNbdClient without any I/O: the fake server runs in the same
|
||||
// process, on the other end of two in-memory streams
|
||||
class InMemoryNbdClient extends AbstractNbdClient {
|
||||
#serverOptions
|
||||
|
||||
/** @type {Array<object>} */
|
||||
transports = []
|
||||
/** @type {Array<object>} */
|
||||
destroyedTransports = []
|
||||
secureTransportCalls = 0
|
||||
|
||||
constructor({ exportname, ...serverOptions } = {}, options) {
|
||||
super({ exportname }, options)
|
||||
this.#serverOptions = serverOptions
|
||||
}
|
||||
|
||||
async _openTransport() {
|
||||
const toServer = new PassThrough()
|
||||
const toClient = new PassThrough()
|
||||
const transport = { readable: toClient, writable: toServer }
|
||||
this.transports.push(transport)
|
||||
|
||||
// the server runs alongside the client, a failure on its side is reported
|
||||
// to the client the same way a network error would be
|
||||
serveNbd({ readable: toServer, writable: toClient, data: DATA, ...this.#serverOptions }).catch(error =>
|
||||
toClient.destroy(error)
|
||||
)
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
async _secureTransport(transport) {
|
||||
this.secureTransportCalls++
|
||||
return super._secureTransport(transport)
|
||||
}
|
||||
|
||||
_destroyTransport(transport) {
|
||||
this.destroyedTransports.push(transport)
|
||||
super._destroyTransport(transport)
|
||||
}
|
||||
}
|
||||
|
||||
// the first connection is opened on a transport nobody ever answers on: its
|
||||
// handshake stalls until the message timeout, well after connect() gave up
|
||||
class StallingFirstConnectClient extends InMemoryNbdClient {
|
||||
#openCalls = 0
|
||||
|
||||
async _openTransport() {
|
||||
if (this.#openCalls++ === 0) {
|
||||
const transport = { readable: new PassThrough(), writable: new PassThrough() }
|
||||
this.transports.push(transport)
|
||||
return transport
|
||||
}
|
||||
return super._openTransport()
|
||||
}
|
||||
}
|
||||
|
||||
describe('AbstractNbdClient', () => {
|
||||
it('handshakes and reads blocks', async () => {
|
||||
const client = new InMemoryNbdClient()
|
||||
await client.connect()
|
||||
try {
|
||||
assert.equal(client.exportSize, BigInt(DATA.length))
|
||||
assert.equal(client.connected, true)
|
||||
|
||||
const block = await client.readBlock(1, BLOCK_SIZE)
|
||||
assert.ok(block.equals(DATA.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE)))
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
assert.equal(client.connected, false)
|
||||
})
|
||||
|
||||
it('truncates the last block of a non aligned export', async () => {
|
||||
const client = new InMemoryNbdClient()
|
||||
await client.connect()
|
||||
try {
|
||||
const block = await client.readBlock(3, BLOCK_SIZE)
|
||||
assert.equal(block.length, DATA.length - 3 * BLOCK_SIZE)
|
||||
assert.ok(block.equals(DATA.subarray(3 * BLOCK_SIZE)))
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles answers coming out of order', async () => {
|
||||
const client = new InMemoryNbdClient({ answerInReverse: true })
|
||||
await client.connect()
|
||||
try {
|
||||
const [first, second] = await Promise.all([client.readBlock(0, BLOCK_SIZE), client.readBlock(1, BLOCK_SIZE)])
|
||||
assert.ok(first.equals(DATA.subarray(0, BLOCK_SIZE)))
|
||||
assert.ok(second.equals(DATA.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE)))
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
it('calls _secureTransport during the handshake', async () => {
|
||||
const client = new InMemoryNbdClient()
|
||||
await client.connect()
|
||||
assert.equal(client.secureTransportCalls, 1)
|
||||
await client.disconnect()
|
||||
})
|
||||
|
||||
it('does not leak the transport when the handshake fails', async () => {
|
||||
// the server expects another export name, and fails the handshake
|
||||
const client = new InMemoryNbdClient({ exportname: 'wrong', exportName: 'expected' })
|
||||
await assert.rejects(client.connect())
|
||||
|
||||
assert.equal(client.connected, false)
|
||||
assert.equal(client.transports.length, 1)
|
||||
assert.deepEqual(client.destroyedTransports, client.transports)
|
||||
assert.equal(client.transports[0].writable.destroyed, true)
|
||||
})
|
||||
|
||||
it('reports the errors answered by the server, after having retried', async () => {
|
||||
const client = new InMemoryNbdClient(
|
||||
{ errorCode: () => 5 /* EIO */ },
|
||||
{ readBlockRetries: 2, reconnectRetry: 1, waitBeforeReconnect: 0 }
|
||||
)
|
||||
await client.connect()
|
||||
try {
|
||||
await assert.rejects(client.readBlock(0, BLOCK_SIZE), /ERROR CODE/)
|
||||
// one transport for the initial connection, one for the retry
|
||||
assert.equal(client.transports.length, 2)
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let a connection which timed out disturb the next one', async () => {
|
||||
const client = new StallingFirstConnectClient({}, { connectTimeout: 50, messageTimeout: 150 })
|
||||
await assert.rejects(client.connect())
|
||||
|
||||
// the previous attempt is still running: it must not publish itself, nor
|
||||
// touch the transport of this one
|
||||
await client.connect()
|
||||
try {
|
||||
// let the stalled handshake reach its message timeout
|
||||
await pSleep(250)
|
||||
|
||||
assert.equal(client.connected, true)
|
||||
assert.equal(client.transports.length, 2)
|
||||
// only the stalled transport has been destroyed
|
||||
assert.deepEqual(client.destroyedTransports, [client.transports[0]])
|
||||
|
||||
const block = await client.readBlock(0, BLOCK_SIZE)
|
||||
assert.ok(block.equals(DATA.subarray(0, BLOCK_SIZE)))
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('can be disconnected without being connected', async () => {
|
||||
await new InMemoryNbdClient().disconnect()
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,18 @@ import NbdClient from '@vates/nbd-client'
|
||||
* @typedef {{ offset: number, length: number }} DataRange
|
||||
*/
|
||||
|
||||
/**
|
||||
* any NBD client, whatever transport it uses
|
||||
*
|
||||
* @typedef {import('@vates/nbd-client').AbstractNbdClient} AbstractNbdClient
|
||||
*/
|
||||
|
||||
/**
|
||||
* @extends {RandomAccessDisk}
|
||||
*/
|
||||
export class NbdDisk extends RandomAccessDisk {
|
||||
#nbdInfos
|
||||
/** @type {NbdClient|undefined} */
|
||||
/** @type {AbstractNbdClient|undefined} */
|
||||
#nbdClient
|
||||
|
||||
/** @type {Array<DataRange> | undefined} */
|
||||
@@ -25,10 +31,21 @@ export class NbdDisk extends RandomAccessDisk {
|
||||
/** @type {number | undefined} */
|
||||
#hasBlockPreviousIndex
|
||||
|
||||
constructor(nbdInfos, blockSize, { dataMap } = {}) {
|
||||
/** @type {typeof AbstractNbdClient} */
|
||||
#ClientClass
|
||||
|
||||
/**
|
||||
* @param {object} nbdInfos - the settings of the client, depends on `ClientClass`
|
||||
* @param {number} blockSize
|
||||
* @param {object} [options]
|
||||
* @param {Array<{offset: number, length: number, type: number}>} [options.dataMap] - computed through `getMap()` when not provided
|
||||
* @param {typeof AbstractNbdClient} [options.ClientClass] - to talk to a NBD server through another transport
|
||||
*/
|
||||
constructor(nbdInfos, blockSize, { dataMap, ClientClass = NbdClient } = {}) {
|
||||
super()
|
||||
this.#blockSize = blockSize
|
||||
this.#nbdInfos = nbdInfos
|
||||
this.#ClientClass = ClientClass
|
||||
this.#dataMap = dataMap && this.#processDatamap(dataMap)
|
||||
}
|
||||
|
||||
@@ -111,7 +128,7 @@ export class NbdDisk extends RandomAccessDisk {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init() {
|
||||
this.#nbdClient = new NbdClient(this.#nbdInfos)
|
||||
this.#nbdClient = new this.#ClientClass(this.#nbdInfos)
|
||||
await this.#nbdClient.connect()
|
||||
if (this.#dataMap === undefined) {
|
||||
this.#dataMap = this.#processDatamap(await this.#nbdClient.getMap())
|
||||
|
||||
157
@vates/nbd-client/NbdStdioClient.mjs
Normal file
157
@vates/nbd-client/NbdStdioClient.mjs
Normal file
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { pFromCallback } from 'promise-toolbox'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import AbstractNbdClient from './AbstractNbdClient.mjs'
|
||||
|
||||
const { warn } = createLogger('vates:nbd-client:stdio')
|
||||
|
||||
// only the tail is kept: it's the part describing why the server died
|
||||
const STDERR_MAX_LENGTH = 4096
|
||||
|
||||
/**
|
||||
* NBD client talking to a server through its standard streams, like
|
||||
* `nbdkit -s` or `qemu-nbd` in `--fd` mode: the queries are written to the
|
||||
* server stdin and its answers are read from its stdout.
|
||||
*
|
||||
* The process is spawned on `connect()` and killed on `disconnect()`, and
|
||||
* `reconnect()` (used by `readBlock()` retries) spawns a brand new one: the
|
||||
* server must be restartable, and must serve the same content on each run.
|
||||
*
|
||||
* There is no TLS here: the transport is a pair of pipes, nothing to encrypt.
|
||||
*
|
||||
* @extends {AbstractNbdClient}
|
||||
*/
|
||||
export default class NbdStdioClient extends AbstractNbdClient {
|
||||
#args
|
||||
#command
|
||||
#cwd
|
||||
#env
|
||||
|
||||
/** @type {{ child: import('node:child_process').ChildProcess, closing: boolean, stderr: string }|undefined} */
|
||||
#state
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {string} settings.command - the NBD server to run
|
||||
* @param {ReadonlyArray<string>} [settings.args] - its arguments
|
||||
* @param {string} [settings.exportname] - empty (the default export) for most single connection servers
|
||||
* @param {NodeJS.ProcessEnv} [settings.env]
|
||||
* @param {string} [settings.cwd]
|
||||
* @param {object} [options] - see {@link AbstractNbdClient}
|
||||
*/
|
||||
constructor({ command, args = [], exportname, env, cwd }, options) {
|
||||
super({ exportname }, options)
|
||||
assert.strictEqual(typeof command, 'string', 'command must be a string')
|
||||
this.#command = command
|
||||
this.#args = args
|
||||
this.#env = env
|
||||
this.#cwd = cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* the tail of what the server wrote on stderr since it was spawned, useful to
|
||||
* report why it failed
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get stderr() {
|
||||
return this.#state?.stderr ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* the pid of the current server process, undefined if it's not running
|
||||
*
|
||||
* @returns {number|undefined}
|
||||
*/
|
||||
get pid() {
|
||||
return this.#state?.child.pid
|
||||
}
|
||||
|
||||
async _openTransport() {
|
||||
const child = spawn(this.#command, this.#args, {
|
||||
cwd: this.#cwd,
|
||||
env: this.#env,
|
||||
// the protocol goes through stdin/stdout, stderr is only used for diagnostics
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const state = { child, closing: false, stderr: '' }
|
||||
this.#state = state
|
||||
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', chunk => {
|
||||
state.stderr = (state.stderr + chunk).slice(-STDERR_MAX_LENGTH)
|
||||
})
|
||||
|
||||
// spawn failures (ENOENT, EACCES, ...) are reported asynchronously
|
||||
await new Promise((resolve, reject) => {
|
||||
const onError = error => {
|
||||
error.command = this.#command
|
||||
reject(error)
|
||||
}
|
||||
child.once('error', onError)
|
||||
child.once('spawn', () => {
|
||||
child.removeListener('error', onError)
|
||||
// from now on, an error on the process itself (a failed kill for
|
||||
// example) must not be thrown as an uncaught exception
|
||||
child.on('error', error => warn('error on the nbd server process', { error, command: this.#command }))
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
if (state.closing) {
|
||||
// expected: disconnect() asked it to quit and is waiting for this
|
||||
return
|
||||
}
|
||||
const error = new Error(`the nbd server process exited (code: ${code}, signal: ${signal})`)
|
||||
error.code = 'NBD_SERVER_EXITED'
|
||||
error.exitCode = code
|
||||
error.signal = signal
|
||||
error.command = this.#command
|
||||
error.stderr = state.stderr
|
||||
warn('the nbd server process exited unexpectedly', { error })
|
||||
// make the pending and future reads fail with this error instead of
|
||||
// waiting for the message timeout on a stream that will never answer
|
||||
child.stdout.destroy(error)
|
||||
child.stdin.destroy()
|
||||
})
|
||||
|
||||
return { readable: child.stdout, writable: child.stdin, state }
|
||||
}
|
||||
|
||||
async _closeTransport(transport, lastMessage) {
|
||||
const { state } = transport
|
||||
state.closing = true
|
||||
|
||||
// hand over NBD_CMD_DISC, then close stdin: that's how the server knows
|
||||
// it can quit
|
||||
await pFromCallback(cb => transport.writable.end(lastMessage, cb))
|
||||
|
||||
// don't let the server block on a write while we wait for its exit
|
||||
transport.readable.resume()
|
||||
|
||||
const { child } = state
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
await new Promise(resolve => child.once('exit', () => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
_destroyTransport(transport) {
|
||||
const { state } = transport
|
||||
const { child } = state
|
||||
// we're the one tearing it down: the exit that follows is expected, not a
|
||||
// crash, and destroying the streams can be enough to make the server quit
|
||||
state.closing = true
|
||||
transport.readable.destroy()
|
||||
transport.writable.destroy()
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
|
||||
// getMap() is not implemented: `nbdinfo` needs an URI or a server supporting
|
||||
// systemd socket activation, and this one only speaks through its standard
|
||||
// streams. Compute the map on the caller side and pass it explicitly (
|
||||
// `new NbdDisk(infos, blockSize, { dataMap })`).
|
||||
}
|
||||
197
@vates/nbd-client/NbdTcpClient.mjs
Normal file
197
@vates/nbd-client/NbdTcpClient.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
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'
|
||||
import { NBD_DEFAULT_PORT, NBD_OPT_STARTTLS } from './constants.mjs'
|
||||
|
||||
/**
|
||||
* NBD client talking to a server over TCP, optionally upgrading the connection
|
||||
* to TLS during the handshake.
|
||||
*
|
||||
* @extends {AbstractNbdClient}
|
||||
*/
|
||||
export default class NbdTcpClient extends AbstractNbdClient {
|
||||
#httpProxy
|
||||
#proxyRejectUnauthorized
|
||||
#proxyTimeout
|
||||
#serverAddress
|
||||
#serverCert
|
||||
#serverPort
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {string} settings.address
|
||||
* @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,
|
||||
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)
|
||||
socket.once('error', reject)
|
||||
socket.once('connect', () => {
|
||||
socket.removeListener('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
return { readable: socket, writable: socket }
|
||||
}
|
||||
|
||||
async _secureTransport(transport) {
|
||||
if (this.#serverCert === undefined) {
|
||||
return transport
|
||||
}
|
||||
await this._sendOption(transport, NBD_OPT_STARTTLS)
|
||||
const secured = await new Promise((resolve, reject) => {
|
||||
const socket = connect({
|
||||
socket: transport.writable,
|
||||
rejectUnauthorized: false,
|
||||
cert: this.#serverCert,
|
||||
})
|
||||
socket.once('error', reject)
|
||||
socket.once('secureConnect', () => {
|
||||
socket.removeListener('error', reject)
|
||||
resolve(socket)
|
||||
})
|
||||
})
|
||||
return { readable: secured, writable: secured }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbortSignal} [signal]
|
||||
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
|
||||
*/
|
||||
/* async */ getMap(signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn('nbdinfo', [
|
||||
'--json',
|
||||
'--map',
|
||||
`nbd://${this.#serverAddress}:${this.#serverPort}/${encodeURIComponent(this.exportName)}`,
|
||||
])
|
||||
let text = ''
|
||||
let errText = ''
|
||||
process.stdout.on('data', data => (text += data))
|
||||
process.stderr.on('data', data => (errText += data))
|
||||
const onAbort = () => {
|
||||
process.kill()
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
process.on('error', err => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reject(err)
|
||||
})
|
||||
process.on('close', code => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
resolve(json)
|
||||
} catch (error) {
|
||||
reject(new Error(`${error} ${text}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,15 @@ npm install --save @vates/nbd-client
|
||||
|
||||
## Usage
|
||||
|
||||
### `new NdbClient({address, exportname, secure = true, port = 10809})`
|
||||
This package provides a NBD client, in two flavors sharing the same protocol
|
||||
implementation (`AbstractNbdClient`): over the network, or over the standard
|
||||
streams of a NBD server run as a child process.
|
||||
|
||||
create a new nbd client
|
||||
### `new NbdClient({address, exportname, cert, port = 10809})`
|
||||
|
||||
Create a new nbd client connecting to a server over TCP.
|
||||
|
||||
`NbdClient` is also exported as `NbdTcpClient`.
|
||||
|
||||
```js
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
@@ -31,6 +37,58 @@ const block = await client.readBlock(blockIndex, BlockSize)
|
||||
await client.disconnect()
|
||||
```
|
||||
|
||||
### `new NbdStdioClient({command, args, exportname})`
|
||||
|
||||
Create a new nbd client talking to a server through its standard streams, like
|
||||
`nbdkit --single` or any server reading its queries on stdin and writing its
|
||||
answers on stdout.
|
||||
|
||||
The process is spawned by `connect()` and stopped by `disconnect()`.
|
||||
|
||||
```js
|
||||
import { NbdStdioClient } from '@vates/nbd-client'
|
||||
const client = new NbdStdioClient({
|
||||
command: 'nbdkit',
|
||||
args: ['--single', '--exit-with-parent', '--read-only', 'file', '/path/to/disk.raw'],
|
||||
exportname: '', // optional, the default export of the server
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
const block = await client.readBlock(blockIndex, BlockSize)
|
||||
await client.disconnect()
|
||||
```
|
||||
|
||||
There is no TLS here: the transport is a pair of pipes, there is nothing to
|
||||
encrypt.
|
||||
|
||||
`readBlock()` retries on error, and a retry reconnects: with this client that
|
||||
means killing the server and spawning a new one, so the command must be
|
||||
restartable and must serve the same content on each run.
|
||||
|
||||
`getMap()` is not implemented, since `nbdinfo` needs either an URI or a server
|
||||
supporting systemd socket activation: compute the map on the caller side and
|
||||
pass it explicitly, for example `new NbdDisk(infos, blockSize, { dataMap })`.
|
||||
|
||||
The tail of the server stderr is available as `client.stderr` and attached to
|
||||
the errors raised when the server dies unexpectedly.
|
||||
|
||||
### Writing another client
|
||||
|
||||
Subclass `AbstractNbdClient` and implement the transport:
|
||||
|
||||
- `_openTransport()`: **required**, returns `{ readable, writable }`, called on
|
||||
every (re)connection.
|
||||
- `_secureTransport(transport)`: optional, called during the handshake, the only
|
||||
place where the transport can be upgraded (`NBD_OPT_STARTTLS`).
|
||||
- `_closeTransport(transport, lastMessage)`: optional, must hand over
|
||||
`lastMessage` (`NBD_CMD_DISC`) before closing.
|
||||
- `_destroyTransport(transport)`: optional, forceful cleanup, must not throw.
|
||||
- `getMap(signal)`: optional, the base implementation rejects with the code
|
||||
`NBD_MAP_UNSUPPORTED`.
|
||||
|
||||
Any extra property set on the transport object is passed back to
|
||||
`_closeTransport()` and `_destroyTransport()`.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -1,408 +1,6 @@
|
||||
import assert from 'node:assert'
|
||||
import { Socket } from 'node:net'
|
||||
import { connect } from 'node:tls'
|
||||
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
|
||||
import { readChunkStrict } from '@vates/read-chunk'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import {
|
||||
INIT_PASSWD,
|
||||
NBD_CMD_READ,
|
||||
NBD_DEFAULT_BLOCK_SIZE,
|
||||
NBD_DEFAULT_PORT,
|
||||
NBD_FLAG_FIXED_NEWSTYLE,
|
||||
NBD_FLAG_HAS_FLAGS,
|
||||
NBD_OPT_EXPORT_NAME,
|
||||
NBD_OPT_REPLY_MAGIC,
|
||||
NBD_OPT_STARTTLS,
|
||||
NBD_REPLY_ACK,
|
||||
NBD_REPLY_MAGIC,
|
||||
NBD_REQUEST_MAGIC,
|
||||
OPTS_MAGIC,
|
||||
NBD_CMD_DISC,
|
||||
} from './constants.mjs'
|
||||
import { spawn } from 'node:child_process'
|
||||
// the TCP client is the historical default export of this package
|
||||
export { default } from './NbdTcpClient.mjs'
|
||||
|
||||
const { warn } = createLogger('vates:nbd-client')
|
||||
|
||||
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
|
||||
|
||||
export default class NbdClient {
|
||||
#serverAddress
|
||||
#serverCert
|
||||
#serverPort
|
||||
#serverSocket
|
||||
|
||||
#exportName
|
||||
#exportSize
|
||||
|
||||
#waitBeforeReconnect
|
||||
#readBlockRetries
|
||||
#reconnectRetry
|
||||
#connectTimeout
|
||||
#messageTimeout
|
||||
|
||||
// AFAIK, there is no guaranty the server answers in the same order as the queries
|
||||
// so we handle a backlog of command waiting for response and handle concurrency manually
|
||||
|
||||
#waitingForResponse // there is already a listener waiting for a response
|
||||
#nextCommandQueryId = BigInt(0)
|
||||
#commandQueryBacklog // map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
|
||||
#connected = false
|
||||
|
||||
#reconnectingPromise
|
||||
constructor(
|
||||
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
|
||||
{
|
||||
connectTimeout = 6e4,
|
||||
messageTimeout = 6e4,
|
||||
waitBeforeReconnect = 1e3,
|
||||
readBlockRetries = 5,
|
||||
reconnectRetry = 5,
|
||||
} = {}
|
||||
) {
|
||||
this.#serverAddress = address
|
||||
this.#serverPort = port
|
||||
this.#exportName = exportname
|
||||
this.#serverCert = cert
|
||||
this.#waitBeforeReconnect = waitBeforeReconnect
|
||||
this.#readBlockRetries = readBlockRetries
|
||||
this.#reconnectRetry = reconnectRetry
|
||||
this.#connectTimeout = connectTimeout
|
||||
this.#messageTimeout = messageTimeout
|
||||
}
|
||||
|
||||
get exportSize() {
|
||||
return this.#exportSize
|
||||
}
|
||||
|
||||
async #tlsConnect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#serverSocket = connect({
|
||||
socket: this.#serverSocket,
|
||||
rejectUnauthorized: false,
|
||||
cert: this.#serverCert,
|
||||
})
|
||||
this.#serverSocket.once('error', reject)
|
||||
this.#serverSocket.once('secureConnect', () => {
|
||||
this.#serverSocket.removeListener('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// mandatory , at least to start the handshake
|
||||
async #unsecureConnect() {
|
||||
this.#serverSocket = new Socket()
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
|
||||
this.#serverSocket.once('error', reject)
|
||||
this.#serverSocket.once('connect', () => {
|
||||
this.#serverSocket.removeListener('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async #connect() {
|
||||
// first we connect to the server without tls, and then we upgrade the connection
|
||||
// to tls during the handshake
|
||||
await this.#unsecureConnect()
|
||||
await this.#handshake()
|
||||
this.#connected = true
|
||||
// reset internal state if we reconnected a nbd client
|
||||
this.#commandQueryBacklog = new Map()
|
||||
this.#waitingForResponse = false
|
||||
}
|
||||
async connect() {
|
||||
return pTimeout.call(this.#connect(), this.#connectTimeout)
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (!this.#connected) {
|
||||
return
|
||||
}
|
||||
this.#connected = false
|
||||
const socket = this.#serverSocket
|
||||
|
||||
const queryId = this.#nextCommandQueryId
|
||||
this.#nextCommandQueryId++
|
||||
|
||||
const buffer = Buffer.alloc(28)
|
||||
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
|
||||
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
|
||||
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
|
||||
buffer.writeBigUInt64BE(queryId, 8)
|
||||
buffer.writeBigUInt64BE(0n, 16)
|
||||
buffer.writeInt32BE(0, 24)
|
||||
const promise = pFromCallback(cb => {
|
||||
socket.end(buffer, 'utf8', cb)
|
||||
})
|
||||
try {
|
||||
await pTimeout.call(promise, this.#messageTimeout)
|
||||
} catch (error) {
|
||||
socket.destroy()
|
||||
}
|
||||
this.#serverSocket = undefined
|
||||
this.#connected = false
|
||||
}
|
||||
|
||||
#clearReconnectPromise = () => {
|
||||
this.#reconnectingPromise = undefined
|
||||
}
|
||||
|
||||
async #reconnect() {
|
||||
await this.disconnect().catch(() => {})
|
||||
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
// we need to ensure reconnections do not occur in parallel
|
||||
if (this.#reconnectingPromise === undefined) {
|
||||
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
|
||||
tries: this.#reconnectRetry,
|
||||
})
|
||||
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
|
||||
}
|
||||
|
||||
return this.#reconnectingPromise
|
||||
}
|
||||
|
||||
// we can use individual read/write from the socket here since there is no concurrency
|
||||
async #sendOption(option, buffer = Buffer.alloc(0)) {
|
||||
await this.#write(OPTS_MAGIC)
|
||||
await this.#writeInt32(option)
|
||||
await this.#writeInt32(buffer.length)
|
||||
await this.#write(buffer)
|
||||
assert.strictEqual(await this.#readInt64(), NBD_OPT_REPLY_MAGIC) // magic number everywhere
|
||||
assert.strictEqual(await this.#readInt32(), option) // the option passed
|
||||
assert.strictEqual(await this.#readInt32(), NBD_REPLY_ACK) // ACK
|
||||
const length = await this.#readInt32()
|
||||
assert.strictEqual(length, 0) // length
|
||||
}
|
||||
|
||||
// we can use individual read/write from the socket here since there is only one handshake at once, no concurrency
|
||||
async #handshake() {
|
||||
assert((await this.#read(8)).equals(INIT_PASSWD))
|
||||
assert((await this.#read(8)).equals(OPTS_MAGIC))
|
||||
const flagsBuffer = await this.#read(2)
|
||||
const flags = flagsBuffer.readInt16BE(0)
|
||||
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
|
||||
await this.#writeInt32(NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
|
||||
|
||||
if (this.#serverCert !== undefined) {
|
||||
// upgrade socket to TLS if needed
|
||||
await this.#sendOption(NBD_OPT_STARTTLS)
|
||||
await this.#tlsConnect()
|
||||
}
|
||||
|
||||
// send export name we want to access.
|
||||
// it's implicitly closing the negotiation phase.
|
||||
await this.#write(OPTS_MAGIC)
|
||||
await this.#writeInt32(NBD_OPT_EXPORT_NAME)
|
||||
const exportNameBuffer = Buffer.from(this.#exportName)
|
||||
await this.#writeInt32(exportNameBuffer.length)
|
||||
await this.#write(exportNameBuffer)
|
||||
|
||||
// 8 (export size ) + 2 (flags) + 124 zero = 134
|
||||
// must read all to ensure nothing stays in the buffer
|
||||
const answer = await this.#read(134)
|
||||
this.#exportSize = answer.readBigUInt64BE(0)
|
||||
const transmissionFlags = answer.readInt16BE(8)
|
||||
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
|
||||
|
||||
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
|
||||
}
|
||||
|
||||
#read(length) {
|
||||
const promise = readChunkStrict(this.#serverSocket, length)
|
||||
return pTimeout.call(promise, this.#messageTimeout)
|
||||
}
|
||||
|
||||
#write(buffer) {
|
||||
let timeout
|
||||
const messageTimeout = this.#messageTimeout
|
||||
const socket = this.#serverSocket
|
||||
return Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('timeout'))
|
||||
}, messageTimeout)
|
||||
}),
|
||||
new Promise(resolve =>
|
||||
socket.write(buffer, () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
async #readInt32() {
|
||||
const buffer = await this.#read(4)
|
||||
return buffer.readInt32BE(0)
|
||||
}
|
||||
|
||||
async #readInt64() {
|
||||
const buffer = await this.#read(8)
|
||||
return buffer.readBigUInt64BE(0)
|
||||
}
|
||||
|
||||
#writeInt32(int) {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeInt32BE(int)
|
||||
return this.#write(buffer)
|
||||
}
|
||||
|
||||
// when one read fail ,stop everything
|
||||
async #rejectAll(error) {
|
||||
this.#commandQueryBacklog.forEach(({ reject }) => {
|
||||
reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
async #readBlockResponse() {
|
||||
// ensure at most one read occur in parallel
|
||||
if (this.#waitingForResponse) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const buffer = await this.#read(16)
|
||||
const magic = buffer.readInt32BE(0)
|
||||
|
||||
if (magic !== NBD_REPLY_MAGIC) {
|
||||
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
|
||||
}
|
||||
|
||||
const error = buffer.readInt32BE(4)
|
||||
if (error !== 0) {
|
||||
// @todo use error code from constants.mjs
|
||||
throw new Error(`GOT ERROR CODE : ${error}`)
|
||||
}
|
||||
|
||||
const blockQueryId = buffer.readBigUInt64BE(8)
|
||||
const query = this.#commandQueryBacklog.get(blockQueryId)
|
||||
if (!query) {
|
||||
throw new Error(` no query associated with id ${blockQueryId}`)
|
||||
}
|
||||
this.#commandQueryBacklog.delete(blockQueryId)
|
||||
const data = await this.#read(query.size)
|
||||
query.resolve(data)
|
||||
this.#waitingForResponse = false
|
||||
if (this.#commandQueryBacklog.size > 0) {
|
||||
// it doesn't throw directly but will throw all relevant promise on failure
|
||||
this.#readBlockResponse()
|
||||
}
|
||||
} catch (error) {
|
||||
// reject all the promises
|
||||
// we don't need to call readBlockResponse on failure
|
||||
// since we will empty the backlog
|
||||
await this.#rejectAll(error)
|
||||
}
|
||||
}
|
||||
|
||||
async #readBlock(index, size) {
|
||||
// we don't want to add anything in backlog while reconnecting
|
||||
if (this.#reconnectingPromise) {
|
||||
await this.#reconnectingPromise
|
||||
}
|
||||
|
||||
const queryId = this.#nextCommandQueryId
|
||||
this.#nextCommandQueryId++
|
||||
|
||||
// create and send command at once to ensure there is no concurrency issue
|
||||
const buffer = Buffer.alloc(28)
|
||||
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
|
||||
buffer.writeInt16BE(0, 4) // no command flags for a simple block read
|
||||
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
|
||||
buffer.writeBigUInt64BE(queryId, 8)
|
||||
// byte offset in the raw disk
|
||||
const offset = BigInt(index) * BigInt(size)
|
||||
const remaining = this.#exportSize - offset
|
||||
if (remaining < BigInt(size)) {
|
||||
size = Number(remaining)
|
||||
}
|
||||
|
||||
buffer.writeBigUInt64BE(offset, 16)
|
||||
buffer.writeInt32BE(size, 24)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
function decoratedReject(error) {
|
||||
error.index = index
|
||||
error.size = size
|
||||
reject(error)
|
||||
}
|
||||
|
||||
// this will handle one block response, but it can be another block
|
||||
// since server does not guaranty to handle query in order
|
||||
this.#commandQueryBacklog.set(queryId, {
|
||||
size,
|
||||
resolve,
|
||||
reject: decoratedReject,
|
||||
})
|
||||
// really send the command to the server
|
||||
this.#write(buffer).catch(decoratedReject)
|
||||
|
||||
// #readBlockResponse never throws directly
|
||||
// but if it fails it will reject all the promises in the backlog
|
||||
this.#readBlockResponse()
|
||||
})
|
||||
}
|
||||
|
||||
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
|
||||
return pRetry(() => this.#readBlock(index, size), {
|
||||
tries: this.#readBlockRetries,
|
||||
when: error => error.code !== 'ERR_ABORTED',
|
||||
onRetry: async err => {
|
||||
warn('will retry reading block ', index, err)
|
||||
await this.reconnect()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the map of the file with holes, zeros and data, useful to handle efficiently sparse source
|
||||
* to implement this internally: use structure response if the server supports it, and then ask for BLOCK_STATUS *
|
||||
*
|
||||
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
|
||||
* A promise that resolves to an array where each object represents a segment:
|
||||
* - `offset` — The byte offset from the start.
|
||||
* - `length` — The size of the segment in bytes.
|
||||
* - `type` — A numeric code indicating the segment type ( 0 means data).
|
||||
*/
|
||||
/* async */ getMap(signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn('nbdinfo', [
|
||||
'--json',
|
||||
'--map',
|
||||
`nbd://${this.#serverAddress}:${this.#serverPort}/${encodeURIComponent(this.#exportName)}`,
|
||||
])
|
||||
let text = ''
|
||||
let errText = ''
|
||||
process.stdout.on('data', data => (text += data))
|
||||
process.stderr.on('data', data => (errText += data))
|
||||
const onAbort = () => {
|
||||
process.kill()
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
process.on('error', err => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reject(err)
|
||||
})
|
||||
process.on('close', code => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
resolve(json)
|
||||
} catch (error) {
|
||||
reject(new Error(`${error} ${text}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
export { default as AbstractNbdClient } from './AbstractNbdClient.mjs'
|
||||
export { default as NbdStdioClient } from './NbdStdioClient.mjs'
|
||||
export { default as NbdTcpClient } from './NbdTcpClient.mjs'
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "3.5.1",
|
||||
"version": "3.7.0",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
@@ -21,11 +21,11 @@
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/read-chunk": "^1.2.1",
|
||||
"@xen-orchestra/async-map": "^0.1.4",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3",
|
||||
"@xen-orchestra/async-map": "^0.1.5",
|
||||
"@xen-orchestra/disk-transform": "^1.3.4",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^4.9.0"
|
||||
"xen-api": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^18.7.0",
|
||||
|
||||
132
@vates/nbd-client/tests/fake-nbd-server.mjs
Normal file
132
@vates/nbd-client/tests/fake-nbd-server.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert'
|
||||
import { pFromCallback } from 'promise-toolbox'
|
||||
import { readChunk, readChunkStrict } from '@vates/read-chunk'
|
||||
import {
|
||||
INIT_PASSWD,
|
||||
NBD_CMD_DISC,
|
||||
NBD_CMD_READ,
|
||||
NBD_FLAG_FIXED_NEWSTYLE,
|
||||
NBD_FLAG_HAS_FLAGS,
|
||||
NBD_FLAG_READ_ONLY,
|
||||
NBD_OPT_EXPORT_NAME,
|
||||
NBD_OPT_REPLY_MAGIC,
|
||||
NBD_REPLY_ACK,
|
||||
NBD_REPLY_MAGIC,
|
||||
NBD_REQUEST_MAGIC,
|
||||
OPTS_MAGIC,
|
||||
} from '../constants.mjs'
|
||||
|
||||
const NBD_EINVAL = 22
|
||||
|
||||
/**
|
||||
* Minimal read-only newstyle NBD server, used by the tests only.
|
||||
*
|
||||
* It is transport agnostic on purpose: the unit tests drive it through two
|
||||
* in-memory `PassThrough`, the integration tests through the standard streams
|
||||
* of a real process.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import('node:stream').Readable} options.readable - where the client queries are read from
|
||||
* @param {import('node:stream').Writable} options.writable - where the answers are written to
|
||||
* @param {Buffer} options.data - the content of the export
|
||||
* @param {string} [options.exportName] - the export name the client must ask for
|
||||
* @param {boolean} [options.answerInReverse] - answer by pairs, in reverse order, to check the client handles out of order answers
|
||||
* @param {(request: {offset: bigint, length: number, index: number}) => number} [options.errorCode] - non zero to answer an error to this read
|
||||
* @returns {Promise<void>} resolves when the client disconnected or closed the connection
|
||||
*/
|
||||
export async function serveNbd({ readable, writable, data, exportName = '', answerInReverse = false, errorCode }) {
|
||||
const write = buffer => pFromCallback(cb => writable.write(buffer, cb))
|
||||
|
||||
// handshake: server flags
|
||||
await write(INIT_PASSWD)
|
||||
await write(OPTS_MAGIC)
|
||||
const serverFlags = Buffer.alloc(2)
|
||||
serverFlags.writeInt16BE(NBD_FLAG_FIXED_NEWSTYLE)
|
||||
await write(serverFlags)
|
||||
|
||||
// client flags
|
||||
await readChunkStrict(readable, 4)
|
||||
|
||||
// options, until the client selects an export
|
||||
let selected = false
|
||||
do {
|
||||
assert.ok((await readChunkStrict(readable, 8)).equals(OPTS_MAGIC), 'option magic')
|
||||
const option = (await readChunkStrict(readable, 4)).readInt32BE(0)
|
||||
const length = (await readChunkStrict(readable, 4)).readInt32BE(0)
|
||||
const payload = length > 0 ? await readChunkStrict(readable, length) : Buffer.alloc(0)
|
||||
|
||||
if (option === NBD_OPT_EXPORT_NAME) {
|
||||
assert.strictEqual(payload.toString(), exportName, 'export name')
|
||||
// 8 (size) + 2 (transmission flags) + 124 zeroes
|
||||
const answer = Buffer.alloc(134)
|
||||
answer.writeBigUInt64BE(BigInt(data.length), 0)
|
||||
answer.writeInt16BE(NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY, 8)
|
||||
await write(answer)
|
||||
selected = true
|
||||
} else {
|
||||
// ack everything else
|
||||
const answer = Buffer.alloc(20)
|
||||
answer.writeBigUInt64BE(NBD_OPT_REPLY_MAGIC, 0)
|
||||
answer.writeInt32BE(option, 8)
|
||||
answer.writeInt32BE(NBD_REPLY_ACK, 12)
|
||||
answer.writeInt32BE(0, 16)
|
||||
await write(answer)
|
||||
}
|
||||
} while (!selected)
|
||||
|
||||
// transmission
|
||||
let index = 0
|
||||
let pending = []
|
||||
|
||||
const answer = async ({ handle, offset, length, index }, forcedCode) => {
|
||||
const code = forcedCode ?? errorCode?.({ handle, offset, length, index }) ?? 0
|
||||
const header = Buffer.alloc(16)
|
||||
header.writeInt32BE(NBD_REPLY_MAGIC, 0)
|
||||
header.writeInt32BE(code, 4)
|
||||
header.writeBigUInt64BE(handle, 8)
|
||||
await write(header)
|
||||
if (code === 0) {
|
||||
await write(data.subarray(Number(offset), Number(offset) + length))
|
||||
}
|
||||
}
|
||||
|
||||
const flush = async () => {
|
||||
const requests = pending
|
||||
pending = []
|
||||
for (const request of requests.reverse()) {
|
||||
await answer(request)
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const request = await readChunk(readable, 28)
|
||||
if (request === null || request.length < 28) {
|
||||
// the client closed the connection without a NBD_CMD_DISC
|
||||
break
|
||||
}
|
||||
assert.strictEqual(request.readInt32BE(0), NBD_REQUEST_MAGIC, 'request magic')
|
||||
const type = request.readInt16BE(6)
|
||||
const handle = request.readBigUInt64BE(8)
|
||||
|
||||
if (type === NBD_CMD_DISC) {
|
||||
await flush()
|
||||
break
|
||||
}
|
||||
|
||||
if (type !== NBD_CMD_READ) {
|
||||
await answer({ handle, offset: 0n, length: 0, index: index++ }, NBD_EINVAL)
|
||||
continue
|
||||
}
|
||||
|
||||
const read = {
|
||||
handle,
|
||||
offset: request.readBigUInt64BE(16),
|
||||
length: request.readInt32BE(24),
|
||||
index: index++,
|
||||
}
|
||||
pending.push(read)
|
||||
if (!answerInReverse || pending.length === 2) {
|
||||
await flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
114
@vates/nbd-client/tests/nbdstdio.integ.mjs
Normal file
114
@vates/nbd-client/tests/nbdstdio.integ.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert'
|
||||
import fs from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { pFromCallback } from 'promise-toolbox'
|
||||
import { test } from 'tap'
|
||||
import tmp from 'tmp'
|
||||
|
||||
import NbdStdioClient from '../NbdStdioClient.mjs'
|
||||
|
||||
// nbdkit in single connection mode: the real thing this client targets
|
||||
const NBDKIT = path => ({
|
||||
command: 'nbdkit',
|
||||
args: ['--single', '--exit-with-parent', '--read-only', 'file', path],
|
||||
})
|
||||
|
||||
// a tiny NBD server we can ask to die in the middle of the transfer, which
|
||||
// nbdkit won't do
|
||||
const FLAKY_SERVER = fileURLToPath(new URL('./stdio-nbd-server.mjs', import.meta.url))
|
||||
|
||||
const CHUNK_SIZE = 64 * 1024
|
||||
const FILE_SIZE = CHUNK_SIZE * 8 + 1232 // non aligned file size
|
||||
|
||||
async function createTempFile(size) {
|
||||
const tmpPath = await pFromCallback(cb => tmp.file(cb))
|
||||
const data = Buffer.alloc(size)
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
data.writeUInt32BE(i, i)
|
||||
}
|
||||
await fs.writeFile(tmpPath, data)
|
||||
return tmpPath
|
||||
}
|
||||
|
||||
function checkBlock(tap, block, index) {
|
||||
tap.equal(block.length, Math.min(CHUNK_SIZE, FILE_SIZE - index * CHUNK_SIZE), `block ${index} length`)
|
||||
let ok = true
|
||||
for (let i = 0; i < block.length; i += 4) {
|
||||
ok = ok && block.readUInt32BE(i) === index * CHUNK_SIZE + i
|
||||
}
|
||||
tap.ok(ok, `block ${index} content`)
|
||||
}
|
||||
|
||||
async function readWholeExport(tap, client) {
|
||||
for (let index = 0; index < Math.ceil(FILE_SIZE / CHUNK_SIZE); index++) {
|
||||
checkBlock(tap, await client.readBlock(index, CHUNK_SIZE), index)
|
||||
}
|
||||
}
|
||||
|
||||
const isAlive = pid => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
test('it reads an export served by nbdkit on stdin/stdout', async tap => {
|
||||
const path = await createTempFile(FILE_SIZE)
|
||||
const client = new NbdStdioClient(NBDKIT(path))
|
||||
|
||||
await client.connect()
|
||||
const { pid } = client
|
||||
tap.equal(client.exportSize, BigInt(FILE_SIZE))
|
||||
|
||||
await readWholeExport(tap, client)
|
||||
|
||||
await client.disconnect()
|
||||
tap.notOk(isAlive(pid), 'the server process is stopped after disconnect')
|
||||
|
||||
// double disconnection shouldn't pose any problem
|
||||
await client.disconnect()
|
||||
await fs.unlink(path)
|
||||
})
|
||||
|
||||
test('it fails when the server does not serve the requested export', async tap => {
|
||||
const path = await createTempFile(CHUNK_SIZE)
|
||||
// the file plugin of nbdkit ignores the export name, so use our own server here
|
||||
const client = new NbdStdioClient(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [FLAKY_SERVER, path, '--export-name=MY_SECRET_EXPORT'],
|
||||
exportname: 'NOPE',
|
||||
},
|
||||
{ connectTimeout: 5e3 }
|
||||
)
|
||||
await assert.rejects(client.connect())
|
||||
tap.match(client.stderr, /NOPE/, 'the stderr of the server is captured')
|
||||
await fs.unlink(path)
|
||||
})
|
||||
|
||||
test('it recovers from a server dying in the middle of the transfer', async tap => {
|
||||
const path = await createTempFile(FILE_SIZE)
|
||||
const client = new NbdStdioClient(
|
||||
{ command: process.execPath, args: [FLAKY_SERVER, path, '--die-after=3'] },
|
||||
{ waitBeforeReconnect: 100 }
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
await readWholeExport(tap, client)
|
||||
await client.disconnect()
|
||||
await fs.unlink(path)
|
||||
})
|
||||
|
||||
test('it fails cleanly when the server cannot be spawned', async () => {
|
||||
const client = new NbdStdioClient({ command: '/does/not/exist' })
|
||||
await assert.rejects(client.connect(), ({ code, command }) => code === 'ENOENT' && command === '/does/not/exist')
|
||||
// nothing to disconnect from
|
||||
await client.disconnect()
|
||||
})
|
||||
|
||||
test('it does not implement getMap', async () => {
|
||||
const client = new NbdStdioClient({ command: 'nbdkit' })
|
||||
await assert.rejects(client.getMap(), ({ code }) => code === 'NBD_MAP_UNSUPPORTED')
|
||||
})
|
||||
52
@vates/nbd-client/tests/stdio-nbd-server.mjs
Normal file
52
@vates/nbd-client/tests/stdio-nbd-server.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Read-only NBD server serving a file through its standard streams, à la
|
||||
// `nbdkit -s`. Used by nbdstdio.integ.mjs, since nbdkit is not necessarily
|
||||
// installed on the test machine.
|
||||
//
|
||||
// Usage: stdio-nbd-server.mjs <file> [--export-name=NAME] [--die-after=N]
|
||||
//
|
||||
// --die-after=N exits with a failure after having answered N read requests, to
|
||||
// exercise the reconnection path of the client.
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import { serveNbd } from './fake-nbd-server.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const path = args.find(arg => !arg.startsWith('--'))
|
||||
if (path === undefined) {
|
||||
console.error('usage: stdio-nbd-server.mjs <file> [--export-name=NAME] [--die-after=N]')
|
||||
process.exit(64)
|
||||
}
|
||||
|
||||
const getOption = (name, defaultValue) => {
|
||||
const prefix = `--${name}=`
|
||||
const arg = args.find(arg => arg.startsWith(prefix))
|
||||
return arg === undefined ? defaultValue : arg.slice(prefix.length)
|
||||
}
|
||||
|
||||
const exportName = getOption('export-name', '')
|
||||
const dieAfter = Number(getOption('die-after', Infinity))
|
||||
|
||||
const data = await fs.readFile(path)
|
||||
|
||||
let answered = 0
|
||||
|
||||
try {
|
||||
await serveNbd({
|
||||
readable: process.stdin,
|
||||
writable: process.stdout,
|
||||
data,
|
||||
exportName,
|
||||
errorCode: () => {
|
||||
if (++answered > dieAfter) {
|
||||
console.error(`stdio-nbd-server: dying after ${dieAfter} read(s)`)
|
||||
process.exit(1)
|
||||
}
|
||||
return 0
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('stdio-nbd-server:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/node-vsphere-soap",
|
||||
"version": "2.1.3",
|
||||
"version": "2.2.0",
|
||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||
"main": "lib/client.mjs",
|
||||
"author": "reedog117",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@vates/types",
|
||||
"private": false,
|
||||
"version": "2.0.1",
|
||||
"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[]
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
VM_OPERATIONS,
|
||||
} from '../common.mjs'
|
||||
import type { DiskPassthrough } from '@xen-orchestra/disk-transform'
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import type { PassThrough, Readable } from 'node:stream'
|
||||
import type {
|
||||
XapiXoRecord,
|
||||
@@ -129,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 }
|
||||
@@ -146,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'] }>,
|
||||
@@ -282,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,
|
||||
@@ -470,6 +517,12 @@ export interface Xapi {
|
||||
destroySr(id: XoSr['id']): Promise<void>
|
||||
xostor_delete(ref: XenApiSr['$ref']): Promise<void>
|
||||
objects: {
|
||||
// emits `add`/`update`/`remove` only while the connection watches the pool events
|
||||
allIndexes: {
|
||||
type: {
|
||||
getEventEmitterByType(type: WrappedXenApiRecord['$type']): EventEmitter
|
||||
}
|
||||
}
|
||||
indexes: {
|
||||
type: {
|
||||
[XenApiRecord in WrappedXenApiRecord as XenApiRecord['$type']]: Record<XenApiRecord['uuid'], XenApiRecord>
|
||||
|
||||
@@ -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,26 +1,33 @@
|
||||
import { EventEmitter } from 'node:stream'
|
||||
import type { EventEmitter, Readable } from 'node:stream'
|
||||
import type {
|
||||
AnyXoJob,
|
||||
AnyXoLog,
|
||||
NonXapiXoRecord,
|
||||
XapiXoRecord,
|
||||
XoAuthenticationToken,
|
||||
XoBackupRepository,
|
||||
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,
|
||||
@@ -150,13 +157,13 @@ export type PoolRollingUpdateRecoveryHost = {
|
||||
export type PoolRollingUpdateRecoveryRun = {
|
||||
runId: string
|
||||
poolId: string
|
||||
status: 'preparing' | 'running' | 'interrupted' | 'resuming' | 'failed' | 'cleaning'
|
||||
status: 'preparing' | 'running' | 'interrupted' | 'resuming' | 'failed' | 'cleaning' | 'succeeded'
|
||||
startedAt: string
|
||||
updatedAt: string
|
||||
finishedAt?: string
|
||||
interruptedAt?: string
|
||||
taskId?: string
|
||||
variant?: 'xcp' | 'xs-cdn' | 'xs-legacy'
|
||||
variant?: 'xcp' | 'xs-cdn'
|
||||
hostOrder?: string[]
|
||||
hosts: Record<string, PoolRollingUpdateRecoveryHost>
|
||||
lastError: PoolRollingUpdateRecoveryError | null
|
||||
@@ -174,7 +181,41 @@ export type PoolRollingUpdateRecoveryBlocked = {
|
||||
|
||||
export type PoolRollingUpdateRecovery = PoolRollingUpdateRecoveryRun | PoolRollingUpdateRecoveryBlocked
|
||||
|
||||
export type XoApp = {
|
||||
/** 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 VDI exposing the backup disk, read-only unless it is cached */
|
||||
vdiUuid: string
|
||||
/** IQN of the target serving the disk */
|
||||
iqn: string
|
||||
/** Address of the portal, as advertised to the host */
|
||||
address: string
|
||||
/** Port of the portal (ephemeral, one target per mount) */
|
||||
port: number
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -229,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>
|
||||
@@ -253,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) */
|
||||
@@ -268,7 +312,18 @@ export type XoApp = {
|
||||
id: XoAclRole['id'],
|
||||
params?: { name?: XoAclRole['name']; description?: XoAclRole['description'] }
|
||||
): Promise<XoAclRole['id']>
|
||||
createAclV2Role(role: { name: XoAclRole['name']; description?: XoAclRole['description'] }): Promise<XoAclRole>
|
||||
createAclV2Role(role: {
|
||||
name: XoAclRole['name']
|
||||
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
|
||||
@@ -284,7 +339,14 @@ export type XoApp = {
|
||||
proxy?: XoProxy['id']
|
||||
url: string
|
||||
}): Promise<XoBackupRepository>
|
||||
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
|
||||
createUser(params: {
|
||||
firstname?: string
|
||||
lastname?: string
|
||||
name?: string
|
||||
password?: string
|
||||
username?: string
|
||||
[key: string]: unknown
|
||||
}): Promise<XoUser>
|
||||
deleteAclV2GroupRole(
|
||||
groupId: XoGroup['id'],
|
||||
roleId: XoAclRole['id'],
|
||||
@@ -296,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[]>
|
||||
@@ -308,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 }
|
||||
@@ -322,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[]>
|
||||
@@ -345,8 +412,19 @@ export type XoApp = {
|
||||
getAllUsers(opts?: { obfuscatePassword?: boolean }): Promise<XoUser[]>
|
||||
getAllXenServers(): Promise<XoServer[]>
|
||||
getAuthenticationTokensForUser(userId: XoUser['id']): Promise<XoAuthenticationToken[]>
|
||||
/** Archive/host a live-mounted disk belongs to, as recorded by `mountBackupArchiveDisk` */
|
||||
getBackupArchiveDiskMountOwner(id: BackupArchiveDiskMount['id']): {
|
||||
archiveId: XoVmBackupArchive['id']
|
||||
hostId: XoHost['id']
|
||||
/** set when the disk is served by a proxy instead of this appliance */
|
||||
proxyId?: XoProxy['id']
|
||||
}
|
||||
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
|
||||
@@ -356,6 +434,7 @@ export type XoApp = {
|
||||
getGroup(id: XoGroup['id']): Promise<XoGroup>
|
||||
getHVSupportedVersions: undefined | (() => Promise<{ [key: XoHost['productBrand']]: string }>)
|
||||
getJob<T extends AnyXoJob>(id: T['id']): Promise<T>
|
||||
isJobSequence(job: AnyXoJob): boolean
|
||||
getObject: <T extends XapiXoRecord>(id: T['id'], type?: T['type'] | T['type'][]) => T
|
||||
getObjectsByType: <T extends XapiXoRecord>(
|
||||
type: T['type'],
|
||||
@@ -363,7 +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>(
|
||||
@@ -373,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[]>>
|
||||
@@ -385,7 +482,52 @@ 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 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
|
||||
* through `mountBackupArchiveDisk`, so nothing else knows which proxy serves them.
|
||||
*/
|
||||
registerProxyBackupArchiveDiskMounts(params: {
|
||||
archiveId: XoVmBackupArchive['id']
|
||||
/** as reported by the restore, each with the host it is attached to */
|
||||
mounts: { id: BackupArchiveDiskMount['id']; hostId: XoHost['id'] }[]
|
||||
proxyId: XoProxy['id']
|
||||
}): void
|
||||
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
|
||||
registerXenServer(
|
||||
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
|
||||
@@ -400,34 +542,53 @@ export type XoApp = {
|
||||
): Promise<void>
|
||||
rollingPoolUpdate(
|
||||
pool: XoPool,
|
||||
opts?: { bypassBackupCheck?: boolean; rebootVm?: boolean; parentTask?: VatesTask; shutdownPinnedVms?: boolean }
|
||||
opts?: {
|
||||
acceptCurrentStateAsBaseline?: boolean
|
||||
bypassBackupCheck?: boolean
|
||||
rebootVm?: boolean
|
||||
parentTask?: VatesTask
|
||||
shutdownPinnedVms?: boolean
|
||||
}
|
||||
): Promise<void>
|
||||
getRollingUpdateRecovery(poolId: XoPool['id']): Promise<PoolRollingUpdateRecovery | undefined>
|
||||
finalizeRollingUpdate(pool: XoPool, opts?: { force?: boolean; parentTask?: VatesTask }): Promise<void>
|
||||
setVmResourceSet(vmId: XoVm['id'], resourceSetId: string | null, force?: boolean): Promise<void>
|
||||
shareVmResourceSet(vmId: XoVm['id']): Promise<void>
|
||||
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
|
||||
runJob(job: AnyXoJob, schedule: XoSchedule): void
|
||||
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
|
||||
testPlugin(id: XoPlugin['id'], data?: unknown): Promise<void>
|
||||
testRemote(
|
||||
id: XoBackupRepository['id']
|
||||
): Promise<
|
||||
| { success: true; readRate: number; writeRate: number }
|
||||
| { success: false; step: string; file: string; error: unknown }
|
||||
>
|
||||
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 }
|
||||
): Promise<{ vmUuid: string; success: boolean; merge?: boolean; size?: number; error?: string }[]>
|
||||
/** Remove a server from the DB (XCP-ng/XenServer) */
|
||||
unregisterXenServer(id: XoServer['id']): Promise<void>
|
||||
updateUser(
|
||||
id: XoUser['id'],
|
||||
updates: {
|
||||
email?: string
|
||||
authProviders?: Record<string, string>
|
||||
firstname?: string
|
||||
lastname?: string
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
email?: string
|
||||
authProviders?: Record<string, string>
|
||||
name?: string
|
||||
password?: string
|
||||
permission?: string
|
||||
preferences?: Record<string, string>
|
||||
username?: string
|
||||
}
|
||||
): Promise<void>
|
||||
updateAclV2Privilege(
|
||||
|
||||
@@ -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
|
||||
@@ -201,15 +224,24 @@ export type XoPoolBackupArchive = XoMetadataBackupArchive & {
|
||||
type: 'xo-pool-metadata-backup'
|
||||
}
|
||||
|
||||
export type XoBackupRepositoryBenchmark = {
|
||||
readRate: number
|
||||
timestamp: number
|
||||
writeRate: number
|
||||
}
|
||||
|
||||
export type XoBackupRepository = {
|
||||
benchmarks?: { readRate: number; timestamp: number; writeRate: number }[]
|
||||
available?: number
|
||||
benchmarks?: XoBackupRepositoryBenchmark[]
|
||||
enabled: boolean
|
||||
error?: Record<string, unknown>
|
||||
id: Branded<'backup-repository'>
|
||||
name: string
|
||||
options?: string
|
||||
proxy?: XoProxy['id']
|
||||
size?: number
|
||||
url: string
|
||||
used?: number
|
||||
}
|
||||
|
||||
export type XoGpuGroup = BaseXapiXo & {
|
||||
@@ -451,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
|
||||
@@ -579,6 +634,9 @@ export type XoJob = BaseXoJob & {
|
||||
[key: string]: unknown
|
||||
}[]
|
||||
}
|
||||
userId?: XoUser['id']
|
||||
createdBy?: XoUser['id']
|
||||
updatedBy?: XoUser['id']
|
||||
}
|
||||
|
||||
export type XoSchedule = {
|
||||
@@ -600,6 +658,7 @@ export type XoServer = {
|
||||
label?: string
|
||||
master?: XoHost['id']
|
||||
poolId?: XoPool['id']
|
||||
poolMembersAddresses?: string[]
|
||||
poolNameDescription?: string
|
||||
poolNameLabel?: string
|
||||
readOnly: boolean
|
||||
@@ -619,6 +678,7 @@ export type XoSr = BaseXapiXo & {
|
||||
current_operations: Record<string, STORAGE_OPERATIONS>
|
||||
id: Branded<'SR'>
|
||||
inMaintenanceMode: boolean
|
||||
is_tools_sr: boolean
|
||||
name_description: string
|
||||
name_label: string
|
||||
other_config: Record<string, string>
|
||||
@@ -687,6 +747,7 @@ export type XoTask = {
|
||||
| 'backup-log'
|
||||
| 'backup-repository'
|
||||
| 'group'
|
||||
| 'plugin'
|
||||
| 'proxy'
|
||||
| 'restore'
|
||||
| 'restore-log'
|
||||
@@ -713,12 +774,18 @@ export type XoTask = {
|
||||
export type XoUser = {
|
||||
authProviders?: Record<string, string>
|
||||
email: string
|
||||
firstname?: string
|
||||
groups: XoGroup['id'][]
|
||||
id: Branded<'user'>
|
||||
lastname?: string
|
||||
/**
|
||||
* @deprecated use username instead
|
||||
*/
|
||||
name?: string
|
||||
permission: 'none' | 'admin'
|
||||
pw_hash?: string
|
||||
preferences: Record<string, string>
|
||||
username?: string
|
||||
}
|
||||
|
||||
export type XoAuthenticationToken = {
|
||||
@@ -894,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.6.1",
|
||||
"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.0.1",
|
||||
"@vates/types": "^2.5.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "~5.6",
|
||||
"typescript-eslint": "^8.61.0"
|
||||
|
||||
@@ -10,4 +10,5 @@ export default {
|
||||
proxy: true,
|
||||
url: true,
|
||||
},
|
||||
'reclaim-space': true,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -3,9 +3,12 @@ export default {
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
firstname: true,
|
||||
lastname: true,
|
||||
name: true,
|
||||
password: true,
|
||||
permission: true,
|
||||
preferences: true,
|
||||
username: 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,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/async-map",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"license": "ISC",
|
||||
"description": "Promise.all + map for all iterables",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/backup-archive",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"description": "Helper to handle generic backup archives",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
@@ -21,25 +21,25 @@
|
||||
"tar-stream": "^3.1.8",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"tmp": "^0.2.1",
|
||||
"@vates/types": "^2.0.1"
|
||||
"@vates/types": "^2.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"@xen-orchestra/fs": "^4.9.3",
|
||||
"@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.1"
|
||||
"vhd-lib": "^4.17.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn run clean && tsc",
|
||||
"dev": "tsc --watch",
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "yarn run clean && tsc && node --test **/*.test.mjs",
|
||||
"test-integration": "tsc && node --test **/*.integ.mjs",
|
||||
"test": "yarn run clean && tsc && node --test './dist/**/*.test.mjs'",
|
||||
"test-integration": "tsc && node --test './src/**/*.integ.mjs'",
|
||||
"clean": "rimraf dist/"
|
||||
},
|
||||
"private": false,
|
||||
|
||||
@@ -42,12 +42,13 @@ export class RemoteDiskLineage {
|
||||
#childOf: Map<string, string> = new Map()
|
||||
// Disk paths declared active by their owning archives (accumulated across all referencing archives)
|
||||
#activeDiskPaths: Set<string> = new Set()
|
||||
// Interrupted merges: normalized parent path { stateFilePath, chain }
|
||||
#interruptedMerges: Map<string, { stateFilePath: string; chain?: string[] }> = new Map()
|
||||
// Interrupted merges: normalized parent path { stateFilePath, chain, step }
|
||||
#interruptedMerges: Map<string, { stateFilePath: string; chain?: string[]; step?: 'mergeBlocks' | 'cleanup' }> =
|
||||
new Map()
|
||||
|
||||
constructor(handler: RemoteHandlerAbstract, vdiDir: string, opts: ResolvedBackupCleanOptions) {
|
||||
this.#handler = handler
|
||||
this.#vdiDir = vdiDir
|
||||
this.#vdiDir = normalize(vdiDir)
|
||||
this.#opts = opts
|
||||
}
|
||||
|
||||
@@ -266,8 +267,38 @@ export class RemoteDiskLineage {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Read-only walk toward the descendants: true if any of them is still referenced by a backup.
|
||||
// Unlike getUsedChildChainOrDelete, it neither marks disks visited nor schedules deletions.
|
||||
const hasUsedDescendant = (diskPath: string): boolean => {
|
||||
// `seen` guards against a cycle in corrupted headers
|
||||
const seen = new Set<string>()
|
||||
for (let p: string | undefined = diskPath; p !== undefined && !seen.has(p); p = this.#childOf.get(p)) {
|
||||
seen.add(p)
|
||||
if (!orphanDisks.has(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Keeps the orphan loop away from a chain that must not be touched this run. Pinned disks are
|
||||
// differencing: every ancestor they read through must survive too, however deep the lineage is
|
||||
// (e.g. after a retention change). The ancestors are merged normally once the chain is
|
||||
// resumed or dropped.
|
||||
const pin = (paths: readonly string[]): void => {
|
||||
for (const path of paths) {
|
||||
// `seen` guards against a cycle in corrupted headers
|
||||
const seen = new Set<string>()
|
||||
for (let p: string | undefined = path; p !== undefined && !seen.has(p); p = this.#parentOf.get(p)) {
|
||||
seen.add(p)
|
||||
visited.add(p)
|
||||
toDelete.delete(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process interrupted merges first so their disks are protected from the orphan loop
|
||||
for (const [parentPath, { stateFilePath, chain: stateChain }] of this.#interruptedMerges) {
|
||||
for (const [parentPath, { stateFilePath, chain: stateChain, step }] of this.#interruptedMerges) {
|
||||
if (!this.#diskPaths.has(parentPath)) {
|
||||
this.#opts.logWarn('orphan merge state', { stateFilePath, missingDisk: parentPath })
|
||||
if (remove) {
|
||||
@@ -279,14 +310,85 @@ export class RemoteDiskLineage {
|
||||
|
||||
let chain: string[] | undefined
|
||||
if (stateChain !== undefined) {
|
||||
const missing = stateChain.filter(p => !this.#diskPaths.has(p))
|
||||
|
||||
// A merge only removes disks during its cleanup step, so outside of it every disk of the
|
||||
// recorded chain must still be there. Merging what is left would fold the surviving
|
||||
// children into the parent and then rename it onto the chain tip, silently dropping the
|
||||
// blocks of the disks that went missing. Never resume such a chain.
|
||||
if (missing.length > 0 && step !== 'cleanup') {
|
||||
// Each surviving disk is walked on its own: the missing disk breaks the `childOf` links,
|
||||
// so a walk from the parent alone would stop at the gap.
|
||||
const survivors = stateChain.filter(p => this.#diskPaths.has(p))
|
||||
if (survivors.some(hasUsedDescendant)) {
|
||||
// A backup still references a disk downstream: pin the chain so the orphan loop
|
||||
// neither merges it afresh nor deletes it. It is dropped once nothing uses it anymore.
|
||||
this.#opts.logWarn('merge chain lost disks before its cleanup step, refusing to resume', {
|
||||
stateFilePath,
|
||||
parentPath,
|
||||
step,
|
||||
missing,
|
||||
})
|
||||
pin(survivors)
|
||||
continue
|
||||
}
|
||||
|
||||
// Nothing uses the chain anymore (descendants of the missing disk are broken, so never
|
||||
// active): drop the state, the orphan loop below deletes the disks.
|
||||
this.#opts.logWarn('unmergeable merge chain on a fully orphaned lineage', {
|
||||
stateFilePath,
|
||||
parentPath,
|
||||
step,
|
||||
missing,
|
||||
})
|
||||
if (remove) {
|
||||
this.#opts.logInfo('deleting merge state of orphaned lineage', { stateFilePath })
|
||||
await this.#handler.unlink(stateFilePath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = stateChain.filter(p => this.#diskPaths.has(p))
|
||||
if (existing.length >= 2) chain = existing
|
||||
if (existing.length >= 2) {
|
||||
chain = existing
|
||||
} else {
|
||||
// fewer than 2 disks of the recorded chain are left, there is nothing to resume and
|
||||
// the state would otherwise stay on disk forever.
|
||||
//
|
||||
// only done when the chain was read successfully: an undefined `stateChain` also
|
||||
// covers a state file that is merely unreadable right now, which must be kept
|
||||
this.#opts.logWarn('merge state without a resumable chain', { stateFilePath, parentPath })
|
||||
if (remove) {
|
||||
this.#opts.logInfo('deleting unresumable merge state', { stateFilePath })
|
||||
await this.#handler.unlink(stateFilePath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (chain !== undefined) {
|
||||
chain.forEach(p => visited.add(p))
|
||||
toMerge.push({ chain, isResuming: true })
|
||||
if (chain === undefined) {
|
||||
// state file unreadable: leave it alone, the orphan loop below schedules a fresh merge
|
||||
continue
|
||||
}
|
||||
|
||||
// Nothing downstream of the merge parent is still referenced by a backup: merging would
|
||||
// write into a lineage nothing points at, and pinning it below would exempt it from the
|
||||
// orphan loop forever. The walk already marked the whole lineage for deletion, so only
|
||||
// the state file is left to drop.
|
||||
//
|
||||
// It walks the live lineage rather than `stateChain`, which is frozen at the retention
|
||||
// boundary of the run that created it and can be shorter than the real chain.
|
||||
if (getUsedChildChainOrDelete(parentPath) === undefined) {
|
||||
this.#opts.logWarn('merge state on a fully orphaned lineage', { stateFilePath, parentPath })
|
||||
if (remove) {
|
||||
this.#opts.logInfo('deleting merge state of orphaned lineage', { stateFilePath })
|
||||
await this.#handler.unlink(stateFilePath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pin(chain)
|
||||
toMerge.push({ chain, isResuming: true })
|
||||
}
|
||||
|
||||
for (const orphan of orphanDisks) {
|
||||
|
||||
279
@xen-orchestra/backup-archive/src/RemoteDiskLineage.test.mjs
Normal file
279
@xen-orchestra/backup-archive/src/RemoteDiskLineage.test.mjs
Normal file
@@ -0,0 +1,279 @@
|
||||
import { test } from 'node:test'
|
||||
import { strict as assert } from 'node:assert'
|
||||
|
||||
import tmp from 'tmp'
|
||||
import * as uuid from 'uuid'
|
||||
import { getHandler } from '@xen-orchestra/fs'
|
||||
import { normalize } from '@xen-orchestra/fs/path'
|
||||
import { pFromCallback } from 'promise-toolbox'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { Constants, VhdAbstract, VhdDirectory } from 'vhd-lib'
|
||||
|
||||
import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
|
||||
// `RemoteDiskLineage` is a `.mts` module: this is the TS ESM spelling of the sibling import, which
|
||||
// resolves at runtime because the `test` script runs the built `dist/` copy of this file. It must
|
||||
// NOT go through `../dist/`, which would make `tsc` pull the emitted declarations back in as
|
||||
// inputs (TS5055) — the `*.integ.mjs` files can only do that because tsconfig excludes them.
|
||||
/* eslint-disable n/no-missing-import */
|
||||
import { RemoteDiskLineage } from './RemoteDiskLineage.mjs'
|
||||
/* eslint-enable n/no-missing-import */
|
||||
|
||||
const { beforeEach, afterEach, describe } = test
|
||||
|
||||
let handler, tempDir, vdiDir
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await pFromCallback(cb => tmp.dir(cb))
|
||||
handler = getHandler({ url: `file://${tempDir}` })
|
||||
await handler.sync()
|
||||
// VmIncrementalBackupArchive passes a normalized path, and RemoteDiskLineage compares
|
||||
// #vdiDir against normalized paths, so the fixture has to match
|
||||
vdiDir = normalize(`xo-vm-backups/VMUUID/vdis/${uuid.v1()}/${uuid.v1()}`)
|
||||
await handler.mktree(`${vdiDir}/data`)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rimraf(tempDir)
|
||||
await handler.forget()
|
||||
})
|
||||
|
||||
// Creates an aliased VHD directory in `vdiDir`, chained onto `parent` when given.
|
||||
async function generateDisk(name, { parent } = {}) {
|
||||
const aliasPath = `${vdiDir}/${name}.alias.vhd`
|
||||
const dataPath = `${vdiDir}/data/${name}.vhd`
|
||||
|
||||
await handler.mkdir(dataPath)
|
||||
const vhd = new VhdDirectory(handler, dataPath)
|
||||
vhd.header = { ...VHDHEADER }
|
||||
vhd.footer = { ...VHDFOOTER, uuid: uuid.v1({}, Buffer.alloc(16)) }
|
||||
if (parent !== undefined) {
|
||||
vhd.header.parentUnicodeName = `${parent.name}.alias.vhd`
|
||||
vhd.header.parentUuid = parent.vhd.footer.uuid
|
||||
vhd.footer.diskType = Constants.DISK_TYPES.DIFFERENCING
|
||||
} else {
|
||||
vhd.footer.diskType = Constants.DISK_TYPES.DYNAMIC
|
||||
}
|
||||
await vhd.writeEntireBlock({ id: 0, buffer: Buffer.alloc(2 * 1024 * 1024 + 512, 1) })
|
||||
await vhd.writeBlockAllocationTable()
|
||||
await vhd.writeHeader()
|
||||
await vhd.writeFooter()
|
||||
await VhdAbstract.createAlias(handler, aliasPath, dataPath)
|
||||
|
||||
return { name, aliasPath, dataPath, vhd }
|
||||
}
|
||||
|
||||
// Plants the merge state file a previous interrupted merge would have left on `parent`.
|
||||
async function writeMergeState(parent, { chain, step = 'mergeBlocks' }) {
|
||||
const statePath = `${vdiDir}/.${parent.name}.alias.vhd.merge.json`
|
||||
await handler.writeFile(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
child: { uuid: uuid.v1() },
|
||||
parent: { uuid: uuid.v1() },
|
||||
chain: chain.map(name => `${name}.alias.vhd`),
|
||||
currentBlock: 0,
|
||||
mergedDataSize: 0,
|
||||
step,
|
||||
diskSize: 0,
|
||||
})
|
||||
)
|
||||
return statePath
|
||||
}
|
||||
|
||||
async function cleanLineage({ activeDisks = [], lineageDir = vdiDir, merge = false } = {}) {
|
||||
const warnings = []
|
||||
const infos = []
|
||||
const lineage = new RemoteDiskLineage(handler, lineageDir, {
|
||||
remove: true,
|
||||
merge,
|
||||
logInfo: (message, data) => infos.push({ message, data }),
|
||||
logWarn: (message, data) => warnings.push({ message, data }),
|
||||
})
|
||||
await lineage.init()
|
||||
for (const disk of activeDisks) {
|
||||
lineage.addActiveDiskPath(disk.aliasPath)
|
||||
}
|
||||
await lineage.clean({ remove: true, merge })
|
||||
return { warnings, infos }
|
||||
}
|
||||
|
||||
const exists = path => handler.list(vdiDir).then(files => files.includes(path.slice(vdiDir.length + 1)))
|
||||
|
||||
describe('RemoteDiskLineage.clean() interrupted merges', { concurrency: 1 }, () => {
|
||||
test('drops the merge state and the disks when no descendant is still referenced', async () => {
|
||||
const base = await generateDisk('base')
|
||||
const child = await generateDisk('child', { parent: base })
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
|
||||
|
||||
// no active disk: the whole lineage is out of retention
|
||||
const { warnings } = await cleanLineage()
|
||||
|
||||
assert.equal(await exists(statePath), false, 'merge state should be deleted')
|
||||
assert.equal(await exists(base.aliasPath), false, 'base should be deleted')
|
||||
assert.equal(await exists(child.aliasPath), false, 'child should be deleted')
|
||||
assert.ok(
|
||||
warnings.some(({ message }) => message === 'merge state on a fully orphaned lineage'),
|
||||
'the dropped chain should be reported'
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses to resume, and keeps everything, when the chain lost a disk before its cleanup step', async () => {
|
||||
const base = await generateDisk('base')
|
||||
const child = await generateDisk('child', { parent: base })
|
||||
// `missing` was recorded in the chain but is no longer on the remote: merging base + child
|
||||
// and renaming onto the tip would silently drop its blocks
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'missing', 'child'] })
|
||||
|
||||
// merge enabled: the orphan loop must not pick the refused chain up and merge it afresh
|
||||
const { warnings, infos } = await cleanLineage({ activeDisks: [child], merge: true })
|
||||
|
||||
assert.equal(
|
||||
infos.some(({ message }) => message === 'Disk chain needs merging'),
|
||||
false,
|
||||
'no merge should be scheduled for a chain that was refused'
|
||||
)
|
||||
assert.equal(await exists(statePath), true, 'merge state should be kept while a backup uses the chain')
|
||||
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
|
||||
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
|
||||
|
||||
const warning = warnings.find(
|
||||
({ message }) => message === 'merge chain lost disks before its cleanup step, refusing to resume'
|
||||
)
|
||||
assert.notEqual(warning, undefined, 'the incomplete chain should be reported')
|
||||
assert.deepEqual(warning.data.missing, [`${vdiDir}/missing.alias.vhd`])
|
||||
})
|
||||
|
||||
test('keeps every ancestor of a chain it refused to resume', async () => {
|
||||
// several ancestors fell out of retention after the merge was interrupted (e.g. the retention
|
||||
// was lowered): deleting any of them would leave `base`, a differencing disk, without a parent
|
||||
const ggp = await generateDisk('ggp')
|
||||
const gp = await generateDisk('gp', { parent: ggp })
|
||||
const base = await generateDisk('base', { parent: gp })
|
||||
const child = await generateDisk('child', { parent: base })
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'missing', 'child'] })
|
||||
|
||||
const { warnings, infos } = await cleanLineage({ activeDisks: [child], merge: true })
|
||||
|
||||
assert.ok(
|
||||
warnings.some(({ message }) => message === 'merge chain lost disks before its cleanup step, refusing to resume'),
|
||||
'the incomplete chain should be reported'
|
||||
)
|
||||
assert.equal(
|
||||
infos.some(({ message }) => message === 'Disk chain needs merging'),
|
||||
false,
|
||||
'neither the refused chain nor its ancestors should be merged'
|
||||
)
|
||||
assert.equal(await exists(ggp.aliasPath), true, 'ggp should be kept')
|
||||
assert.equal(await exists(gp.aliasPath), true, 'gp should be kept')
|
||||
assert.equal(await exists(statePath), true, 'merge state should be kept')
|
||||
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
|
||||
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
|
||||
})
|
||||
|
||||
test('keeps every ancestor of a resumed chain, and merges them on the next run', async () => {
|
||||
const ggp = await generateDisk('ggp')
|
||||
const gp = await generateDisk('gp', { parent: ggp })
|
||||
const base = await generateDisk('base', { parent: gp })
|
||||
const child = await generateDisk('child', { parent: base })
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
|
||||
|
||||
// first run: only the interrupted merge is resumed, its ancestors are left alone
|
||||
const firstRun = await cleanLineage({ activeDisks: [child], merge: true })
|
||||
|
||||
assert.ok(
|
||||
firstRun.infos.some(({ message, data }) => message === 'merging disk chain' && data.chain.length === 2),
|
||||
'the interrupted merge should be resumed'
|
||||
)
|
||||
assert.equal(await exists(statePath), false, 'merge state should be deleted once the merge completed')
|
||||
assert.equal(await exists(base.aliasPath), false, 'base should be merged into child')
|
||||
assert.equal(await exists(ggp.aliasPath), true, 'ggp should be kept')
|
||||
assert.equal(await exists(gp.aliasPath), true, 'gp should be kept')
|
||||
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
|
||||
|
||||
// second run: the ancestors are merged into child as a regular chain
|
||||
const secondRun = await cleanLineage({ activeDisks: [child], merge: true })
|
||||
|
||||
assert.deepEqual(
|
||||
secondRun.warnings.filter(({ message }) => message === 'disk broken: parent missing or broken'),
|
||||
[],
|
||||
'child should still have its parents'
|
||||
)
|
||||
assert.ok(
|
||||
secondRun.infos.some(({ message, data }) => message === 'merging disk chain' && data.chain.length === 3),
|
||||
'ggp and gp should be merged into child'
|
||||
)
|
||||
assert.equal(await exists(ggp.aliasPath), false, 'ggp should be merged into child')
|
||||
assert.equal(await exists(gp.aliasPath), false, 'gp should be merged into child')
|
||||
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
|
||||
})
|
||||
|
||||
test('drops an unmergeable chain, state and disks, once nothing uses it', async () => {
|
||||
const base = await generateDisk('base')
|
||||
const missing = await generateDisk('missing', { parent: base })
|
||||
const child = await generateDisk('child', { parent: missing })
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'missing', 'child'] })
|
||||
// the intermediate disk vanished mid-merge: child is now broken, so its backup is incomplete
|
||||
// and never declares it active
|
||||
await handler.unlink(missing.aliasPath)
|
||||
await handler.rmtree(missing.dataPath)
|
||||
|
||||
const { warnings, infos } = await cleanLineage({ merge: true })
|
||||
|
||||
assert.equal(
|
||||
infos.some(({ message }) => message === 'Disk chain needs merging'),
|
||||
false,
|
||||
'an unmergeable chain should not be merged'
|
||||
)
|
||||
assert.equal(await exists(statePath), false, 'merge state should be deleted')
|
||||
assert.equal(await exists(base.aliasPath), false, 'base should be deleted')
|
||||
assert.equal(await exists(child.aliasPath), false, 'child should be deleted')
|
||||
assert.ok(
|
||||
warnings.some(({ message }) => message === 'unmergeable merge chain on a fully orphaned lineage'),
|
||||
'the dropped chain should be reported'
|
||||
)
|
||||
})
|
||||
|
||||
test('drops a merge state whose chain no longer has two disks to merge, during cleanup', async () => {
|
||||
const base = await generateDisk('base')
|
||||
// the cleanup step unlinks the children as it goes, so a gap there is expected
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'alreadyUnlinked'], step: 'cleanup' })
|
||||
|
||||
const { warnings } = await cleanLineage({ activeDisks: [base] })
|
||||
|
||||
assert.equal(await exists(statePath), false, 'merge state should be deleted')
|
||||
assert.equal(await exists(base.aliasPath), true, 'the active disk should be kept')
|
||||
assert.ok(
|
||||
warnings.some(({ message }) => message === 'merge state without a resumable chain'),
|
||||
'the unresumable state should be reported'
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps a resumable merge state and its disks', async () => {
|
||||
const base = await generateDisk('base')
|
||||
const child = await generateDisk('child', { parent: base })
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
|
||||
|
||||
const { warnings } = await cleanLineage({ activeDisks: [child] })
|
||||
|
||||
assert.equal(await exists(statePath), true, 'merge state should be kept')
|
||||
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
|
||||
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
|
||||
assert.deepEqual(warnings, [], 'a resumable merge should not warn')
|
||||
})
|
||||
|
||||
test('behaves the same when built with a non-normalized VDI directory', async () => {
|
||||
const base = await generateDisk('base')
|
||||
const child = await generateDisk('child', { parent: base })
|
||||
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
|
||||
|
||||
// the constructor must normalize: otherwise #cleanOrphanDataFiles fails to exclude the VDI
|
||||
// directory from the data directories it sweeps and deletes every unclaimed file in it,
|
||||
// starting with the merge state
|
||||
const { warnings } = await cleanLineage({ activeDisks: [child], lineageDir: vdiDir.replace(/^\//, '') })
|
||||
|
||||
assert.equal(await exists(statePath), true, 'merge state should be kept')
|
||||
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
|
||||
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
|
||||
assert.deepEqual(warnings, [], 'nothing should be reported as orphaned')
|
||||
})
|
||||
})
|
||||
@@ -331,7 +331,7 @@ export class MergeRemoteDisk {
|
||||
* @param {string[]} files - all file paths in vdiDir (prependDir: true)
|
||||
* @param {Map<string, string>} uuidToPath - diskUuid: normalized disk path
|
||||
* @param {Map<string, string>} childOf - parentPath: child path (from disk headers)
|
||||
* @returns {Promise<Map<string, { stateFilePath: string, chain?: string[] }>>}
|
||||
* @returns {Promise<Map<string, { stateFilePath: string, chain?: string[], step?: 'mergeBlocks' | 'cleanup' }>>}
|
||||
*/
|
||||
static async findInterruptedMerges(handler, vdiDir, files, uuidToPath, childOf) {
|
||||
const STATE_FILE_RE = /^\.(.+)\.merge\.json$/
|
||||
@@ -343,8 +343,12 @@ export class MergeRemoteDisk {
|
||||
|
||||
const parentPath = normalize(vdiDir + '/' + match[1])
|
||||
let chain
|
||||
let step
|
||||
try {
|
||||
const state = JSON.parse(await handler.readFile(filePath))
|
||||
// disks are only removed during the cleanup step, so the caller needs the step to tell
|
||||
// an expected gap in the chain from a lineage that lost data
|
||||
step = state?.step ?? 'mergeBlocks'
|
||||
if (Array.isArray(state?.chain)) {
|
||||
chain = state.chain.map((/** @type {string} */ relPath) => normalize(resolveFromFile(filePath, relPath)))
|
||||
} else {
|
||||
@@ -357,7 +361,7 @@ export class MergeRemoteDisk {
|
||||
warn("Merge state unreadable, can't restart merging.", error)
|
||||
}
|
||||
|
||||
result.set(parentPath, { stateFilePath: filePath, chain })
|
||||
result.set(parentPath, { stateFilePath: filePath, chain, step })
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@xen-orchestra/async-map": "^0.1.4",
|
||||
"@xen-orchestra/backups": "^0.74.2",
|
||||
"@xen-orchestra/fs": "^4.9.3",
|
||||
"@xen-orchestra/async-map": "^0.1.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.14",
|
||||
"version": "1.1.18",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
## Documentation
|
||||
|
||||
- [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md): file structure on a remote, XAPI attributes, task logs, run description object, writer API
|
||||
- [Incremental replication](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/incrementalReplication.md): how CR selects its target VM and VDIs
|
||||
- [Metadata backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/Metadata%20backups/README.md): file structure on a remote, structure of `metadata.json`, task logs
|
||||
- [Health check](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/healthcheck/example.sh): example guest script reporting to xenstore
|
||||
|
||||
## Changing the default backup configuration
|
||||
|
||||
This library receives a backup configuration from `xo-server` or `xo-proxy`. This configuration is split between multiple properties:
|
||||
|
||||
@@ -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'
|
||||
@@ -10,6 +11,7 @@ import { dirname, join } from 'node:path'
|
||||
import pickBy from 'lodash/pickBy.js'
|
||||
import { defer } from 'golike-defer'
|
||||
import { NegativeDisk } from '@xen-orchestra/disk-transform'
|
||||
import { normalizeVdiRestoreTargets } from './_vdiRestoreTargets.mjs'
|
||||
import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
|
||||
import { resetVmOtherConfig } from './_otherConfig.mjs'
|
||||
|
||||
@@ -25,20 +27,55 @@ async function resolveUuid(xapi, cache, uuid, type) {
|
||||
return cache.get(uuid)
|
||||
}
|
||||
export class ImportVmBackup {
|
||||
// live mounts created by this restore, with the host they are attached to, so a failure can
|
||||
// release them and a success can report them to the caller, which is the one keeping track of them
|
||||
#liveMounts = []
|
||||
|
||||
// ref of the host serving the live mounts, resolved once on the destination pool
|
||||
#liveMountHostRef
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {object} params.adapter - remote adapter of the backup repository
|
||||
* @param {object} params.metadata - metadata of the backup to restore
|
||||
* @param {string} params.srUuid - SR the disks are restored to, unless a per disk target says otherwise
|
||||
* @param {object} params.xapi - XAPI connection of the pool owning `srUuid`
|
||||
* @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 {({ 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]
|
||||
*/
|
||||
constructor({
|
||||
adapter,
|
||||
liveMount,
|
||||
metadata,
|
||||
srUuid,
|
||||
xapi,
|
||||
settings: { additionalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false } = {},
|
||||
settings: { additionalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false, vmNamePrefix } = {},
|
||||
}) {
|
||||
this._adapter = adapter
|
||||
this._importIncrementalVmSettings = { additionalVmTag, newMacAddresses, mapVdisSrs, useDifferentialRestore }
|
||||
this._importIncrementalVmSettings = {
|
||||
additionalVmTag,
|
||||
newMacAddresses,
|
||||
useDifferentialRestore,
|
||||
vmNamePrefix,
|
||||
}
|
||||
this._liveMount = liveMount
|
||||
this._metadata = metadata
|
||||
this._srUuid = srUuid
|
||||
this._vdiRestoreTargets = normalizeVdiRestoreTargets(mapVdisSrs, { useDifferentialRestore })
|
||||
this._xapi = xapi
|
||||
}
|
||||
|
||||
/** SR a disk must be restored to, the restore's default one unless its target names another */
|
||||
#getTargetSrUuid(vdiUuid) {
|
||||
const target = this._vdiRestoreTargets.get(vdiUuid)
|
||||
return (target.type === 'restore' ? target.sr : undefined) ?? this._srUuid
|
||||
}
|
||||
|
||||
async #getPathOfVdiSnapshot(snapshotUuid) {
|
||||
const metadata = this._metadata
|
||||
if (this._pathToVdis === undefined) {
|
||||
@@ -57,13 +94,13 @@ export class ImportVmBackup {
|
||||
return this._pathToVdis.get(snapshotUuid)
|
||||
}
|
||||
|
||||
async _reuseNearestSnapshot($defer, ignoredVdis) {
|
||||
async _reuseNearestSnapshot($defer, excludedVdiUuids) {
|
||||
const metadata = this._metadata
|
||||
const { mapVdisSrs } = this._importIncrementalVmSettings
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
|
||||
const disks = {}
|
||||
const metadataDir = dirname(metadata._filename)
|
||||
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
|
||||
const vdis =
|
||||
excludedVdiUuids === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !excludedVdiUuids.has(vdi.uuid))
|
||||
|
||||
for (const [vdiRef, vdi] of Object.entries(vdis)) {
|
||||
const vhdPath = join(metadataDir, vhds[vdiRef])
|
||||
@@ -100,8 +137,11 @@ export class ImportVmBackup {
|
||||
debug('no backup linked to this snapshot')
|
||||
continue
|
||||
}
|
||||
if (snapshot.$SR.uuid !== (mapVdisSrs[vdi.$snapshot_of$uuid] ?? this._srUuid)) {
|
||||
debug('not restored on the same SR', { snapshotSr: snapshot.$SR.uuid, mapVdisSrs, srUuid: this._srUuid })
|
||||
// reusing a snapshot means cloning it, and a clone lands on the SR of its source: a
|
||||
// snapshot which is not on the SR this disk is restored to is not a usable base
|
||||
const targetSrUuid = this.#getTargetSrUuid(vdi.uuid)
|
||||
if (snapshot.$SR.uuid !== targetSrUuid) {
|
||||
debug('not restored on the same SR', { snapshotSr: snapshot.$SR.uuid, targetSrUuid })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -194,52 +234,204 @@ export class ImportVmBackup {
|
||||
}
|
||||
}
|
||||
|
||||
async #decorateIncrementalVmMetadata() {
|
||||
const { additionalVmTag, mapVdisSrs, useDifferentialRestore } = this._importIncrementalVmSettings
|
||||
async _decorateIncrementalVmMetadata() {
|
||||
const { additionalVmTag, useDifferentialRestore } = this._importIncrementalVmSettings
|
||||
const targets = this._vdiRestoreTargets
|
||||
|
||||
this.#checkTargetsMatchBackup()
|
||||
|
||||
// a live mounted disk stays on the backup repository and is served from there, so it is left
|
||||
// out of the disks to read, exactly like an ignored one, then added back below
|
||||
const liveMountedVdiUuids = targets.getLiveMountedVdiUuids()
|
||||
const excludedVdiUuids = new Set([...targets.getIgnoredVdiUuids(), ...liveMountedVdiUuids])
|
||||
|
||||
const ignoredVdis = new Set(
|
||||
Object.entries(mapVdisSrs)
|
||||
.filter(([_, srUuid]) => srUuid === null)
|
||||
.map(([vdiUuid]) => vdiUuid)
|
||||
)
|
||||
let backup
|
||||
if (useDifferentialRestore) {
|
||||
backup = await this._reuseNearestSnapshot(ignoredVdis)
|
||||
backup = await this._reuseNearestSnapshot(excludedVdiUuids)
|
||||
} else {
|
||||
backup = await this._adapter.readIncrementalVmBackup(this._metadata, ignoredVdis)
|
||||
backup = await this._adapter.readIncrementalVmBackup(this._metadata, excludedVdiUuids)
|
||||
}
|
||||
const xapi = this._xapi
|
||||
|
||||
const cache = new Map()
|
||||
const mapVdisSrRefs = {}
|
||||
if (additionalVmTag !== undefined) {
|
||||
backup.vm.tags.push(additionalVmTag)
|
||||
}
|
||||
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
|
||||
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
|
||||
for (const vdi of Object.values(backup.vdis)) {
|
||||
vdi.SR = await resolveUuid(xapi, cache, this.#getTargetSrUuid(vdi.uuid), 'SR')
|
||||
}
|
||||
const srRef = await resolveUuid(xapi, cache, this._srUuid, 'SR')
|
||||
Object.values(backup.vdis).forEach(vdi => {
|
||||
vdi.SR = mapVdisSrRefs[vdi.uuid] ?? srRef
|
||||
})
|
||||
|
||||
// after the SR resolution: a live mounted VDI already exists, it is not created on any SR
|
||||
await this.#addLiveMountedVdis(backup)
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
async run() {
|
||||
/**
|
||||
* Reject a restore whose targets name disks this backup does not contain.
|
||||
*
|
||||
* An unknown uuid is a mistake in every case — a stale `mapVdisSrs`, a uuid taken from the
|
||||
* running VM instead of the backup — but it is only fatal for a live mount: that target is new,
|
||||
* and silently restoring the disk the usual way would copy the very data the caller asked not
|
||||
* to. The historical targets keep their lenient behavior, so a restore which used to work still
|
||||
* does, and say so in the logs.
|
||||
*/
|
||||
#checkTargetsMatchBackup() {
|
||||
const backedUpVdiUuids = new Set(Object.values(this._metadata.vdis ?? {}).map(vdi => vdi.uuid))
|
||||
|
||||
const unknownLiveMounted = []
|
||||
for (const [vdiUuid, target] of this._vdiRestoreTargets.entries()) {
|
||||
if (backedUpVdiUuids.has(vdiUuid)) {
|
||||
continue
|
||||
}
|
||||
if (target.type === 'live-mount') {
|
||||
unknownLiveMounted.push(vdiUuid)
|
||||
} else {
|
||||
warn('restore target of a disk which is not in this backup, ignoring it', {
|
||||
type: target.type,
|
||||
vdiUuid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownLiveMounted.length !== 0) {
|
||||
throw new Error(
|
||||
`these disks cannot be live mounted, they are not in this backup: ${unknownLiveMounted.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ref, on the pool the VM is restored to, of the host serving the live mounts.
|
||||
*
|
||||
* A live mount plugs its SR into a single host, which therefore has to belong to that pool: the
|
||||
* uuid is resolved here, once and before anything is mounted, so a host picked on another pool
|
||||
* is rejected with an explicit message instead of a bare `UUID_INVALID` raised halfway through
|
||||
* the restore.
|
||||
*/
|
||||
async #resolveLiveMountHostRef() {
|
||||
if (this.#liveMountHostRef === undefined) {
|
||||
// also validates that every live mount of this restore uses the same host
|
||||
const hostId = this._vdiRestoreTargets.getLiveMountHost()
|
||||
try {
|
||||
this.#liveMountHostRef = await this._xapi.call('host.get_by_uuid', hostId)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'UUID_INVALID') {
|
||||
throw error
|
||||
}
|
||||
throw new Error(
|
||||
`host ${hostId} cannot live mount a disk of this restore: it does not belong to the pool the VM is restored to`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
return this.#liveMountHostRef
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount every disk whose target asks for it and add it to the backup to import, as an existing
|
||||
* VDI to attach instead of a disk to transfer.
|
||||
*/
|
||||
async #addLiveMountedVdis(backup) {
|
||||
const vdiUuids = this._vdiRestoreTargets.getLiveMountedVdiUuids()
|
||||
if (vdiUuids.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const liveMount = this._liveMount
|
||||
if (liveMount === undefined) {
|
||||
throw new Error('live mounting a disk during a restore is not supported here')
|
||||
}
|
||||
|
||||
const xapi = this._xapi
|
||||
const metadata = this._metadata
|
||||
const metadataDir = dirname(metadata._filename)
|
||||
// before the first mount, so a host which cannot serve them leaves nothing behind
|
||||
await this.#resolveLiveMountHostRef()
|
||||
const hostId = this._vdiRestoreTargets.getLiveMountHost()
|
||||
|
||||
for (const [vdiRef, vdi] of Object.entries(metadata.vdis)) {
|
||||
// a disk of the backup which is restored the usual way, or not at all; a target naming a
|
||||
// disk absent from the backup has already been rejected by `#checkTargetsMatchBackup`
|
||||
if (!vdiUuids.has(vdi.uuid)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const diskPath = join(metadataDir, metadata.vhds[vdiRef])
|
||||
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', { cacheSrUuid, diskPath, hostId, mountId: mount.id, vdiUuid: vdi.uuid })
|
||||
|
||||
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
|
||||
delete record.SR
|
||||
backup.vdis[vdiRef] = record
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the restored VM at the host serving its live mounted disks.
|
||||
*
|
||||
* Their SR is plugged on that host only, so the VM can run nowhere else.
|
||||
*/
|
||||
async #setLiveMountAffinity(vmRef) {
|
||||
if (this.#liveMounts.length === 0) {
|
||||
return
|
||||
}
|
||||
await this._xapi.call('VM.set_affinity', vmRef, await this.#resolveLiveMountHostRef())
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the live mounts created by this restore.
|
||||
*
|
||||
* Best effort: this runs while a restore is already failing, and a mount left behind must not
|
||||
* hide the error which caused it.
|
||||
*/
|
||||
async #releaseLiveMounts() {
|
||||
for (const { id } of this.#liveMounts.splice(0)) {
|
||||
try {
|
||||
await this._liveMount.unmountDisk(id)
|
||||
} catch (error) {
|
||||
warn('failed to unmount a live mounted disk', { error, mountId: id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run($defer) {
|
||||
const adapter = this._adapter
|
||||
const metadata = this._metadata
|
||||
const isFull = metadata.mode === 'full'
|
||||
|
||||
const sizeContainer = { size: 0 }
|
||||
const { newMacAddresses } = this._importIncrementalVmSettings
|
||||
const { newMacAddresses, vmNamePrefix, additionalVmTag } = this._importIncrementalVmSettings
|
||||
let backup
|
||||
if (isFull) {
|
||||
if (this._vdiRestoreTargets.getLiveMountedVdiUuids().size > 0) {
|
||||
// a full backup is a single XVA stream: there is no per disk handling at all
|
||||
throw new Error('live mounting a disk is only supported when restoring an incremental backup')
|
||||
}
|
||||
backup = await adapter.readFullVmBackup(metadata)
|
||||
watchStreamSize(backup, sizeContainer)
|
||||
} else {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
|
||||
backup = await this.#decorateIncrementalVmMetadata()
|
||||
// the mounts are created here, before the import, and outlive this restore: only a failure
|
||||
// releases them, a successful one hands their ids back to the caller
|
||||
$defer.onFailure(() => this.#releaseLiveMounts())
|
||||
backup = await this._decorateIncrementalVmMetadata()
|
||||
}
|
||||
|
||||
return Task.run(
|
||||
@@ -250,10 +442,20 @@ export class ImportVmBackup {
|
||||
const xapi = this._xapi
|
||||
const srRef = await xapi.call('SR.get_by_uuid', this._srUuid)
|
||||
|
||||
const onVmCreation =
|
||||
vmNamePrefix === undefined && additionalVmTag === undefined
|
||||
? null
|
||||
: vm =>
|
||||
Promise.all([
|
||||
vmNamePrefix !== undefined && vm.set_name_label(vmNamePrefix + metadata.vm.name_label),
|
||||
additionalVmTag !== undefined && vm.add_tags(additionalVmTag),
|
||||
])
|
||||
|
||||
const vmRef = isFull
|
||||
? await xapi.VM_import(backup, srRef)
|
||||
? await xapi.VM_import(backup, srRef, onVmCreation)
|
||||
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
|
||||
newMacAddresses,
|
||||
vmNamePrefix,
|
||||
})
|
||||
let size = 0
|
||||
if (isFull) {
|
||||
@@ -276,19 +478,21 @@ export class ImportVmBackup {
|
||||
xapi.call(
|
||||
'VM.set_name_label',
|
||||
vmRef,
|
||||
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
|
||||
`${vmNamePrefix ?? ''}${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
|
||||
),
|
||||
xapi.call('VM.set_name_description', vmRef, desc),
|
||||
resetVmOtherConfig(xapi, vmRef),
|
||||
this.#setLiveMountAffinity(vmRef),
|
||||
])
|
||||
|
||||
return {
|
||||
size,
|
||||
id: await xapi.getField('VM', vmRef, 'uuid'),
|
||||
liveMounts: [...this.#liveMounts],
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
decorateClass(ImportVmBackup, { _reuseNearestSnapshot: defer })
|
||||
decorateClass(ImportVmBackup, { _reuseNearestSnapshot: defer, run: defer })
|
||||
|
||||
306
@xen-orchestra/backups/ImportVmBackup.test.mjs
Normal file
306
@xen-orchestra/backups/ImportVmBackup.test.mjs
Normal file
@@ -0,0 +1,306 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import pickBy from 'lodash/pickBy.js'
|
||||
|
||||
import { ImportVmBackup } from './ImportVmBackup.mjs'
|
||||
|
||||
const VM_UUID = 'vm-uuid'
|
||||
const JOB_ID = 'job-id'
|
||||
const DEFAULT_SR = 'default-sr-uuid'
|
||||
const OTHER_SR = 'other-sr-uuid'
|
||||
const HOST = 'host-uuid'
|
||||
|
||||
// two disks, each identified in the backup by the uuid of the snapshot it was taken from
|
||||
const SYSTEM = { ref: 'OpaqueRef:system', uuid: 'snapshot-vdi-system', liveUuid: 'live-vdi-system' }
|
||||
const DATA = { ref: 'OpaqueRef:data', uuid: 'snapshot-vdi-data', liveUuid: 'live-vdi-data' }
|
||||
|
||||
const metadataFilename = `/xo-vm-backups/${VM_UUID}/20250801T080832Z.json`
|
||||
|
||||
const vhdPath = disk => `vdis/${JOB_ID}/${disk.liveUuid}/20250801T080832Z.vhd`
|
||||
|
||||
function makeMetadata() {
|
||||
return {
|
||||
_filename: metadataFilename,
|
||||
jobId: JOB_ID,
|
||||
mode: 'delta',
|
||||
timestamp: 1754035712000,
|
||||
vbds: {},
|
||||
vdis: {
|
||||
// `SR` is the ref the VDI had on the pool the backup was taken from
|
||||
[SYSTEM.ref]: { uuid: SYSTEM.uuid, $snapshot_of$uuid: SYSTEM.liveUuid, name_label: 'system', SR: 'OpaqueRef:sr' },
|
||||
[DATA.ref]: { uuid: DATA.uuid, $snapshot_of$uuid: DATA.liveUuid, name_label: 'data', SR: 'OpaqueRef:sr' },
|
||||
},
|
||||
vhds: {
|
||||
[SYSTEM.ref]: vhdPath(SYSTEM),
|
||||
[DATA.ref]: vhdPath(DATA),
|
||||
},
|
||||
vifs: {},
|
||||
vm: { uuid: VM_UUID, name_label: 'a vm', tags: [] },
|
||||
vmSnapshot: { suspend_VDI: 'OpaqueRef:NULL' },
|
||||
vtpms: [],
|
||||
}
|
||||
}
|
||||
|
||||
// mimics `RemoteAdapter#readIncrementalVmBackup`: excluded VDIs are dropped, the others get a disk
|
||||
function makeAdapter(metadata) {
|
||||
return {
|
||||
_handler: {},
|
||||
readIncrementalVmBackup(_metadata, excludedVdiUuids) {
|
||||
const vdis = pickBy(metadata.vdis, vdi => !excludedVdiUuids.has(vdi.uuid))
|
||||
const disks = {}
|
||||
for (const ref of Object.keys(vdis)) {
|
||||
disks[ref] = { path: metadata.vhds[ref] }
|
||||
}
|
||||
return { disks, vbds: metadata.vbds, vdis, version: '1.0.0', vifs: {}, vm: metadata.vm, vtpms: [] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// `unknownUuids` are refused exactly like XAPI refuses a uuid of another pool
|
||||
const makeXapi = ({ unknownUuids = [] } = {}) => ({
|
||||
call(method, uuid) {
|
||||
const [type, getter] = method.split('.')
|
||||
assert.equal(getter, 'get_by_uuid')
|
||||
if (unknownUuids.includes(uuid)) {
|
||||
const error = new Error(`UUID_INVALID(${type}, ${uuid})`)
|
||||
error.code = 'UUID_INVALID'
|
||||
throw error
|
||||
}
|
||||
return `${type}_REF:${uuid}`
|
||||
},
|
||||
})
|
||||
|
||||
function makeLiveMount() {
|
||||
const mounted = []
|
||||
return {
|
||||
mounted,
|
||||
unmounted: [],
|
||||
mountDisk(params) {
|
||||
mounted.push(params)
|
||||
return { id: `mount-${mounted.length}`, vdiUuid: `mounted-vdi-${mounted.length}` }
|
||||
},
|
||||
unmountDisk(id) {
|
||||
this.unmounted.push(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeImporter({ mapVdisSrs, unknownUuids, useDifferentialRestore = false, withoutLiveMount = false } = {}) {
|
||||
const liveMount = withoutLiveMount ? undefined : makeLiveMount()
|
||||
const metadata = makeMetadata()
|
||||
const importer = new ImportVmBackup({
|
||||
adapter: makeAdapter(metadata),
|
||||
liveMount,
|
||||
metadata,
|
||||
settings: { mapVdisSrs, useDifferentialRestore },
|
||||
srUuid: DEFAULT_SR,
|
||||
xapi: makeXapi({ unknownUuids }),
|
||||
})
|
||||
return { importer, liveMount, metadata }
|
||||
}
|
||||
|
||||
describe('ImportVmBackup#_decorateIncrementalVmMetadata()', () => {
|
||||
it('restores every disk onto the default SR when nothing is mapped', async () => {
|
||||
const { importer } = makeImporter()
|
||||
|
||||
const backup = await importer._decorateIncrementalVmMetadata()
|
||||
|
||||
assert.deepEqual(Object.keys(backup.vdis), [SYSTEM.ref, DATA.ref])
|
||||
for (const vdi of Object.values(backup.vdis)) {
|
||||
assert.equal(vdi.SR, `SR_REF:${DEFAULT_SR}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a per disk SR, in both the legacy and the current shape', async () => {
|
||||
for (const mapVdisSrs of [{ [DATA.uuid]: OTHER_SR }, { [DATA.uuid]: { type: 'restore', sr: OTHER_SR } }]) {
|
||||
const { importer } = makeImporter({ mapVdisSrs })
|
||||
|
||||
const backup = await importer._decorateIncrementalVmMetadata()
|
||||
|
||||
assert.equal(backup.vdis[SYSTEM.ref].SR, `SR_REF:${DEFAULT_SR}`)
|
||||
assert.equal(backup.vdis[DATA.ref].SR, `SR_REF:${OTHER_SR}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves out an ignored disk, in both the legacy and the current shape', async () => {
|
||||
for (const mapVdisSrs of [{ [DATA.uuid]: null }, { [DATA.uuid]: { type: 'ignore' } }]) {
|
||||
const { importer } = makeImporter({ mapVdisSrs })
|
||||
|
||||
const backup = await importer._decorateIncrementalVmMetadata()
|
||||
|
||||
assert.deepEqual(Object.keys(backup.vdis), [SYSTEM.ref])
|
||||
assert.deepEqual(Object.keys(backup.disks), [SYSTEM.ref])
|
||||
}
|
||||
})
|
||||
|
||||
describe('live mount', () => {
|
||||
const mapVdisSrs = { [DATA.uuid]: { type: 'live-mount', host: HOST } }
|
||||
|
||||
it('mounts the disk instead of reading it', async () => {
|
||||
const { importer, liveMount } = makeImporter({ mapVdisSrs })
|
||||
|
||||
const backup = await importer._decorateIncrementalVmMetadata()
|
||||
|
||||
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])
|
||||
})
|
||||
|
||||
it('attaches the mounted VDI and gives it no SR', async () => {
|
||||
const { importer } = makeImporter({ mapVdisSrs })
|
||||
|
||||
const backup = await importer._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
|
||||
assert.equal(backup.vdis[SYSTEM.ref].SR, `SR_REF:${DEFAULT_SR}`)
|
||||
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: {
|
||||
[SYSTEM.uuid]: { type: 'live-mount', host: HOST },
|
||||
[DATA.uuid]: { type: 'live-mount', host: 'another-host' },
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => importer._decorateIncrementalVmMetadata(), /must use the same host/)
|
||||
})
|
||||
|
||||
it('rejects a live mount when the caller cannot serve one', async () => {
|
||||
const { importer } = makeImporter({ mapVdisSrs, withoutLiveMount: true })
|
||||
|
||||
await assert.rejects(() => importer._decorateIncrementalVmMetadata(), /not supported here/)
|
||||
})
|
||||
|
||||
it('rejects a live mount of a disk which is not in this backup', async () => {
|
||||
const { importer, liveMount } = makeImporter({
|
||||
mapVdisSrs: { 'not-in-this-backup': { type: 'live-mount', host: HOST } },
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
() => importer._decorateIncrementalVmMetadata(),
|
||||
/cannot be live mounted, they are not in this backup: not-in-this-backup/
|
||||
)
|
||||
assert.deepEqual(liveMount.mounted, [])
|
||||
})
|
||||
|
||||
it('rejects a host which is not on the pool the VM is restored to', async () => {
|
||||
const { importer, liveMount } = makeImporter({ mapVdisSrs, unknownUuids: [HOST] })
|
||||
|
||||
await assert.rejects(() => importer._decorateIncrementalVmMetadata(), /does not belong to the pool/)
|
||||
// the host is resolved first, so nothing is left mounted behind
|
||||
assert.deepEqual(liveMount.mounted, [])
|
||||
})
|
||||
})
|
||||
|
||||
it('still restores when a disk of `mapVdisSrs` is not in this backup', async () => {
|
||||
// a stale mapping must not break a restore which used to work
|
||||
const { importer } = makeImporter({ mapVdisSrs: { 'not-in-this-backup': OTHER_SR } })
|
||||
|
||||
const backup = await importer._decorateIncrementalVmMetadata()
|
||||
|
||||
assert.deepEqual(Object.keys(backup.vdis), [SYSTEM.ref, DATA.ref])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImportVmBackup#_reuseNearestSnapshot()', () => {
|
||||
// a snapshot whose backup is the very file being restored: `_reuseNearestSnapshot` reuses it as
|
||||
// is, which is the one branch that opens no VHD chain
|
||||
function makeDifferentialImporter({ mapVdisSrs, snapshotSrUuid }) {
|
||||
const metadata = makeMetadata()
|
||||
const adapter = makeAdapter(metadata)
|
||||
adapter.listVmBackups = () => [{ vdis: metadata.vdis, vhds: metadata.vhds }]
|
||||
// reusing a snapshot as is opens nothing: any attempt to open the backup chain means the disk
|
||||
// is about to be transferred instead
|
||||
adapter._handler = {
|
||||
openFile() {
|
||||
throw new Error('would transfer the disk')
|
||||
},
|
||||
}
|
||||
|
||||
const snapshots = {
|
||||
'OpaqueRef:snapshot': {
|
||||
uuid: DATA.uuid,
|
||||
type: 'user',
|
||||
snapshot_time: 1,
|
||||
$SR: { uuid: snapshotSrUuid },
|
||||
},
|
||||
}
|
||||
|
||||
const xapi = {
|
||||
...makeXapi(),
|
||||
getRecordByUuid(type, uuid) {
|
||||
assert.equal(type, 'VDI')
|
||||
return uuid === DATA.liveUuid ? { snapshots: Object.keys(snapshots) } : undefined
|
||||
},
|
||||
getRecord(type, ref) {
|
||||
assert.equal(type, 'VDI')
|
||||
return snapshots[ref]
|
||||
},
|
||||
}
|
||||
|
||||
return new ImportVmBackup({
|
||||
adapter,
|
||||
metadata,
|
||||
settings: { mapVdisSrs, useDifferentialRestore: true },
|
||||
srUuid: DEFAULT_SR,
|
||||
xapi,
|
||||
})
|
||||
}
|
||||
|
||||
it('reuses a snapshot sitting on the SR that disk is restored to', async () => {
|
||||
// the disk is remapped to another SR, and the snapshot is there too
|
||||
const importer = makeDifferentialImporter({
|
||||
mapVdisSrs: { [DATA.uuid]: OTHER_SR, [SYSTEM.uuid]: null },
|
||||
snapshotSrUuid: OTHER_SR,
|
||||
})
|
||||
|
||||
const backup = await importer._reuseNearestSnapshot(new Set([SYSTEM.uuid]))
|
||||
|
||||
assert.equal(backup.vdis[DATA.ref].baseVdi.uuid, DATA.uuid)
|
||||
assert.equal(backup.disks[DATA.ref], undefined)
|
||||
})
|
||||
|
||||
it('skips a snapshot sitting on another SR', async () => {
|
||||
// the snapshot is on the default SR, but that disk is restored to another one: cloning it
|
||||
// would land on the wrong SR, so it must be transferred instead
|
||||
const importer = makeDifferentialImporter({
|
||||
mapVdisSrs: { [DATA.uuid]: OTHER_SR, [SYSTEM.uuid]: null },
|
||||
snapshotSrUuid: DEFAULT_SR,
|
||||
})
|
||||
|
||||
await assert.rejects(() => importer._reuseNearestSnapshot(new Set([SYSTEM.uuid])), /would transfer the disk/)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,13 @@ npm install --save @xen-orchestra/backups
|
||||
|
||||
## Usage
|
||||
|
||||
## Documentation
|
||||
|
||||
- [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md): file structure on a remote, XAPI attributes, task logs, run description object, writer API
|
||||
- [Incremental replication](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/incrementalReplication.md): how CR selects its target VM and VDIs
|
||||
- [Metadata backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/Metadata%20backups/README.md): file structure on a remote, structure of `metadata.json`, task logs
|
||||
- [Health check](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/healthcheck/example.sh): example guest script reporting to xenstore
|
||||
|
||||
## Changing the default backup configuration
|
||||
|
||||
This library receives a backup configuration from `xo-server` or `xo-proxy`. This configuration is split between multiple properties:
|
||||
|
||||
@@ -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,12 +3,15 @@ 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'
|
||||
import Disposable from 'promise-toolbox/Disposable'
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import pickBy from 'lodash/pickBy.js'
|
||||
import reduce from 'lodash/reduce.js'
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
|
||||
import {
|
||||
@@ -22,7 +25,12 @@ import {
|
||||
import { fileRestoreDecorators, fileRestoreMethods } from './_fileRestore.mjs'
|
||||
import { formatFilenameDate } from './_filenameDate.mjs'
|
||||
import { isMetadataFile } from './_backupType.mjs'
|
||||
import { readBackupJournal, writeBackupJournalEntries, writeBackupJournalEntry } from './_backupJournal.mjs'
|
||||
import {
|
||||
isKnownJournalEvent,
|
||||
readBackupJournal,
|
||||
writeBackupJournalEntries,
|
||||
writeBackupJournalEntry,
|
||||
} from './_backupJournal.mjs'
|
||||
import { isValidXva } from './_isValidXva.mjs'
|
||||
import { watchStreamSize } from './_watchStreamSize.mjs'
|
||||
|
||||
@@ -121,7 +129,7 @@ export class RemoteAdapter {
|
||||
* @param {import('./_backupJournal.mjs').BackupJournalReason} [opts.reason] what triggered the
|
||||
* deletion, as recorded in the journal
|
||||
*/
|
||||
async deleteDeltaVmBackups(backups, { reason = 'retention' } = {}) {
|
||||
async deleteDeltaVmBackups(backups, { reason = 'retention', immediate = false } = {}) {
|
||||
// this will delete the json, unused VHDs will be detected by `cleanVm`
|
||||
await deleteDeltaVmBackupFiles(
|
||||
this._handler,
|
||||
@@ -129,6 +137,62 @@ export class RemoteAdapter {
|
||||
)
|
||||
|
||||
await this.#forgetVmBackups(backups, reason)
|
||||
|
||||
if (immediate) {
|
||||
return this.#mergeVmDirsAfterDelete(backups)
|
||||
}
|
||||
|
||||
return new Set()
|
||||
}
|
||||
|
||||
// group by VM backup dir so multiple disks/backups deleted for the same
|
||||
// VM in one call trigger a single merge, not one per backup
|
||||
async #mergeVmDirsAfterDelete(backups) {
|
||||
const dirs = new Set(backups.map(({ _filename }) => dirname(_filename)))
|
||||
const mergedDirs = new Set()
|
||||
await Task.run(
|
||||
{
|
||||
properties: {
|
||||
name: 'clean VM',
|
||||
total: dirs.size,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
let done = 0
|
||||
|
||||
await asyncEach(
|
||||
dirs,
|
||||
async dir => {
|
||||
await Task.run(
|
||||
{
|
||||
properties: {
|
||||
name: `clean VM dir: ${dir}`,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
await this.cleanVm(dir, {
|
||||
remove: true,
|
||||
merge: true,
|
||||
logInfo: Task.info,
|
||||
logWarn: Task.warning,
|
||||
})
|
||||
mergedDirs.add(dir)
|
||||
} catch (error) {
|
||||
Task.warning('failed to merge VM backup chain after immediate delete', { error, path: dir })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
done++
|
||||
Task.set('progress', Math.round((done / dirs.size) * 100))
|
||||
},
|
||||
{ concurrency: 2, stopOnError: false }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return mergedDirs
|
||||
}
|
||||
|
||||
async deleteMetadataBackup(backupId) {
|
||||
@@ -166,7 +230,7 @@ export class RemoteAdapter {
|
||||
return this.deleteVmBackups([file])
|
||||
}
|
||||
|
||||
async deleteVmBackups(files) {
|
||||
async deleteVmBackups(files, { immediate = false } = {}) {
|
||||
const metadataOrNull = await asyncMap(files, async file => {
|
||||
try {
|
||||
return await this.readVmBackupMetadata(file)
|
||||
@@ -197,8 +261,10 @@ export class RemoteAdapter {
|
||||
throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
|
||||
}
|
||||
const promises = []
|
||||
let deltaBackupDirsPromise = Promise.resolve(new Set())
|
||||
if (delta !== undefined) {
|
||||
promises.push(this.deleteDeltaVmBackups(delta, { reason: 'user' }))
|
||||
deltaBackupDirsPromise = this.deleteDeltaVmBackups(delta, { reason: 'user', immediate })
|
||||
promises.push(deltaBackupDirsPromise)
|
||||
}
|
||||
if (full !== undefined) {
|
||||
promises.push(this.deleteFullVmBackups(full, { reason: 'user' }))
|
||||
@@ -208,12 +274,43 @@ export class RemoteAdapter {
|
||||
}
|
||||
await Promise.all(promises)
|
||||
|
||||
await asyncMap(new Set(files.map(file => dirname(file))), dir =>
|
||||
// - don't merge in main process, unused VHDs will be merged in the next backup run
|
||||
// - don't error in case this fails:
|
||||
// - if lock is already being held, a backup is running and cleanVm will be ran at the end
|
||||
// - otherwise, there is nothing more we can do, orphan file will be cleaned in the future
|
||||
this.cleanVm(dir, { remove: true, logWarn: warn }).catch(noop)
|
||||
const deltaBackupDirs = await deltaBackupDirsPromise
|
||||
const otherBackupDirs = new Set(files.map(file => dirname(file)).filter(dir => !deltaBackupDirs.has(dir)))
|
||||
|
||||
await Task.run(
|
||||
{
|
||||
properties: {
|
||||
name: 'clean VM of non-delta backups(full backups, ...) dirs',
|
||||
total: otherBackupDirs.size,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
let processed = 0
|
||||
await asyncEach(
|
||||
otherBackupDirs,
|
||||
async dir => {
|
||||
await Task.run(
|
||||
{
|
||||
properties: {
|
||||
name: `clean VM dir: ${dir}`,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
// - don't merge in main process, unused VHDs will be merged in the next backup run
|
||||
try {
|
||||
await this.cleanVm(dir, { remove: true, logWarn: warn })
|
||||
} catch (error) {
|
||||
Task.warning('failed to remove VM backup', { error, path: dir })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
processed++
|
||||
Task.set('progress', Math.round((processed / otherBackupDirs.size) * 100))
|
||||
},
|
||||
{ concurrency: 2, stopOnError: false }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -221,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()
|
||||
}
|
||||
@@ -253,9 +358,13 @@ export class RemoteAdapter {
|
||||
return vmsUuids
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Record<string, import('./formatVmBackups.mjs').VmBackupMetadata[]>>} keyed by VM UUID,
|
||||
* without the VMs which have no backups
|
||||
*/
|
||||
async listAllVmBackups() {
|
||||
const vmsUuids = await this.listAllVms()
|
||||
const backups = { __proto__: null }
|
||||
const backups = Object.create(null)
|
||||
await asyncEach(vmsUuids, async vmUuid => {
|
||||
const vmBackups = await this.listVmBackups(vmUuid)
|
||||
if (vmBackups.length !== 0) {
|
||||
@@ -435,14 +544,99 @@ export class RemoteAdapter {
|
||||
return backups.sort(compareTimestamp)
|
||||
}
|
||||
|
||||
// read the backup events which happened on this remote after `since` (timestamp in ms),
|
||||
// oldest first
|
||||
// read the backup events which happened on this remote after `cursor`, oldest first
|
||||
/**
|
||||
* @param {number} [since] timestamp in ms, exclusive
|
||||
* @param {string} [cursor] path of the last entry already read, exclusive
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.mustExist] whether a missing journal directory should throw
|
||||
* @returns {Promise<import('./_backupJournal.mjs').BackupJournalEntry[]>}
|
||||
*/
|
||||
async readBackupJournal(since) {
|
||||
return readBackupJournal(this._handler, since)
|
||||
async readBackupJournal(cursor, opts) {
|
||||
return readBackupJournal(this._handler, cursor, opts)
|
||||
}
|
||||
|
||||
// Same as `readBackupJournal()`, with the current metadata of the added and changed backups
|
||||
// attached, so that a listing can be brought up to date from the result alone, in a single
|
||||
// round-trip for a caller which is not on this host.
|
||||
//
|
||||
// The metadata is read back from the repository instead of being carried by the journal, so that
|
||||
// the result always reflects the current content of the file, e.g. the size a merge updated.
|
||||
/**
|
||||
* @param {string} [cursor] path of the last entry already read, exclusive
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.mustExist] whether a missing journal directory should throw
|
||||
* @returns {Promise<{
|
||||
* events: import('./formatVmBackups.mjs').ResolvedJournalEvent[]
|
||||
* cursor: string | undefined
|
||||
* }>} the cursor to pass on the next call: unchanged when nothing new was read
|
||||
*/
|
||||
async readBackupJournalEvents(cursor, opts) {
|
||||
const entries = await this.readBackupJournal(cursor, opts)
|
||||
|
||||
// the entries are oldest first, therefore the last event of a backup is its current state: a
|
||||
// backup which was written then deleted costs no metadata read at all, and one which was
|
||||
// rewritten several times costs a single one
|
||||
const lastEventByFilename = new Map()
|
||||
entries.forEach(({ event, filename, vmUuid }, index) => {
|
||||
if (!isKnownJournalEvent(event)) {
|
||||
warn('ignoring unsupported journal event', { event, filename })
|
||||
return
|
||||
}
|
||||
|
||||
// the entries are written by several code paths which don't agree on the leading slash
|
||||
lastEventByFilename.set(normalize(filename), { event, index, vmUuid })
|
||||
})
|
||||
|
||||
// the index, in `entries`, of the earliest one whose metadata could not be read: the cursor is
|
||||
// clamped to just before it, so that the next call retries it instead of skipping it
|
||||
let minFailedIndex = entries.length
|
||||
|
||||
// there is at most one event per backup left, therefore they can be resolved concurrently and
|
||||
// the order `asyncEach` returns them in does not matter
|
||||
const events = []
|
||||
await asyncEach(lastEventByFilename, async ([filename, { event, index, vmUuid }]) => {
|
||||
if (event === 'del') {
|
||||
events.push({ event, vmUuid, filename })
|
||||
return
|
||||
}
|
||||
|
||||
let metadata
|
||||
try {
|
||||
metadata = await this.readVmBackupMetadata(filename)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// the metadata is gone while its last event says it should be there: it was deleted
|
||||
// without being journaled, e.g. by a user or a third party tool directly on the
|
||||
// repository. Report it as a deletion instead of waiting for the next full rebuild.
|
||||
debug('reporting a backup whose metadata is missing as deleted', { event, filename })
|
||||
events.push({ event: 'del', vmUuid, filename })
|
||||
return
|
||||
}
|
||||
|
||||
// One unreadable metadata must not fail the whole read: the caller would forget the
|
||||
// repository and list it in full on every call for as long as the file stays unreadable,
|
||||
// which is much more expensive than what this read costs.
|
||||
//
|
||||
// Its event is kept behind the cursor instead of being dropped, so that the next read
|
||||
// tries it again: a transient failure costs nothing, and a permanent one only widens the
|
||||
// window of a journal read, until the caller rebuilds from scratch anyway.
|
||||
warn(`can't read the metadata of a backup an event is about`, { error, event, filename })
|
||||
if (index < minFailedIndex) {
|
||||
minFailedIndex = index
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
events.push({ event, vmUuid, filename, metadata })
|
||||
})
|
||||
|
||||
// advance the cursor up to the last successfully read entry, oldest first: unchanged when
|
||||
// nothing was read at all, or when the very first entry already failed
|
||||
if (minFailedIndex > 0) {
|
||||
cursor = entries[minFailedIndex - 1]._filename
|
||||
}
|
||||
|
||||
return { events, cursor }
|
||||
}
|
||||
|
||||
async writeVmBackupMetadata(vmUuid, metadata) {
|
||||
@@ -491,7 +685,7 @@ export class RemoteAdapter {
|
||||
path,
|
||||
concurrency: writeBlockConcurrency,
|
||||
validator,
|
||||
compression: handler.getConfig('compressionType') ?? 'brotli', // compatibility layer
|
||||
compression: this.getVhdDirectoryCompression(),
|
||||
uuid,
|
||||
parentUuid,
|
||||
parentPath,
|
||||
@@ -517,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,
|
||||
@@ -530,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)
|
||||
@@ -550,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 {
|
||||
@@ -568,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
|
||||
|
||||
@@ -12,7 +12,7 @@ import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
|
||||
import { VhdFile, Constants } from 'vhd-lib'
|
||||
import { rimraf } from 'rimraf'
|
||||
|
||||
import { BACKUP_JOURNAL_DIR, formatJournalDay, formatJournalTime } from './_backupJournal.mjs'
|
||||
import { BACKUP_JOURNAL_DIR, formatJournalDay, formatJournalTime, journalCursorAt } from './_backupJournal.mjs'
|
||||
import { formatFilenameDate } from './_filenameDate.mjs'
|
||||
|
||||
const { beforeEach, afterEach, describe } = test
|
||||
@@ -65,13 +65,13 @@ function writeJournalEntryAt(timestamp, entry) {
|
||||
)
|
||||
}
|
||||
|
||||
// journal entries are stamped with the writer's clock, therefore a watermark used in an assertion
|
||||
// must be strictly between the entries written before it and the ones written after it
|
||||
// journal entries are stamped with the writer's clock, therefore a cursor used in an assertion must
|
||||
// be strictly between the entries written before it and the ones written after it
|
||||
async function mark() {
|
||||
await sleep(5)
|
||||
const since = Date.now()
|
||||
const cursor = journalCursorAt(Date.now())
|
||||
await sleep(5)
|
||||
return since
|
||||
return cursor
|
||||
}
|
||||
|
||||
async function generateVhd(path, opts = {}) {
|
||||
@@ -128,7 +128,7 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
test('writeVmBackupMetadata() records an `add` event', async () => {
|
||||
const path = await writeFullBackup()
|
||||
|
||||
const entries = await adapter.readBackupJournal(0)
|
||||
const entries = await adapter.readBackupJournal()
|
||||
assert.equal(entries.length, 1)
|
||||
|
||||
const { date, timestamp, _filename, ...entry } = entries[0]
|
||||
@@ -147,7 +147,7 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
const path = await writeFullBackup()
|
||||
|
||||
const [entryPath] = await listJournal()
|
||||
const { timestamp } = (await adapter.readBackupJournal(0))[0]
|
||||
const { timestamp } = (await adapter.readBackupJournal())[0]
|
||||
|
||||
const name = entryPath.split('/').pop()
|
||||
assert.equal(entryPath, `/${BACKUP_JOURNAL_DIR}/${formatJournalDay(timestamp)}/${name}`)
|
||||
@@ -159,10 +159,10 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
const full = await writeFullBackup(Date.now() - 2000)
|
||||
const delta = await writeDeltaBackup(Date.now() - 1000)
|
||||
|
||||
const since = await mark()
|
||||
const cursor = await mark()
|
||||
await adapter.deleteVmBackups([full, delta])
|
||||
|
||||
const entries = await adapter.readBackupJournal(since)
|
||||
const entries = await adapter.readBackupJournal(cursor)
|
||||
assert.deepEqual(
|
||||
entries
|
||||
.map(({ event, filename, vmUuid, reason, who }) => ({ event, filename, vmUuid, reason, who }))
|
||||
@@ -178,10 +178,10 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
await writeDeltaBackup()
|
||||
const [backup] = await adapter.listVmBackups(vmUuid)
|
||||
|
||||
const since = await mark()
|
||||
const cursor = await mark()
|
||||
await adapter.deleteDeltaVmBackups([backup])
|
||||
|
||||
const entries = await adapter.readBackupJournal(since)
|
||||
const entries = await adapter.readBackupJournal(cursor)
|
||||
assert.equal(entries.length, 1)
|
||||
assert.equal(entries[0].event, 'del')
|
||||
assert.equal(entries[0].reason, 'retention')
|
||||
@@ -191,10 +191,10 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
const path = await writeFullBackup()
|
||||
await handler.unlink(path)
|
||||
|
||||
const since = await mark()
|
||||
const cursor = await mark()
|
||||
await adapter.deleteVmBackup(path)
|
||||
|
||||
const entries = await adapter.readBackupJournal(since)
|
||||
const entries = await adapter.readBackupJournal(cursor)
|
||||
assert.equal(entries.length, 1)
|
||||
assert.equal(entries[0].event, 'del')
|
||||
assert.equal(entries[0].filename, path)
|
||||
@@ -220,7 +220,7 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
JSON.stringify({ mode: 'delta', vhds: { vdi: `${relativePath}/child.vhd` }, vdis: { vdi: {} }, size: 1 })
|
||||
)
|
||||
|
||||
const since = await mark()
|
||||
const cursor = await mark()
|
||||
const result = await adapter.cleanVm(rootPath, {
|
||||
remove: true,
|
||||
merge: true,
|
||||
@@ -230,7 +230,7 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
})
|
||||
assert.ok(result.size > 0, 'nothing was merged')
|
||||
|
||||
const entries = await adapter.readBackupJournal(since)
|
||||
const entries = await adapter.readBackupJournal(cursor)
|
||||
assert.deepEqual(
|
||||
entries.map(({ event, filename, vmUuid, reason }) => ({ event, filename, vmUuid, reason })).sort(byFilename),
|
||||
[
|
||||
@@ -246,10 +246,10 @@ describe('backup journal', { concurrency: 1 }, () => {
|
||||
JSON.stringify({ mode: 'delta', vhds: { vdi: `${relativePath}/gone.vhd` }, vdis: { vdi: {} } })
|
||||
)
|
||||
|
||||
const since = await mark()
|
||||
const cursor = await mark()
|
||||
await adapter.cleanVm(rootPath, { remove: false, logInfo: noop, logWarn: noop, lock: false })
|
||||
|
||||
assert.deepEqual(await adapter.readBackupJournal(since), [])
|
||||
assert.deepEqual(await adapter.readBackupJournal(cursor), [])
|
||||
})
|
||||
|
||||
test('a failure to journal does not fail the operation it journals', async () => {
|
||||
@@ -274,22 +274,38 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
|
||||
const DAY = 24 * 60 * 60 * 1e3
|
||||
|
||||
test('returns nothing on a repository without a journal', async () => {
|
||||
assert.deepEqual(await adapter.readBackupJournal(0), [])
|
||||
assert.deepEqual(await adapter.readBackupJournal(), [])
|
||||
})
|
||||
|
||||
test('replays the days since `since`, oldest first', async () => {
|
||||
test('a missing journal directory is not an error unless mustExist is set', async () => {
|
||||
const cursor = journalCursorAt(Date.now())
|
||||
assert.deepEqual(await adapter.readBackupJournal(cursor), [])
|
||||
await assert.rejects(adapter.readBackupJournal(cursor, { mustExist: true }), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('a directory removed after an entry was read from it throws when mustExist is set', async () => {
|
||||
await writeFullBackup()
|
||||
const [cursor] = await listJournal()
|
||||
|
||||
await rimraf(`${tempDir}/${BACKUP_JOURNAL_DIR}`)
|
||||
|
||||
assert.deepEqual(await adapter.readBackupJournal(cursor), [])
|
||||
await assert.rejects(adapter.readBackupJournal(cursor, { mustExist: true }), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('replays the days since the cursor, oldest first', async () => {
|
||||
const old = `/${rootPath}/old.json`
|
||||
const twoDaysAgo = Date.now() - 2 * DAY
|
||||
await writeJournalEntryAt(twoDaysAgo, { event: 'add', vmUuid, filename: old, reason: 'backup' })
|
||||
const recent = await writeFullBackup()
|
||||
|
||||
assert.deepEqual(
|
||||
(await adapter.readBackupJournal(twoDaysAgo - 1000)).map(_ => _.filename),
|
||||
(await adapter.readBackupJournal(journalCursorAt(twoDaysAgo - 1000))).map(_ => _.filename),
|
||||
[old, recent]
|
||||
)
|
||||
})
|
||||
|
||||
test('does not read the days older than `since`', async () => {
|
||||
test('does not read the days older than the cursor', async () => {
|
||||
await writeJournalEntryAt(Date.now() - 2 * DAY, {
|
||||
event: 'add',
|
||||
vmUuid,
|
||||
@@ -305,20 +321,20 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
|
||||
return list(dir, ...rest)
|
||||
}
|
||||
|
||||
const since = Date.now() - 60e3
|
||||
const bound = Date.now() - 60e3
|
||||
assert.deepEqual(
|
||||
(await adapter.readBackupJournal(since)).map(_ => _.filename),
|
||||
(await adapter.readBackupJournal(journalCursorAt(bound))).map(_ => _.filename),
|
||||
[recent]
|
||||
)
|
||||
|
||||
// only the journal root and today's directory, whatever the number of days the journal holds
|
||||
assert.deepEqual(listed, [`/${BACKUP_JOURNAL_DIR}`, `/${BACKUP_JOURNAL_DIR}/${formatJournalDay(since)}`])
|
||||
assert.deepEqual(listed, [`/${BACKUP_JOURNAL_DIR}`, `/${BACKUP_JOURNAL_DIR}/${formatJournalDay(bound)}`])
|
||||
})
|
||||
|
||||
test('`since` only bounds the time of its own day, not of the days after it', async () => {
|
||||
// regression guard: entries are named after their time only, so the time of `since` must be
|
||||
// compared with the entries of its own day and with no other, otherwise an entry of an early
|
||||
// hour of a later day looks older than a `since` of a late hour
|
||||
test('the cursor only bounds the time of its own day, not of the days after it', async () => {
|
||||
// regression guard: entries are named after their time only, so the day+time of the cursor must
|
||||
// be compared with the entries of its own day and with no other, otherwise an entry of an early
|
||||
// hour of a later day looks older than a cursor of a late hour
|
||||
const midnight = Math.floor(Date.now() / DAY) * DAY
|
||||
const lateYesterday = `/${rootPath}/late-yesterday.json`
|
||||
const earlyToday = `/${rootPath}/early-today.json`
|
||||
@@ -326,31 +342,31 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
|
||||
await writeJournalEntryAt(midnight + 30 * 60e3, { event: 'add', vmUuid, filename: earlyToday })
|
||||
|
||||
assert.deepEqual(
|
||||
(await adapter.readBackupJournal(midnight - 60 * 60e3)).map(_ => _.filename),
|
||||
(await adapter.readBackupJournal(journalCursorAt(midnight - 60 * 60e3))).map(_ => _.filename),
|
||||
[lateYesterday, earlyToday]
|
||||
)
|
||||
|
||||
// 23:45 of yesterday: its own day is bounded, today is not
|
||||
assert.deepEqual(
|
||||
(await adapter.readBackupJournal(midnight - 15 * 60e3)).map(_ => _.filename),
|
||||
(await adapter.readBackupJournal(journalCursorAt(midnight - 15 * 60e3))).map(_ => _.filename),
|
||||
[earlyToday]
|
||||
)
|
||||
})
|
||||
|
||||
test('returns the entries stamped after `since`, oldest first', async () => {
|
||||
test('returns the entries after the cursor, oldest first', async () => {
|
||||
const first = await writeFullBackup(Date.now() - 2000)
|
||||
const since = await mark()
|
||||
const cursor = await mark()
|
||||
const second = await writeDeltaBackup(Date.now() - 1000)
|
||||
|
||||
assert.deepEqual(
|
||||
(await adapter.readBackupJournal(0)).map(_ => _.filename),
|
||||
(await adapter.readBackupJournal()).map(_ => _.filename),
|
||||
[first, second]
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await adapter.readBackupJournal(since)).map(_ => _.filename),
|
||||
(await adapter.readBackupJournal(cursor)).map(_ => _.filename),
|
||||
[second]
|
||||
)
|
||||
assert.deepEqual(await adapter.readBackupJournal(Date.now() + 1000), [])
|
||||
assert.deepEqual(await adapter.readBackupJournal(journalCursorAt(Date.now() + 1000)), [])
|
||||
})
|
||||
|
||||
test('skips truncated entries and unrecognized filenames without hiding their siblings', async () => {
|
||||
@@ -368,7 +384,7 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
|
||||
// a stray file where only day directories are expected
|
||||
await handler.writeFile(`/${BACKUP_JOURNAL_DIR}/README`, 'hello')
|
||||
|
||||
const entries = await adapter.readBackupJournal(0)
|
||||
const entries = await adapter.readBackupJournal()
|
||||
assert.deepEqual(
|
||||
entries.map(_ => _.filename),
|
||||
[path]
|
||||
@@ -377,6 +393,146 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readBackupJournalEvents()', { concurrency: 1 }, () => {
|
||||
// records the paths whose metadata is read from now on, to check a replay does not re-read the
|
||||
// same backup once per event
|
||||
function watchMetadataReads() {
|
||||
const read = adapter.readVmBackupMetadata.bind(adapter)
|
||||
const paths = []
|
||||
adapter.readVmBackupMetadata = path => {
|
||||
paths.push(path)
|
||||
return read(path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
test('reads the metadata of a backup once, whatever the number of events on it', async () => {
|
||||
const path = await writeFullBackup()
|
||||
const now = Date.now()
|
||||
await writeJournalEntryAt(now + 1000, { event: 'change', vmUuid, filename: path, reason: 'merge' })
|
||||
await writeJournalEntryAt(now + 2000, { event: 'change', vmUuid, filename: path, reason: 'merge' })
|
||||
|
||||
const reads = watchMetadataReads()
|
||||
const { events } = await adapter.readBackupJournalEvents()
|
||||
|
||||
assert.deepEqual(reads, [path])
|
||||
// the last event of a backup is its current state
|
||||
assert.equal(events.length, 1)
|
||||
assert.equal(events[0].event, 'change')
|
||||
assert.equal(events[0].filename, path)
|
||||
assert.equal(events[0].metadata._filename, path)
|
||||
})
|
||||
|
||||
test('costs no metadata read at all for a backup which was added then deleted', async () => {
|
||||
const path = await writeFullBackup()
|
||||
await adapter.deleteVmBackup(path)
|
||||
|
||||
const reads = watchMetadataReads()
|
||||
const { events } = await adapter.readBackupJournalEvents()
|
||||
|
||||
assert.deepEqual(reads, [])
|
||||
assert.deepEqual(
|
||||
events.filter(_ => _.filename === path),
|
||||
[{ event: 'del', vmUuid, filename: path }]
|
||||
)
|
||||
})
|
||||
|
||||
test('reports a backup whose metadata is gone as deleted', async () => {
|
||||
// deleted directly on the repository by a user or a third party tool, therefore not journaled
|
||||
const path = await writeFullBackup()
|
||||
await handler.unlink(path)
|
||||
|
||||
const { events } = await adapter.readBackupJournalEvents()
|
||||
assert.deepEqual(events, [{ event: 'del', vmUuid, filename: path }])
|
||||
})
|
||||
|
||||
test('names the backups the way a listing does, whatever the leading slash of the entry', async () => {
|
||||
const path = await writeFullBackup()
|
||||
await writeJournalEntryAt(Date.now() + 1000, {
|
||||
event: 'change',
|
||||
vmUuid,
|
||||
filename: path.slice(1),
|
||||
reason: 'merge',
|
||||
})
|
||||
|
||||
const { events } = await adapter.readBackupJournalEvents()
|
||||
|
||||
// both entries are about the same backup, therefore they must reduce to a single event
|
||||
assert.equal(events.length, 1)
|
||||
assert.equal(events[0].filename, path)
|
||||
})
|
||||
|
||||
test('ignores the event kinds it does not know', async () => {
|
||||
const path = await writeFullBackup()
|
||||
await writeJournalEntryAt(Date.now() + 1000, {
|
||||
event: 'a-future-event',
|
||||
vmUuid,
|
||||
filename: `/${rootPath}/other.json`,
|
||||
})
|
||||
|
||||
const { events } = await adapter.readBackupJournalEvents()
|
||||
assert.deepEqual(
|
||||
events.map(_ => _.filename),
|
||||
[path]
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps an unreadable metadata behind the cursor instead of failing the whole read', async () => {
|
||||
const readable = await writeFullBackup(Date.now() - 2000)
|
||||
const unreadable = await writeDeltaBackup(Date.now() - 1000)
|
||||
|
||||
const readFile = handler.readFile.bind(handler)
|
||||
handler.readFile = async (path, ...rest) => {
|
||||
if (path === unreadable) {
|
||||
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
|
||||
}
|
||||
return readFile(path, ...rest)
|
||||
}
|
||||
|
||||
const { events, cursor } = await adapter.readBackupJournalEvents()
|
||||
|
||||
// the backups which could be read are still brought up to date
|
||||
assert.deepEqual(
|
||||
events.map(_ => _.filename),
|
||||
[readable]
|
||||
)
|
||||
|
||||
// and the one which could not is read again on the next call
|
||||
handler.readFile = readFile
|
||||
const { events: retried } = await adapter.readBackupJournalEvents(cursor)
|
||||
assert.deepEqual(
|
||||
retried.map(_ => _.filename),
|
||||
[unreadable]
|
||||
)
|
||||
})
|
||||
|
||||
test('returns the cursor to start from on the next call', async () => {
|
||||
await writeFullBackup(Date.now() - 2000)
|
||||
|
||||
const { events: first, cursor } = await adapter.readBackupJournalEvents()
|
||||
assert.equal(first.length, 1)
|
||||
|
||||
await sleep(5)
|
||||
const path = await writeDeltaBackup()
|
||||
|
||||
const { events } = await adapter.readBackupJournalEvents(cursor)
|
||||
assert.deepEqual(
|
||||
events.map(_ => _.filename),
|
||||
[path]
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps the same cursor when nothing new was read', async () => {
|
||||
await writeFullBackup(Date.now() - 2000)
|
||||
|
||||
const { cursor } = await adapter.readBackupJournalEvents()
|
||||
const { events, cursor: next } = await adapter.readBackupJournalEvents(cursor)
|
||||
|
||||
assert.deepEqual(events, [])
|
||||
assert.equal(next, cursor)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backup journal on an encrypted remote', { concurrency: 1 }, () => {
|
||||
test('entries are readable without the encryption key', async () => {
|
||||
const encryptedDir = await pFromCallback(cb => tmp.dir(cb))
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// @ts-check
|
||||
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { basename, normalize } from '@xen-orchestra/fs/path'
|
||||
import { basename, dirname, normalize } from '@xen-orchestra/fs/path'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { utcFormat, utcParse } from 'd3-time-format'
|
||||
|
||||
const { debug, warn } = createLogger('xo:backups:backupJournal')
|
||||
|
||||
const BACKUP_JOURNAL_EVENTS = /** @type {const} */ (['add', 'change', 'del'])
|
||||
|
||||
/**
|
||||
* A remote handler, plus the two unencrypted read/write primitives this module relies on to keep
|
||||
* the journal readable without the encryption key. They are not part of the handler's declared
|
||||
@@ -24,7 +28,7 @@ const { debug, warn } = createLogger('xo:backups:backupJournal')
|
||||
* - `change`: the metadata was rewritten in place, the backup itself still exists
|
||||
* - `del`: the backup was removed
|
||||
*
|
||||
* @typedef {'add'|'change'|'del'} BackupJournalEvent
|
||||
* @typedef {(typeof BACKUP_JOURNAL_EVENTS)[number]} BackupJournalEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -78,6 +82,17 @@ const { debug, warn } = createLogger('xo:backups:backupJournal')
|
||||
// (Object Lock) repositories, where `cache.json.gz` cannot be rewritten.
|
||||
export const BACKUP_JOURNAL_DIR = 'xo-backup-log'
|
||||
|
||||
/**
|
||||
* Whether this version knows what to do with an event.
|
||||
*
|
||||
* A journal can hold entries written by a more recent version, and a proxy can be more recent than
|
||||
* the xo-server it answers to: readers drop the events this rejects instead of guessing.
|
||||
*
|
||||
* @param {unknown} event
|
||||
* @returns {event is BackupJournalEvent}
|
||||
*/
|
||||
export const isKnownJournalEvent = event => BACKUP_JOURNAL_EVENTS.includes(/** @type {any} */ (event))
|
||||
|
||||
// Same idea as `formatFilenameDate` but with milliseconds, so that two events on the same VM
|
||||
// within the same second keep distinct, ordered names.
|
||||
const formatDate = utcFormat('%Y%m%dT%H%M%S.%LZ')
|
||||
@@ -114,6 +129,20 @@ export const formatJournalDay = timestamp => dayOfDate(formatJournalDate(timesta
|
||||
*/
|
||||
export const formatJournalTime = timestamp => formatTime(new Date(timestamp))
|
||||
|
||||
/**
|
||||
* Builds a synthetic cursor for `readBackupJournal()`, pointing just before any entry which could
|
||||
* have been written at `timestamp` or later.
|
||||
*
|
||||
* Only meant to bootstrap the cursor of a reader which has never read the journal before: every
|
||||
* cursor after that should be the `_filename` of an entry actually read, which sorts correctly
|
||||
* against other entries regardless of clock skew, unlike this one.
|
||||
*
|
||||
* @param {number} timestamp in ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export const journalCursorAt = timestamp =>
|
||||
`/${BACKUP_JOURNAL_DIR}/${formatJournalDay(timestamp)}/${formatJournalTime(timestamp)}`
|
||||
|
||||
/**
|
||||
* Reads back the date of a journal entry, from its day and the time part of its name.
|
||||
*
|
||||
@@ -210,37 +239,47 @@ export async function writeBackupJournalEntries(handler, entries, opts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the journal entries stamped after `since`, oldest first.
|
||||
* Reads the journal entries stamped after `cursor`, oldest first.
|
||||
*
|
||||
* Corrupt or partially written entries are skipped, they must not hide their siblings. Computing the
|
||||
* watermark to pass as `since` on the next call is the caller's responsibility.
|
||||
* Corrupt or partially written entries are skipped, they must not hide their siblings. The `_filename`
|
||||
* of the last entry returned is the cursor to pass on the next call (or `journalCursorAt()` to
|
||||
* bootstrap the first one): comparing full entry names is immune to clock skew between the process
|
||||
* which writes the journal and the one reading it, unlike comparing timestamps.
|
||||
*
|
||||
* `since` is exclusive: entries stamped exactly at `since` are not returned.
|
||||
* `cursor` is exclusive: the entry it points to, if any, is not returned again.
|
||||
*
|
||||
* A missing journal directory is not an error unless `mustExist` is set: a repository which was never
|
||||
* written to (or a bootstrap cursor guessed from the clock instead of an entry actually read) has no
|
||||
* directory to find, which is benign. `mustExist` is for a caller which has already read real entries
|
||||
* from that directory before, for which a disappearance is anomalous and worth failing loudly on
|
||||
* instead of being silently treated as "no new entries".
|
||||
*
|
||||
* @param {RemoteHandler} handler
|
||||
* @param {number} [since] timestamp in ms
|
||||
* @param {string} [cursor] path to bound the read; entries at or before it are not returned
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.mustExist] whether a missing journal directory should throw
|
||||
* @returns {Promise<BackupJournalEntry[]>} oldest first
|
||||
*/
|
||||
export async function readBackupJournal(handler, since = 0) {
|
||||
const minDay = formatJournalDay(since)
|
||||
const minTime = formatJournalTime(since)
|
||||
export async function readBackupJournal(handler, cursor, { mustExist = false } = {}) {
|
||||
const minDay = cursor === undefined ? '' : basename(dirname(cursor))
|
||||
const minName = cursor === undefined ? '' : basename(cursor)
|
||||
|
||||
// only the days which can hold entries newer than `since` are listed, therefore the cost of a read
|
||||
// only the days which can hold entries newer than `cursor` are listed, therefore the cost of a read
|
||||
// does not grow with the whole history of the repository
|
||||
const days = (
|
||||
await handler.list(`/${BACKUP_JOURNAL_DIR}`, {
|
||||
filter: name => isDayName(name) && name >= minDay,
|
||||
ignoreMissing: true,
|
||||
ignoreMissing: !mustExist,
|
||||
})
|
||||
).sort()
|
||||
|
||||
/** @type {Array<{ day: string, path: string }>} */
|
||||
const found = []
|
||||
for (const day of days) {
|
||||
// entries are named after their time only: `since` bounds the day it falls into, every later
|
||||
// entries are named after their time only: `cursor` bounds the day it falls into, every later
|
||||
// day is taken whole
|
||||
const names = await handler.list(`/${BACKUP_JOURNAL_DIR}/${day}`, {
|
||||
filter: day === minDay ? name => name.slice(0, TIME_LENGTH) > minTime : undefined,
|
||||
filter: day === minDay ? name => name > minName : undefined,
|
||||
ignoreMissing: true,
|
||||
})
|
||||
for (const name of names) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -135,7 +135,12 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
$defer,
|
||||
incrementalVm,
|
||||
sr,
|
||||
{ cancelToken = CancelToken.none, newMacAddresses = false, targetRef = undefined } = {}
|
||||
{
|
||||
cancelToken = CancelToken.none,
|
||||
newMacAddresses = false,
|
||||
targetRef = undefined,
|
||||
vmNamePrefix = '[Importing…] ',
|
||||
} = {}
|
||||
) {
|
||||
const { version } = incrementalVm
|
||||
if (compareVersions(version, '1.0.0') < 0) {
|
||||
@@ -224,7 +229,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
},
|
||||
ha_always_run: false,
|
||||
is_a_template: false,
|
||||
name_label: '[Importing…] ' + vmRecord.name_label,
|
||||
name_label: vmNamePrefix + vmRecord.name_label,
|
||||
},
|
||||
{
|
||||
bios_strings: vmRecord.bios_strings,
|
||||
@@ -255,7 +260,12 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
const vdi = vdiRecords[vdiRef]
|
||||
let newVdi
|
||||
|
||||
if (vdi.baseVdi?.$ref !== undefined) {
|
||||
const isLiveMounted = vdi.liveMountedVdiRef !== undefined
|
||||
if (isLiveMounted) {
|
||||
// the disk is served by a live mount: the VDI already exists and only has to be attached.
|
||||
// No `$defer.onFailure` destroy here, it belongs to the mount and not to this import.
|
||||
newVdi = await xapi.getRecord('VDI', vdi.liveMountedVdiRef)
|
||||
} else if (vdi.baseVdi?.$ref !== undefined) {
|
||||
if (isUpdate) {
|
||||
// In update mode, reuse the existing target VDI directly — no clone needed.
|
||||
newVdi = vdi.baseVdi
|
||||
@@ -284,6 +294,9 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
await asyncMap(Object.values(vdiVbds), vbd =>
|
||||
xapi.VBD_create({
|
||||
...vbd,
|
||||
// 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,
|
||||
})
|
||||
@@ -294,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) {
|
||||
|
||||
@@ -111,93 +111,97 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
|
||||
const preTakenTimestampByVmId = {}
|
||||
const failedSnapshotByVmId = {}
|
||||
if (settings.synchronizedSnapshot) {
|
||||
await Task.run({ properties: { name: 'snapshot VMs' } }, async () => {
|
||||
await Disposable.use(
|
||||
Disposable.all(vmIds.map(vmId => this._getRecord('VM', vmId).catch(noop))),
|
||||
async vms => {
|
||||
// remove vms that failed (already handled)
|
||||
vms = vms.filter(_ => _ !== undefined)
|
||||
|
||||
const batchIds = selectSynchronizedSnapshotVms(settings.synchronizedSnapshot, vms)
|
||||
|
||||
await asyncEach(
|
||||
[...vms].filter(vm => batchIds.has(vm.uuid)),
|
||||
async vm => {
|
||||
const vmSettings = { ...settings, ...allSettings[vm.uuid] }
|
||||
const opts = {
|
||||
baseSettings,
|
||||
config,
|
||||
getSnapshotNameLabel,
|
||||
healthCheckSr,
|
||||
job,
|
||||
remoteAdapters,
|
||||
schedule,
|
||||
settings: vmSettings,
|
||||
srs,
|
||||
throttleGenerator,
|
||||
throttleStream,
|
||||
vm,
|
||||
}
|
||||
|
||||
let vmBackup
|
||||
try {
|
||||
vmBackup = this._getVmBackup(job.mode, opts)
|
||||
if (
|
||||
!vmBackup._settings.offlineBackup &&
|
||||
!vmBackup._settings.offlineSnapshot &&
|
||||
(await vmBackup._mustDoSnapshot())
|
||||
) {
|
||||
await vmBackup._prepareAndSnapshot()
|
||||
preTakenTimestampByVmId[vm.uuid] = vmBackup.timestamp
|
||||
}
|
||||
} catch (error) {
|
||||
failedSnapshotByVmId[vm.uuid] = error
|
||||
if (vmBackup !== undefined) {
|
||||
try {
|
||||
await vmBackup._fetchJobSnapshots()
|
||||
await vmBackup._removeUnusedSnapshots()
|
||||
await vmBackup._cleanMetadata()
|
||||
} catch (cleanupError) {
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
concurrency: settings.snapshotConcurrency,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const snapshotedVmIds = new Set(vmIds.filter(id => !(id in failedSnapshotByVmId)))
|
||||
Object.entries(failedSnapshotByVmId).forEach(([vmId, error]) => {
|
||||
Task.run(
|
||||
{
|
||||
properties: { id: vmId, name: 'backup VM', type: 'VM' },
|
||||
},
|
||||
() => Promise.reject(error)
|
||||
).catch(noop)
|
||||
})
|
||||
|
||||
const queue = new Set(snapshotedVmIds)
|
||||
const taskByVmId = {}
|
||||
const nTriesByVmId = {}
|
||||
|
||||
const handleVm = vmUuid => {
|
||||
const getVmTask = () => {
|
||||
const started = taskByVmId[vmUuid] !== undefined
|
||||
if (!started) {
|
||||
taskByVmId[vmUuid] = new Task(taskStart)
|
||||
}
|
||||
return {
|
||||
task: taskByVmId[vmUuid],
|
||||
started,
|
||||
}
|
||||
const getVmTask = (vmUuid, name_label) => {
|
||||
const started = taskByVmId[vmUuid] !== undefined
|
||||
if (!started) {
|
||||
taskByVmId[vmUuid] = new Task({ properties: { id: vmUuid, name: 'backup VM', type: 'VM', name_label } })
|
||||
}
|
||||
return {
|
||||
task: taskByVmId[vmUuid],
|
||||
started,
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.synchronizedSnapshot) {
|
||||
await Disposable.use(
|
||||
Disposable.all(vmIds.map(vmId => this._getRecord('VM', vmId).catch(noop))),
|
||||
async vms => {
|
||||
// remove vms that failed (already handled)
|
||||
vms = vms.filter(_ => _ !== undefined)
|
||||
|
||||
const batchIds = selectSynchronizedSnapshotVms(settings.synchronizedSnapshot, vms)
|
||||
|
||||
if (batchIds.size > 0) {
|
||||
Task.info('synchronized snapshot', { vms: Array.from(batchIds) })
|
||||
}
|
||||
|
||||
await asyncEach(
|
||||
[...vms].filter(vm => batchIds.has(vm.uuid)),
|
||||
async vm => {
|
||||
const { task } = getVmTask(vm.uuid, vm.name_label)
|
||||
|
||||
const vmSettings = { ...settings, ...allSettings[vm.uuid] }
|
||||
const opts = {
|
||||
baseSettings,
|
||||
config,
|
||||
getSnapshotNameLabel,
|
||||
healthCheckSr,
|
||||
job,
|
||||
remoteAdapters,
|
||||
schedule,
|
||||
settings: vmSettings,
|
||||
srs,
|
||||
throttleGenerator,
|
||||
throttleStream,
|
||||
vm,
|
||||
}
|
||||
|
||||
await task
|
||||
.runInside(async () => {
|
||||
let vmBackup
|
||||
try {
|
||||
vmBackup = this._getVmBackup(job.mode, opts)
|
||||
if (
|
||||
!vmBackup._settings.offlineBackup &&
|
||||
!vmBackup._settings.offlineSnapshot &&
|
||||
(await vmBackup._mustDoSnapshot())
|
||||
) {
|
||||
await vmBackup._prepareAndSnapshot()
|
||||
preTakenTimestampByVmId[vm.uuid] = vmBackup.timestamp
|
||||
}
|
||||
} catch (error) {
|
||||
failedSnapshotByVmId[vm.uuid] = error
|
||||
if (vmBackup !== undefined) {
|
||||
try {
|
||||
await vmBackup._fetchJobSnapshots()
|
||||
await vmBackup._removeUnusedSnapshots()
|
||||
await vmBackup._cleanMetadata()
|
||||
} catch (cleanupError) {
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
})
|
||||
.catch(noop)
|
||||
},
|
||||
{
|
||||
concurrency: settings.snapshotConcurrency,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const snapshotedVmIds = new Set(vmIds.filter(id => !(id in failedSnapshotByVmId)))
|
||||
|
||||
const queue = new Set(snapshotedVmIds)
|
||||
|
||||
const handleVm = vmUuid => {
|
||||
const vmBackupFailed = async (error, task) => {
|
||||
if (isLastRun) {
|
||||
return task.failure(error)
|
||||
@@ -218,17 +222,13 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
nTriesByVmId[vmUuid]++
|
||||
|
||||
const vmSettings = { ...settings, ...allSettings[vmUuid] }
|
||||
const taskStart = { properties: { id: vmUuid, name: 'backup VM', type: 'VM' } }
|
||||
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
|
||||
|
||||
return this._getRecord('VM', vmUuid).then(
|
||||
disposableVm =>
|
||||
Disposable.use(disposableVm, async vm => {
|
||||
if (taskStart.properties.name_label === undefined) {
|
||||
taskStart.properties.name_label = vm.name_label
|
||||
}
|
||||
const { task } = getVmTask(vmUuid, vm.name_label)
|
||||
|
||||
const { task } = getVmTask()
|
||||
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
|
||||
let taskError
|
||||
return task
|
||||
@@ -268,7 +268,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
.catch(noop) // errors are handled by logs
|
||||
}),
|
||||
error => {
|
||||
const { task: vmTask, started } = getVmTask()
|
||||
const { task: vmTask, started } = getVmTask(vmUuid)
|
||||
if (!started) {
|
||||
// the task is not started (except if it's a retry), and an unstarted task can't be failed
|
||||
vmTask.start()
|
||||
|
||||
@@ -8,6 +8,10 @@ export function selectSynchronizedSnapshotVms(synchronizedSnapshot, vms) {
|
||||
let matches
|
||||
if (typeof synchronizedSnapshot === 'string') {
|
||||
matches = vms.filter(vm => vm.tags.includes(synchronizedSnapshot))
|
||||
|
||||
if (matches.length === 0) {
|
||||
Task.warning('no vm matched the synchronizedSnapshot tag', { synchronizedSnapshot })
|
||||
}
|
||||
} else if (synchronizedSnapshot === true) {
|
||||
matches = vms
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -425,8 +425,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
|
||||
// The synchronized snapshot for this run is taken up-front by the batch
|
||||
// phase but transferred later. Until it has been exported, hide it from
|
||||
// retention so it is neither reclaimed nor allowed to steal the delta
|
||||
// base's "most recent" protection. This makes the pre-transfer pass behave
|
||||
// retention so it is not reclaimed. This makes the pre-transfer pass behave
|
||||
// like a normal run, where the snapshot does not exist yet.
|
||||
if (this._synchronizedSnapshotTimestamp !== undefined) {
|
||||
const datetime = formatDateTime(this._synchronizedSnapshotTimestamp)
|
||||
@@ -436,8 +435,11 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
disklessVmSnapshots = disklessVmSnapshots.filter(vm => !isInFlightSyncSnapshot(vm))
|
||||
}
|
||||
|
||||
// get the datetime of the most recent snapshot across both VDI and diskless VM snapshots
|
||||
const lastSnapshotDateTime = [...this._jobSnapshotVdis, ...disklessVmSnapshots]
|
||||
const isExported = ({ other_config }) => EXPORTED_SUCCESSFULLY in other_config
|
||||
|
||||
// get the datetime of the most recent exported snapshot across both VDI and diskless VM snapshots
|
||||
const lastExportedSnapshotDateTime = [...this._jobSnapshotVdis, ...disklessVmSnapshots]
|
||||
.filter(isExported)
|
||||
.map(({ other_config }) => other_config[DATETIME])
|
||||
.sort()
|
||||
.pop()
|
||||
@@ -457,9 +459,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
}
|
||||
const retention = settings.snapshotRetention ?? 0
|
||||
await asyncMap(getOldEntries(retention, datetimes), async datetime => {
|
||||
// keep the last snapshot across all schedules for delta
|
||||
// keep the last exported snapshot across all schedules for delta
|
||||
// since we'll need it to compute delta for next backup
|
||||
if (this.job.mode === 'delta' && datetime === lastSnapshotDateTime) {
|
||||
if (
|
||||
this.job.mode === 'delta' &&
|
||||
lastExportedSnapshotDateTime !== undefined &&
|
||||
datetime === lastExportedSnapshotDateTime
|
||||
) {
|
||||
return
|
||||
}
|
||||
const vdis = snapshotPerDatetime[datetime]
|
||||
@@ -504,6 +510,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
}
|
||||
|
||||
if (vm?.$ref !== undefined) {
|
||||
this._forgetExportedVm(vm.$ref)
|
||||
return xapi.VM_destroy(vm.$ref)
|
||||
} else {
|
||||
return asyncMap(
|
||||
@@ -529,10 +536,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
}
|
||||
const retention = settings.snapshotRetention ?? 0
|
||||
await asyncEach(getOldEntries(retention, datetimes), async datetime => {
|
||||
if (this.job.mode === 'delta' && datetime === lastSnapshotDateTime) {
|
||||
if (
|
||||
this.job.mode === 'delta' &&
|
||||
lastExportedSnapshotDateTime !== undefined &&
|
||||
datetime === lastExportedSnapshotDateTime
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this._forgetExportedVm(snapshotPerDatetime[datetime])
|
||||
await xapi.VM_destroy(snapshotPerDatetime[datetime])
|
||||
})
|
||||
})
|
||||
@@ -544,6 +556,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
await asyncEach(snapshots, snapshot => snapshot.$destroy())
|
||||
}
|
||||
|
||||
// _exportedVm is the VM (usually a snapshot) this run exported. Retention may destroy
|
||||
// it in the same run and everything reading it would then operate on a destroyed VM.
|
||||
// Forget it as soon as it is reclaimed.
|
||||
_forgetExportedVm(vmRef) {
|
||||
if (vmRef === this._exportedVm?.$ref) {
|
||||
this._exportedVm = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async _removeSnapshotData() {
|
||||
// now that we use CBT, we can destroy the data of the snapshot used for this backup
|
||||
// going back to a previous version of XO not supporting CBT will create a full backup
|
||||
|
||||
@@ -181,12 +181,10 @@ describe('_removeUnusedSnapshots() in the synchronized batch pre-snapshot state'
|
||||
name_label: 'base',
|
||||
is_control_domain: false,
|
||||
$snapshot_of: 'live-vm-ref',
|
||||
// exported successfully by the previous run
|
||||
other_config: { [EXPORTED_SUCCESSFULLY]: 'true' },
|
||||
}
|
||||
const baseVdi = {
|
||||
$ref: 'vdi-base',
|
||||
other_config: { [DATETIME]: BASE_DATETIME, [SCHEDULE_ID]: 'schedule-1' },
|
||||
other_config: { [DATETIME]: BASE_DATETIME, [SCHEDULE_ID]: 'schedule-1', [EXPORTED_SUCCESSFULLY]: 'true' },
|
||||
$VBDs: [{ $VM: baseSnapshotVm }],
|
||||
}
|
||||
const registry = { 'vdi-base': baseVdi }
|
||||
@@ -217,6 +215,118 @@ describe('_removeUnusedSnapshots() in the synchronized batch pre-snapshot state'
|
||||
})
|
||||
})
|
||||
|
||||
describe('_removeUnusedSnapshots() after a failed transfer', () => {
|
||||
const BASE_DATETIME = '20260902T090100Z'
|
||||
const FAILED_DATETIME = '20260902T092200Z'
|
||||
|
||||
const makeFailedRunRunner = ({ freshExported = false } = {}) => {
|
||||
const destroyed = []
|
||||
|
||||
const snapshotVm = $ref => ({
|
||||
$ref,
|
||||
name_label: $ref,
|
||||
is_control_domain: false,
|
||||
$snapshot_of: 'live-vm-ref',
|
||||
other_config: {},
|
||||
})
|
||||
const baseSnapshotVm = snapshotVm('vm-base')
|
||||
const freshSnapshotVm = snapshotVm('vm-failed')
|
||||
|
||||
const vdi = ($ref, datetime, snapshotVmRecord, isExported) => ({
|
||||
$ref,
|
||||
other_config: {
|
||||
[DATETIME]: datetime,
|
||||
[SCHEDULE_ID]: 'schedule-1',
|
||||
...(isExported ? { [EXPORTED_SUCCESSFULLY]: 'true' } : {}),
|
||||
},
|
||||
$VBDs: [{ $VM: snapshotVmRecord }],
|
||||
})
|
||||
// the base of the last successful run and the snapshot of the run that just failed
|
||||
const baseVdi = vdi('vdi-base', BASE_DATETIME, baseSnapshotVm, true)
|
||||
const freshVdi = vdi('vdi-failed', FAILED_DATETIME, freshSnapshotVm, freshExported)
|
||||
|
||||
const registry = { 'vdi-base': baseVdi, 'vdi-failed': freshVdi }
|
||||
|
||||
const runner = makeRunner({
|
||||
// no synchronized snapshot: this is a plain incremental replication job
|
||||
_synchronizedSnapshotTimestamp: undefined,
|
||||
_vm: { uuid: 'live-uuid', $snapshots: [] },
|
||||
_baseSettings: { snapshotRetention: 0 },
|
||||
_jobSnapshotVdis: [baseVdi, freshVdi],
|
||||
_disklessJobSnapshotVms: [],
|
||||
job: { mode: 'delta', settings: {} },
|
||||
_xapi: {
|
||||
barrier: async () => {},
|
||||
getObject: ref => registry[ref],
|
||||
VM_destroy: async ref => {
|
||||
destroyed.push(ref)
|
||||
},
|
||||
VDI_destroy: async ref => {
|
||||
destroyed.push(ref)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { runner, destroyed }
|
||||
}
|
||||
|
||||
it('keeps the last successfully exported snapshot as the delta base', async () => {
|
||||
const { runner, destroyed } = makeFailedRunRunner()
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.equal(
|
||||
destroyed.includes('vm-base'),
|
||||
false,
|
||||
'the last exported snapshot is the only usable delta base: destroying it forces a full on the next run'
|
||||
)
|
||||
})
|
||||
|
||||
it('reclaims the snapshot of the failed run (never exported, retention 0)', async () => {
|
||||
const { runner, destroyed } = makeFailedRunRunner()
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.deepEqual(destroyed, ['vm-failed'], 'only the never-exported snapshot should be removed')
|
||||
})
|
||||
|
||||
it('keeps the most recent snapshot once the transfer succeeded', async () => {
|
||||
const { runner, destroyed } = makeFailedRunRunner({ freshExported: true })
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.deepEqual(destroyed, ['vm-base'], 'the newly exported snapshot should replace the previous base')
|
||||
})
|
||||
|
||||
it('keeps the newest exported snapshot even when several runs failed in a row', async () => {
|
||||
const { runner, destroyed } = makeFailedRunRunner()
|
||||
// a second consecutive failure adds another never-exported snapshot
|
||||
const secondFailedVm = {
|
||||
$ref: 'vm-failed-2',
|
||||
name_label: 'vm-failed-2',
|
||||
is_control_domain: false,
|
||||
$snapshot_of: 'live-vm-ref',
|
||||
other_config: {},
|
||||
}
|
||||
const secondFailedVdi = {
|
||||
$ref: 'vdi-failed-2',
|
||||
other_config: { [DATETIME]: '20260902T092600Z', [SCHEDULE_ID]: 'schedule-1' },
|
||||
$VBDs: [{ $VM: secondFailedVm }],
|
||||
}
|
||||
runner._jobSnapshotVdis.push(secondFailedVdi)
|
||||
const getObject = runner._xapi.getObject
|
||||
runner._xapi.getObject = ref => (ref === 'vdi-failed-2' ? secondFailedVdi : getObject(ref))
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.deepEqual(
|
||||
destroyed.sort(),
|
||||
['vm-failed', 'vm-failed-2'],
|
||||
'both failed snapshots should be reclaimed and the exported base kept'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_removeUnusedSnapshots() reclaims orphan / CBT snapshot VDIs (no attached VM)', () => {
|
||||
// Guards the `else` branch: snapshot VDIs that are not attached to any user VM
|
||||
// (e.g. CBT metadata, orphans) must be reclaimed via VDI_destroy, not VM_destroy.
|
||||
@@ -325,3 +435,225 @@ describe('_removeUnusedSnapshots() diskless VM snapshots', () => {
|
||||
assert.deepEqual(destroyed, ['dl-old'], 'the most recent diskless snapshot should be kept in delta mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('_removeUnusedSnapshots() forgets the exported snapshot when it reclaims it', () => {
|
||||
// `_exportedVm` is read after retention has run (by `_removeSnapshotData()`), so it must
|
||||
// not survive the destruction of the VM it points at.
|
||||
const BASE_DATETIME = '20260902T090100Z'
|
||||
const SYNC_TIMESTAMP = 1717200000000
|
||||
|
||||
const makeRunner_ = ({ freshDatetime, freshExported = false, synchronized = false, exportedVmRef }) => {
|
||||
const destroyed = []
|
||||
|
||||
const snapshotVm = $ref => ({
|
||||
$ref,
|
||||
name_label: $ref,
|
||||
is_control_domain: false,
|
||||
$snapshot_of: 'live-vm-ref',
|
||||
other_config: {},
|
||||
})
|
||||
const baseSnapshotVm = snapshotVm('vm-base')
|
||||
const freshSnapshotVm = snapshotVm('vm-fresh')
|
||||
|
||||
const vdi = ($ref, datetime, snapshotVmRecord, isExported) => ({
|
||||
$ref,
|
||||
other_config: {
|
||||
[DATETIME]: datetime,
|
||||
[SCHEDULE_ID]: 'schedule-1',
|
||||
...(isExported ? { [EXPORTED_SUCCESSFULLY]: 'true' } : {}),
|
||||
},
|
||||
$VBDs: [{ $VM: snapshotVmRecord }],
|
||||
})
|
||||
const baseVdi = vdi('vdi-base', BASE_DATETIME, baseSnapshotVm, true)
|
||||
const freshVdi = vdi('vdi-fresh', freshDatetime, freshSnapshotVm, freshExported)
|
||||
|
||||
const registry = { 'vdi-base': baseVdi, 'vdi-fresh': freshVdi }
|
||||
const vms = { 'vm-base': baseSnapshotVm, 'vm-fresh': freshSnapshotVm }
|
||||
|
||||
const runner = makeRunner({
|
||||
_synchronizedSnapshotTimestamp: synchronized ? SYNC_TIMESTAMP : undefined,
|
||||
_exportedVm: vms[exportedVmRef],
|
||||
_vm: { uuid: 'live-uuid', $snapshots: [] },
|
||||
_baseSettings: { snapshotRetention: 0 },
|
||||
_jobSnapshotVdis: [baseVdi, freshVdi],
|
||||
_disklessJobSnapshotVms: [],
|
||||
job: { mode: 'delta', settings: {} },
|
||||
_xapi: {
|
||||
barrier: async () => {},
|
||||
getObject: ref => registry[ref],
|
||||
VM_destroy: async ref => {
|
||||
destroyed.push(ref)
|
||||
},
|
||||
VDI_destroy: async ref => {
|
||||
destroyed.push(ref)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { runner, destroyed }
|
||||
}
|
||||
|
||||
it('clears _exportedVm when the snapshot of the failed run is reclaimed', async () => {
|
||||
const { runner, destroyed } = makeRunner_({ freshDatetime: '20260902T092200Z', exportedVmRef: 'vm-fresh' })
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.deepEqual(destroyed, ['vm-fresh'], 'the never-exported snapshot should be reclaimed')
|
||||
assert.equal(runner._exportedVm, undefined, '_exportedVm must not outlive the VM it points at')
|
||||
})
|
||||
|
||||
it('keeps _exportedVm when the snapshot survives retention (synchronized snapshot)', async () => {
|
||||
// the in-flight synchronized snapshot is hidden from retention, so it is still
|
||||
// there afterwards and its data must still be purgeable
|
||||
const { runner, destroyed } = makeRunner_({
|
||||
freshDatetime: formatDateTime(SYNC_TIMESTAMP),
|
||||
synchronized: true,
|
||||
exportedVmRef: 'vm-fresh',
|
||||
})
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.deepEqual(destroyed, [], 'the in-flight synchronized snapshot must be kept')
|
||||
assert.notEqual(runner._exportedVm, undefined, '_exportedVm should be kept while the snapshot exists')
|
||||
})
|
||||
|
||||
it('keeps _exportedVm when another snapshot is reclaimed', async () => {
|
||||
// nominal run: the fresh snapshot has been exported, the previous base ages out
|
||||
const { runner, destroyed } = makeRunner_({
|
||||
freshDatetime: '20260902T092200Z',
|
||||
freshExported: true,
|
||||
exportedVmRef: 'vm-fresh',
|
||||
})
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.deepEqual(destroyed, ['vm-base'], 'only the previous base should be reclaimed')
|
||||
assert.notEqual(runner._exportedVm, undefined, 'the exported snapshot is untouched')
|
||||
})
|
||||
|
||||
it('clears _exportedVm when a reclaimed diskless snapshot is the exported one', async () => {
|
||||
const destroyed = []
|
||||
const oldSnap = { $ref: 'dl-old', other_config: { [DATETIME]: '20240101T000000Z', [SCHEDULE_ID]: 'schedule-1' } }
|
||||
const registry = { 'dl-old': oldSnap }
|
||||
|
||||
const runner = makeRunner({
|
||||
_exportedVm: oldSnap,
|
||||
_vm: { uuid: 'live-uuid', $snapshots: [] },
|
||||
_baseSettings: { snapshotRetention: 0 },
|
||||
_jobSnapshotVdis: [],
|
||||
_disklessJobSnapshotVms: [oldSnap],
|
||||
job: { mode: 'full', settings: {} },
|
||||
_xapi: {
|
||||
barrier: async () => {},
|
||||
getObject: ref => registry[ref],
|
||||
VM_destroy: async ref => {
|
||||
destroyed.push(ref)
|
||||
},
|
||||
VDI_destroy: async () => {},
|
||||
},
|
||||
})
|
||||
|
||||
await runner._removeUnusedSnapshots()
|
||||
|
||||
assert.deepEqual(destroyed, ['dl-old'], 'the diskless snapshot should be reclaimed')
|
||||
assert.equal(runner._exportedVm, undefined, '_exportedVm must not outlive the VM it points at')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() purges the snapshot data only while the snapshot exists', () => {
|
||||
// `_removeSnapshotData()` runs from the `finally` of run(), right after
|
||||
// `_removeUnusedSnapshots()`. Whether it acts is decided by `_exportedVm` alone: the
|
||||
// snapshot of a failed run is reclaimed (nothing to purge), but a synchronized snapshot
|
||||
// is hidden from retention and survives, so its data must still be reclaimed.
|
||||
const makeRunRunner = ({ copyFails = false, isSnapshot = true, snapshotReclaimed = copyFails } = {}) => {
|
||||
const calls = { markExportSuccessfull: 0, vmDestroyed: [], dataDestroyed: [] }
|
||||
|
||||
const runner = makeRunner({
|
||||
_healthCheckSr: undefined,
|
||||
// the only configuration in which _removeSnapshotData() acts
|
||||
_settings: { offlineBackup: false, snapshotRetention: 0, preferNbd: true, cbtDestroySnapshotData: true },
|
||||
_vm: {
|
||||
uuid: 'live-uuid',
|
||||
power_state: 'Halted',
|
||||
blocked_operations: {},
|
||||
update_blocked_operations: async () => {},
|
||||
$call: async () => {},
|
||||
},
|
||||
_writers: new Set([{ beforeBackup: async () => {}, afterBackup: async () => {} }]),
|
||||
_xapi: {
|
||||
VM_getDisks: async () => ['vdi-ref'],
|
||||
// used by markExportSuccessfull()
|
||||
setFieldEntry: async () => {
|
||||
calls.markExportSuccessfull++
|
||||
},
|
||||
call: async (method, ref) => {
|
||||
calls.vmDestroyed.push(`${method}:${ref}`)
|
||||
},
|
||||
VDI_dataDestroy: async ref => {
|
||||
calls.dataDestroyed.push(ref)
|
||||
},
|
||||
},
|
||||
_cleanMetadata: async () => {},
|
||||
_fetchJobSnapshots: async () => {},
|
||||
_selectBaseVm: async () => {},
|
||||
_removeUnusedSnapshots: async function () {
|
||||
if (snapshotReclaimed) {
|
||||
this._forgetExportedVm(this._exportedVm?.$ref)
|
||||
}
|
||||
},
|
||||
_snapshot: async function () {
|
||||
this._exportedVm = {
|
||||
$ref: 'snapshot-ref',
|
||||
is_a_snapshot: isSnapshot,
|
||||
// a snapshot of the live VM; undefined for an offline backup, where the live VM is exported
|
||||
$snapshot_of: isSnapshot ? 'live-vm-ref' : undefined,
|
||||
}
|
||||
},
|
||||
_copy: async () => {
|
||||
if (copyFails) {
|
||||
throw new Error('transfer failed: target unreachable')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { runner, calls }
|
||||
}
|
||||
|
||||
it('does not purge anything when the failed run snapshot has been reclaimed', async () => {
|
||||
const { runner, calls } = makeRunRunner({ copyFails: true })
|
||||
|
||||
await assert.rejects(runner.run(), /transfer failed/)
|
||||
|
||||
assert.deepEqual(calls.vmDestroyed, [], 'the snapshot is already destroyed: nothing to purge')
|
||||
assert.deepEqual(calls.dataDestroyed, [], 'no VDI to data_destroy either')
|
||||
})
|
||||
|
||||
it('purges the snapshot data of a failed run whose snapshot survived retention', async () => {
|
||||
// synchronized snapshot: hidden from retention, so it is still holding its data
|
||||
const { runner, calls } = makeRunRunner({ copyFails: true, snapshotReclaimed: false })
|
||||
|
||||
await assert.rejects(runner.run(), /transfer failed/)
|
||||
|
||||
assert.deepEqual(calls.vmDestroyed, ['VM.destroy:snapshot-ref'], 'the surviving snapshot should be destroyed')
|
||||
assert.deepEqual(calls.dataDestroyed, ['vdi-ref'], 'its data should be reclaimed, as before this fix')
|
||||
})
|
||||
|
||||
it('purges the snapshot data when the transfer succeeded', async () => {
|
||||
const { runner, calls } = makeRunRunner()
|
||||
|
||||
await runner.run()
|
||||
|
||||
assert.equal(calls.markExportSuccessfull, 2, 'the exported snapshot and its VDIs should be marked')
|
||||
assert.deepEqual(calls.vmDestroyed, ['VM.destroy:snapshot-ref'], 'the exported snapshot should be purged')
|
||||
assert.deepEqual(calls.dataDestroyed, ['vdi-ref'], 'its data should be reclaimed')
|
||||
})
|
||||
|
||||
it('does not purge anything when the live VM was exported (offline backup)', async () => {
|
||||
const { runner, calls } = makeRunRunner({ isSnapshot: false })
|
||||
|
||||
await runner.run()
|
||||
|
||||
assert.equal(calls.markExportSuccessfull, 0, 'the live VM is not marked as exported')
|
||||
assert.deepEqual(calls.vmDestroyed, [], 'the live VM must never be destroyed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user