mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-14 03:00:25 -06:00
Compare commits
660 Commits
core/fulls
...
web-stack/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dba45f6cf | ||
|
|
b112a72f23 | ||
|
|
069ed9215f | ||
|
|
93a11cfb28 | ||
|
|
fd9c9b214a | ||
|
|
34c1cce029 | ||
|
|
320a61323c | ||
|
|
db9c85df7a | ||
|
|
c4104b2d58 | ||
|
|
79847a9129 | ||
|
|
0ec86e409c | ||
|
|
c41ae05275 | ||
|
|
9af2463922 | ||
|
|
22f9b3598d | ||
|
|
97e7b9e5fe | ||
|
|
87cfa649d3 | ||
|
|
a945888e63 | ||
|
|
4833c143de | ||
|
|
8c8519f02d | ||
|
|
8ddd7b5290 | ||
|
|
2b679c6cc1 | ||
|
|
2f2a0aeed0 | ||
|
|
68168647aa | ||
|
|
fd7d6b7ad7 | ||
|
|
086a42f38d | ||
|
|
d864cb57e0 | ||
|
|
ee76c2c481 | ||
|
|
28cd54780d | ||
|
|
e5acbe7a32 | ||
|
|
8bfd864ed8 | ||
|
|
3c0a5df8e8 | ||
|
|
14da4fb8df | ||
|
|
5a933ed9ef | ||
|
|
56a18eba9a | ||
|
|
1aef44bca5 | ||
|
|
04dd9e9c40 | ||
|
|
4a54685829 | ||
|
|
1bc0f8f245 | ||
|
|
02b067e1c5 | ||
|
|
e263f891ff | ||
|
|
7132b119c7 | ||
|
|
eb4b2c00ce | ||
|
|
5a88a48146 | ||
|
|
c7657a5489 | ||
|
|
708b9b5bc8 | ||
|
|
c08f84bf04 | ||
|
|
d67a2ade37 | ||
|
|
568e14f7ee | ||
|
|
c87d7a9eaf | ||
|
|
69347e9a4b | ||
|
|
bfb5ecc270 | ||
|
|
6b2efe7039 | ||
|
|
e87334c0de | ||
|
|
427394f63f | ||
|
|
2fda4130fe | ||
|
|
3b4e772eac | ||
|
|
511e2f70d1 | ||
|
|
b41764405e | ||
|
|
11a801c129 | ||
|
|
07f8468317 | ||
|
|
ba679a24c1 | ||
|
|
10eafb493f | ||
|
|
5219e41a64 | ||
|
|
fa97887d7e | ||
|
|
c5570c1e15 | ||
|
|
d5217f92f9 | ||
|
|
4082f12ad3 | ||
|
|
01cc733cac | ||
|
|
49d5f273f2 | ||
|
|
904273f48b | ||
|
|
e80b39e417 | ||
|
|
96769322a7 | ||
|
|
be05b69d41 | ||
|
|
a22e4a6340 | ||
|
|
aecc5f6aa9 | ||
|
|
b2ac0d3abc | ||
|
|
ad9a05b03e | ||
|
|
65d98eb5a9 | ||
|
|
1bc3d5c1bd | ||
|
|
fa974f920c | ||
|
|
87b7016669 | ||
|
|
21f0643c4d | ||
|
|
7445acdafe | ||
|
|
1e98a3ed26 | ||
|
|
a5ad395b33 | ||
|
|
86c45ef3de | ||
|
|
9939cee832 | ||
|
|
b120f5486c | ||
|
|
c72e6ab66e | ||
|
|
7c68ff83cf | ||
|
|
2a8acd2770 | ||
|
|
6825b24108 | ||
|
|
0603b63035 | ||
|
|
99c56d282b | ||
|
|
29b4c0a764 | ||
|
|
b81e83c619 | ||
|
|
d7d4b40d66 | ||
|
|
a3340a7a7e | ||
|
|
91bfdf54ec | ||
|
|
317a6587d3 | ||
|
|
d229e2b82e | ||
|
|
36bab99a10 | ||
|
|
2dafb3a1ec | ||
|
|
09b4246207 | ||
|
|
92d504012a | ||
|
|
cd6a856986 | ||
|
|
2f20370aec | ||
|
|
e5abd5c390 | ||
|
|
834c32f1da | ||
|
|
b46598f2b1 | ||
|
|
d317e70282 | ||
|
|
f9c7aa92f9 | ||
|
|
fc57a7f9be | ||
|
|
9abfc1509c | ||
|
|
224c09ed57 | ||
|
|
10a82b07a9 | ||
|
|
d6e76f4558 | ||
|
|
75a19e7ea0 | ||
|
|
05c4e8ea8a | ||
|
|
ec63244ada | ||
|
|
bc5182b3ca | ||
|
|
ce16eb5ee4 | ||
|
|
c7ddf33bcf | ||
|
|
02f34c18e6 | ||
|
|
86accbf912 | ||
|
|
efee92dbdf | ||
|
|
28735191bc | ||
|
|
96abd49646 | ||
|
|
d8c4b60b63 | ||
|
|
41bc59bdf4 | ||
|
|
a8fd967576 | ||
|
|
9de714b5d6 | ||
|
|
a4ba695125 | ||
|
|
7e4c33b2f7 | ||
|
|
bb51de262d | ||
|
|
4a7a2b74e0 | ||
|
|
804af1dc68 | ||
|
|
3a0155b464 | ||
|
|
ff1189ef69 | ||
|
|
350fae90eb | ||
|
|
4fc97a572e | ||
|
|
f077ccb09a | ||
|
|
8ffd7f993c | ||
|
|
34bccbda56 | ||
|
|
07ad2ca201 | ||
|
|
f1a7436a90 | ||
|
|
7c9e1439a9 | ||
|
|
9ec7462a04 | ||
|
|
73e170e849 | ||
|
|
10150e5902 | ||
|
|
30e6d4b4f1 | ||
|
|
b018e5acb3 | ||
|
|
f2fe7731d9 | ||
|
|
d89fb329ef | ||
|
|
5b34022d1d | ||
|
|
297b9b2025 | ||
|
|
53266ec8f6 | ||
|
|
163e80b6c5 | ||
|
|
04b3fc26fb | ||
|
|
c0b61d5f67 | ||
|
|
2da3721d83 | ||
|
|
0cf1d52935 | ||
|
|
41e847a32f | ||
|
|
14e9059cb3 | ||
|
|
58bace558e | ||
|
|
40fc999a3a | ||
|
|
8116535b1b | ||
|
|
95e5f50ce7 | ||
|
|
d1cdf6c0c5 | ||
|
|
bc74a0bd42 | ||
|
|
0eb50099af | ||
|
|
65fe1d83be | ||
|
|
8c85a4657b | ||
|
|
f236238c73 | ||
|
|
3d681f868a | ||
|
|
c79efa0fe8 | ||
|
|
bc5fa0f3f5 | ||
|
|
fa9f865494 | ||
|
|
ba3753ab1a | ||
|
|
a71e78fdbc | ||
|
|
a3cb9d5619 | ||
|
|
cc1d492510 | ||
|
|
d0b7d305a2 | ||
|
|
c0bc294886 | ||
|
|
ac8659021f | ||
|
|
6756c9de99 | ||
|
|
56f3d9d7a9 | ||
|
|
649add3ab3 | ||
|
|
78ae16b21e | ||
|
|
4102810be6 | ||
|
|
86883857d4 | ||
|
|
79450a1c51 | ||
|
|
6495d563f5 | ||
|
|
761c03e46c | ||
|
|
1eeff13de1 | ||
|
|
2027f8a282 | ||
|
|
2e8d398623 | ||
|
|
fff3ec1f8b | ||
|
|
a7d7cf18c0 | ||
|
|
00d1792578 | ||
|
|
8f6c087e3a | ||
|
|
e886e91271 | ||
|
|
3d054c8358 | ||
|
|
a596702a96 | ||
|
|
bfb2ea6b17 | ||
|
|
52c79caa45 | ||
|
|
8d48797639 | ||
|
|
30172caa1f | ||
|
|
a9b99a8b45 | ||
|
|
9321b04c5f | ||
|
|
6c258af43a | ||
|
|
0a28abaada | ||
|
|
a609e98c40 | ||
|
|
ac06a8ef49 | ||
|
|
2ea2f8040a | ||
|
|
f6e6e052ef | ||
|
|
748ed07241 | ||
|
|
e5b833dada | ||
|
|
68b1641e07 | ||
|
|
ad8cd3791b | ||
|
|
030b3ad4d1 | ||
|
|
cfea2b3a2b | ||
|
|
44e33666cf | ||
|
|
939ab29d04 | ||
|
|
cdfe0b0374 | ||
|
|
eb39e14972 | ||
|
|
d520e53f9e | ||
|
|
f7df1c4109 | ||
|
|
558ea84880 | ||
|
|
0e9242be51 | ||
|
|
943ad781cc | ||
|
|
6b21f08ab0 | ||
|
|
ea5358e76b | ||
|
|
742109ccba | ||
|
|
00f2f8c103 | ||
|
|
291c5a430d | ||
|
|
9ad13608eb | ||
|
|
fc3e954d3e | ||
|
|
cc3d5f2efc | ||
|
|
090c4ecb23 | ||
|
|
b78c7d228d | ||
|
|
13f2afb0d6 | ||
|
|
f651943add | ||
|
|
c0f1d61c01 | ||
|
|
e65823b4a5 | ||
|
|
75684cbe12 | ||
|
|
971a561879 | ||
|
|
d6bba81d36 | ||
|
|
206f59575a | ||
|
|
dbf3d82a1b | ||
|
|
d3414a94c6 | ||
|
|
7e9b4447bf | ||
|
|
8a10070f4d | ||
|
|
b365e4aa25 | ||
|
|
28709b660e | ||
|
|
19067f7f6c | ||
|
|
a297160432 | ||
|
|
381c83d0b3 | ||
|
|
7e14152473 | ||
|
|
21bd7394fa | ||
|
|
89aa7df744 | ||
|
|
b62e8035fc | ||
|
|
02ed1bcee2 | ||
|
|
63a88b1525 | ||
|
|
2b1d6f21a0 | ||
|
|
e13c0789e8 | ||
|
|
778362d8db | ||
|
|
530c30fd50 | ||
|
|
4debc0f893 | ||
|
|
57a399c64d | ||
|
|
0acd7c3530 | ||
|
|
088bd7cfd3 | ||
|
|
b47019e0e4 | ||
|
|
aa490dc066 | ||
|
|
b8388bb255 | ||
|
|
f969056d02 | ||
|
|
5e11547439 | ||
|
|
04f3a09ab0 | ||
|
|
385ff21f4a | ||
|
|
40f5f48f17 | ||
|
|
74e6fa26a6 | ||
|
|
e26e1a2f90 | ||
|
|
c7970b3809 | ||
|
|
4c7acc1614 | ||
|
|
fd18bee62f | ||
|
|
a9ad679933 | ||
|
|
cd2b7a2553 | ||
|
|
b061ff63e7 | ||
|
|
28d1d8b473 | ||
|
|
8f5c4f336d | ||
|
|
3c5ad184b5 | ||
|
|
3ced19b4dc | ||
|
|
9504132585 | ||
|
|
fc67abd25b | ||
|
|
72d0866174 | ||
|
|
073b15ed07 | ||
|
|
7c31b44bb0 | ||
|
|
e830bf0f0c | ||
|
|
790ba9bdd1 | ||
|
|
6c8d41db00 | ||
|
|
576a3aa007 | ||
|
|
af17f4a28a | ||
|
|
2f7e0d0963 | ||
|
|
01c71414ae | ||
|
|
1ca468064f | ||
|
|
69ffac65f0 | ||
|
|
f38b449624 | ||
|
|
07cd06c245 | ||
|
|
813ca246c4 | ||
|
|
3b217ea975 | ||
|
|
2151450584 | ||
|
|
7cebe46e86 | ||
|
|
89b9a1f4b7 | ||
|
|
974668587d | ||
|
|
dfe6911aba | ||
|
|
f1780b7b0f | ||
|
|
20c88b4e90 | ||
|
|
aa9c49ce6a | ||
|
|
951de95e4b | ||
|
|
385b6b2fee | ||
|
|
a976a5a3b3 | ||
|
|
e8b32fef81 | ||
|
|
edb021e40e | ||
|
|
ae2e9e1a58 | ||
|
|
83a8f295c7 | ||
|
|
bddfb21e2d | ||
|
|
cf7dbd1c36 | ||
|
|
204cb25d2b | ||
|
|
5656ab1d48 | ||
|
|
99ffefae10 | ||
|
|
a78e1346f6 | ||
|
|
0d5f86620d | ||
|
|
18bea75d28 | ||
|
|
5e6bb22025 | ||
|
|
091da7b913 | ||
|
|
ec3fe212e1 | ||
|
|
259beead81 | ||
|
|
b1ce62f23a | ||
|
|
cd25873182 | ||
|
|
a7165da4a0 | ||
|
|
95f0ef504d | ||
|
|
732cab3ac0 | ||
|
|
fb01d20c2e | ||
|
|
034d4faa6a | ||
|
|
beb93cf66f | ||
|
|
ad1bf3b348 | ||
|
|
43c3a82800 | ||
|
|
7fc5b850ca | ||
|
|
916c15d8c7 | ||
|
|
4b47bf9220 | ||
|
|
877fe9a663 | ||
|
|
88c7c8d3ae | ||
|
|
23720dd05f | ||
|
|
d796e37df5 | ||
|
|
9734410833 | ||
|
|
aa7ff37b94 | ||
|
|
0eaa6a93a3 | ||
|
|
b33a84e5c7 | ||
|
|
e65c5d02f3 | ||
|
|
dc7fd78b4a | ||
|
|
8eae8e7f46 | ||
|
|
da84ab1590 | ||
|
|
290b5df27c | ||
|
|
72592a54d2 | ||
|
|
e5c53dfa52 | ||
|
|
9d196c4211 | ||
|
|
e15f2a1428 | ||
|
|
f78a140fbb | ||
|
|
79ce97b3fd | ||
|
|
1e7d02603b | ||
|
|
59bf23a41a | ||
|
|
39e34eede7 | ||
|
|
32228e8c6a | ||
|
|
7163002c51 | ||
|
|
700149dabe | ||
|
|
06af045dac | ||
|
|
b6ccb72e43 | ||
|
|
14da422b8b | ||
|
|
5dd305228b | ||
|
|
e75cccf1be | ||
|
|
0e6bc063db | ||
|
|
0291d42af7 | ||
|
|
656786eb86 | ||
|
|
4dfd77b2d2 | ||
|
|
895a551c7b | ||
|
|
e750a66c02 | ||
|
|
4d768252f0 | ||
|
|
f0f5cdba72 | ||
|
|
8cc3d0cbaf | ||
|
|
3284021964 | ||
|
|
e333332131 | ||
|
|
14325fe992 | ||
|
|
ddfdce9da5 | ||
|
|
a2c62117a1 | ||
|
|
cb6cfc3a74 | ||
|
|
9d61e0232d | ||
|
|
fedab820f1 | ||
|
|
7eac1d1c5a | ||
|
|
06a9ab6718 | ||
|
|
d0bd6562d0 | ||
|
|
7b60fab7ea | ||
|
|
045cae24fb | ||
|
|
bd7e82cb6b | ||
|
|
307179b142 | ||
|
|
0a3c32f9d2 | ||
|
|
124d96f31a | ||
|
|
916418b6cf | ||
|
|
7592734c9a | ||
|
|
b1088e2a5b | ||
|
|
83715bedc6 | ||
|
|
e30f289073 | ||
|
|
0977542dbd | ||
|
|
c9699c8f02 | ||
|
|
636c88539f | ||
|
|
333012bd29 | ||
|
|
8fd516040b | ||
|
|
2017ec26ad | ||
|
|
72d6da38c5 | ||
|
|
59784315a8 | ||
|
|
c5f6bf574f | ||
|
|
991b4641ae | ||
|
|
0fee31b345 | ||
|
|
d58ee9611f | ||
|
|
da9d796d3f | ||
|
|
e4c66bc0e8 | ||
|
|
d61429852d | ||
|
|
a06e78c77c | ||
|
|
e66bdda6a1 | ||
|
|
659c735b70 | ||
|
|
35c6248251 | ||
|
|
4344e1567c | ||
|
|
2291505865 | ||
|
|
92acd16779 | ||
|
|
3c5307cd64 | ||
|
|
f55ffc7d06 | ||
|
|
6c118e6af5 | ||
|
|
d0984a7cb4 | ||
|
|
76336fa422 | ||
|
|
2a9f5bcf53 | ||
|
|
9d3ec46086 | ||
|
|
79690fcdfa | ||
|
|
23e42c1764 | ||
|
|
4b8f7146f6 | ||
|
|
8172a327e4 | ||
|
|
919d215b64 | ||
|
|
161169f295 | ||
|
|
e91b7f8571 | ||
|
|
60291e317c | ||
|
|
c704f156fc | ||
|
|
9a811d7f1e | ||
|
|
a97d55c53c | ||
|
|
8542b83f7c | ||
|
|
73755c0e8c | ||
|
|
0702454fa8 | ||
|
|
a0c3920d00 | ||
|
|
d4637e3335 | ||
|
|
6fcb838b70 | ||
|
|
065c44cc92 | ||
|
|
b539b5f5e3 | ||
|
|
d1ce35f20a | ||
|
|
3f754fdc05 | ||
|
|
57d2d301ae | ||
|
|
d2507025a9 | ||
|
|
de934ec609 | ||
|
|
6894841038 | ||
|
|
c5e4f05d3b | ||
|
|
dad3dc5926 | ||
|
|
3fe46cae64 | ||
|
|
14c5d2c4ec | ||
|
|
4a4122b9a6 | ||
|
|
acffdbec8f | ||
|
|
f9220cd272 | ||
|
|
518b0f77bd | ||
|
|
b35a5cffa6 | ||
|
|
bb88834fcd | ||
|
|
e492be3a67 | ||
|
|
a223207ff3 | ||
|
|
4d42f86b2c | ||
|
|
da3aa59781 | ||
|
|
2b46e6cf30 | ||
|
|
9406d20672 | ||
|
|
0e6d3ae27a | ||
|
|
ee3bc185da | ||
|
|
28f94b3888 | ||
|
|
e44933e2f7 | ||
|
|
30dd89243f | ||
|
|
96b7673e27 | ||
|
|
69a312a0c4 | ||
|
|
a0e6c8b115 | ||
|
|
dc047d47ea | ||
|
|
a00b24f6b8 | ||
|
|
7601dee7f2 | ||
|
|
1ace3cbbe8 | ||
|
|
8651e61210 | ||
|
|
6c6b6b9fa1 | ||
|
|
6374e0d3f5 | ||
|
|
664d4b1af6 | ||
|
|
0117e9dbc8 | ||
|
|
174e32173f | ||
|
|
0e4a300058 | ||
|
|
76b54ec579 | ||
|
|
7b256ab8ee | ||
|
|
7a2bb5104c | ||
|
|
56c881fd2a | ||
|
|
db89485efe | ||
|
|
3c6a7a9575 | ||
|
|
992d636bcd | ||
|
|
253aac4eaa | ||
|
|
ea551a5175 | ||
|
|
fe1f5cbad7 | ||
|
|
ce4c2e17af | ||
|
|
e5608680ec | ||
|
|
6ee7a274d9 | ||
|
|
ffc93250f0 | ||
|
|
52b27ae884 | ||
|
|
f5cee45c02 | ||
|
|
818a5b7f5b | ||
|
|
19a2f4e002 | ||
|
|
83920589db | ||
|
|
bc1db9960a | ||
|
|
5bf1aea761 | ||
|
|
dcf6885dfa | ||
|
|
37a0d9da7c | ||
|
|
abcbeb8719 | ||
|
|
8db808a415 | ||
|
|
9383306231 | ||
|
|
3235963cfb | ||
|
|
b393d72eb9 | ||
|
|
cd2942af71 | ||
|
|
e92cb66954 | ||
|
|
dac0b20a19 | ||
|
|
0fc5d60791 | ||
|
|
2df144d43d | ||
|
|
758e3c47b8 | ||
|
|
cfcc78b6d8 | ||
|
|
5f5580925d | ||
|
|
a667e7cdbd | ||
|
|
a14c4bbb1c | ||
|
|
2db21d3086 | ||
|
|
6b1036f5ce | ||
|
|
5cc6b4fe70 | ||
|
|
442dd16813 | ||
|
|
6e000af7cb | ||
|
|
3c13e0ab43 | ||
|
|
7b8a4e7209 | ||
|
|
c63d2ab00b | ||
|
|
ba7a29eb5e | ||
|
|
304b06ca97 | ||
|
|
0661fbae78 | ||
|
|
16ae1c0d4b | ||
|
|
9c29046a88 | ||
|
|
bb0fd2898e | ||
|
|
276d920cca | ||
|
|
dd8ad3f274 | ||
|
|
7ddd579818 | ||
|
|
290f510843 | ||
|
|
c134bbaee3 | ||
|
|
e1dd5354a1 | ||
|
|
070258d0e3 | ||
|
|
f5a49b6da3 | ||
|
|
a85f44d699 | ||
|
|
31461118e0 | ||
|
|
306a58e412 | ||
|
|
32448cfa1b | ||
|
|
3dde723e31 | ||
|
|
be5a523b95 | ||
|
|
ad18a860f3 | ||
|
|
be2efce595 | ||
|
|
4295dad3ac | ||
|
|
cdf902bdf3 | ||
|
|
97dd211d0f | ||
|
|
0f11e4c4c4 | ||
|
|
33a388193c | ||
|
|
bed18fc954 | ||
|
|
1d8385e1f0 | ||
|
|
11fbbf5158 | ||
|
|
209dddda2b | ||
|
|
7f0984aff0 | ||
|
|
3f32f9981e | ||
|
|
faf54e18a8 | ||
|
|
f70f4ea0e6 | ||
|
|
c346cadf24 | ||
|
|
00c8f37e73 | ||
|
|
ee6fa67246 | ||
|
|
691f36a223 | ||
|
|
3c41d612d0 | ||
|
|
0294c3dd68 | ||
|
|
6c25d9e147 | ||
|
|
58cc1de804 | ||
|
|
3f4ca1b322 | ||
|
|
99e44fe139 | ||
|
|
b87bc29f0e | ||
|
|
f6672a643f | ||
|
|
2a2fe93476 | ||
|
|
93303ca7ac | ||
|
|
3a211524cb | ||
|
|
1bba37adb1 | ||
|
|
8c4fcc43d2 | ||
|
|
e20e3526a5 | ||
|
|
17fec5876c | ||
|
|
4cf03cf0a2 | ||
|
|
57e44734e2 | ||
|
|
3075ce9232 | ||
|
|
196f3969eb | ||
|
|
d635de0a96 | ||
|
|
d1e3d79bf8 | ||
|
|
5adec6b2ef | ||
|
|
b2b6262aea | ||
|
|
82d3ec6033 | ||
|
|
3dd5a6ba13 | ||
|
|
0215ac2491 | ||
|
|
3044a429cc | ||
|
|
53ee6988db | ||
|
|
89950e77f4 | ||
|
|
800655a9a3 | ||
|
|
59fbb3ef48 | ||
|
|
9133e32dd0 | ||
|
|
cb6df6d155 | ||
|
|
20f398398f | ||
|
|
c4d1dee6fd | ||
|
|
93627a794c | ||
|
|
bfb2442df1 | ||
|
|
24d584e905 | ||
|
|
882facd246 | ||
|
|
4c37be5b31 | ||
|
|
5811a2ede2 | ||
|
|
8fb918a64d | ||
|
|
682198df42 | ||
|
|
de9288b432 | ||
|
|
42d58e9dbf | ||
|
|
d55f614540 | ||
|
|
61ecc85beb | ||
|
|
08dc33bc9d | ||
|
|
e23f649e74 | ||
|
|
f0cd83e3c4 | ||
|
|
136ea23199 | ||
|
|
73ea4fa6b4 | ||
|
|
990c23b8f5 | ||
|
|
5fca2f0c74 | ||
|
|
5cd9e50fa3 | ||
|
|
18a2b5b549 | ||
|
|
da9b9419ae | ||
|
|
8c599b0bad | ||
|
|
1ded79169b | ||
|
|
bea123f347 | ||
|
|
88fb977db9 | ||
|
|
b48e16c316 | ||
|
|
a8ffa91d41 | ||
|
|
6a67944112 | ||
|
|
4908982263 | ||
|
|
6444f88662 | ||
|
|
644654c7eb | ||
|
|
5629bbef05 | ||
|
|
a627fe31e2 | ||
|
|
2534d9ff38 | ||
|
|
13863beed8 | ||
|
|
796bb60b62 | ||
|
|
7882debe7a | ||
|
|
548a2e8141 | ||
|
|
63f89bb6af |
176
.eslintrc.js
176
.eslintrc.js
@@ -39,6 +39,12 @@ module.exports = {
|
||||
version: '>=16',
|
||||
},
|
||||
],
|
||||
'n/no-unsupported-features/es-builtins': [
|
||||
'error',
|
||||
{
|
||||
version: '>=16',
|
||||
},
|
||||
],
|
||||
'n/no-unsupported-features/es-syntax': [
|
||||
'error',
|
||||
{
|
||||
@@ -49,16 +55,17 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
files: ['@xen-orchestra/{web-core,lite,web}/**/*.{vue,ts}'],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
plugins: ['import'],
|
||||
extends: [
|
||||
'plugin:import/recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'@vue/eslint-config-typescript/recommended',
|
||||
'@vue/eslint-config-prettier',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:vue/vue3-essential',
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
@@ -73,11 +80,166 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
XO_LITE_VERSION: true,
|
||||
XO_LITE_GIT_HEAD: true,
|
||||
},
|
||||
rules: {
|
||||
'no-void': 'off',
|
||||
'n/no-missing-import': 'off', // using 'import' plugin instead to support TS aliases
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: ['builtin', 'object', 'internal', ['type', 'external']],
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true,
|
||||
},
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: '@/**',
|
||||
group: 'internal',
|
||||
},
|
||||
{
|
||||
pattern: '@core/**',
|
||||
group: 'internal',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-useless-constructor': 'off',
|
||||
'@typescript-eslint/no-useless-constructor': 'error',
|
||||
'no-void': ['error', { allowAsStatement: true }],
|
||||
'n/no-missing-import': 'off', // using 'import' plugin instead, to support TS aliases
|
||||
'no-redeclare': 'off', // automatically checked by the TypeScript compiler
|
||||
'no-dupe-class-members': 'off', // automatically checked by the TypeScript compiler
|
||||
'no-undef': 'off', // automatically checked by the TypeScript compiler
|
||||
'no-use-before-define': 'off', // replaced with the rule below
|
||||
'@typescript-eslint/no-use-before-define': ['error', { functions: false }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
// Vue 3 - Strongly Recommended
|
||||
'vue/attribute-hyphenation': 'error',
|
||||
'vue/component-definition-name-casing': 'error',
|
||||
'vue/first-attribute-linebreak': 'error',
|
||||
'vue/html-closing-bracket-newline': 'off', // Conflicts with Prettier
|
||||
'vue/html-closing-bracket-spacing': 'error',
|
||||
'vue/html-end-tags': 'error',
|
||||
'vue/html-indent': 'off', // Conflicts with Prettier
|
||||
'vue/html-quotes': 'error',
|
||||
'vue/html-self-closing': ['error', { html: { void: 'always', normal: 'always', component: 'always' } }],
|
||||
'vue/max-attributes-per-line': 'off', // Conflicts with Prettier
|
||||
'vue/multiline-html-element-content-newline': 'error',
|
||||
'vue/mustache-interpolation-spacing': 'error',
|
||||
'vue/no-multi-spaces': 'error',
|
||||
'vue/no-spaces-around-equal-signs-in-attribute': 'error',
|
||||
'vue/no-template-shadow': 'error',
|
||||
'vue/prop-name-casing': 'error',
|
||||
'vue/require-default-prop': 'off', // https://github.com/vuejs/eslint-plugin-vue/issues/2051
|
||||
'vue/require-explicit-emits': 'error',
|
||||
'vue/require-prop-types': 'error',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/v-bind-style': 'error',
|
||||
'vue/v-on-event-hyphenation': ['error', 'always', { autofix: true }],
|
||||
'vue/v-on-style': 'error',
|
||||
'vue/v-slot-style': 'error',
|
||||
// Vue 3 - Recommended
|
||||
'vue/attributes-order': 'error',
|
||||
'vue/no-lone-template': 'error',
|
||||
'vue/no-multiple-slot-args': 'error',
|
||||
'vue/no-v-html': 'error',
|
||||
'vue/this-in-template': 'error',
|
||||
// Vue 3 - Uncategorized
|
||||
'vue/block-lang': ['error', { script: { lang: 'ts' }, style: { lang: 'postcss' } }],
|
||||
'vue/block-order': ['error', { order: ['template', 'script', 'style'] }],
|
||||
'vue/block-tag-newline': ['error', { singleline: 'consistent', multiline: 'always', maxEmptyLines: 0 }],
|
||||
'vue/component-api-style': ['error', ['script-setup']],
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false }],
|
||||
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||
'vue/define-emits-declaration': ['error', 'type-literal'],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['defineOptions', 'defineProps', 'defineEmits', 'defineModel', 'defineSlots'],
|
||||
defineExposeLast: true,
|
||||
},
|
||||
],
|
||||
'vue/define-props-declaration': ['error', 'type-based'],
|
||||
'vue/enforce-style-attribute': ['error', { allow: ['scoped', 'module'] }],
|
||||
'vue/html-button-has-type': 'error',
|
||||
'vue/html-comment-content-newline': ['error', { singleline: 'never', multiline: 'always' }],
|
||||
'vue/html-comment-content-spacing': ['error', 'always'],
|
||||
'vue/no-duplicate-attr-inheritance': 'error',
|
||||
'vue/no-empty-component-block': 'error',
|
||||
'vue/no-multiple-objects-in-class': 'error',
|
||||
'vue/no-ref-object-reactivity-loss': 'error',
|
||||
'vue/no-required-prop-with-default': 'error',
|
||||
'vue/no-static-inline-styles': 'error',
|
||||
'vue/no-template-target-blank': 'error',
|
||||
'vue/no-undef-components': ['error', { ignorePatterns: ['RouterLink', 'RouterView', 'I18nT'] }],
|
||||
'vue/no-undef-properties': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/no-useless-mustaches': 'error',
|
||||
'vue/no-useless-v-bind': 'error',
|
||||
'vue/no-v-text': 'error',
|
||||
'vue/padding-line-between-blocks': 'error',
|
||||
'vue/prefer-define-options': 'error',
|
||||
'vue/prefer-separate-static-class': 'error',
|
||||
'vue/prefer-true-attribute-shorthand': 'error',
|
||||
'vue/require-macro-variable-name': 'error',
|
||||
'vue/valid-define-options': 'error',
|
||||
},
|
||||
},
|
||||
// Specific rules for XO i18n-related files
|
||||
{
|
||||
files: [
|
||||
'@xen-orchestra/lite/**/*.{vue,ts,json}',
|
||||
'@xen-orchestra/web/**/*.{vue,ts,json}',
|
||||
'@xen-orchestra/web-core/**/*.{vue,ts,json}',
|
||||
],
|
||||
extends: ['plugin:@intlify/vue-i18n/base'],
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: [
|
||||
'@xen-orchestra/lite/src/locales/*.json',
|
||||
'@xen-orchestra/web/src/locales/*.json',
|
||||
'@xen-orchestra/web-core/lib/locales/*.json',
|
||||
],
|
||||
messageSyntaxVersion: '^9.9.0',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
strict: 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'@intlify/vue-i18n/no-deprecated-i18n-component': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-i18n-place-attr': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-i18n-places-prop': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-modulo-syntax': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-tc': 'error',
|
||||
'@intlify/vue-i18n/no-html-messages': 'error',
|
||||
'@intlify/vue-i18n/no-i18n-t-path-prop': 'error',
|
||||
'@intlify/vue-i18n/no-missing-keys': 'error',
|
||||
'@intlify/vue-i18n/no-raw-text': [
|
||||
'error',
|
||||
{
|
||||
ignoreText: ['Xen Orchestra Lite', 'Xen Orchestra', 'XO Lite', 'XCP-ng', 'XO 5', '404', '⚠️'],
|
||||
},
|
||||
],
|
||||
'@intlify/vue-i18n/no-v-html': 'error',
|
||||
'@intlify/vue-i18n/valid-message-syntax': 'error',
|
||||
'@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
|
||||
'@intlify/vue-i18n/no-missing-keys-in-other-locales': ['error', { ignoreLocales: ['de', 'fa', 'cs'] }],
|
||||
},
|
||||
},
|
||||
// Specific rules for XO dev-related files
|
||||
{
|
||||
files: [
|
||||
'@xen-orchestra/lite/src/stories/**/*',
|
||||
'@xen-orchestra/lite/src/components/component-story/**/*',
|
||||
'@xen-orchestra/web/src/pages/dev/**/*',
|
||||
'@xen-orchestra/lite/src/views/story/**/*',
|
||||
],
|
||||
rules: {
|
||||
'@intlify/vue-i18n/no-raw-text': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -101,6 +263,8 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
|
||||
ignorePatterns: ['@vates/fatfs/'],
|
||||
|
||||
parserOptions: {
|
||||
ecmaVersion: 13,
|
||||
sourceType: 'script',
|
||||
@@ -124,7 +288,7 @@ module.exports = {
|
||||
// this rule can prevent race condition bugs like parallel `a += await foo()`
|
||||
//
|
||||
// as it has a lots of false positive, it is only enabled as a warning for now
|
||||
'require-atomic-updates': 'warn',
|
||||
'require-atomic-updates': ['warn', { allowProperties: true }],
|
||||
|
||||
strict: 'error',
|
||||
},
|
||||
|
||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -5,34 +5,46 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# https://github.com/actions/checkout
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit
|
||||
|
||||
# https://turbo.build/repo/docs/guides/ci-vendors/github-actions#caching-with-github-actionscache
|
||||
- name: Cache Turbo
|
||||
# https://github.com/actions/cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: '**/node_modules/.cache/turbo'
|
||||
key: ${{ runner.os }}-turbo-cache
|
||||
path: '.turbo'
|
||||
key: ${{ runner.os }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-turbo-
|
||||
|
||||
# https://github.com/actions/setup-node
|
||||
- name: Setup Node environment
|
||||
# https://github.com/actions/setup-node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install project dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Ensure packages are sorted in CHANGELOG.unreleased.md
|
||||
run: ./scripts/gen-deps-list.js --check-order > /dev/null
|
||||
|
||||
- name: Ensure yarn.lock is up-to-date
|
||||
run: git diff --exit-code yarn.lock
|
||||
|
||||
- name: Build the project
|
||||
run: yarn build
|
||||
|
||||
- name: Unit tests
|
||||
run: yarn test-unit
|
||||
|
||||
- name: Lint tests
|
||||
run: yarn test-lint
|
||||
|
||||
- name: Integration tests
|
||||
run: sudo yarn test-integration
|
||||
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# Ignore Handlebars files because partials are not currently supported
|
||||
# https://github.com/prettier/prettier/issues/11834
|
||||
**/*.hbs
|
||||
|
||||
# Generated files
|
||||
/@xen-orchestra/web/typed-router.d.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it, beforeEach } = require('test')
|
||||
const { describe, it, beforeEach } = require('node:test')
|
||||
const assert = require('assert').strict
|
||||
const { spy } = require('sinon')
|
||||
|
||||
|
||||
@@ -30,10 +30,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1"
|
||||
"sinon": "^18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('assert')
|
||||
|
||||
const { coalesceCalls } = require('./')
|
||||
|
||||
@@ -31,9 +31,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert').strict
|
||||
|
||||
const { compose } = require('./')
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const { decorateClass, decorateWith, decorateMethodsWith, perInstance } = require('./')
|
||||
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
const test = require('node:test')
|
||||
|
||||
const diff = require('./index.js')
|
||||
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const { useFakeTimers, spy, assert } = require('sinon')
|
||||
|
||||
const { createDebounceResource } = require('./debounceResource')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const { spy, assert } = require('sinon')
|
||||
|
||||
const { deduped } = require('./deduped')
|
||||
|
||||
@@ -14,22 +14,21 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/multi-key-map": "^0.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1"
|
||||
"sinon": "^18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
3
@vates/fatfs/.gitignore
vendored
Normal file
3
@vates/fatfs/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
.vscode/
|
||||
node_modules/
|
||||
127
@vates/fatfs/README.md
Normal file
127
@vates/fatfs/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# @vates/fatfs
|
||||
|
||||
This is a fork of the original library [natevw/fatfs](https://github.com/natevw/fatfs).
|
||||
|
||||
## Why?
|
||||
|
||||
This fork was created to add a missing feature in the original library: the ability to create labels (createLabel).
|
||||
The original library did not support this functionality, which was required to make Cloudbase-Init work on windows VMs.
|
||||
See: https://github.com/natevw/fatfs/issues/30, https://github.com/natevw/fatfs/pull/31
|
||||
|
||||
A standalone FAT16/FAT32 implementation that takes in a block-access interface and exposes something quite similar to `require('fs')` (i.e. the node.js built-in [Filesystem API](http://nodejs.org/api/fs.html)).
|
||||
|
||||
## Installation
|
||||
|
||||
`npm install fatfs`
|
||||
|
||||
## Example
|
||||
|
||||
```js
|
||||
var fatfs = require('fatfs'),
|
||||
fs = fatfs.createFileSystem(exampleDriver) // see below
|
||||
fs.stat('autoexec.bat', function (e, stats) {
|
||||
if (e) console.error(e)
|
||||
else console.log(stats)
|
||||
})
|
||||
// TODO: open a file and write to it or something…
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `fs = fatfs.createFileSystem(vol, [opts], [cb])` — Simply pass in a block driver (see below) mapped to a FAT partition somewhere, and get back the API documented [here](http://nodejs.org/api/fs.html). An options dictionary can be provided, details are in the next section. You may also optionally provide a callback `cb(err)` which will be automatically registered for the on `'ready'` or `'error'` event.
|
||||
- `'ready'` event — fired on `fs` when initial volume information has been determined and the API is ready to use. It is safe to call other `fs` methods before this fires **only if** you are sure the first sector will be readable and represents a valid FAT volume.
|
||||
- `'error'` event — fired if initialization fails for whatever reason.
|
||||
|
||||
### Filesystem options
|
||||
|
||||
The `opts` dictionary you pass to `fatfs.createFileSystem` can contain any of the following options:
|
||||
|
||||
- `ro` — Enables readonly mode if `true`. It defaults to `false`, but if your volume driver does not provide a `writeSectors` method it will be overriden to `true`.
|
||||
- `noatime` — The FAT filesystem can track the last access time (just a date, actually) but this means every read operation would also incur some write overhead. Defaults to `true`, meaning by default access times will **not** be updated on reads. Set this to `false` to track access times.
|
||||
- `modmode` — chooses how `fs.chmod` (and the mode field from `fs.stat`–family calls) should map FAT attributes to POSIX permissions. Set to the number `0111` to map the readonly flag to the user's write bit being unset, and the archive/system/hidden flags to the user/group/other executable bits respectively. Set to the number `07000` to map the readonly flag to _all_ write bits being unset, and the archive/system/hidden flags to the sticky/setgid/setuid bits respectively. Set to `null` for readonly mapping. Defaults to `0111`.
|
||||
- `umask` — any bits _set_ in this octal number will be _unset_ in the 'mode' field from `fs.stat`–family calls. It does not affect anything else. Defaults to `process.umask()`, or `0022` if that is unavailable.
|
||||
- `uid` — This value will be returned as the 'uid' stat field. It does not affect anything else. Defaults to `process.getuid()`, or `0` if that is unavailable.
|
||||
- `gid` — This value will be returned as the 'gid' stat field. It does not affect anything else. Defaults to `process.getgid()`, or `0` if that is unavailable.
|
||||
|
||||
(Note that these are similar to the options you could use with a POSIX `mount` operation.)
|
||||
|
||||
And that's it! The [rest of the API](http://nodejs.org/api/fs.html) (`fs.readdir`, `fs.open`, `fs.createReadStream`, `fs.appendFile`, etc.) is as documented by the node.js project.
|
||||
|
||||
Well, sort of…
|
||||
|
||||
## Caveats
|
||||
|
||||
### Temporary
|
||||
|
||||
- **BETA** **BETA** **BETA**. Seriously, this is a _brand new_, _from scratch_, _completely unproven_ filesystem implementation. It does not have full automated test coverage, and it has not been manually tested very much either. Please please please **make sure you have a backup** of any important drive/image/card you unleash this upon.
|
||||
- A few methods are not quite implemented, either: `fs.rename`, `fs.unlink` and `fs.rmdir`, as well as `fs.watchFile`/`fs.unwatchFile` and `fs.watch`. These are Coming Soon™.
|
||||
- There are several internal housekeeping items (redundant FAT tables, extra FAT32 information, etc.) that are not done. These do not seem to affect interop, but you may see warnings when repairing a filesystem written by this module.
|
||||
- Oh, and not to scare you, but if an IO error happens while writing, the library usually just bails — bubbling an error up to your callback as if it were a hot potato. Although some attempt has been made to do separate writes in the safest order (e.g. allocating an additional file cluster, then appending data into it, and then finally updating the file's size), but this behavior has not been thoroughly audited for all operations. There's certainly no attempt to retry/cleanup/rollback if a multi-step change runs into trouble partway through.
|
||||
|
||||
### As-planned
|
||||
|
||||
Some of the differences between `fatfs` and the node.js `fs` module are "by design" for architectural simplicity and/or due to underlying FAT limitations.
|
||||
|
||||
- There are no `fs.*Sync` methods. (The volume driver is async, not to mention that supporting a separate \*Sync codepath would be an enormous duplication of effort of dubious value.)
|
||||
- This module does [almost] no read/write caching. This should be done in your volume driver, but see notes below.
|
||||
- You'll need multiple `createFileSystem` instances for multiple volumes; paths are relative to each, and don't share a namespace.
|
||||
- The FAT filesystem has no concept of symlinks, and hardlinks are not really an intentional feature. You will get an ENOSYS-like error when trying to create either type of link.
|
||||
|
||||
## "Volume driver" API
|
||||
|
||||
To use 'fatfs', you must provide a driver object with the following properties/methods:
|
||||
|
||||
- `driver.sectorSize` — number of bytes per sector on this device
|
||||
- `driver.numSectors` — count of sectors available on this media
|
||||
- `driver.readSectors(i, dest, cb)` — Fill `dest` with data starting at the `i`th sector and notify `cb(e)` when finished. You may assume `dest.length` is a multiple of `driver.sectorSize`.
|
||||
- `driver.writeSectors(i, data, cb)` — (optional) Write `data` starting at the `i`th sector and notify `cb(e)` when finished. You may assume `data.length` is a multiple of `driver.sectorSize`.
|
||||
|
||||
If you do not provide a `writeSectors` method, then `fatfs` will work in readonly mode. Pretty simple, eh? And the 'fatfs' module makes a good effort to check the parameters passed to your driver methods!
|
||||
|
||||
**TBD:** to facilitate proper cache handling, this module might add an optional `driver.flush(cb)` method at some point in the future.
|
||||
|
||||
Here's an example taken from code used to run this module's own tests:
|
||||
|
||||
```js
|
||||
// NOTE: this assumes image at `path` has no partition table.
|
||||
// If it did, you'd need to translate positions, natch…
|
||||
var fs = require('fs')
|
||||
|
||||
exports.createDriverSync = function (path, opts) {
|
||||
opts || (opts = {})
|
||||
|
||||
var secSize = 512,
|
||||
ro = opts.readOnly || false,
|
||||
fd = fs.openSync(path, ro ? 'r' : 'r+'),
|
||||
s = fs.fstatSync(fd)
|
||||
|
||||
return {
|
||||
sectorSize: secSize,
|
||||
numSectors: s.size / secSize,
|
||||
readSectors: function (i, dest, cb) {
|
||||
if (dest.length % secSize) throw Error('Unexpected buffer length!')
|
||||
fs.read(fd, dest, 0, dest.length, i * secSize, function (e, n, d) {
|
||||
cb(e, d)
|
||||
})
|
||||
},
|
||||
writeSectors: ro
|
||||
? null
|
||||
: function (i, data, cb) {
|
||||
if (data.length % secSize) throw Error('Unexpected buffer length!')
|
||||
fs.write(fd, data, 0, data.length, i * secSize, function (e) {
|
||||
cb(e)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
© 2014 Nathan Vander Wilt.
|
||||
Funding for this work was provided by Technical Machine, Inc.
|
||||
|
||||
Reuse under your choice of:
|
||||
|
||||
- [BSD-2-Clause](http://opensource.org/licenses/BSD-2-Clause)
|
||||
- [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
60
@vates/fatfs/cache.js
Normal file
60
@vates/fatfs/cache.js
Normal file
@@ -0,0 +1,60 @@
|
||||
var _ = require("./helpers.js");
|
||||
|
||||
exports.wrapDriver = function (volume, opts) {
|
||||
opts = _.extend({
|
||||
maxSectors: 2048
|
||||
}, opts);
|
||||
|
||||
var cache = {},
|
||||
advice = 'NORMAL',
|
||||
secSize = volume.sectorSize;
|
||||
|
||||
function _freezeBuffer(b) {
|
||||
var f = _.allocBuffer(b.length);
|
||||
b.copy(f);
|
||||
return f;
|
||||
}
|
||||
|
||||
function addToCache(i, data) {
|
||||
if (advice === 'SEQUENTIAL' || advice === 'NOREUSE') return;
|
||||
data = _freezeBuffer(data);
|
||||
cache[i] = data;
|
||||
//if (data.length > secSize) addToCache(i+1, data.slice(secSize));
|
||||
while (data.length > secSize) {
|
||||
data = data.slice(secSize);
|
||||
cache[++i] = data;
|
||||
}
|
||||
// simple highest-sectors-lose eviction policy for now
|
||||
Object.keys(cache).sort().slice(opts.maxSectors).forEach(function (x) {
|
||||
delete cache[x];
|
||||
});
|
||||
_.log(_.log.DBG, "Cache now contains:", Object.keys(cache).join(','));
|
||||
}
|
||||
|
||||
return {
|
||||
sectorSize: volume.sectorSize,
|
||||
numSectors: volume.numSectors,
|
||||
advice: function (val) {
|
||||
if (!arguments.length) return advice;
|
||||
else advice = val;
|
||||
if (advice === 'SEQUENTIAL' || advice === 'NOREUSE') cache = {};
|
||||
return this;
|
||||
},
|
||||
readSectors: function (i, dest, cb) {
|
||||
// TODO: handle having partial parts of dest!
|
||||
if (i in cache && dest.length === secSize) {
|
||||
cache[i].copy(dest);
|
||||
setImmediate(cb);
|
||||
} else volume.readSectors(i, dest, function (e) {
|
||||
if (e) cb(e);
|
||||
else addToCache(i, dest), cb();
|
||||
});
|
||||
},
|
||||
writeSectors: (!volume.writeSectors) ? null : function (i, data, cb) {
|
||||
volume.writeSectors(i, data, function (e) {
|
||||
if (e) cb(e);
|
||||
else addToCache(i, data), cb();
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
287
@vates/fatfs/chains.js
Normal file
287
@vates/fatfs/chains.js
Normal file
@@ -0,0 +1,287 @@
|
||||
var S = require("./structs.js"),
|
||||
_ = require("./helpers.js");
|
||||
|
||||
function _baseChain(vol) {
|
||||
var chain = {};
|
||||
|
||||
chain.sectorSize = vol._sectorSize;
|
||||
|
||||
function posFromOffset(off) {
|
||||
var secSize = chain.sectorSize,
|
||||
offset = off % secSize,
|
||||
sector = (off - offset) / secSize;
|
||||
return {sector:sector, offset:offset};
|
||||
}
|
||||
|
||||
var sectorCache = vol._makeCache();
|
||||
Object.defineProperty(chain, 'cacheAdvice', {
|
||||
enumerable: true,
|
||||
get: function () { return sectorCache.advice(); },
|
||||
set: function (v) { sectorCache.advice(v); }
|
||||
});
|
||||
chain._vol_readSectors = vol._readSectors.bind(vol, sectorCache);
|
||||
chain._vol_writeSectors = vol._writeSectors.bind(vol, sectorCache);
|
||||
|
||||
// cb(error, bytesRead, buffer)
|
||||
chain.readFromPosition = function (targetPos, buffer, cb) {
|
||||
if (typeof targetPos === 'number') targetPos = posFromOffset(targetPos);
|
||||
if (typeof buffer === 'number') buffer = _.allocBuffer(buffer);
|
||||
/* NOTE: to keep our contract with the volume driver, we need to read on _full_ sector boundaries!
|
||||
So we divide the read into [up to] three parts: {preface, main, trailer}
|
||||
This is kind of unfortunate, but in practice should often still be reasonably efficient. */
|
||||
if (targetPos.offset) chain.readSectors(targetPos.sector, _.allocBuffer(chain.sectorSize), function (e,d) {
|
||||
if (e || !d) cb(e, 0, buffer);
|
||||
else { // copy preface into `buffer`
|
||||
var dBeg = targetPos.offset,
|
||||
dEnd = dBeg + buffer.length;
|
||||
d.copy(buffer, 0, dBeg, dEnd);
|
||||
if (dEnd > d.length) readMain();
|
||||
else cb(null, buffer.length, buffer);
|
||||
}
|
||||
}); else readMain();
|
||||
function readMain() {
|
||||
var prefaceLen = targetPos.offset && (chain.sectorSize - targetPos.offset),
|
||||
trailerLen = (buffer.length - prefaceLen) % chain.sectorSize,
|
||||
mainSector = (prefaceLen) ? targetPos.sector + 1 : targetPos.sector,
|
||||
mainBuffer = (trailerLen) ? buffer.slice(prefaceLen, -trailerLen) : buffer.slice(prefaceLen);
|
||||
if (mainBuffer.length) chain.readSectors(mainSector, mainBuffer, function (e,d) {
|
||||
if (e || !d) cb(e, prefaceLen, buffer);
|
||||
else if (!trailerLen) cb(null, buffer.length, buffer);
|
||||
else readTrailer();
|
||||
}); else readTrailer();
|
||||
function readTrailer() {
|
||||
var trailerSector = mainSector + (mainBuffer.length / chain.sectorSize);
|
||||
chain.readSectors(trailerSector, _.allocBuffer(chain.sectorSize), function (e,d) {
|
||||
if (e || !d) cb(e, buffer.length-trailerLen, buffer);
|
||||
else {
|
||||
d.copy(buffer, buffer.length-trailerLen, 0, trailerLen);
|
||||
cb(null, buffer.length, buffer);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// cb(error)
|
||||
chain.writeToPosition = function (targetPos, data, cb) {
|
||||
_.log(_.log.DBG, "WRITING", data.length, "bytes at", targetPos, "in", this.toJSON(), data);
|
||||
if (typeof targetPos === 'number') targetPos = posFromOffset(targetPos);
|
||||
|
||||
var prefaceBuffer = (targetPos.offset) ? data.slice(0, chain.sectorSize-targetPos.offset) : null;
|
||||
if (prefaceBuffer) _modifySector(targetPos.sector, targetPos.offset, prefaceBuffer, function (e) {
|
||||
if (e) cb(e);
|
||||
else if (prefaceBuffer.length < data.length) writeMain();
|
||||
else cb();
|
||||
}); else writeMain();
|
||||
function writeMain() {
|
||||
var prefaceLen = (prefaceBuffer) ? prefaceBuffer.length : 0,
|
||||
trailerLen = (data.length - prefaceLen) % chain.sectorSize,
|
||||
mainSector = (prefaceLen) ? targetPos.sector + 1 : targetPos.sector,
|
||||
mainBuffer = (trailerLen) ? data.slice(prefaceLen, -trailerLen) : data.slice(prefaceLen);
|
||||
if (mainBuffer.length) chain.writeSectors(mainSector, mainBuffer, function (e) {
|
||||
if (e) cb(e);
|
||||
else if (!trailerLen) cb();
|
||||
else writeTrailer();
|
||||
}); else writeTrailer();
|
||||
function writeTrailer() {
|
||||
var trailerSector = mainSector + (mainBuffer.length / chain.sectorSize),
|
||||
trailerBuffer = data.slice(data.length-trailerLen); // WORKAROUND: https://github.com/tessel/runtime/issues/721
|
||||
_modifySector(trailerSector, 0, trailerBuffer, cb);
|
||||
}
|
||||
}
|
||||
function _modifySector(sec, off, data, cb) {
|
||||
chain.readSectors(sec, _.allocBuffer(chain.sectorSize), function (e, orig) {
|
||||
if (e) return cb(e);
|
||||
orig || (orig = _.allocBuffer(chain.sectorSize, 0));
|
||||
data.copy(orig, off);
|
||||
chain.writeSectors(sec, orig, cb);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return chain;
|
||||
};
|
||||
|
||||
|
||||
|
||||
exports.clusterChain = function (vol, firstCluster, _parent) {
|
||||
var chain = _baseChain(vol),
|
||||
cache = [firstCluster];
|
||||
|
||||
chain.firstCluster = firstCluster;
|
||||
|
||||
function _cacheIsComplete() {
|
||||
return cache[cache.length-1] === 'eof';
|
||||
}
|
||||
|
||||
function extendCacheToInclude(i, cb) { // NOTE: may `cb()` before returning!
|
||||
if (i < cache.length) cb(null, cache[i]);
|
||||
else if (_cacheIsComplete()) cb(null, 'eof');
|
||||
else vol.fetchFromFAT(cache[cache.length-1], function (e,d) {
|
||||
if (e) cb(e);
|
||||
else if (typeof d === 'string' && d !== 'eof') cb(S.err.IO());
|
||||
else {
|
||||
cache.push(d);
|
||||
extendCacheToInclude(i, cb);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function expandChainToLength(clusterCount, cb) {
|
||||
if (!_cacheIsComplete()) throw Error("Must be called only when cache is complete!");
|
||||
else cache.pop(); // remove 'eof' entry until finished
|
||||
|
||||
function addCluster(clustersNeeded, lastCluster) {
|
||||
if (!clustersNeeded) cache.push('eof'), cb();
|
||||
else vol.allocateInFAT(lastCluster, function (e, newCluster) {
|
||||
if (e) cb(e);
|
||||
else vol.storeToFAT(lastCluster, newCluster, function (e) {
|
||||
if (e) return cb(e);
|
||||
|
||||
cache.push(newCluster);
|
||||
addCluster(clustersNeeded-1, newCluster);
|
||||
});
|
||||
});
|
||||
}
|
||||
addCluster(clusterCount - cache.length, cache[cache.length - 1]);
|
||||
}
|
||||
|
||||
function shrinkChainToLength(clusterCount, cb) {
|
||||
if (!_cacheIsComplete()) throw Error("Must be called only when cache is complete!");
|
||||
else cache.pop(); // remove 'eof' entry until finished
|
||||
|
||||
function removeClusters(count, cb) {
|
||||
if (!count) cache.push('eof'), cb();
|
||||
else vol.storeToFAT(cache.pop(), 'free', function (e) {
|
||||
if (e) cb(e);
|
||||
else removeClusters(count - 1, cb);
|
||||
});
|
||||
}
|
||||
// NOTE: for now, we don't remove the firstCluster ourselves; we should though!
|
||||
if (clusterCount) removeClusters(cache.length - clusterCount, cb);
|
||||
else removeClusters(cache.length - 1, cb);
|
||||
}
|
||||
|
||||
// [{firstSector,numSectors},{firstSector,numSectors},…]
|
||||
function determineSectorGroups(sectorIdx, numSectors, alloc, cb) {
|
||||
var sectorOffset = sectorIdx % vol._sectorsPerCluster,
|
||||
clusterIdx = (sectorIdx - sectorOffset) / vol._sectorsPerCluster,
|
||||
numClusters = Math.ceil((numSectors + sectorOffset) / vol._sectorsPerCluster),
|
||||
chainLength = clusterIdx + numClusters;
|
||||
extendCacheToInclude(chainLength-1, function (e,c) {
|
||||
if (e) cb(e);
|
||||
else if (c === 'eof' && alloc) expandChainToLength(chainLength, function (e) {
|
||||
if (e) cb(e);
|
||||
else _determineSectorGroups();
|
||||
});
|
||||
else _determineSectorGroups();
|
||||
});
|
||||
function _determineSectorGroups() {
|
||||
// …now we have a complete cache
|
||||
var groups = [],
|
||||
_group = null;
|
||||
for (var i = clusterIdx; i < chainLength; ++i) {
|
||||
var c = (i < cache.length) ? cache[i] : 'eof';
|
||||
if (c === 'eof') break;
|
||||
else if (_group && c !== _group._nextCluster) {
|
||||
groups.push(_group);
|
||||
_group = null;
|
||||
}
|
||||
if (!_group) _group = {
|
||||
_nextCluster: c+1,
|
||||
firstSector: vol._firstSectorOfCluster(c) + sectorOffset,
|
||||
numSectors: vol._sectorsPerCluster - sectorOffset
|
||||
}; else {
|
||||
_group._nextCluster += 1;
|
||||
_group.numSectors += vol._sectorsPerCluster;
|
||||
}
|
||||
sectorOffset = 0; // only first group is offset
|
||||
}
|
||||
if (_group) groups.push(_group);
|
||||
cb(null, groups, i === chainLength);
|
||||
}
|
||||
}
|
||||
|
||||
chain.readSectors = function (i, dest, cb) {
|
||||
var groupOffset = 0, groupsPending;
|
||||
determineSectorGroups(i, dest.length / chain.sectorSize, false, function (e, groups, complete) {
|
||||
if (e) cb(e);
|
||||
else if (!complete) groupsPending = -1, _pastEOF(cb);
|
||||
else if ((groupsPending = groups.length)) groups.forEach(function (group) {
|
||||
var groupLength = group.numSectors * chain.sectorSize,
|
||||
groupBuffer = dest.slice(groupOffset, groupOffset += groupLength);
|
||||
chain._vol_readSectors(group.firstSector, groupBuffer, function (e,d) {
|
||||
if (e && groupsPending !== -1) groupsPending = -1, cb(e);
|
||||
else if (--groupsPending === 0) cb(null, dest);
|
||||
});
|
||||
});
|
||||
else cb(null, dest); // 0-length destination case
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: does this handle NOSPC condition?
|
||||
chain.writeSectors = function (i, data, cb) {
|
||||
var groupOffset = 0, groupsPending;
|
||||
determineSectorGroups(i, data.length / chain.sectorSize, true, function (e, groups) {
|
||||
if (e) cb(e);
|
||||
else if ((groupsPending = groups.length)) groups.forEach(function (group) {
|
||||
var groupLength = group.numSectors * chain.sectorSize,
|
||||
groupBuffer = data.slice(groupOffset, groupOffset += groupLength);
|
||||
chain._vol_writeSectors(group.firstSector, groupBuffer, function (e) {
|
||||
if (e && groupsPending !== -1) groupsPending = -1, cb(e);
|
||||
else if (--groupsPending === 0) cb();
|
||||
});
|
||||
});
|
||||
else cb(); // 0-length data case
|
||||
});
|
||||
};
|
||||
|
||||
chain.truncate = function (numSectors, cb) {
|
||||
extendCacheToInclude(Infinity, function (e,c) {
|
||||
if (e) return cb(e);
|
||||
|
||||
var currentLength = cache.length-1,
|
||||
clustersNeeded = Math.ceil(numSectors / vol._sectorsPerCluster);
|
||||
if (clustersNeeded < currentLength) shrinkChainToLength(clustersNeeded, cb);
|
||||
else if (clustersNeeded > currentLength) expandChainToLength(clustersNeeded, cb);
|
||||
else cb();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
chain.toJSON = function () {
|
||||
return {firstCluster:firstCluster};
|
||||
};
|
||||
|
||||
return chain;
|
||||
};
|
||||
|
||||
exports.sectorChain = function (vol, firstSector, numSectors) {
|
||||
var chain = _baseChain(vol);
|
||||
|
||||
chain.firstSector = firstSector;
|
||||
chain.numSectors = numSectors;
|
||||
|
||||
chain.readSectors = function (i, dest, cb) {
|
||||
if (i < numSectors) chain._vol_readSectors(firstSector+i, dest, cb);
|
||||
else _pastEOF(cb);
|
||||
};
|
||||
|
||||
chain.writeSectors = function (i, data, cb) {
|
||||
if (i < numSectors) chain._vol_writeSectors(firstSector+i, data, cb);
|
||||
else _.delayedCall(cb, S.err.NOSPC());
|
||||
};
|
||||
|
||||
chain.truncate = function (i, cb) {
|
||||
_.delayedCall(cb, S.err.INVAL());
|
||||
};
|
||||
|
||||
chain.toJSON = function () {
|
||||
return {firstSector:firstSector, numSectors:numSectors};
|
||||
};
|
||||
|
||||
return chain;
|
||||
};
|
||||
|
||||
// NOTE: used with mixed feelings, broken out to mark uses
|
||||
function _pastEOF(cb) { _.delayedCall(cb, null, null); }
|
||||
372
@vates/fatfs/dir.js
Normal file
372
@vates/fatfs/dir.js
Normal file
@@ -0,0 +1,372 @@
|
||||
var S = require("./structs.js"),
|
||||
_ = require("./helpers.js");
|
||||
|
||||
var dir = exports;
|
||||
|
||||
dir.iterator = function (dirChain, opts) {
|
||||
opts || (opts = {});
|
||||
|
||||
var cache = {buffer:null, n: null};
|
||||
function getSectorBuffer(n, cb) {
|
||||
if (cache.n === n) cb(null, cache.buffer);
|
||||
else cache.n = cache.buffer = null, dirChain.readSectors(n, _.allocBuffer(dirChain.sectorSize), function (e,d) {
|
||||
if (e) cb(e);
|
||||
else if (!d) return cb(null, null);
|
||||
else {
|
||||
cache.n = n;
|
||||
cache.buffer = d;
|
||||
getSectorBuffer(n, cb);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var secIdx = 0,
|
||||
off = {bytes:0},
|
||||
long = null;
|
||||
function getNextEntry(cb) {
|
||||
if (off.bytes >= dirChain.sectorSize) {
|
||||
secIdx += 1;
|
||||
off.bytes -= dirChain.sectorSize;
|
||||
}
|
||||
var entryPos = {chain:dirChain, sector:secIdx, offset:off.bytes};
|
||||
getSectorBuffer(secIdx, function (e, buf) {
|
||||
if (e) return cb(S.err.IO());
|
||||
else if (!buf) return cb(null, null, entryPos);
|
||||
|
||||
var entryIdx = off.bytes,
|
||||
signalByte = buf[entryIdx];
|
||||
if (signalByte === S.entryDoneFlag) return cb(null, null, entryPos);
|
||||
else if (signalByte === S.entryFreeFlag) {
|
||||
off.bytes += S.dirEntry.size;
|
||||
long = null;
|
||||
if (opts.includeFree) return cb(null, {_free:true,_pos:entryPos}, entryPos);
|
||||
else return getNextEntry(cb); // usually just skip these
|
||||
}
|
||||
|
||||
var attrByte = buf[entryIdx+S.dirEntry.fields.Attr.offset],
|
||||
entryType = (attrByte === S.longDirFlag) ? S.longDirEntry : S.dirEntry_simple;
|
||||
var entry = entryType.valueFromBytes(buf, off);
|
||||
entry._pos = entryPos;
|
||||
_.log(_.log.DBG, "entry:", entry, secIdx, entryIdx);
|
||||
if (entryType === S.longDirEntry) {
|
||||
var firstEntry;
|
||||
if (entry.Ord & S.lastLongFlag) {
|
||||
firstEntry = true;
|
||||
entry.Ord &= ~S.lastLongFlag;
|
||||
long = {
|
||||
name: -1,
|
||||
sum: entry.Chksum,
|
||||
_rem: entry.Ord-1,
|
||||
_arr: []
|
||||
}
|
||||
}
|
||||
if (firstEntry || long && entry.Chksum === long.sum && entry.Ord === long._rem--) {
|
||||
var S_lde_f = S.longDirEntry.fields,
|
||||
namepart = entry.Name1;
|
||||
if (entry.Name1.length === S_lde_f.Name1.size/2) {
|
||||
namepart += entry.Name2;
|
||||
if (entry.Name2.length === S_lde_f.Name2.size/2) {
|
||||
namepart += entry.Name3;
|
||||
}
|
||||
}
|
||||
long._arr.push(namepart);
|
||||
if (!long._rem) {
|
||||
long.name = long._arr.reverse().join('');
|
||||
delete long._arr;
|
||||
delete long._rem;
|
||||
}
|
||||
} else long = null;
|
||||
} else if ((attrByte & 0x08) === 0) { // NOTE: checks `!entry.Attr.volume_id`
|
||||
var bestName = null;
|
||||
if (long && long.name) {
|
||||
var pos = entryIdx + S.dirEntry.fields['Name'].offset,
|
||||
sum = _.checksumName(buf, pos);
|
||||
if (sum === long.sum) bestName = long.name;
|
||||
}
|
||||
if (!bestName) {
|
||||
if (signalByte === S.entryIsE5Flag) entry.Name.filename = '\u00E5'+entry.Name.filename.slice(1);
|
||||
|
||||
var nam = entry.Name.filename.replace(/ +$/, ''),
|
||||
ext = entry.Name.extension.replace(/ +$/, '');
|
||||
// TODO: lowercase bits http://en.wikipedia.org/wiki/8.3_filename#Compatibility
|
||||
// via NTRes, bits 0x08 and 0x10 http://www.fdos.org/kernel/fatplus.txt.1
|
||||
bestName = (ext) ? nam+'.'+ext : nam;
|
||||
}
|
||||
entry._name = bestName;
|
||||
|
||||
// OPTIMIZATION: avoid processing any fields for non-matching entries
|
||||
// TODO: we could make this automatic via getters, but…?
|
||||
var _entryBuffer = buf.slice(off.bytes-S.dirEntry.size, off.bytes);
|
||||
entry._full = function () {
|
||||
var _entry = S.dirEntry.valueFromBytes(_entryBuffer);
|
||||
_.extend(entry, _entry);
|
||||
entry._size = entry.FileSize;
|
||||
entry._firstCluster = (entry.FstClusHI << 16) + entry.FstClusLO;
|
||||
return entry;
|
||||
};
|
||||
|
||||
long = null;
|
||||
return cb(null, entry, entryPos);
|
||||
} else long = null;
|
||||
getNextEntry(cb);
|
||||
});
|
||||
}
|
||||
|
||||
function iter(cb) {
|
||||
getNextEntry(cb);
|
||||
return iter; // TODO: previous value can't be re-used, so why make caller re-assign?
|
||||
}
|
||||
return iter;
|
||||
};
|
||||
|
||||
function _updateEntry(vol, entry, newStats) {
|
||||
if ('size' in newStats) entry._size = entry.FileSize = newStats.size;
|
||||
|
||||
if ('_touch' in newStats) newStats.archive = newStats.atime = newStats.mtime = true;
|
||||
if ('archive' in newStats) entry.Attr.archive = true; // TODO: also via newStats.mode?
|
||||
|
||||
var _now;
|
||||
function applyDate(d, prefix, timeToo, tenthToo) {
|
||||
if (d === true) d = _now || (_now = new Date());
|
||||
entry[prefix+'Date'] = {year:d.getFullYear()-1980, month:d.getMonth()+1, day:d.getDate()};
|
||||
if (timeToo) {
|
||||
entry[prefix+'Time'] = {hours:d.getHours(), minutes:d.getMinutes(), seconds_2:d.getSeconds()>>>1};
|
||||
if (tenthToo) {
|
||||
var msec = (d.getSeconds() % 2)*1000 + d.getMilliseconds();
|
||||
entry[prefix+'TimeTenth'] = Math.floor(msec / 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('ctime' in newStats) applyDate(newStats.ctime, 'Crt', true, true);
|
||||
if ('mtime' in newStats) applyDate(newStats.mtime, 'Wrt', true);
|
||||
if ('atime' in newStats) applyDate(newStats.atime, 'LstAcc');
|
||||
|
||||
if ('mode' in newStats) {
|
||||
entry.Attr.directory = (newStats.mode & S._I.FDIR) ? true : false;
|
||||
entry.Attr.volume_id = (newStats.mode & S._I.FREG) ? false : true;
|
||||
if (vol.opts.modmode === 0111) {
|
||||
entry.Attr.archive = (newStats.mode & S._I.XUSR) ? true : false;
|
||||
entry.Attr.system = (newStats.mode & S._I.XGRP) ? true : false;
|
||||
entry.Attr.hidden = (newStats.mode & S._I.XOTH) ? true : false;
|
||||
entry.Attr.readonly = (newStats.mode & S._I.WUSR) ? false : true;
|
||||
} else if (vol.opts.modmode === 07000) {
|
||||
entry.Attr.archive = (newStats.mode & S._I.SVTX) ? true : false;
|
||||
entry.Attr.system = (newStats.mode & S._I.SGID) ? true : false;
|
||||
entry.Attr.hidden = (newStats.mode & S._I.SUID) ? true : false;
|
||||
entry.Attr.readonly = (
|
||||
newStats.mode & S._I.WUSR ||
|
||||
newStats.mode & S._I.WGRP ||
|
||||
newStats.mode & S._I.WOTH
|
||||
) ? false : true;
|
||||
}
|
||||
}
|
||||
|
||||
if ('firstCluster' in newStats) {
|
||||
entry.FstClusLO = newStats.firstCluster & 0xFFFF;
|
||||
entry.FstClusHI = newStats.firstCluster >>> 16;
|
||||
entry._firstCluster = newStats.firstCluster;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
dir.makeStat = function (vol, entry) {
|
||||
var stats = {}; // TODO: return an actual `instanceof fs.Stat` somehow?
|
||||
|
||||
stats.isFile = function () {
|
||||
return (!entry.Attr.volume_id && !entry.Attr.directory);
|
||||
};
|
||||
stats.isDirectory = function () {
|
||||
return entry.Attr.directory;
|
||||
};
|
||||
stats.isBlockDevice = function () { return false; }
|
||||
stats.isCharacterDevice = function () { return false; }
|
||||
stats.isSymbolicLink = function () { return false; }
|
||||
stats.isFIFO = function () { return false; }
|
||||
stats.isSocket = function () { return false; }
|
||||
stats.size = entry.FileSize;
|
||||
stats.blksize = vol._sectorsPerCluster*vol._sectorSize;
|
||||
stats.blocks = Math.ceil(stats.size / stats.blksize) || 1;
|
||||
stats.nlink = 1;
|
||||
|
||||
stats.mode = S._I.RUSR | S._I.RGRP | S._I.ROTH;
|
||||
if (!entry.Attr.readonly) stats.mode |= S._I.WUSR | S._I.WGRP | S._I.WOTH;
|
||||
if (entry.Attr.directory) stats.mode |= S._I.FDIR;
|
||||
else if (!entry.Attr.volume_id) stats.mode |= S._I.FREG;
|
||||
// NOTE: discussion at https://github.com/natevw/fatfs/issues/7
|
||||
if (vol.opts.modmode === 0111) {
|
||||
// expose using executable bits, like Samba
|
||||
if (entry.Attr.archive) stats.mode |= S._I.XUSR;
|
||||
if (entry.Attr.system) stats.mode |= S._I.XGRP;
|
||||
if (entry.Attr.hidden) stats.mode |= S._I.XOTH;
|
||||
} else if (vol.opts.modmode === 07000) {
|
||||
// expose using setXid/sticky bits, like MKS
|
||||
if (entry.Attr.archive) stats.mode |= S._I.SVTX;
|
||||
if (entry.Attr.system) stats.mode |= S._I.SGID;
|
||||
if (entry.Attr.hidden) stats.mode |= S._I.SUID;
|
||||
}
|
||||
|
||||
stats.mode &= ~vol.opts.umask;
|
||||
stats.uid = vol.opts.uid;
|
||||
stats.gid = vol.opts.gid;
|
||||
|
||||
function extractDate(prefix) {
|
||||
var date = entry[prefix+'Date'],
|
||||
time = entry[prefix+'Time'] || {hours:0, minutes:0, seconds_2:0},
|
||||
secs = time.seconds_2 * 2,
|
||||
sect = entry[prefix+'TimeTenth'] || 0;
|
||||
if (sect > 100) {
|
||||
secs += 1;
|
||||
sect -= 100;
|
||||
}
|
||||
return new Date(date.year+1980, date.month-1, date.day, time.hours, time.minutes, secs, sect*100);
|
||||
}
|
||||
stats.atime = extractDate('LstAcc');
|
||||
stats.mtime = extractDate('Wrt');
|
||||
stats.ctime = extractDate('Crt');
|
||||
|
||||
entry = { // keep immutable copy (with only the fields we need)
|
||||
Attr: _.extend({},entry.Attr),
|
||||
};
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
dir.init = function (vol, dirInfo, cb) {
|
||||
var dirChain = dirInfo.chain,
|
||||
isRootDir = ('numSectors' in dirChain), // HACK: all others would be a clusterChain
|
||||
initialCluster = _.allocBuffer(dirChain.sectorSize*vol._sectorsPerCluster),
|
||||
entriesOffset = {bytes:0};
|
||||
initialCluster.fill(0);
|
||||
function writeEntry(name, clusterNum) {
|
||||
while (name.length < 8) name += " ";
|
||||
S.dirEntry.bytesFromValue(_updateEntry(vol, {
|
||||
Name: {filename:name, extension:" "},
|
||||
Attr: {directory:true}
|
||||
}, {firstCluster:clusterNum, _touch:true,ctime:true}), initialCluster, entriesOffset);
|
||||
}
|
||||
if (!isRootDir) {
|
||||
writeEntry(".", dirChain.firstCluster);
|
||||
writeEntry("..", dirInfo.parent.chain.firstCluster);
|
||||
};
|
||||
dirChain.writeToPosition(0, initialCluster, cb);
|
||||
};
|
||||
|
||||
dir.addFile = function (vol, dirChain, entryInfo, opts, cb) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts;
|
||||
opts = {};
|
||||
}
|
||||
var name = entryInfo.name,
|
||||
entries = [], mainEntry;
|
||||
entries.push(mainEntry = {
|
||||
Name: _.shortname(name),
|
||||
Attr: {directory:opts.dir||false},
|
||||
_name: name
|
||||
});
|
||||
if (1 || mainEntry.Name._lossy) { // HACK: always write long names until `._lossy` is more useful!
|
||||
var workaroundTessel427 = ('\uFFFF'.length !== 1);
|
||||
if (workaroundTessel427) throw Error("Your JS runtime does not have proper Unicode string support. (If Tessel, is your firmware up-to-date?)");
|
||||
|
||||
// name entries should be 0x0000-terminated and 0xFFFF-filled
|
||||
var S_lde_f = S.longDirEntry.fields,
|
||||
ENTRY_CHUNK_LEN = (S_lde_f.Name1.size + S_lde_f.Name2.size + S_lde_f.Name3.size)/2,
|
||||
partialLen = name.length % ENTRY_CHUNK_LEN,
|
||||
paddingNeeded = partialLen && (ENTRY_CHUNK_LEN - partialLen);
|
||||
if (paddingNeeded--) name += '\u0000';
|
||||
while (paddingNeeded-- > 0) name += '\uFFFF';
|
||||
// now fill in as many entries as it takes
|
||||
var off = 0,
|
||||
ord = 1;
|
||||
while (off < name.length) entries.push({
|
||||
Ord: ord++,
|
||||
Name1: name.slice(off, off+=S_lde_f.Name1.size/2),
|
||||
Attr_raw: S.longDirFlag,
|
||||
Chksum: null,
|
||||
Name2: name.slice(off, off+=S_lde_f.Name2.size/2),
|
||||
Name3: name.slice(off, off+=S_lde_f.Name3.size/2)
|
||||
});
|
||||
entries[entries.length - 1].Ord |= S.lastLongFlag;
|
||||
}
|
||||
|
||||
if (entryInfo.tail) {
|
||||
var name = mainEntry.Name.filename,
|
||||
suffix = '~'+entryInfo.tail,
|
||||
endIdx = name.indexOf(' '),
|
||||
sufIdx = (~endIdx) ? Math.min(endIdx, name.length-suffix.length) : name.length-suffix.length;
|
||||
if (sufIdx < 0) return cb(S.err.NAMETOOLONG()); // TODO: would EXIST be more correct?
|
||||
mainEntry.Name.filename = name.slice(0,sufIdx)+suffix+name.slice(sufIdx+suffix.length);
|
||||
_.log(_.log.DBG, "Shortname amended to:", mainEntry.Name);
|
||||
}
|
||||
|
||||
vol.allocateInFAT(dirChain.firstCluster || 2, function (e,fileCluster) {
|
||||
if (e) return cb(e);
|
||||
|
||||
var nameBuf = S.dirEntry.fields['Name'].bytesFromValue(mainEntry.Name),
|
||||
nameSum = _.checksumName(nameBuf);
|
||||
// TODO: finalize initial properties… (via `opts.mode` instead?)
|
||||
_updateEntry(vol, mainEntry, {firstCluster:fileCluster, size:0, ctime:true,_touch:true});
|
||||
mainEntry._pos = _.adjustedPos(vol, entryInfo.target, S.dirEntry.size*(entries.length-1));
|
||||
entries.slice(1).forEach(function (entry) {
|
||||
entry.Chksum = nameSum;
|
||||
});
|
||||
entries.reverse();
|
||||
if (entryInfo.lastEntry) entries.push({});
|
||||
|
||||
var entriesData = _.allocBuffer(S.dirEntry.size*entries.length),
|
||||
dataOffset = {bytes:0};
|
||||
entries.forEach(function (entry) {
|
||||
var entryType = ('Ord' in entry) ? S.longDirEntry : S.dirEntry;
|
||||
entryType.bytesFromValue(entry, entriesData, dataOffset);
|
||||
});
|
||||
|
||||
_.log(_.log.DBG, "Writing", entriesData.length, "byte directory entry", mainEntry, "into", dirChain.toJSON(), "at", entryInfo.target);
|
||||
dirChain.writeToPosition(entryInfo.target, entriesData, function (e) {
|
||||
// TODO: if we get error, what/should we clean up?
|
||||
if (e) cb(e);
|
||||
else cb(null, mainEntry, vol.chainForCluster(fileCluster, dirChain));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
dir.findInDirectory = function (vol, dirChain, name, opts, cb) {
|
||||
var matchName = name.toUpperCase(),
|
||||
tailName = (opts.prepareForCreate) ? _.shortname(name) : null,
|
||||
maxTail = 0;
|
||||
|
||||
function processNext(next) {
|
||||
next = next(function (e, d, entryPos) {
|
||||
if (e) cb(e);
|
||||
else if (!d) cb(S.err.NOENT(), {tail:maxTail, target:entryPos, lastEntry:true});
|
||||
else if (d._free) processNext(next); // TODO: look for long enough reusable run
|
||||
else if (d._name.toUpperCase() === matchName) return cb(null, d._full());
|
||||
else if (!opts.prepareForCreate) processNext(next);
|
||||
else {
|
||||
var dNum = 1,
|
||||
dName = d.Name.filename,
|
||||
dTail = dName.match(/(.*)~(\d+)/);
|
||||
if (dTail) {
|
||||
dNum = +dTail[2];
|
||||
dName = dTail[1];
|
||||
}
|
||||
if (tailName.extension === d.Name.extension &&
|
||||
tailName.filename.indexOf(dName) === 0)
|
||||
{
|
||||
maxTail = Math.max(dNum+1, maxTail);
|
||||
}
|
||||
processNext(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
processNext(dir.iterator(dirChain, {includeFree:(0 && opts.prepareForCreate)}));
|
||||
};
|
||||
|
||||
dir.updateEntry = function (vol, entry, newStats, cb) {
|
||||
if (!entry._pos || !entry._pos.chain) throw Error("Entry source unknown!");
|
||||
|
||||
var entryPos = entry._pos,
|
||||
newEntry = _updateEntry(vol, entry, newStats),
|
||||
data = S.dirEntry.bytesFromValue(newEntry);
|
||||
_.log(_.log.DBG, "UPDATING ENTRY", newStats, newEntry, entryPos, data);
|
||||
// TODO: if write fails, then entry becomes corrupt!
|
||||
entryPos.chain.writeToPosition(entryPos, data, cb);
|
||||
};
|
||||
206
@vates/fatfs/helpers.js
Normal file
206
@vates/fatfs/helpers.js
Normal file
@@ -0,0 +1,206 @@
|
||||
var S = require("./structs.js"),
|
||||
_xok = require('xok');
|
||||
|
||||
// ponyfills for older node.js
|
||||
exports.allocBuffer = Buffer.alloc || function (len, val) {
|
||||
var b = Buffer(len);
|
||||
if (arguments.length > 1) b.fill(val);
|
||||
return b;
|
||||
};
|
||||
exports.bufferFrom = Buffer.from || function (arg0, arg1) {
|
||||
return (arguments.length > 1) ? Buffer(arg0, arg1) : Buffer(arg0);
|
||||
};
|
||||
|
||||
// flag for WORKAROUND: https://github.com/tessel/beta/issues/380
|
||||
exports.workaroundTessel380 = !Buffer.from && function () {
|
||||
var b = Buffer([0]),
|
||||
s = b.slice(0);
|
||||
return ((s[0] = 0xFF) !== b[0]);
|
||||
}();
|
||||
|
||||
|
||||
// WORKAROUND: https://github.com/tessel/beta/issues/433
|
||||
var oldslice;
|
||||
if (!Buffer.alloc && Buffer(5).slice(10).length < 0) oldslice = Buffer.prototype.slice, Buffer.prototype.slice = function (s, e) {
|
||||
if (s > this.length) s = this.length;
|
||||
// ~WORKAROUND: https://github.com/tessel/beta/issues/434
|
||||
return (arguments.length > 1) ? oldslice.call(this, s, e) : oldslice.call(this, s);
|
||||
}
|
||||
|
||||
exports.absoluteSteps = function (path) {
|
||||
var steps = [];
|
||||
path.split('/').forEach(function (str) {
|
||||
// NOTE: these should actually be fine, just wasteful…
|
||||
if (str === '..') steps.pop();
|
||||
else if (str && str !== '.') steps.push(str);
|
||||
});
|
||||
return steps.map(exports.longname);
|
||||
};
|
||||
|
||||
exports.absolutePath = function (path) {
|
||||
return '/'+exports.absoluteSteps(path).join('/');
|
||||
};
|
||||
|
||||
exports.parseFlags = function (flags) {
|
||||
// read, write, append, create, truncate, exclusive
|
||||
var info, _dir; // NOTE: there might be more clever ways to "parse", but…
|
||||
if (flags[0] === '\\') {
|
||||
// internal flag used internally to `fs.open` directories without `S.err.ISDIR()`
|
||||
flags = flags.slice(1);
|
||||
_dir = true;
|
||||
}
|
||||
switch (flags) {
|
||||
case 'r': info = {read:true, write:false, create:false}; break;
|
||||
case 'r+': info = {read:true, write:true, create:false}; break;
|
||||
case 'rs': info = {read:true, write:false, create:false, sync:true}; break;
|
||||
case 'rs+': info = {read:true, write:true, create:false, sync:true}; break;
|
||||
case 'w': info = {read:false, write:true, create:true, truncate:true}; break;
|
||||
case 'wx': info = {read:false, write:true, create:true, exclusive:true}; break;
|
||||
case 'w+': info = {read:true, write:true, create:true, truncate:true}; break;
|
||||
case 'wx+': info = {read:true, write:true, create:true, exclusive:true}; break;
|
||||
case 'a': info = {read:false, write:true, create:true, append:true}; break;
|
||||
case 'ax': info = {read:false, write:true, create:true, append:true, exclusive:true}; break;
|
||||
case 'a+': info = {read:true, write:true, create:true, append:true}; break;
|
||||
case 'ax+': info = {read:true, write:true, create:true, append:true, exclusive:true}; break;
|
||||
default: throw Error("Uknown mode: "+flags); // TODO: throw as `S.err.INVAL`
|
||||
}
|
||||
if (info.sync) throw Error("Mode not implemented."); // TODO: what would this require of us?
|
||||
if (_dir) info._openDir = true;
|
||||
return info;
|
||||
};
|
||||
|
||||
|
||||
// TODO: these are great candidates for special test coverage!
|
||||
var _snInvalid = /[^A-Z0-9$%'-_@~`!(){}^#&.]/g; // NOTE: '.' is not valid but we split it away
|
||||
exports.shortname = function (name) {
|
||||
var lossy = false;
|
||||
// TODO: support preservation of case for otherwise non-lossy name!
|
||||
name = name.toUpperCase().replace(/ /g, '').replace(/^\.+/, '');
|
||||
name = name.replace(_snInvalid, function () {
|
||||
lossy = true;
|
||||
return '_';
|
||||
});
|
||||
|
||||
var parts = name.split('.'),
|
||||
basis3 = parts.pop(),
|
||||
basis8 = parts.join('');
|
||||
if (!parts.length) {
|
||||
basis8 = basis3;
|
||||
basis3 = ' ';
|
||||
}
|
||||
if (basis8.length > 8) {
|
||||
basis8 = basis8.slice(0,8);
|
||||
// NOTE: technically, spec's "lossy conversion" flag is NOT set by excess length.
|
||||
// But since lossy conversion and truncated names both need a numeric tail…
|
||||
lossy = true;
|
||||
} else while (basis8.length < 8) basis8 += ' ';
|
||||
if (basis3.length > 3) {
|
||||
basis3 = basis3.slice(0,3);
|
||||
lossy = true;
|
||||
} else while (basis3.length < 3) basis3 += ' ';
|
||||
|
||||
return {filename:basis8, extension:basis3, _lossy:lossy};
|
||||
return {basis:[basis8,basis3], lossy:lossy};
|
||||
};
|
||||
//shortname("autoexec.bat") => {basis:['AUTOEXEC','BAT'],lossy:false}
|
||||
//shortname("autoexecutable.batch") => {basis:['AUTOEXEC','BAT'],lossy:true}
|
||||
// TODO: OS X stores `shortname("._.Trashes")` as ['~1', 'TRA'] — should we?
|
||||
|
||||
var _lnInvalid = /[^a-zA-Z0-9$%'-_@~`!(){}^#&.+,;=[\] ]/g;
|
||||
exports.longname = function (name) {
|
||||
name = name.trim().replace(/\.+$/, '').replace(_lnInvalid, function (c) {
|
||||
if (c.length > 1) throw Error("Internal problem: unexpected match length!");
|
||||
if (c.charCodeAt(0) > 127) return c;
|
||||
else throw Error("Invalid character "+JSON.stringify(c)+" in name.");
|
||||
lossy = true;
|
||||
return '_';
|
||||
});
|
||||
if (name.length > 255) throw Error("Name is too long.");
|
||||
return name;
|
||||
};
|
||||
|
||||
function nameChkSum(sum, c) {
|
||||
return ((sum & 1) ? 0x80 : 0) + (sum >>> 1) + c & 0xFF;
|
||||
}
|
||||
|
||||
// WORKAROUND: https://github.com/tessel/beta/issues/335
|
||||
function reduceBuffer(buf, start, end, fn, res) {
|
||||
// NOTE: does not handle missing `res` like Array.prototype.reduce would
|
||||
for (var i = start; i < end; ++i) {
|
||||
res = fn(res, buf[i]);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
exports.checksumName = function (buf,off) {
|
||||
off || (off = 0);
|
||||
var len = S.dirEntry.fields['Name'].size;
|
||||
return reduceBuffer(buf, off, off+len, nameChkSum, 0);
|
||||
};
|
||||
|
||||
|
||||
/* comparing C rounding trick from FAT spec with Math.ceil
|
||||
function tryBoth(d) {
|
||||
var a = ((D.RootEntCnt * 32) + (D.BytsPerSec - 1)) / D.BytsPerSec >>> 0,
|
||||
b = Math.ceil((D.RootEntCnt * 32) / D.BytsPerSec);
|
||||
if (b !== a) console.log("try", b, a, (b === a) ? '' : '*');
|
||||
return (b === a);
|
||||
}
|
||||
// BytsPerSec — "may take on only the following values: 512, 1024, 2048 or 4096"
|
||||
[512, 1024, 2048, 4096].forEach(function (bps) {
|
||||
// RootEntCnt — "a count that when multiplied by 32 results in an even multiple of BPB_BytsPerSec"
|
||||
for (var evenMultiplier = 0; evenMultiplier < 1024*1024*16; evenMultiplier += 2) {
|
||||
var rec = (bps * evenMultiplier) / 32;
|
||||
tryBoth({RootEntCnt:rec, BytsPerSec:bps});
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
exports.fmtHex = function (n, ff) {
|
||||
return (1+ff+n).toString(16).slice(1);
|
||||
};
|
||||
|
||||
exports.delayedCall = function (fn) {
|
||||
if (!fn) throw Error("No function provided!"); // debug aid
|
||||
var ctx = this,
|
||||
args = Array.prototype.slice.call(arguments, 1);
|
||||
setImmediate(function () {
|
||||
fn.apply(ctx, args);
|
||||
});
|
||||
};
|
||||
|
||||
exports.adjustedPos = function (vol, pos, bytes) {
|
||||
var _pos = {
|
||||
chain: pos.chain,
|
||||
sector: pos.sector,
|
||||
offset: pos.offset + bytes
|
||||
}, secSize = vol._sectorSize;
|
||||
while (_pos.offset >= secSize) {
|
||||
_pos.sector += 1;
|
||||
_pos.offset -= secSize;
|
||||
}
|
||||
return _pos;
|
||||
};
|
||||
|
||||
exports.extend = _xok;
|
||||
|
||||
var _prevDbg = Date.now(),
|
||||
_thresh = 50;
|
||||
|
||||
function log(level) {
|
||||
if (level < log.level) return;
|
||||
|
||||
var now = Date.now(),
|
||||
diff = now - _prevDbg;
|
||||
arguments[0] = ((diff < _thresh) ? " " : '') + diff.toFixed(0) + "ms";
|
||||
console.log.apply(console, arguments);
|
||||
_prevDbg = now;
|
||||
}
|
||||
log.DBG = -4;
|
||||
log.INFO = -3;
|
||||
log.WARN = -2;
|
||||
log.ERR = -1;
|
||||
|
||||
log.level = log.WARN;
|
||||
|
||||
exports.log = log;
|
||||
25
@vates/fatfs/img_volume.js
Normal file
25
@vates/fatfs/img_volume.js
Normal file
@@ -0,0 +1,25 @@
|
||||
var fs = require('fs');
|
||||
|
||||
exports.createDriverSync = function (path, opts) {
|
||||
opts || (opts = {});
|
||||
|
||||
var secSize = 512,
|
||||
ro = opts.readOnly || false,
|
||||
fd = fs.openSync(path, (ro) ? 'r' : 'r+'),
|
||||
s = fs.fstatSync(fd);
|
||||
|
||||
return {
|
||||
sectorSize: secSize,
|
||||
numSectors: s.size / secSize,
|
||||
readSectors: function (i, dest, cb) {
|
||||
fs.read(fd, dest, 0, dest.length, i*secSize, function (e,n,d) {
|
||||
cb(e,d);
|
||||
});
|
||||
},
|
||||
writeSectors: (ro) ? null : function (i, data, cb) {
|
||||
fs.write(fd, data, 0, data.length, i*secSize, function (e) {
|
||||
cb(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
771
@vates/fatfs/index.js
Normal file
771
@vates/fatfs/index.js
Normal file
@@ -0,0 +1,771 @@
|
||||
var events = require('events'),
|
||||
streams = require('stream'),
|
||||
fifolock = require('fifolock'),
|
||||
S = require('./structs.js'),
|
||||
_ = require('./helpers.js')
|
||||
|
||||
//_.log.level = _.log.DBG;
|
||||
|
||||
exports.createFileSystem = function (volume, opts, cb) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts
|
||||
opts = null
|
||||
}
|
||||
opts = _.extend(
|
||||
{
|
||||
// c.f. https://www.kernel.org/doc/Documentation/filesystems/vfat.txt
|
||||
ro: false,
|
||||
noatime: true,
|
||||
modmode: 0111, // or `07000`
|
||||
umask: 'umask' in process ? process.umask() : 0022,
|
||||
uid: 'getuid' in process ? process.getuid() : 0,
|
||||
gid: 'getgid' in process ? process.getgid() : 0,
|
||||
},
|
||||
opts
|
||||
)
|
||||
if (!volume.writeSectors) opts.ro = true
|
||||
if (opts.ro) opts.noatime = true // natch
|
||||
|
||||
var fs = new events.EventEmitter(),
|
||||
vol = null,
|
||||
dir = require('./dir.js'),
|
||||
c = require('./chains.js'),
|
||||
q = fifolock()
|
||||
|
||||
var GROUP =
|
||||
_.log.level < _.log.INFO
|
||||
? q.TRANSACTION_WRAPPER.bind({
|
||||
postAcquire: function (proceed) {
|
||||
_.log(_.log.DBG, '=== Starting GROUP ===')
|
||||
proceed()
|
||||
},
|
||||
preRelease: function (finish) {
|
||||
_.log(_.log.DBG, '=== Finishing GROUP ===')
|
||||
finish()
|
||||
},
|
||||
})
|
||||
: q.TRANSACTION_WRAPPER
|
||||
|
||||
q.acquire(function (unlock) {
|
||||
// because of this, callers can start before 'ready'
|
||||
var d = _.allocBuffer(volume.sectorSize)
|
||||
volume.readSectors(0, d, function (e) {
|
||||
if (e) fs.emit('error', e)
|
||||
else {
|
||||
try {
|
||||
init(d)
|
||||
} catch (e) {
|
||||
fs.emit('error', e)
|
||||
unlock()
|
||||
return
|
||||
}
|
||||
fs.emit('ready')
|
||||
}
|
||||
unlock()
|
||||
})
|
||||
})
|
||||
|
||||
if (cb) fs.on('error', cb).on('ready', cb.bind(null, null))
|
||||
|
||||
function init(bootSector) {
|
||||
vol = require('./vol.js').init(volume, opts, bootSector)
|
||||
fs._dirIterator = dir.iterator.bind(dir)
|
||||
|
||||
var entryInfoByPath = {},
|
||||
baseEntry = {
|
||||
_refs: 0,
|
||||
_record: function () {
|
||||
entryInfoByPath[this.path] = this
|
||||
if (this.parent) this.parent.retain()
|
||||
return this
|
||||
},
|
||||
retain: function () {
|
||||
if (!this._refs) this._record()
|
||||
this._refs += 1
|
||||
return this
|
||||
},
|
||||
release: function () {
|
||||
this._refs -= 1
|
||||
if (!this._refs) this._rescind()
|
||||
},
|
||||
_rescind: function () {
|
||||
if (this.parent) this.parent.release()
|
||||
delete entryInfoByPath[this.path]
|
||||
},
|
||||
}
|
||||
fs._createSharedEntry = function (path, entry, chain, parent) {
|
||||
return _.extend(Object.create(baseEntry), {
|
||||
_refs: 0, // WORKAROUND: https://github.com/tessel/beta/issues/455
|
||||
path: path,
|
||||
entry: entry,
|
||||
chain: chain,
|
||||
parent: parent,
|
||||
}).retain()
|
||||
}
|
||||
fs._createSharedEntry('/', { Attr: { directory: true } }, vol.rootDirectoryChain)
|
||||
fs._sharedEntryForSteps = function (steps, opts, cb) {
|
||||
// NOTE: may `cb` before returning!
|
||||
var path = steps.join('/') || '/',
|
||||
name = steps.pop(), // n.b.
|
||||
info = entryInfoByPath[path]
|
||||
if (info) cb(null, info.retain())
|
||||
else
|
||||
fs._sharedEntryForSteps(steps, {}, function (e, parentInfo) {
|
||||
// n.b. `steps` don't include `name`
|
||||
if (e) cb(e)
|
||||
else if (!parentInfo.entry.Attr.directory) cb(S.err.NOTDIR())
|
||||
else
|
||||
dir.findInDirectory(vol, parentInfo.chain, name, opts, function (e, entry) {
|
||||
if (e && !opts.prepareForCreate) cb(e)
|
||||
else if (e) cb(e, { missingChild: _.extend(entry, { name: name }), parent: parentInfo })
|
||||
else cb(null, fs._createSharedEntry(path, entry, vol.chainForCluster(entry._firstCluster), parentInfo))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fs._updateEntry = dir.updateEntry.bind(dir, vol)
|
||||
fs._makeStat = dir.makeStat.bind(dir, vol)
|
||||
fs._addFile = dir.addFile.bind(dir, vol)
|
||||
fs._initDir = dir.init.bind(dir, vol)
|
||||
}
|
||||
|
||||
/**** ---- CORE API ---- ****/
|
||||
|
||||
// NOTE: we really don't share namespace, but avoid first three anyway…
|
||||
var fileDescriptors = [null, null, null]
|
||||
|
||||
fs.open = function (path, flags, mode, cb, _n_) {
|
||||
if (typeof mode === 'function') {
|
||||
_n_ = cb
|
||||
cb = mode
|
||||
mode = 0666
|
||||
}
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = { flags: null, entry: null, chain: null, pos: 0 },
|
||||
f = _.parseFlags(flags)
|
||||
if (vol.opts.ro && (f.write || f.create || f.truncate)) return _.delayedCall(cb, S.err.ROFS())
|
||||
else _fd.flags = f
|
||||
|
||||
fs._sharedEntryForSteps(_.absoluteSteps(path), { prepareForCreate: f.create }, function (e, info) {
|
||||
if (e && !(e.code === 'NOENT' && f.create && info)) cb(e)
|
||||
else if (e)
|
||||
fs._addFile(info.parent.chain, info.missingChild, { dir: f._openDir }, function (e, newEntry, newChain) {
|
||||
if (e) cb(e)
|
||||
else finish(fs._createSharedEntry(_.absolutePath(path), newEntry, newChain, info.parent))
|
||||
})
|
||||
else if (info && f.exclusive) cb(S.err.EXIST())
|
||||
else if (info.entry.Attr.directory && !f._openDir) cb(S.err.ISDIR())
|
||||
else if (f.write && info.entry.Attr.readonly) cb(S.err.ACCES())
|
||||
else finish(info)
|
||||
function finish(fileInfo) {
|
||||
var fd = fileDescriptors.push(_fd) - 1
|
||||
_fd.info = fileInfo
|
||||
_fd.entry = fileInfo.entry
|
||||
_fd.chain = fileInfo.chain
|
||||
if (f.append) _fd.pos = _fd.entry._size
|
||||
if (f._openDir) _fd.chain.cacheAdvice = 'WILLNEED'
|
||||
if (f.truncate && _fd.entry._size)
|
||||
fs.ftruncate(
|
||||
fd,
|
||||
0,
|
||||
function (e) {
|
||||
cb(e, fd)
|
||||
},
|
||||
'_nested_'
|
||||
)
|
||||
else _.delayedCall(cb, null, fd) // (delay in case fs._sharedEntryForSteps all cached!)
|
||||
}
|
||||
})
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs.fstat = function (fd, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.read) _.delayedCall(cb, S.err.BADF())
|
||||
else _.delayedCall(cb, null, fs._makeStat(_fd.entry))
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs.futimes = function (fd, atime, mtime, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
|
||||
// NOTE: ctime would get touched on POSIX; but we map that to create time!
|
||||
else fs._updateEntry(_fd.entry, { atime: atime || true, mtime: mtime || true }, cb)
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs.fchmod = function (fd, mode, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
|
||||
else {
|
||||
mode &= S._I._chmoddable
|
||||
if (_fd.entry.Attr.directory) mode |= S._I.FDIR
|
||||
else if (!_fd.entry.Attr.volume_id) mode |= S._I.FREG
|
||||
fs._updateEntry(_fd.entry, { mode: mode }, cb)
|
||||
}
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs.read = function (fd, buf, off, len, pos, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.read) return _.delayedCall(cb, S.err.BADF())
|
||||
|
||||
var _pos = pos === null ? _fd.pos : pos,
|
||||
_len = Math.min(len, _fd.entry._size - _pos),
|
||||
_buf = buf.slice(off, off + _len)
|
||||
_fd.chain.readFromPosition(_pos, _buf, function (e, bytes, slice) {
|
||||
if (_.workaroundTessel380) _buf.copy(buf, off) // WORKAROUND: https://github.com/tessel/beta/issues/380
|
||||
_fd.pos = _pos + bytes
|
||||
if (e || vol.opts.noatime) finish(e)
|
||||
else fs._updateEntry(_fd.entry, { atime: true }, finish)
|
||||
function finish(e) {
|
||||
cb(e, bytes, buf)
|
||||
}
|
||||
})
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs._readdir = function (fd, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.read) return _.delayedCall(cb, S.err.BADF())
|
||||
|
||||
var entryNames = [],
|
||||
getNextEntry = fs._dirIterator(_fd.chain)
|
||||
function processNext() {
|
||||
getNextEntry(function (e, d) {
|
||||
if (e) cb(e)
|
||||
else if (!d && !entryNames.length)
|
||||
cb(null, entryNames) // WORKAROUND: https://github.com/tessel/beta/issues/435
|
||||
else if (!d)
|
||||
cb(null, entryNames.sort()) // NOTE: sort not required, but… [simplifies tests for starters!]
|
||||
else {
|
||||
if (d._name !== '.' && d._name !== '..') entryNames.push(d._name)
|
||||
processNext()
|
||||
}
|
||||
})
|
||||
}
|
||||
processNext()
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs._mkdir = function (fd, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
|
||||
else fs._initDir(_fd.info, cb)
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs.write = function (fd, buf, off, len, pos, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.write) return _.delayedCall(cb, S.err.BADF())
|
||||
|
||||
var _pos = pos === null || _fd.flags.append ? _fd.pos : pos,
|
||||
_buf = buf.slice(off, off + len)
|
||||
if (_pos > _fd.entry._size) {
|
||||
// TODO: handle huge jumps by zeroing clusters individually?
|
||||
var padLen = _pos - _fd.entry._size,
|
||||
padBuf = _.allocBuffer(padLen + _buf.length)
|
||||
padBuf.fill(0x00, 0, padLen)
|
||||
_buf.copy(padBuf, padLen)
|
||||
_pos = _fd.entry._size
|
||||
_buf = padBuf
|
||||
}
|
||||
_fd.chain.writeToPosition(_pos, _buf, function (e) {
|
||||
_fd.pos = _pos + len
|
||||
var newSize = Math.max(_fd.entry._size, _fd.pos),
|
||||
newInfo = { size: newSize, _touch: true }
|
||||
fs._updateEntry(_fd.entry, newInfo, function (ee) {
|
||||
cb(e || ee, len, buf)
|
||||
})
|
||||
})
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
fs.ftruncate = function (fd, len, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.write) return _.delayedCall(cb, S.err.BADF())
|
||||
|
||||
var newStats = { size: len, _touch: true }
|
||||
// NOTE: we order operations for best state in case of only partial success
|
||||
if (len === _fd.entry._size) _.delayedCall(cb)
|
||||
else if (len < _fd.entry._size)
|
||||
fs._updateEntry(_fd.entry, newStats, function (e) {
|
||||
if (e) cb(e)
|
||||
else _fd.chain.truncate(Math.ceil(len / _fd.chain.sectorSize), cb)
|
||||
})
|
||||
// TODO: handle huge file expansions without as much memory pressure
|
||||
else
|
||||
_fd.chain.writeToPosition(_fd.entry._size, _.allocBuffer(len - _fd.entry._size, 0x00), function (e) {
|
||||
if (e) cb(e)
|
||||
else fs._updateEntry(_fd.entry, newStats, cb)
|
||||
})
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
// 'NORMAL', 'SEQUENTIAL', 'RANDOM', 'WILLNEED', 'DONTNEED', 'NOREUSE'
|
||||
fs._fadviseSync = function (fd, off, len, advice) {
|
||||
if (off !== 0 || len !== 0) throw Error('Cache advise can currently be given only for whole file!')
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd) throw S.err.BADF()
|
||||
else _fd.chain.cacheAdvice = advice
|
||||
}
|
||||
|
||||
fs.fsync = function (fd, cb) {
|
||||
// NOTE: we'll need to flush write cache here once we have one…
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd) _.delayedCall(cb, S.err.BADF())
|
||||
else _.delayedCall(cb)
|
||||
}
|
||||
|
||||
fs.close = function (fd, cb) {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd) _.delayedCall(cb, S.err.BADF())
|
||||
else setTimeout(_fd.info.release.bind(_fd.info), 500), _.delayedCall(cb, (fileDescriptors[fd] = null))
|
||||
}
|
||||
|
||||
/* STREAM WRAPPERS */
|
||||
|
||||
var workaroundTessel436
|
||||
try {
|
||||
new require('stream').Readable({ encoding: 'utf8' })
|
||||
new streams.Readable({ encoding: 'utf8' })
|
||||
} catch (e) {
|
||||
workaroundTessel436 = true
|
||||
}
|
||||
|
||||
function _createStream(StreamType, path, opts) {
|
||||
// [NOT REALLY A] WORKAROUND: https://github.com/tessel/beta/issues/436
|
||||
if (workaroundTessel436 && 'encoding' in opts) {
|
||||
console.warn('Tessel does not currently support encoding option for Readable streams, discarding!')
|
||||
delete opts.encoding
|
||||
}
|
||||
|
||||
var fd = opts.fd !== null ? opts.fd : '_opening_',
|
||||
pos = opts.start,
|
||||
stream = new StreamType(opts)
|
||||
|
||||
if (fd === '_opening_')
|
||||
fs.open(path, opts.flags, opts.mode, function (e, _fd) {
|
||||
if (e) {
|
||||
fd = '_open_error_'
|
||||
stream.emit('error', e)
|
||||
} else {
|
||||
fd = _fd
|
||||
fs._fadviseSync(fd, 0, 0, 'SEQUENTIAL')
|
||||
stream.emit('open', fd)
|
||||
}
|
||||
})
|
||||
|
||||
function autoClose(tombstone) {
|
||||
// NOTE: assumes caller will clear `fd`
|
||||
if (opts.autoClose)
|
||||
fs.close(fd, function (e) {
|
||||
if (e) stream.emit('error', e)
|
||||
else stream.emit('close')
|
||||
})
|
||||
fd = tombstone
|
||||
}
|
||||
|
||||
if (StreamType === streams.Readable) {
|
||||
stream._read = function (n) {
|
||||
var buf
|
||||
// TODO: optimize to fetch at least a full sector regardless of `n`…
|
||||
n = Math.min(n, opts.end - pos)
|
||||
if (fd === '_opening_')
|
||||
stream.once('open', function () {
|
||||
stream._read(n)
|
||||
})
|
||||
else if (pos > opts.end) stream.push(null)
|
||||
else if (n > 0)
|
||||
(buf = _.allocBuffer(n)),
|
||||
fs.read(fd, buf, 0, n, pos, function (e, n, d) {
|
||||
if (e) {
|
||||
autoClose('_read_error_')
|
||||
stream.emit('error', e)
|
||||
} else stream.push(n ? d.slice(0, n) : null)
|
||||
}),
|
||||
(pos += n)
|
||||
else stream.push(null)
|
||||
}
|
||||
|
||||
stream.once('end', function () {
|
||||
autoClose('_ended_')
|
||||
})
|
||||
} else if (StreamType === streams.Writable) {
|
||||
stream.bytesWritten = 0
|
||||
|
||||
stream._write = function (data, _enc, cb) {
|
||||
if (fd === '_opening_')
|
||||
stream.once('open', function () {
|
||||
stream._write(data, null, cb)
|
||||
})
|
||||
else
|
||||
fs.write(fd, data, 0, data.length, pos, function (e, n) {
|
||||
if (e) {
|
||||
autoClose('_write_error_')
|
||||
cb(e)
|
||||
} else {
|
||||
stream.bytesWritten += n
|
||||
cb()
|
||||
}
|
||||
}),
|
||||
(pos += data.length)
|
||||
}
|
||||
|
||||
stream.once('finish', function () {
|
||||
autoClose('_finished_')
|
||||
})
|
||||
}
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
fs.createReadStream = function (path, opts) {
|
||||
return _createStream(
|
||||
streams.Readable,
|
||||
path,
|
||||
_.extend(
|
||||
{
|
||||
start: 0,
|
||||
end: Infinity,
|
||||
flags: 'r',
|
||||
mode: 0666,
|
||||
encoding: null,
|
||||
fd: null, // ??? see https://github.com/joyent/node/issues/7708
|
||||
autoClose: true,
|
||||
},
|
||||
opts
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fs.createWriteStream = function (path, opts) {
|
||||
return _createStream(
|
||||
streams.Writable,
|
||||
path,
|
||||
_.extend(
|
||||
{
|
||||
start: 0,
|
||||
flags: 'w',
|
||||
mode: 0666,
|
||||
//encoding: null, // see https://github.com/joyent/node/issues/7710
|
||||
fd: null, // ??? see https://github.com/joyent/node/issues/7708
|
||||
autoClose: true,
|
||||
},
|
||||
opts,
|
||||
{ decodeStrings: true, objectMode: false }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/* PATH WRAPPERS (albeit the only public interface for some folder operations) */
|
||||
|
||||
function _fdOperation(path, opts, fn, cb) {
|
||||
cb = GROUP(cb, function () {
|
||||
opts.advice || (opts.advice = 'NORMAL')
|
||||
fs.open(
|
||||
path,
|
||||
opts.flag,
|
||||
function (e, fd) {
|
||||
if (e) cb(e)
|
||||
else
|
||||
fs._fadviseSync(fd, 0, 0, opts.advice),
|
||||
fn(fd, function () {
|
||||
var ctx = this,
|
||||
args = arguments
|
||||
fs.close(
|
||||
fd,
|
||||
function (closeErr) {
|
||||
cb.apply(ctx, args)
|
||||
},
|
||||
'_nested_'
|
||||
)
|
||||
})
|
||||
},
|
||||
'_nested_'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fs.stat = fs.lstat = function (path, cb) {
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: 'r' },
|
||||
function (fd, cb) {
|
||||
fs.fstat(fd, cb, '_nested_')
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.exists = function (path, cb) {
|
||||
fs.stat(path, function (err) {
|
||||
cb(err ? false : true)
|
||||
})
|
||||
}
|
||||
|
||||
fs.readFile = function (path, opts, cb) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts
|
||||
opts = {}
|
||||
}
|
||||
opts.flag || (opts.flag = 'r')
|
||||
opts.advice || (opts.advice = 'NOREUSE')
|
||||
_fdOperation(
|
||||
path,
|
||||
opts,
|
||||
function (fd, cb) {
|
||||
fs.fstat(
|
||||
fd,
|
||||
function (e, stat) {
|
||||
if (e) return cb(e)
|
||||
else {
|
||||
var buffer = _.allocBuffer(stat.size)
|
||||
fs.read(
|
||||
fd,
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
null,
|
||||
function (e) {
|
||||
if (e) cb(e)
|
||||
else cb(null, opts.encoding ? buffer.toString(opts.encoding) : buffer)
|
||||
},
|
||||
'_nested_'
|
||||
)
|
||||
}
|
||||
},
|
||||
'_nested_'
|
||||
)
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.writeFile = function (path, data, opts, cb) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts
|
||||
opts = {}
|
||||
}
|
||||
opts.flag || (opts.flag = 'w')
|
||||
opts.advice || (opts.advice = 'NOREUSE')
|
||||
_fdOperation(
|
||||
path,
|
||||
opts,
|
||||
function (fd, cb) {
|
||||
if (typeof data === 'string') data = _.bufferFrom(data, opts.encoding || 'utf8')
|
||||
fs.write(
|
||||
fd,
|
||||
data,
|
||||
0,
|
||||
data.length,
|
||||
null,
|
||||
function (e) {
|
||||
cb(e)
|
||||
},
|
||||
'_nested_'
|
||||
)
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.appendFile = function (path, data, opts, cb) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts
|
||||
opts = {}
|
||||
}
|
||||
opts.flag || (opts.flag = 'a')
|
||||
fs.writeFile(path, data, opts, cb)
|
||||
}
|
||||
|
||||
fs.truncate = function (path, len, cb) {
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: 'r+' },
|
||||
function (fd, cb) {
|
||||
fs.ftruncate(fd, len, cb, '_nested_')
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.readdir = function (path, cb) {
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: '\\r' },
|
||||
function (fd, cb) {
|
||||
fs._readdir(fd, cb, '_nested_')
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.mkdir = function (path, mode, cb) {
|
||||
if (typeof mode === 'function') {
|
||||
cb = mode
|
||||
mode = 0777
|
||||
}
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: '\\wx' },
|
||||
function (fd, cb) {
|
||||
fs._mkdir(fd, cb, '_nested_')
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.utimes = function (path, atime, mtime, cb) {
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: 'r+' },
|
||||
function (fd, cb) {
|
||||
fs.futimes(fd, atime, mtime, cb, '_nested_')
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.chmod = fs.lchmod = function (path, mode, cb) {
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: '\\r+' },
|
||||
function (fd, cb) {
|
||||
fs.fchmod(fd, mode, cb, '_nested_')
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.chown = fs.lchown = function (path, uid, gid, cb) {
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: '\\r+' },
|
||||
function (fd, cb) {
|
||||
fs.fchown(fd, uid, gid, cb, '_nested_')
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
/* STUBS */
|
||||
|
||||
fs.link = function (src, dst, cb) {
|
||||
// NOTE: theoretically we _could_ do hard links [with untracked `stat.nlink` count…]
|
||||
_.delayedCall(cb, S.err.NOSYS())
|
||||
}
|
||||
|
||||
fs.symlink = function (src, dst, type, cb) {
|
||||
if (typeof type === 'function') {
|
||||
cb = type
|
||||
type = null
|
||||
}
|
||||
_.delayedCall(cb, S.err.NOSYS())
|
||||
}
|
||||
|
||||
fs.readlink = function (path, cb) {
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: '\\r' },
|
||||
function (fd, cb) {
|
||||
// the named file is *never* a symbolic link…
|
||||
// NOTE: we still use _fdOperation for catching e.g. NOENT/NOTDIR errors…
|
||||
cb(S.err.INVAL())
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.realpath = function (path, cache, cb) {
|
||||
if (typeof cache === 'function') {
|
||||
cb = cache
|
||||
cache = null
|
||||
}
|
||||
if (cache)
|
||||
_.delayedCall(cb, S.err.NOSYS()) // TODO: what would be involved here?
|
||||
else
|
||||
_fdOperation(
|
||||
path,
|
||||
{ flag: '\\r' },
|
||||
function (fd, cb) {
|
||||
cb(null, _.absolutePath(path))
|
||||
},
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
fs.fchown = function (fd, uid, gid, cb, _n_) {
|
||||
cb = GROUP(
|
||||
cb,
|
||||
function () {
|
||||
var _fd = fileDescriptors[fd]
|
||||
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
|
||||
else _.delayedCall(cb, S.err.NOSYS())
|
||||
},
|
||||
_n_ === '_nested_'
|
||||
)
|
||||
}
|
||||
|
||||
// See https://github.com/natevw/fatfs/pull/31/files
|
||||
fs.createLabel = function (name, cb) {
|
||||
fs.open(name, 'a', (e, _fd) => {
|
||||
if (e) {
|
||||
cb(e)
|
||||
} else {
|
||||
let fd = fileDescriptors[_fd]
|
||||
fd.entry.Attr.volume_id = true
|
||||
fd.entry.FstClusLO = 0
|
||||
fd.entry.FstClusHI = 0
|
||||
|
||||
fs._updateEntry(fd.entry, {}, cb)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return fs
|
||||
}
|
||||
36
@vates/fatfs/make_sample.sh
Executable file
36
@vates/fatfs/make_sample.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#! /bin/sh
|
||||
|
||||
|
||||
# NOTE: this only works on OS X
|
||||
# TODO: Linux could use mkfs stuff without attach?
|
||||
|
||||
# ./make_sample.sh /tmp/fat16.img FAT16
|
||||
|
||||
FILE=$1
|
||||
: ${FILE:?must be provided as first argument}
|
||||
|
||||
TYPE=$2
|
||||
: ${TYPE:?must be provided as second argument}
|
||||
|
||||
SIZE=$3 # in MB
|
||||
case "$TYPE" in
|
||||
"FAT12") : ${SIZE:=1} # <128
|
||||
;;
|
||||
"FAT16") : ${SIZE:=5} # >4
|
||||
;;
|
||||
"FAT32") : ${SIZE:=33} # >32
|
||||
;;
|
||||
*) : ${SIZE:=1} # e.g. ExFAT
|
||||
esac
|
||||
|
||||
echo Making $TYPE of $SIZE MB at $FILE
|
||||
|
||||
if [[ "$TYPE" = FAT* ]]
|
||||
then
|
||||
TYPE="MS-DOS $TYPE"
|
||||
fi
|
||||
|
||||
dd if=/dev/zero of=$FILE bs=1048576 count=$SIZE
|
||||
DEV=`hdiutil attach $FILE -nomount`
|
||||
diskutil eraseVolume "$TYPE" "FATFS TEST" $DEV
|
||||
hdiutil detach $DEV
|
||||
32
@vates/fatfs/package.json
Normal file
32
@vates/fatfs/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/fatfs",
|
||||
"version": "0.11.0",
|
||||
"description": "fs implementation on top of raw FAT16/FAT32 block source",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra"
|
||||
},
|
||||
"keywords": [
|
||||
"filesystem",
|
||||
"fs",
|
||||
"fat",
|
||||
"fat32",
|
||||
"fat16"
|
||||
],
|
||||
"author": "Vates SAS",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vatesfr/xen-orchestra/issues"
|
||||
},
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra",
|
||||
"dependencies": {
|
||||
"fifolock": "^1.0.0",
|
||||
"struct-fu": "^1.2.1",
|
||||
"xok": "^1.0.0"
|
||||
}
|
||||
}
|
||||
255
@vates/fatfs/structs.js
Normal file
255
@vates/fatfs/structs.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// see http://staff.washington.edu/dittrich/misc/fatgen103.pdf
|
||||
// and http://www.cse.scu.edu/~tschwarz/COEN252_09/Lectures/FAT.html
|
||||
|
||||
var _ = require('struct-fu'),
|
||||
__ = require("./helpers.js");
|
||||
|
||||
var bootBase = _.struct([
|
||||
_.byte('jmpBoot', 3),
|
||||
_.char('OEMName', 8),
|
||||
_.uint16le('BytsPerSec'),
|
||||
_.uint8('SecPerClus'),
|
||||
_.uint16le('ResvdSecCnt'), // Rsvd in table, but Resvd in calcs…
|
||||
_.uint8('NumFATs'),
|
||||
_.uint16le('RootEntCnt'),
|
||||
_.uint16le('TotSec16'),
|
||||
_.uint8('Media'),
|
||||
_.uint16le('FATSz16'),
|
||||
_.uint16le('SecPerTrk'),
|
||||
_.uint16le('NumHeads'),
|
||||
_.uint32le('HiddSec'),
|
||||
_.uint32le('TotSec32')
|
||||
]);
|
||||
|
||||
var bootInfo = _.struct([
|
||||
_.uint8('DrvNum'),
|
||||
_.uint8('Reserved1'),
|
||||
_.uint8('BootSig'),
|
||||
_.uint32le('VolID'),
|
||||
_.char('VolLab', 11),
|
||||
_.char('FilSysType', 8)
|
||||
]);
|
||||
|
||||
exports.boot16 = _.struct([
|
||||
bootBase,
|
||||
bootInfo
|
||||
]);
|
||||
|
||||
exports.boot32 = _.struct([
|
||||
bootBase,
|
||||
_.uint32le('FATSz32'),
|
||||
_.struct('ExtFlags', [
|
||||
_.ubit('NumActiveFAT', 4),
|
||||
_.ubit('_reserved1', 3),
|
||||
_.bool('MirroredFAT'),
|
||||
_.ubit('_reserved2', 8)
|
||||
].reverse()),
|
||||
_.struct('FSVer', [
|
||||
_.uint8('Major'),
|
||||
_.uint8('Minor')
|
||||
]),
|
||||
_.uint32le('RootClus'),
|
||||
_.uint16le('FSInfo'),
|
||||
_.uint16le('BkBootSec'),
|
||||
_.byte('Reserved', 12),
|
||||
bootInfo
|
||||
]);
|
||||
|
||||
|
||||
var _time = _.struct([
|
||||
_.ubit('hours',5),
|
||||
_.ubit('minutes',6),
|
||||
_.ubit('seconds_2',5)
|
||||
]), time = {
|
||||
valueFromBytes: function (buf, off) {
|
||||
off || (off = {bytes:0});
|
||||
|
||||
var _buf = __.bufferFrom([buf[off.bytes+1], buf[off.bytes+0]]),
|
||||
val = _time.valueFromBytes(_buf);
|
||||
off.bytes += this.size;
|
||||
return val;
|
||||
},
|
||||
bytesFromValue: function (val, buf, off) {
|
||||
val || (val = {hours:0, minutes:0, seconds_2:0});
|
||||
buf || (buf = __.allocBuffer(this.size));
|
||||
off || (off = {bytes:0});
|
||||
|
||||
var _buf = _time.bytesFromValue(val);
|
||||
buf[off.bytes+1] = _buf[0];
|
||||
buf[off.bytes+0] = _buf[1];
|
||||
off.bytes += this.size;
|
||||
return buf;
|
||||
},
|
||||
size: _time.size
|
||||
};
|
||||
|
||||
var _date = _.struct([
|
||||
_.ubit('year',7),
|
||||
_.ubit('month',4),
|
||||
_.ubit('day',5)
|
||||
]), date = {
|
||||
valueFromBytes: function (buf, off) {
|
||||
off || (off = {bytes:0});
|
||||
|
||||
var _buf = __.bufferFrom([buf[off.bytes+1], buf[off.bytes+0]]),
|
||||
val = _date.valueFromBytes(_buf);
|
||||
off.bytes += this.size;
|
||||
return val;
|
||||
},
|
||||
bytesFromValue: function (val, buf, off) {
|
||||
val || (val = {year:0, month:0, day:0});
|
||||
buf || (buf = __.allocBuffer(this.size));
|
||||
off || (off = {bytes:0});
|
||||
|
||||
var _buf = _date.bytesFromValue(val);
|
||||
buf[off.bytes+1] = _buf[0];
|
||||
buf[off.bytes+0] = _buf[1];
|
||||
off.bytes += this.size;
|
||||
return buf;
|
||||
},
|
||||
size: _date.size
|
||||
};
|
||||
|
||||
exports.dirEntry = _.struct([
|
||||
_.struct('Name', [
|
||||
_.char('filename',8),
|
||||
_.char('extension',3)
|
||||
]),
|
||||
_.struct('Attr', [
|
||||
_.bool('readonly'),
|
||||
_.bool('hidden'),
|
||||
_.bool('system'),
|
||||
_.bool('volume_id'),
|
||||
_.bool('directory'),
|
||||
_.bool('archive'),
|
||||
_.ubit('reserved', 2)
|
||||
].reverse()),
|
||||
_.byte('NTRes', 1),
|
||||
_.uint8('CrtTimeTenth'),
|
||||
_.struct('CrtTime', [time]),
|
||||
_.struct('CrtDate', [date]),
|
||||
_.struct('LstAccDate', [date]),
|
||||
_.uint16le('FstClusHI'),
|
||||
_.struct('WrtTime', [time]),
|
||||
_.struct('WrtDate', [date]),
|
||||
_.uint16le('FstClusLO'),
|
||||
_.uint32le('FileSize')
|
||||
]);
|
||||
exports.entryDoneFlag = 0x00;
|
||||
exports.entryFreeFlag = 0xE5;
|
||||
exports.entryIsE5Flag = 0x05;
|
||||
|
||||
exports.dirEntry_simple = _.struct([
|
||||
_.struct('Name', [
|
||||
_.char('filename',8),
|
||||
_.char('extension',3)
|
||||
]),
|
||||
_.padTo(exports.dirEntry.size)
|
||||
/*
|
||||
_.uint8('Attr_raw'),
|
||||
_.byte('NTRes', 1),
|
||||
_.byte('Crt_raw', 1+2+2),
|
||||
_.byte('Lst_raw', 2),
|
||||
_.uint16le('FstClusHI'),
|
||||
_.byte('Wrt_raw', 2+2),
|
||||
_.uint16le('FstClusLO'),
|
||||
_.uint32le('FileSize')
|
||||
*/
|
||||
]);
|
||||
|
||||
|
||||
exports.lastLongFlag = 0x40;
|
||||
exports.longDirFlag = 0x0F;
|
||||
exports.longDirEntry = _.struct([
|
||||
_.uint8('Ord'),
|
||||
_.char16le('Name1', 10),
|
||||
_.uint8('Attr_raw'),
|
||||
_.uint8('Type'),
|
||||
_.uint8('Chksum'),
|
||||
_.char16le('Name2', 12),
|
||||
_.uint16le('FstClusLO'),
|
||||
_.char16le('Name3', 4)
|
||||
]);
|
||||
|
||||
if (exports.longDirEntry.size !== exports.dirEntry.size) throw Error("Structs ain't right!");
|
||||
|
||||
exports.fatField = {
|
||||
'fat12': _.struct('Status', [
|
||||
_.ubit('field0bc', 8),
|
||||
_.ubit('field1c', 4),
|
||||
_.ubit('field0a', 4),
|
||||
_.ubit('field1ab', 8),
|
||||
]),
|
||||
'fat16': _.uint16le('Status'),
|
||||
'fat32': _.uint32le('Status') // more properly this 4 bits reserved + uint28le
|
||||
};
|
||||
|
||||
exports.fatPrefix = {
|
||||
'fat12': 0xF00,
|
||||
'fat16': 0xFF00,
|
||||
'fat32': 0x0FFFFF00
|
||||
};
|
||||
|
||||
exports.fatStat = {
|
||||
free: 0x00,
|
||||
_undef: 0x01,
|
||||
rsvMin: 0xF0,
|
||||
bad: 0xF7,
|
||||
eofMin: 0xF8,
|
||||
eof: 0xFF
|
||||
};
|
||||
|
||||
exports._I = {
|
||||
RUSR: 0400,
|
||||
WUSR: 0200,
|
||||
XUSR: 0100,
|
||||
|
||||
RGRP: 0040,
|
||||
WGRP: 0020,
|
||||
XGRP: 0010,
|
||||
|
||||
ROTH: 0004,
|
||||
WOTH: 0002,
|
||||
XOTH: 0001,
|
||||
|
||||
SUID: 04000,
|
||||
SGID: 02000,
|
||||
SVTX: 01000,
|
||||
|
||||
FDIR: 040000,
|
||||
FREG: 0100000,
|
||||
};
|
||||
|
||||
exports._I.RWXU = exports._I.RUSR | exports._I.WUSR | exports._I.XUSR;
|
||||
exports._I.RWXG = exports._I.RGRP | exports._I.WGRP | exports._I.XGRP;
|
||||
exports._I.RWXO = exports._I.ROTH | exports._I.WOTH | exports._I.XOTH;
|
||||
exports._I._sss = exports._I.SUID | exports._I.SGID | exports._I.SVTX;
|
||||
exports._I._chmoddable = exports._I.RWXU | exports._I.RWXG | exports._I.RWXO | exports._I._sss;
|
||||
|
||||
var _errors = {
|
||||
IO: "Input/output error",
|
||||
NOENT: "No such file or directory",
|
||||
INVAL: "Invalid argument",
|
||||
EXIST: "File exists",
|
||||
NAMETOOLONG: "Filename too long",
|
||||
NOSPC: "No space left on device",
|
||||
NOSYS: "Function not supported",
|
||||
ROFS: "ROFLCopter file system",
|
||||
NOTDIR: "Not a directory",
|
||||
BADF: "Bad file descriptor",
|
||||
EXIST: "File exists",
|
||||
ISDIR: "Is a directory",
|
||||
ACCES: "Permission denied",
|
||||
NOSYS: "Function not implemented",
|
||||
_TODO: "Not implemented yet!"
|
||||
};
|
||||
|
||||
exports.err = {};
|
||||
Object.keys(_errors).forEach(function (sym) {
|
||||
var msg = _errors[sym];
|
||||
exports.err[sym] = function () {
|
||||
var e = new Error(msg);
|
||||
e.code = sym;
|
||||
return e;
|
||||
};
|
||||
});
|
||||
338
@vates/fatfs/test.js
Normal file
338
@vates/fatfs/test.js
Normal file
@@ -0,0 +1,338 @@
|
||||
// can be called from CLI with image type, absolute path to an image, or, required as a module
|
||||
|
||||
var _ = require("./helpers.js");
|
||||
|
||||
var type = process.argv[2];
|
||||
if (module.parent) exports.startTests = startTests;
|
||||
else if (!type) throw "Usage: node test [FAT12|FAT16|FAT32|ExFAT|…|/path/to/image]";
|
||||
else if (type[0] === '/') testWithImage(type);
|
||||
else {
|
||||
var uniq = Math.random().toString(36).slice(2),
|
||||
IMG = require('os').tmpdir()+"fatfs-test-"+uniq+".img";
|
||||
require('child_process').exec("./make_sample.sh "+JSON.stringify(IMG)+" "+JSON.stringify(type), function (e,out,err) {
|
||||
if (e) throw e;
|
||||
console.warn(err.toString());
|
||||
//console.log(out.toString());
|
||||
testWithImage(IMG);
|
||||
//console.log("open", IMG);
|
||||
//return;
|
||||
require('fs').unlink(IMG, function (e) {
|
||||
if (e) console.warn("Error cleaning up test image", e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function testWithImage(imagePath) {
|
||||
var vol = require("./img_volume.js").createDriverSync(imagePath);
|
||||
startTests(vol);
|
||||
}
|
||||
|
||||
|
||||
function startTests(vol, waitTime) {
|
||||
var fatfs = require("./"),
|
||||
fs = fatfs.createFileSystem(vol, {umask:0020, uid:99, gid:42});
|
||||
|
||||
waitTime || (waitTime = 0.5e3);
|
||||
|
||||
[
|
||||
'mkdir','readdir',
|
||||
//'rename','unlink','rmdir',
|
||||
'close','open','fsync',
|
||||
'ftruncate','truncate',
|
||||
'write','read','readFile','writeFile', 'appendFile',
|
||||
|
||||
'chown','lchown','fchown',
|
||||
'chmod','lchmod', 'fchmod',
|
||||
'utimes','futimes',
|
||||
'stat','lstat','fstat','exists',
|
||||
'link','symlink','readlink','realpath',
|
||||
|
||||
//'watchFile','unwatchFile','watch'
|
||||
].forEach(function (method) { assert(method in fs, "fs."+method+" has implementation."); });
|
||||
|
||||
var BASE_DIR = "/fat_test-"+Math.random().toFixed(20).slice(2),
|
||||
FILENAME = "Simple File.txt",
|
||||
TEXTDATA = "Hello world!";
|
||||
|
||||
var isReady = false;
|
||||
fs.on('ready', function () {
|
||||
assert(isReady = true, "Driver is ready.");
|
||||
}).on('error', function (e) {
|
||||
assert(e, "If fs driver fires 'error' event, it should include error object.");
|
||||
assert(false, "…but driver should not error when initializing in our case.");
|
||||
});
|
||||
setTimeout(function () {
|
||||
assert(isReady, "Driver fired ready event in timely fashion.");
|
||||
}, waitTime);
|
||||
|
||||
fs.readdir("/", function (e,files) {
|
||||
assert(isReady, "Method completed after 'ready' event.");
|
||||
assert(!e, "No error reading root directory.");
|
||||
assert(Array.isArray(files), "Got a list of files: "+files);
|
||||
});
|
||||
|
||||
fs.mkdir(BASE_DIR, function (e) {
|
||||
assert(!e, "No error from fs.mkdir");
|
||||
fs.readdir(BASE_DIR, function (e,arr) {
|
||||
assert(!e, "No error from fs.readdir");
|
||||
assert(arr.length === 0 , "No files in BASE_DIR yet.");
|
||||
});
|
||||
var file = [BASE_DIR,FILENAME].join('/');
|
||||
fs.writeFile(file, TEXTDATA, function (e) {
|
||||
assert(!e, "No error from fs.writeFile");
|
||||
startStreamTests();
|
||||
fs.realpath(file, function (e,path) {
|
||||
assert(!e, "No error from basic fs.realpath call.");
|
||||
assert(path === file, "We already had the real path.");
|
||||
});
|
||||
fs.realpath([BASE_DIR,".","garbage",".","..",FILENAME].join('/'), function (e,path) {
|
||||
assert(!e, "No error from fluffy fs.realpath call.");
|
||||
assert(path === file, "Fixed fluffy path matches normal one.");
|
||||
});
|
||||
fs.realpath([BASE_DIR,"non","existent","path"].join('/'), function (e) {
|
||||
assert(e, "Expected error calling fs.realpath on non-existent file.");
|
||||
});
|
||||
|
||||
fs.readdir(BASE_DIR, function (e, arr) {
|
||||
assert(!e, "Still no error from fs.readdir");
|
||||
assert(arr.length === 2, "Test directory contains two files."); // (ours + startStreamTests's)
|
||||
assert(arr[0] === FILENAME, "Filename is correct.");
|
||||
|
||||
fs.stat(file, function (e,d) {
|
||||
assert(!e, "No error from fs.stat");
|
||||
assert(d.isFile() === true, "Result is a file…");
|
||||
assert(d.isDirectory() === false, "…and not a directory.");
|
||||
assert(d.size === Buffer.byteLength(TEXTDATA), "Size matches length of content written.");
|
||||
});
|
||||
fs.exists(file, function (bool) {
|
||||
assert(bool === true, "File exists.");
|
||||
});
|
||||
fs.readFile(file, {encoding:'utf8'}, function (e, d) {
|
||||
assert(!e, "No error from fs.readFile");
|
||||
assert(d === TEXTDATA, "Data matches what was written.");
|
||||
});
|
||||
// now, overwrite the same file and make sure that goes well too
|
||||
fs.writeFile(file, _.bufferFrom([0x42]), function (e) {
|
||||
assert(!e, "Still no error from fs.writeFile");
|
||||
fs.readdir(BASE_DIR, function (e, arr) {
|
||||
assert(!e, "No error from fs.readdir");
|
||||
assert(arr.length === 2, "Test directory still contains two files.");
|
||||
assert(arr[0] === FILENAME, "Filename still correct.");
|
||||
fs.stat(file, function (e,d) {
|
||||
assert(!e, "Still no error from fs.stat");
|
||||
assert(d.isFile() === true, "Result is still a file…");
|
||||
assert(d.isDirectory() === false, "…and not a directory.");
|
||||
assert(d.size === 1, "Size matches length of now-truncated content.");
|
||||
});
|
||||
fs.readFile(file, function (e, d) {
|
||||
assert(!e, "Still no error from fs.readFile");
|
||||
assert(Buffer.isBuffer(d), "Result without encoding is a buffer.");
|
||||
assert(d.length === 1, "Buffer is correct size.");
|
||||
assert(d[0] === 0x42, "Buffer content is correct.");
|
||||
|
||||
fs.truncate(file, 1025, function (e) {
|
||||
assert(!e, "No error from fs.truncate (extending)");
|
||||
fs.readFile(file, function (e, d) {
|
||||
assert(!e, "Still no error from fs.readFile after extension");
|
||||
assert(d.length === 1025, "Read after extension is correct size.");
|
||||
assert(d[0] === 0x42, "First byte is still correct.");
|
||||
var allZeroes = true;
|
||||
for (var i = 1, len = d.length; i < len; ++i) if (d[i] !== 0) allZeroes = false;
|
||||
assert(allZeroes, "Extended portion of file is zero-filled.");
|
||||
|
||||
fs.truncate(file, 3, function (e) {
|
||||
assert(!e, "No error from fs.truncate (shortening)");
|
||||
fs.readFile(file, function (e, d) {
|
||||
assert(!e, "Still no error from fs.readFile after shortening.");
|
||||
assert(d.length === 3, "Read after shortening is correct size.");
|
||||
assert(d[0] === 0x42, "First byte is still correct.");
|
||||
assert(d[1] === 0x00, "Second byte is still correct.");
|
||||
assert(d[2] === 0x00, "Third byte is still correct.");
|
||||
proceedWithMoreTests();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function proceedWithMoreTests() {
|
||||
var fd;
|
||||
fs.open(file, 'r', function (e, _fd) {
|
||||
assert(!e, "No error from fs.open.");
|
||||
fd = _fd;
|
||||
});
|
||||
var was = "\u0042\u0000\u0000",
|
||||
str = "abc";
|
||||
fs.appendFile(file, str, function (e) {
|
||||
assert(!e, "No error from fs.appendFile.");
|
||||
assert(fd, "File descriptor opened before appendFile called.");
|
||||
var buf = _.allocBuffer(str.length);
|
||||
fs.read(fd, buf, 0, buf.length, was.length, function (e,n,d) {
|
||||
assert(!e, "No error from fs.read after append.");
|
||||
assert(n === str.length, "All appended data was readable.");
|
||||
assert(d === buf, "Buffer returned from fs.read matched what was passed in.");
|
||||
assert(d.toString() === str, "Correct data found where expected.");
|
||||
});
|
||||
});
|
||||
fs.readFile(file, {encoding:'ascii'}, function (e,d) {
|
||||
assert(!e, "No error from fs.appendFile.");
|
||||
assert(d.length === 6, "Read is correct size after append.");
|
||||
assert(d === was+str, "Read string matches what was written and then appended.");
|
||||
|
||||
fs.open(file, 'a', function (e, fd2) {
|
||||
assert(!e, "No error from second open of file.");
|
||||
var str2 = "zyx",
|
||||
buf2 = _.allocBuffer(str2.length+2);
|
||||
buf2.write(str2, 1);
|
||||
fs.write(fd2, buf2, 1, buf2.length-2, was.length, function (e,n,d) {
|
||||
assert(!e, "No error from appending fs.write.");
|
||||
assert(n === buf2.length-2, "Wrote proper amount from buffer.");
|
||||
assert(d === buf2, "Returned original buffer.");
|
||||
|
||||
buf2.fill(0);
|
||||
buf2[0] = 0xFF;
|
||||
fs.read(fd, buf2, 1, buf2.length-1, was.length, function (e,n,d) {
|
||||
assert(!e, "No error from twice-appended read.");
|
||||
assert(n === buf2.length-1, "Read proper amount into buffer.");
|
||||
assert(buf2[0] === 0xFF, "Read left first byte in buffer properly alone.");
|
||||
assert(d.slice(1).toString() === (str+str2).slice(0, buf2.length-1), "Data was appended, not written at position.");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var F = [BASE_DIR,"Manually inspect from time to time, please!.txt"].join('/'),
|
||||
S = 512,
|
||||
N = 16,
|
||||
b = _.allocBuffer(S*N);
|
||||
for (var i = 0; i < N; ++i) b.slice(S*i, S*i+S).fill(i.toString(16).charCodeAt(0));
|
||||
fs.writeFile(F, b, function (e) {
|
||||
assert(!e, "No error from fs.writeFile with counting blocks.");
|
||||
fs.readFile(F, function (e,d) {
|
||||
assert(!e, "No error from fs.readFile with counting blocks.");
|
||||
assert(d.length === b.length, "Readback is correct size");
|
||||
var matched = true;
|
||||
for (var i = 0; i < S*N; ++i) if (b[i] !== d[i]) matched = false;
|
||||
assert(matched, "Readback matches write byte-for-byte");
|
||||
});
|
||||
});
|
||||
|
||||
fs.stat(F, function (e,d) {
|
||||
assert(!e, "No error from fs.stat on counting blocks file.");
|
||||
assert(d.atime instanceof Date && !isNaN(d.atime.getTime()), "Access time is a valid date.");
|
||||
assert(d.mtime instanceof Date && !isNaN(d.mtime.getTime()), "Modify time is a valid date.");
|
||||
assert(d.ctime instanceof Date && !isNaN(d.ctime.getTime()), "Change^WCreate time is a valid date.");
|
||||
var tf = d.ctime.getTime(),
|
||||
ct = Date.now();
|
||||
assert(tf - 2*waitTime < ct && ct < tf + 2*waitTime, "Create time is within ± two `waitTime`s of now.");
|
||||
fs.utimes(F, new Date(2009, 7-1, 2), null, function (e) {
|
||||
assert(!e, "No error from fs.utimes.");
|
||||
fs.stat(F, function (e,d2) {
|
||||
assert(!e, "No fs.stat error after touching timestamps.");
|
||||
assert(+d.ctime === +d2.ctime, "Create time not changed by fs.utimes");
|
||||
assert(d2.atime.toString().indexOf("Jul 02 2009 00:00:00") === 4, "Access time set correctly");
|
||||
var tf = d2.mtime.getTime(),
|
||||
ct = Date.now();
|
||||
assert(tf-2e3 < ct && ct < tf+2e3+waitTime, "Modify time is within ± a few seconds of now.");
|
||||
|
||||
// NOTE: due to serialization, this can check results of the `fs.chmod` below, too!
|
||||
assert(!(d2.mode & 0100), "Archive bit is now unset.");
|
||||
assert(!(d2.mode & 0222), "Writable perms are unset.");
|
||||
assert(d2.mode & 0100000, "Regular file bit is set.");
|
||||
});
|
||||
});
|
||||
|
||||
assert(d.uid === 99, "Desired UID applied.");
|
||||
assert(d.gid === 42, "Desired GID applied.");
|
||||
assert(d.mode & 0100, "Archive bit is set.");
|
||||
assert(d.mode & 0200, "Writable perm is set for user.");
|
||||
assert((d.mode & 0022) === 0002, "Writable perm is only masked out for group.");
|
||||
fs.chmod(F, 0422, function (e) {
|
||||
assert(!e, "No error from fs.chmod.");
|
||||
});
|
||||
});
|
||||
|
||||
fs.chown(F, 99, 256, function (e) {
|
||||
assert(e && e.code === 'NOSYS', "Expected error from fs.fchown.");
|
||||
});
|
||||
}
|
||||
|
||||
function startStreamTests() {
|
||||
var file2 = [BASE_DIR,FILENAME+"2"].join('/'),
|
||||
outStream = fs.createWriteStream(file2);
|
||||
var outStreamOpened = false;
|
||||
outStream.on('open', function (fd) {
|
||||
outStreamOpened = true;
|
||||
assert(typeof fd === 'number', "Got file descriptor on fs.createWriteStream open.");
|
||||
}).on('error', function (e) {
|
||||
assert(e, "If fs.createWriteStream fires 'error' event, it should include error object.");
|
||||
assert(false, "But, fs.createWriteStream should not error during these tests.");
|
||||
});
|
||||
setTimeout(function () {
|
||||
assert(outStreamOpened, "outStream fired 'open' event in a timely fashion.");
|
||||
}, waitTime);
|
||||
var TEXT_MOD = TEXTDATA.toLowerCase()+"\n",
|
||||
NUM_REPS = (waitTime <= 1e3) ? 1024 : 16;
|
||||
outStream.write(TEXT_MOD, 'utf16le');
|
||||
outStream.write("Ο καλύτερος χρόνος να φυτευτεί ένα \ud83c\udf31 είναι δέκα έτη πριν.", 'utf16le');
|
||||
outStream.write("La vez del segundo mejor ahora está.\n", 'utf16le');
|
||||
for (var i = 0; i < NUM_REPS; ++i) outStream.write("123456789\n", 'ascii');
|
||||
outStream.write("JavaScript how do they work\n", 'utf16le');
|
||||
outStream.write("The end, almost.\n", 'utf16le');
|
||||
outStream.end(TEXTDATA, 'utf16le');
|
||||
var outStreamFinished = false;
|
||||
outStream.on('finish', function () {
|
||||
outStreamFinished = true;
|
||||
|
||||
var inStream = fs.createReadStream(file2, {start:NUM_REPS*10, encoding:'utf16le', autoClose:false}),
|
||||
gotData = false, gotEOF = false, inStreamFD = null;
|
||||
inStream.on('open', function (fd) {
|
||||
assert(typeof fd === 'number', "Got file descriptor on fs.createReadStream open.");
|
||||
inStreamFD = fd;
|
||||
});
|
||||
inStream.on('data', function (d) {
|
||||
gotData = true;
|
||||
assert(typeof d === 'string', "Data returned as string.");
|
||||
assert(d.slice(d.length-TEXTDATA.length) === TEXTDATA, "End of file matches what was written.");
|
||||
});
|
||||
inStream.on('end', function () {
|
||||
gotEOF = true;
|
||||
|
||||
var len = Buffer.byteLength(TEXT_MOD, 'utf16le'),
|
||||
buf = _.allocBuffer(len);
|
||||
fs.fsync(inStreamFD, function (e) {
|
||||
assert(!e, "No error from proper fsync.");
|
||||
});
|
||||
fs.fsync('garbage', function (e) {
|
||||
assert(e, "Expected error from garbage fsync.");
|
||||
});
|
||||
fs.read(inStreamFD, buf, 0, len, 0, function (e,n,d) {
|
||||
assert(!e, "No error reading from beginning of inStream's file descriptor.");
|
||||
assert(n === len, "Read complete buffer at beginning of inStream's fd.");
|
||||
assert(d.toString('utf16le') === TEXT_MOD, "Data matches at beginning of inStream's fd.");
|
||||
fs.close(inStreamFD, function (e) {
|
||||
assert(!e, "No error closing inStream's fd.");
|
||||
});
|
||||
});
|
||||
});
|
||||
setTimeout(function () {
|
||||
assert(gotData, "inStream fired 'data' event in a timely fashion.");
|
||||
setTimeout(function () {
|
||||
assert(gotEOF, "inStream fired 'eof' event in a timely fashion.");
|
||||
}, waitTime);
|
||||
}, waitTime);
|
||||
});
|
||||
setTimeout(function () {
|
||||
assert(outStreamFinished, "outStream fired 'finish' event in a timely fashion.");
|
||||
}, 2*waitTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function assert(b,msg) { if (!msg) console.warn("no msg", Error().stack); if (!b) throw Error("Assertion failure. "+msg); else console.log(msg); }
|
||||
165
@vates/fatfs/vol.js
Normal file
165
@vates/fatfs/vol.js
Normal file
@@ -0,0 +1,165 @@
|
||||
var S = require("./structs.js"),
|
||||
c = require("./chains.js"),
|
||||
$ = require("./cache.js"),
|
||||
_ = require("./helpers.js");
|
||||
|
||||
exports.init = function (volume, opts, bootSector) {
|
||||
if (bootSector[510] !== 0x55 || bootSector[511] !== 0xAA) throw Error("Invalid volume signature!");
|
||||
|
||||
var isFAT16 = bootSector.readUInt16LE(S.boot16.fields['FATSz16'].offset),
|
||||
bootStruct = (isFAT16) ? S.boot16 : S.boot32,
|
||||
BS = bootStruct.valueFromBytes(bootSector);
|
||||
_.log(_.log.DBG, "Boot sector info:", BS);
|
||||
bootSector = null; // allow GC
|
||||
if (!BS.BytsPerSec) throw Error("This looks like an ExFAT volume! (unsupported)");
|
||||
else if (BS.BytsPerSec !== volume.sectorSize) throw Error("Sector size mismatch with FAT table.");
|
||||
|
||||
var FATSz = (isFAT16) ? BS.FATSz16 : BS.FATSz32,
|
||||
rootDirSectors = Math.ceil((BS.RootEntCnt * 32) / BS.BytsPerSec),
|
||||
firstDataSector = BS.ResvdSecCnt + (BS.NumFATs * FATSz) + rootDirSectors,
|
||||
totSec = (BS.TotSec16) ? BS.TotSec16 : BS.TotSec32,
|
||||
dataSec = totSec - firstDataSector,
|
||||
countofClusters = Math.floor(dataSec / BS.SecPerClus);
|
||||
// avoid corrupting sectors from other partitions or whatnot
|
||||
if (totSec > volume.numSectors) throw Error("Volume size mismatch!");
|
||||
|
||||
var fatType;
|
||||
if (countofClusters < 4085) {
|
||||
fatType = 'fat12';
|
||||
} else if (countofClusters < 65525) {
|
||||
fatType = 'fat16';
|
||||
} else {
|
||||
fatType = 'fat32';
|
||||
}
|
||||
|
||||
_.log(_.log.DBG, "rootDirSectors", rootDirSectors, "firstDataSector", firstDataSector, "countofClusters", countofClusters, "=>", fatType);
|
||||
|
||||
var vol = {};
|
||||
|
||||
vol.opts = opts;
|
||||
|
||||
vol._sectorSize = BS.BytsPerSec;
|
||||
vol._sectorsPerCluster = BS.SecPerClus;
|
||||
vol._firstSectorOfCluster = function (n) {
|
||||
return firstDataSector + (n-2)*vol._sectorsPerCluster;
|
||||
};
|
||||
|
||||
vol._makeCache = function () {
|
||||
return $.wrapDriver(volume);
|
||||
};
|
||||
|
||||
vol._readSectors = function (cache, secNum, dest, cb) {
|
||||
if (typeof dest === 'function') {
|
||||
cb = dest;
|
||||
dest = _.allocBuffer(vol._sectorSize);
|
||||
}
|
||||
_.log(_.log.DBG, "vol._readSectors", secNum, dest.length);
|
||||
if (secNum < volume.numSectors) cache.readSectors(secNum, dest, function (e) { cb(e, dest); });
|
||||
else throw Error("Invalid sector number!");
|
||||
};
|
||||
|
||||
vol._writeSectors = function (cache, secNum, data, cb) {
|
||||
_.log(_.log.DBG, "vol._writeSectors", secNum, data.length);
|
||||
// NOTE: these are internal assertions, public API will get proper `S.err`s
|
||||
if (data.length % volume.sectorSize) throw Error("Buffer length not a multiple of sector size");
|
||||
else if (opts.ro) throw Error("Read-only filesystem");
|
||||
else if (secNum < volume.numSectors) cache.writeSectors(secNum, data, cb);
|
||||
else throw Error("Invalid sector number!");
|
||||
};
|
||||
|
||||
function fatInfoForCluster(n) {
|
||||
var entryStruct = S.fatField[fatType],
|
||||
FATOffset = (fatType === 'fat12') ? Math.floor(n/2) * entryStruct.size : n * entryStruct.size,
|
||||
SecNum = BS.ResvdSecCnt + Math.floor(FATOffset / BS.BytsPerSec);
|
||||
EntOffset = FATOffset % BS.BytsPerSec;
|
||||
return {sector:SecNum-BS.ResvdSecCnt, offset:EntOffset, struct:entryStruct};
|
||||
}
|
||||
|
||||
// TODO: all this FAT manipulation is crazy inefficient! needs read caching *and* write caching
|
||||
// …the best place for cache might be in `volume` handler, though. add a `flush` method to that spec?
|
||||
// TODO: how should we handle redundant FATs? mirror every write? just ignore completely? copy-on-eject?
|
||||
|
||||
var fatChain = c.sectorChain(vol, BS.ResvdSecCnt, FATSz);
|
||||
fatChain.cacheAdvice = 'RANDOM';
|
||||
|
||||
vol.fetchFromFAT = function (clusterNum, cb) {
|
||||
var info = fatInfoForCluster(clusterNum);
|
||||
fatChain.readFromPosition(info, info.struct.size, function (e,n,d) {
|
||||
if (e) return cb(e);
|
||||
var status = info.struct.valueFromBytes(d), prefix;
|
||||
if (fatType === 'fat12') {
|
||||
if (clusterNum % 2) {
|
||||
status = (status.field1ab << 4) + status.field1c;
|
||||
} else {
|
||||
status = (status.field0a << 8) + status.field0bc;
|
||||
}
|
||||
}
|
||||
else if (fatType === 'fat32') {
|
||||
status &= 0x0FFFFFFF;
|
||||
}
|
||||
|
||||
var prefix = S.fatPrefix[fatType];
|
||||
if (status === S.fatStat.free) cb(null, 'free');
|
||||
else if (status === S.fatStat._undef) cb(null, '-invalid-');
|
||||
else if (status > prefix+S.fatStat.eofMin) cb(null, 'eof');
|
||||
else if (status === prefix+S.fatStat.bad) cb(null, 'bad');
|
||||
else if (status > prefix+S.fatStat.rsvMin) cb(null, 'reserved');
|
||||
else cb(null, status);
|
||||
});
|
||||
};
|
||||
|
||||
vol.storeToFAT = function (clusterNum, status, cb) {
|
||||
if (typeof status === 'string') {
|
||||
status = S.fatStat[status];
|
||||
status += S.fatPrefix[fatType];
|
||||
}
|
||||
var info = fatInfoForCluster(clusterNum);
|
||||
// TODO: technically fat32 needs to *preserve* the high 4 bits
|
||||
if (fatType === 'fat12') fatChain.readFromPosition(info, info.struct.size, function (e,n,d) {
|
||||
var value = info.struct.valueFromBytes(d);
|
||||
if (clusterNum % 2) {
|
||||
value.field1ab = status >>> 4;
|
||||
value.field1c = status & 0x0F;
|
||||
} else {
|
||||
value.field0a = status >>> 8;
|
||||
value.field0bc = status & 0xFF;
|
||||
}
|
||||
var entry = info.struct.bytesFromValue(value);
|
||||
fatChain.writeToPosition(info, entry, cb);
|
||||
}); else {
|
||||
var entry = info.struct.bytesFromValue(status);
|
||||
fatChain.writeToPosition(info, entry, cb);
|
||||
}
|
||||
};
|
||||
|
||||
vol.allocateInFAT = function (hint, cb) {
|
||||
if (typeof hint === 'function') {
|
||||
cb = hint;
|
||||
hint = 2; // TODO: cache a better starting point?
|
||||
}
|
||||
function searchForFreeCluster(num, cb) {
|
||||
if (num < countofClusters) vol.fetchFromFAT(num, function (e, status) {
|
||||
if (e) cb(e);
|
||||
else if (status === 'free') cb(null, num);
|
||||
else searchForFreeCluster(num+1, cb);
|
||||
}); else cb(S.err.NOSPC()); // TODO: try searching backwards from hint…
|
||||
}
|
||||
searchForFreeCluster(hint, function (e, clusterNum) {
|
||||
if (e) cb(e);
|
||||
else vol.storeToFAT(clusterNum, 'eof', cb.bind(null,null,clusterNum));
|
||||
});
|
||||
};
|
||||
|
||||
vol.rootDirectoryChain = (isFAT16) ?
|
||||
c.sectorChain(vol, firstDataSector - rootDirSectors, rootDirSectors) :
|
||||
c.clusterChain(vol, BS.RootClus);
|
||||
vol.rootDirectoryChain.cacheAdvice = 'WILLNEED';
|
||||
vol.chainForCluster = c.clusterChain.bind(c, vol);
|
||||
vol.chainFromJSON = function (d) {
|
||||
return ('numSectors' in d) ?
|
||||
c.sectorChain(vol, d.firstSector, d.numSectors) :
|
||||
c.clusterChain(vol, d.firstCluster);
|
||||
};
|
||||
|
||||
return vol;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import LRU from 'lru-cache'
|
||||
import Fuse from 'fuse-native'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { VhdSynthetic } from 'vhd-lib'
|
||||
import { Disposable, fromCallback } from 'promise-toolbox'
|
||||
|
||||
const { warn } = createLogger('vates:fuse-vhd')
|
||||
|
||||
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
|
||||
const stat = st => ({
|
||||
mtime: st.mtime || new Date(),
|
||||
@@ -52,7 +55,10 @@ export const mount = Disposable.factory(async function* mount(handler, diskPath,
|
||||
},
|
||||
read(path, fd, buf, len, pos, cb) {
|
||||
if (path === '/vhd0') {
|
||||
return vhd.readRawData(pos, len, cache, buf).then(cb)
|
||||
return vhd.readRawData(pos, len, cache, buf).then(cb, error => {
|
||||
warn('read error', { path, len, pos, error })
|
||||
cb(Fuse.EIO)
|
||||
})
|
||||
}
|
||||
throw new Error(`read file ${path} not exists`)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/fuse-vhd",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.2",
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
|
||||
@@ -19,11 +19,12 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.1.6",
|
||||
"@xen-orchestra/fs": "^4.3.0",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.9.2"
|
||||
"vhd-lib": "^4.11.2"
|
||||
},
|
||||
"bin": {
|
||||
"xo-fuse-vhd": "./cli.mjs"
|
||||
|
||||
18
@vates/json-hash/.USAGE.md
Normal file
18
@vates/json-hash/.USAGE.md
Normal file
@@ -0,0 +1,18 @@
|
||||
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
|
||||
|
||||
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.
|
||||
|
||||
The hash is encoded using [Base64 URL](https://en.wikipedia.org/wiki/Base64#URL_applications) to make it easy to use in URLs and filenames.
|
||||
|
||||
```js
|
||||
import { jsonHash } from '@vates/json-hash'
|
||||
|
||||
console.log(jsonHash('foo'))
|
||||
// → "siEyldVkkW-JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs"
|
||||
|
||||
// order of properties is ignored
|
||||
console.log(jsonHash({ foo: 0, bar: 1 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
console.log(jsonHash({ bar: 1, foo: 0 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
```
|
||||
1
@vates/json-hash/.npmignore
Symbolic link
1
@vates/json-hash/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
51
@vates/json-hash/README.md
Normal file
51
@vates/json-hash/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/json-hash
|
||||
|
||||
[](https://npmjs.org/package/@vates/json-hash)  [](https://bundlephobia.com/result?p=@vates/json-hash) [](https://npmjs.org/package/@vates/json-hash)
|
||||
|
||||
> Compute a stable hash from a JSON-ifiable value
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/json-hash):
|
||||
|
||||
```sh
|
||||
npm install --save @vates/json-hash
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
|
||||
|
||||
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.
|
||||
|
||||
The hash is encoded using [Base64 URL](https://en.wikipedia.org/wiki/Base64#URL_applications) to make it easy to use in URLs and filenames.
|
||||
|
||||
```js
|
||||
import { jsonHash } from '@vates/json-hash'
|
||||
|
||||
console.log(jsonHash('foo'))
|
||||
// → "siEyldVkkW-JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs"
|
||||
|
||||
// order of properties is ignored
|
||||
console.log(jsonHash({ foo: 0, bar: 1 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
console.log(jsonHash({ bar: 1, foo: 0 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
```
|
||||
|
||||
## 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)
|
||||
38
@vates/json-hash/index.js
Normal file
38
@vates/json-hash/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
'use strict'
|
||||
|
||||
const { createHash } = require('node:crypto')
|
||||
|
||||
function updateJsonHash(value, hash) {
|
||||
if (value !== null && typeof value === 'object') {
|
||||
if (Array.isArray(value)) {
|
||||
hash.update('[')
|
||||
for (const item of value) {
|
||||
updateJsonHash(item, hash)
|
||||
|
||||
// trailing is not a big deal because it does not need to be valid JSON
|
||||
hash.update(',')
|
||||
}
|
||||
hash.update(']')
|
||||
} else {
|
||||
hash.update('{')
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
updateJsonHash(key, hash)
|
||||
hash.update(':')
|
||||
updateJsonHash(value[key], hash)
|
||||
|
||||
// trailing is not a big deal because it does not need to be valid JSON
|
||||
hash.update(',')
|
||||
}
|
||||
hash.update('}')
|
||||
}
|
||||
} else {
|
||||
hash.update(JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
|
||||
exports.jsonHash = function jsonHash(value) {
|
||||
// this hash does not need to be secure, it just needs to be fast and with low collisions
|
||||
const hash = createHash('sha256')
|
||||
updateJsonHash(value, hash)
|
||||
return hash.digest('base64url')
|
||||
}
|
||||
21
@vates/json-hash/index.test.js
Normal file
21
@vates/json-hash/index.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
'use strict'
|
||||
|
||||
const { jsonHash } = require('@vates/json-hash')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const cases = [
|
||||
[null, 'dCNOmK_nSY-12vHzasLXiswzlGT5UHA7jAGYkvmCuQs'],
|
||||
[42, 'c0dctApWjo2ooEXO0RATfhWfiQrE2og7axfcZRs6gEk'],
|
||||
['foo', 'siEyldVkkW-JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs'],
|
||||
[[], 'T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU'],
|
||||
[{}, 'RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o'],
|
||||
[{ a: 1, b: 2 }, 'LMjVxyj2vtMZ3W1cZ2cJrQij5zn7FBOlPiyBB38OmFE'],
|
||||
[{ b: 2, a: 1 }, 'LMjVxyj2vtMZ3W1cZ2cJrQij5zn7FBOlPiyBB38OmFE'],
|
||||
]
|
||||
|
||||
for (const [value, hash] of cases) {
|
||||
test(JSON.stringify(value), function () {
|
||||
assert.equal(jsonHash(value), hash)
|
||||
})
|
||||
}
|
||||
30
@vates/json-hash/package.json
Normal file
30
@vates/json-hash/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/json-hash",
|
||||
"description": "Compute a stable hash from a JSON-ifiable value",
|
||||
"keywords": [
|
||||
"hash",
|
||||
"json",
|
||||
"stable"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/json-hash",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/json-hash",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.2.0",
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { MultiKeyMap } = require('./')
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ export default class NbdClient {
|
||||
#exportSize
|
||||
|
||||
#waitBeforeReconnect
|
||||
#readAhead
|
||||
#readBlockRetries
|
||||
#reconnectRetry
|
||||
#connectTimeout
|
||||
@@ -55,7 +54,6 @@ export default class NbdClient {
|
||||
connectTimeout = 6e4,
|
||||
messageTimeout = 6e4,
|
||||
waitBeforeReconnect = 1e3,
|
||||
readAhead = 10,
|
||||
readBlockRetries = 5,
|
||||
reconnectRetry = 5,
|
||||
} = {}
|
||||
@@ -65,7 +63,6 @@ export default class NbdClient {
|
||||
this.#exportName = exportname
|
||||
this.#serverCert = cert
|
||||
this.#waitBeforeReconnect = waitBeforeReconnect
|
||||
this.#readAhead = readAhead
|
||||
this.#readBlockRetries = readBlockRetries
|
||||
this.#reconnectRetry = reconnectRetry
|
||||
this.#connectTimeout = connectTimeout
|
||||
|
||||
@@ -6,7 +6,10 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
const { warn } = createLogger('vates:nbd-client:multi')
|
||||
export default class MultiNbdClient {
|
||||
#clients = []
|
||||
#nbdConcurrency
|
||||
#options
|
||||
#readAhead
|
||||
#settings
|
||||
|
||||
get exportSize() {
|
||||
return this.#clients[0].exportSize
|
||||
@@ -14,36 +17,63 @@ export default class MultiNbdClient {
|
||||
|
||||
constructor(settings, { nbdConcurrency = 8, readAhead = 16, ...options } = {}) {
|
||||
this.#readAhead = readAhead
|
||||
this.#options = options
|
||||
this.#nbdConcurrency = nbdConcurrency
|
||||
if (!Array.isArray(settings)) {
|
||||
settings = [settings]
|
||||
}
|
||||
for (let i = 0; i < nbdConcurrency; i++) {
|
||||
this.#clients.push(
|
||||
new NbdClient(settings[i % settings.length], { ...options, readAhead: Math.ceil(readAhead / nbdConcurrency) })
|
||||
)
|
||||
}
|
||||
this.#settings = settings
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* open nbdConcurrency connections to NBD servers
|
||||
* it must obtain at least one connection to succeed
|
||||
* it tries to spread connections on multiple host
|
||||
*/
|
||||
async connect() {
|
||||
const connectedClients = []
|
||||
for (const clientId in this.#clients) {
|
||||
const client = this.#clients[clientId]
|
||||
const candidates = [...this.#settings]
|
||||
|
||||
const baseOptions = this.#options
|
||||
const _connect = async () => {
|
||||
if (candidates.length === 0) {
|
||||
return
|
||||
}
|
||||
// a little bit of randomization to spread the load
|
||||
const nbdInfo = candidates[Math.floor(Math.random() * candidates.length)]
|
||||
const client = new NbdClient(nbdInfo, {
|
||||
...baseOptions,
|
||||
readAhead: Math.ceil(this.#readAhead / this.#nbdConcurrency),
|
||||
})
|
||||
try {
|
||||
await client.connect()
|
||||
connectedClients.push(client)
|
||||
this.#clients.push(client)
|
||||
} catch (err) {
|
||||
client.disconnect().catch(() => {})
|
||||
// do not hammer unreachable hosts, once failed, remove from the list
|
||||
const candidateIndex = candidates.findIndex(({ address }) => address === nbdInfo.address)
|
||||
if (candidateIndex >= 0) {
|
||||
// this candidate may have already been deleted by another parallel promise
|
||||
candidates.splice(candidateIndex, 1)
|
||||
}
|
||||
|
||||
warn(`can't connect to one nbd client`, { err })
|
||||
// retry with another candidate (if available)
|
||||
return _connect()
|
||||
}
|
||||
}
|
||||
if (connectedClients.length === 0) {
|
||||
// don't connect in parallel since this can lead to race condition
|
||||
// on distributed systems ( like the NBD server of the XAPI)
|
||||
for (let i = 0; i < this.#nbdConcurrency; i++) {
|
||||
await _connect()
|
||||
}
|
||||
if (this.#clients.length === 0) {
|
||||
throw new Error(`Fail to connect to any Nbd client`)
|
||||
}
|
||||
if (connectedClients.length < this.#clients.length) {
|
||||
if (this.#clients.length < this.#nbdConcurrency) {
|
||||
warn(
|
||||
`incomplete connection by multi Nbd, only ${connectedClients.length} over ${this.#clients.length} expected clients`
|
||||
`incomplete connection by multi Nbd, only ${this.#clients.length} over ${this.#nbdConcurrency} expected clients`
|
||||
)
|
||||
this.#clients = connectedClients
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "3.0.1",
|
||||
"version": "3.1.2",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
@@ -22,9 +22,9 @@
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^3.0.1"
|
||||
"xen-api": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^18.7.0",
|
||||
|
||||
@@ -78,15 +78,14 @@ async function killNbdKit() {
|
||||
)
|
||||
}
|
||||
|
||||
test('it works with unsecured network', async tap => {
|
||||
test('it works secured network', async tap => {
|
||||
const path = await createTempFile(FILE_SIZE)
|
||||
|
||||
let nbdServer = await spawnNbdKit(path)
|
||||
const client = new MultiNbdClient(
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
const connectionSettings = {
|
||||
address: '127.0.0.1',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgIUeHpQ0IeD6BmP2zgsv3LV3J4BI/EwDQYJKoZIhvcNAQEL
|
||||
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA1MTcxMzU1MzBaFw0yNDA1
|
||||
@@ -108,12 +107,18 @@ CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
|
||||
57ABhfYpMlZ4aHjuN1bL
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
},
|
||||
{
|
||||
nbdConcurrency: 1,
|
||||
readAhead: 2,
|
||||
}
|
||||
)
|
||||
}
|
||||
const invalid = {
|
||||
address: '500.500.500.500',
|
||||
port: 0,
|
||||
exportname: 'nop',
|
||||
}
|
||||
// it should work even with some broken servers
|
||||
const nbdInfos = [connectionSettings, connectionSettings, invalid]
|
||||
const client = new MultiNbdClient(nbdInfos, {
|
||||
nbdConcurrency: 4,
|
||||
readAhead: 2,
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
tap.equal(client.exportSize, BigInt(FILE_SIZE))
|
||||
@@ -156,3 +161,18 @@ CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
|
||||
nbdServer.kill()
|
||||
await fs.unlink(path)
|
||||
})
|
||||
|
||||
test('fails if server does not answer', async () => {
|
||||
const client = new MultiNbdClient(
|
||||
{
|
||||
address: '500.500.500.500',
|
||||
port: 0,
|
||||
exportname: 'nop',
|
||||
},
|
||||
{
|
||||
nbdConcurrency: 1,
|
||||
readAhead: 2,
|
||||
}
|
||||
)
|
||||
await assert.rejects(client.connect())
|
||||
})
|
||||
|
||||
@@ -17,7 +17,20 @@ import https from 'node:https'
|
||||
import util from 'util'
|
||||
import soap from 'soap'
|
||||
import Cookie from 'soap-cookie' // required for session persistence
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
const { warn } = createLogger('xo:node-vsphere-soap:client')
|
||||
class VmwareError extends Error {
|
||||
constructor(rawError) {
|
||||
super(rawError.body)
|
||||
const matches = rawError.body?.match(/<faultstring[^>]*>(.*)<\/faultstring>/im)
|
||||
this.message = matches?.[1] ?? rawError.message
|
||||
this.name = 'VmwareError'
|
||||
this.stack = rawError.stack
|
||||
// not putting the cause since the error failed to be stringified correctly down the road
|
||||
warn(rawError)
|
||||
}
|
||||
}
|
||||
// Client class
|
||||
// inherits from EventEmitter
|
||||
// possible events: connect, error, ready
|
||||
@@ -140,8 +153,9 @@ Client.prototype._connect = function () {
|
||||
self.clientopts,
|
||||
function (err, client) {
|
||||
if (err) {
|
||||
self.emit('error', err)
|
||||
throw err
|
||||
const vErr = new VmwareError(err)
|
||||
self.emit('error', vErr)
|
||||
throw vErr
|
||||
}
|
||||
|
||||
self.client = client // save client for later use
|
||||
@@ -150,6 +164,7 @@ Client.prototype._connect = function () {
|
||||
.runCommand('RetrieveServiceContent', { _this: 'ServiceInstance' })
|
||||
.once('result', function (result, raw, soapHeader) {
|
||||
if (!result.returnval) {
|
||||
// this is a domain/server error return it as is
|
||||
self.status = 'disconnected'
|
||||
self.emit('error', raw)
|
||||
return
|
||||
@@ -208,7 +223,7 @@ Client.prototype._close = function () {
|
||||
function _soapErrorHandler(self, emitter, command, args, err) {
|
||||
err = err || { body: 'general error' }
|
||||
|
||||
if (err.body.match(/session is not authenticated/)) {
|
||||
if (err.body?.match(/session is not authenticated/)) {
|
||||
self.status = 'disconnected'
|
||||
process.removeAllListeners('beforeExit')
|
||||
|
||||
@@ -220,16 +235,19 @@ function _soapErrorHandler(self, emitter, command, args, err) {
|
||||
emitter.emit('result', result, raw, soapHeader)
|
||||
})
|
||||
.once('error', function (err) {
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
const vErr = new VmwareError(err)
|
||||
emitter.emit('error', vErr)
|
||||
throw vErr
|
||||
})
|
||||
} else {
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
const vErr = new VmwareError(err)
|
||||
emitter.emit('error', vErr)
|
||||
throw vErr
|
||||
}
|
||||
} else {
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
const vErr = new VmwareError(err)
|
||||
emitter.emit('error', vErr)
|
||||
throw vErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/node-vsphere-soap",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.1",
|
||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||
"main": "lib/client.mjs",
|
||||
"author": "reedog117",
|
||||
@@ -11,13 +11,11 @@
|
||||
},
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"axios": "^1.4.0",
|
||||
"soap": "^1.0.0",
|
||||
"soap-cookie": "^0.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
},
|
||||
"keywords": [
|
||||
"vsphere",
|
||||
"vcenter",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import assert from 'assert'
|
||||
import { describe, it } from 'test'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import * as vc from '../lib/client.mjs'
|
||||
|
||||
|
||||
46
@vates/obfuscate/.USAGE.md
Normal file
46
@vates/obfuscate/.USAGE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
### `replace(value, replacement)`
|
||||
|
||||
> Recursively replace sensitive entries with a replacement value.
|
||||
|
||||
```js
|
||||
import { replace } from '@vates/obfuscate'
|
||||
|
||||
replace({ username: 'foo', password: 'bar' }, 'obfuscated')
|
||||
// → { username: 'foo', password: 'obfuscated'}
|
||||
```
|
||||
|
||||
### `obfuscate(value)`
|
||||
|
||||
> Recursively replace sensitive entries with a special tag.
|
||||
|
||||
```js
|
||||
import { obfuscate } from '@vates/obfuscate'
|
||||
|
||||
obfuscate({ username: 'foo', password: 'bar' })
|
||||
// → {
|
||||
// username: 'foo',
|
||||
// password: 'obfuscated-q3oi6d9X8uenGvdLnHk2',
|
||||
// }
|
||||
```
|
||||
|
||||
### `merge(newValue, oldValue)`
|
||||
|
||||
> Replace entries obfuscated by `obfuscate()` in the new value by entries from the old one.
|
||||
>
|
||||
> This is especially useful to give an obfuscated value to a client, let them modify it, and then merge modified and unmodified values.
|
||||
|
||||
```js
|
||||
import { obfuscate, merge } from '@vates/obfuscate'
|
||||
|
||||
const original = { username: 'foo', password: 'bar', token: 'baz' }
|
||||
|
||||
// compute an obfuscated value to send to a client
|
||||
const obfuscated = obfuscate(original)
|
||||
|
||||
// let the client update any entries
|
||||
obfuscated.token = 'qux'
|
||||
|
||||
// merge the updated value with the original one
|
||||
merge(obfuscated, original)
|
||||
// → { username: 'foo', password: 'bar', token: 'qux' }
|
||||
```
|
||||
1
@vates/obfuscate/.npmignore
Symbolic link
1
@vates/obfuscate/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
77
@vates/obfuscate/README.md
Normal file
77
@vates/obfuscate/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/obfuscate
|
||||
|
||||
[](https://npmjs.org/package/@vates/obfuscate)  [](https://bundlephobia.com/result?p=@vates/obfuscate) [](https://npmjs.org/package/@vates/obfuscate)
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/obfuscate):
|
||||
|
||||
```sh
|
||||
npm install --save @vates/obfuscate
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### `replace(value, replacement)`
|
||||
|
||||
> Recursively replace sensitive entries with a replacement value.
|
||||
|
||||
```js
|
||||
import { replace } from '@vates/obfuscate'
|
||||
|
||||
replace({ username: 'foo', password: 'bar' }, 'obfuscated')
|
||||
// → { username: 'foo', password: 'obfuscated'}
|
||||
```
|
||||
|
||||
### `obfuscate(value)`
|
||||
|
||||
> Recursively replace sensitive entries with a special tag.
|
||||
|
||||
```js
|
||||
import { obfuscate } from '@vates/obfuscate'
|
||||
|
||||
obfuscate({ username: 'foo', password: 'bar' })
|
||||
// → {
|
||||
// username: 'foo',
|
||||
// password: 'obfuscated-q3oi6d9X8uenGvdLnHk2',
|
||||
// }
|
||||
```
|
||||
|
||||
### `merge(newValue, oldValue)`
|
||||
|
||||
> Replace entries obfuscated by `obfuscate()` in the new value by entries from the old one.
|
||||
>
|
||||
> This is especially useful to give an obfuscated value to a client, let them modify it, and then merge modified and unmodified values.
|
||||
|
||||
```js
|
||||
import { obfuscate, merge } from '@vates/obfuscate'
|
||||
|
||||
const original = { username: 'foo', password: 'bar', token: 'baz' }
|
||||
|
||||
// compute an obfuscated value to send to a client
|
||||
const obfuscated = obfuscate(original)
|
||||
|
||||
// let the client update any entries
|
||||
obfuscated.token = 'qux'
|
||||
|
||||
// merge the updated value with the original one
|
||||
merge(obfuscated, original)
|
||||
// → { username: 'foo', password: 'bar', token: 'qux' }
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -1,9 +1,18 @@
|
||||
import mapValues from 'lodash/mapValues.js'
|
||||
'use strict'
|
||||
|
||||
function mapValues(object, iteratee) {
|
||||
const result = {}
|
||||
for (const key of Object.keys(object)) {
|
||||
result[key] = iteratee(object[key], key, object)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// this random value is used to obfuscate real data
|
||||
const OBFUSCATED_VALUE = 'obfuscated-q3oi6d9X8uenGvdLnHk2'
|
||||
exports.OBFUSCATED_VALUE = OBFUSCATED_VALUE
|
||||
|
||||
export const merge = (newValue, oldValue) => {
|
||||
function merge(newValue, oldValue) {
|
||||
if (newValue === OBFUSCATED_VALUE) {
|
||||
return oldValue
|
||||
}
|
||||
@@ -23,14 +32,16 @@ export const merge = (newValue, oldValue) => {
|
||||
const iteratee = (v, k) => merge(v, oldValue[k])
|
||||
return isArray ? newValue.map(iteratee) : mapValues(newValue, iteratee)
|
||||
}
|
||||
exports.merge = merge
|
||||
|
||||
export const obfuscate = value => replace(value, OBFUSCATED_VALUE)
|
||||
const obfuscate = value => replace(value, OBFUSCATED_VALUE)
|
||||
exports.obfuscate = obfuscate
|
||||
|
||||
const SENSITIVE_PARAMS = ['token', 'passphrase', /password/i, 'encryptionKey']
|
||||
const isSensitiveParam = name =>
|
||||
SENSITIVE_PARAMS.some(pattern => (typeof pattern === 'string' ? pattern === name : pattern.test(name)))
|
||||
|
||||
export function replace(value, replacement) {
|
||||
function replace(value, replacement) {
|
||||
function helper(value, name) {
|
||||
if (typeof value === 'string' && isSensitiveParam(name)) {
|
||||
return replacement
|
||||
@@ -45,3 +56,4 @@ export function replace(value, replacement) {
|
||||
|
||||
return helper(value)
|
||||
}
|
||||
exports.replace = replace
|
||||
23
@vates/obfuscate/package.json
Normal file
23
@vates/obfuscate/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/obfuscate",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/obfuscate",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/obfuscate",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import test from 'test'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
generateHotp,
|
||||
|
||||
@@ -31,9 +31,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ exports.parseDuration = value => {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
const duration = ms(value)
|
||||
if (duration === undefined) {
|
||||
throw new TypeError(`not a valid duration: ${value}`)
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
const duration = ms(value)
|
||||
if (duration !== undefined) {
|
||||
return duration
|
||||
}
|
||||
}
|
||||
return duration
|
||||
throw new TypeError(`not a valid duration: ${value}`)
|
||||
}
|
||||
|
||||
22
@vates/parse-duration/index.test.mjs
Normal file
22
@vates/parse-duration/index.test.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import { parseDuration } from '@vates/parse-duration'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
describe('parseDuration()', () => {
|
||||
it('should parse string', () => {
|
||||
const input = '2 days'
|
||||
const expected = 172800000
|
||||
assert.strictEqual(parseDuration(input), expected)
|
||||
})
|
||||
|
||||
it('should return its input if already a number', () => {
|
||||
const input = 172800000
|
||||
assert.strictEqual(parseDuration(input), input)
|
||||
})
|
||||
|
||||
for (const input of [undefined, '', 'invalid duration']) {
|
||||
it('should throw an error for ' + input, () => {
|
||||
assert.throws(() => parseDuration(input), { message: `not a valid duration: ${input}` })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -27,6 +27,7 @@
|
||||
"ms": "^2.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert/strict')
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const { every, not, some } = require('./')
|
||||
|
||||
|
||||
@@ -32,9 +32,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert').strict
|
||||
|
||||
const { Readable } = require('stream')
|
||||
|
||||
@@ -25,15 +25,12 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"isutf8": "^4.0.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert').strict
|
||||
|
||||
const { Readable } = require('stream')
|
||||
|
||||
@@ -31,9 +31,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,15 @@ const result = await task.runInside(fn)
|
||||
// if fn rejects, the task will be marked as failed
|
||||
// if fn resolves, the task will be marked as succeeded
|
||||
const result = await task.run(fn)
|
||||
|
||||
// manually starts the task
|
||||
task.start()
|
||||
|
||||
// manually finishes the task in success state
|
||||
task.success(result)
|
||||
|
||||
// manually finishes the task in failure state
|
||||
task.failure(error)
|
||||
```
|
||||
|
||||
Inside a task:
|
||||
|
||||
@@ -65,6 +65,15 @@ const result = await task.runInside(fn)
|
||||
// if fn rejects, the task will be marked as failed
|
||||
// if fn resolves, the task will be marked as succeeded
|
||||
const result = await task.run(fn)
|
||||
|
||||
// manually starts the task
|
||||
task.start()
|
||||
|
||||
// manually finishes the task in success state
|
||||
task.success(result)
|
||||
|
||||
// manually finishes the task in failure state
|
||||
task.failure(error)
|
||||
```
|
||||
|
||||
Inside a task:
|
||||
|
||||
@@ -44,6 +44,8 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
|
||||
} else if (type === 'property') {
|
||||
;(taskLog.properties ?? (taskLog.properties = { __proto__: null }))[event.name] = event.value
|
||||
} else if (type === 'end') {
|
||||
taskLogs.delete(id)
|
||||
|
||||
taskLog.end = event.timestamp
|
||||
taskLog.result = event.result
|
||||
taskLog.status = event.status
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert').strict
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const { makeOnProgress } = require('./combineEvents.js')
|
||||
const { Task } = require('./index.js')
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
const assert = require('node:assert').strict
|
||||
const { AsyncLocalStorage } = require('node:async_hooks')
|
||||
|
||||
function alreadyEnded() {
|
||||
throw new Error('task has already ended')
|
||||
}
|
||||
|
||||
// define a read-only, non-enumerable, non-configurable property
|
||||
function define(object, property, value) {
|
||||
Object.defineProperty(object, property, { value })
|
||||
@@ -21,7 +25,7 @@ const asyncStorage = global[asyncStorageKey] ?? (global[asyncStorageKey] = new A
|
||||
|
||||
const getTask = () => asyncStorage.getStore()
|
||||
|
||||
exports.Task = class Task {
|
||||
class Task {
|
||||
static get abortSignal() {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
@@ -29,13 +33,6 @@ exports.Task = class Task {
|
||||
}
|
||||
}
|
||||
|
||||
static info(message, data) {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
task.#emit('info', { data, message })
|
||||
}
|
||||
}
|
||||
|
||||
static run(...args) {
|
||||
let opts = args[0]
|
||||
let fn
|
||||
@@ -57,20 +54,6 @@ exports.Task = class Task {
|
||||
return new Task(opts).run(() => fn.apply(thisArg, args))
|
||||
}
|
||||
|
||||
static set(name, value) {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
task.#emit('property', { name, value })
|
||||
}
|
||||
}
|
||||
|
||||
static warning(message, data) {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
task.#emit('warning', { data, message })
|
||||
}
|
||||
}
|
||||
|
||||
static wrap(opts, fn) {
|
||||
// compatibility with @decorateWith
|
||||
if (typeof fn !== 'function') {
|
||||
@@ -127,9 +110,7 @@ exports.Task = class Task {
|
||||
this.#emit('abortionRequested', { reason: signal.reason })
|
||||
|
||||
if (!this.#running) {
|
||||
const status = FAILURE
|
||||
this.#status = status
|
||||
this.#emit('end', { result: signal.reason, status })
|
||||
this.#end(FAILURE, signal.reason)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -140,25 +121,41 @@ exports.Task = class Task {
|
||||
}
|
||||
|
||||
#emit(type, data) {
|
||||
assert.equal(this.#startData, undefined, 'task has not started yet')
|
||||
|
||||
data.id = this.id
|
||||
data.timestamp = Date.now()
|
||||
data.type = type
|
||||
this.#onProgress(data)
|
||||
}
|
||||
|
||||
#end(status, result) {
|
||||
assert.equal(this.#status, PENDING)
|
||||
assert.equal(this.#running, false)
|
||||
|
||||
this.#emit('end', { status, result })
|
||||
this.#onProgress = alreadyEnded
|
||||
this.#status = status
|
||||
}
|
||||
|
||||
failure(error) {
|
||||
this.#end(FAILURE, error)
|
||||
}
|
||||
|
||||
info(message, data) {
|
||||
this.#emit('info', { data, message })
|
||||
}
|
||||
|
||||
#maybeStart() {
|
||||
const startData = this.#startData
|
||||
if (startData !== undefined) {
|
||||
this.#startData = undefined
|
||||
this.#emit('start', startData)
|
||||
if (this.#startData !== undefined) {
|
||||
this.start()
|
||||
}
|
||||
}
|
||||
|
||||
async run(fn) {
|
||||
const result = await this.runInside(fn)
|
||||
if (this.status === PENDING) {
|
||||
this.#status = SUCCESS
|
||||
this.#emit('end', { status: SUCCESS, result })
|
||||
this.#end(SUCCESS, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -172,18 +169,40 @@ exports.Task = class Task {
|
||||
this.#maybeStart()
|
||||
|
||||
try {
|
||||
const result = await asyncStorage.run(this, fn)
|
||||
const result = await asyncStorage.run(this, fn, this)
|
||||
this.#running = false
|
||||
return result
|
||||
} catch (result) {
|
||||
const status = FAILURE
|
||||
|
||||
this.#status = status
|
||||
this.#emit('end', { status, result })
|
||||
this.#running = false
|
||||
this.#end(FAILURE, result)
|
||||
throw result
|
||||
}
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
assert.equal(this.status, PENDING)
|
||||
|
||||
this.#emit('property', { name, value })
|
||||
}
|
||||
|
||||
start() {
|
||||
const startData = this.#startData
|
||||
assert.notEqual(startData, undefined, 'task has already started')
|
||||
|
||||
this.#startData = undefined
|
||||
this.#emit('start', startData)
|
||||
}
|
||||
|
||||
success(result) {
|
||||
this.#end(SUCCESS, result)
|
||||
}
|
||||
|
||||
warning(message, data) {
|
||||
assert.equal(this.status, PENDING)
|
||||
|
||||
this.#emit('warning', { data, message })
|
||||
}
|
||||
|
||||
wrap(fn) {
|
||||
const task = this
|
||||
return function taskRun() {
|
||||
@@ -198,3 +217,15 @@ exports.Task = class Task {
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Task = Task
|
||||
|
||||
// setup static aliases
|
||||
for (const name of ['info', 'set', 'warning']) {
|
||||
const method = Task.prototype[name]
|
||||
Task[name] = function () {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
return method.apply(task, arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert').strict
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const { makeOnProgress } = require('./combineEvents.js')
|
||||
const { Task } = require('./index.js')
|
||||
@@ -145,6 +145,43 @@ describe('Task', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('#failure()', function () {
|
||||
const error = new Error()
|
||||
|
||||
it('throws if the task has not started yet', function () {
|
||||
const task = createTask()
|
||||
|
||||
assert.throws(() => task.failure(error), { message: /^task has not started yet\b/ })
|
||||
})
|
||||
|
||||
it('throws if the task is running', function () {
|
||||
const task = createTask()
|
||||
task.runInside(noop)
|
||||
|
||||
assert.throws(() => task.failure(error), { code: 'ERR_ASSERTION' })
|
||||
})
|
||||
|
||||
it('throws if the task has already finished', function () {
|
||||
const task = createTask()
|
||||
task.start()
|
||||
task.success()
|
||||
|
||||
assert.throws(() => task.failure(error), { code: 'ERR_ASSERTION' })
|
||||
})
|
||||
|
||||
it('finishes the task and mark it as failed', function () {
|
||||
const task = createTask()
|
||||
task.start()
|
||||
task.failure(error)
|
||||
|
||||
assertEvent(task, {
|
||||
status: 'failure',
|
||||
result: error,
|
||||
type: 'end',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.info()', function () {
|
||||
it('does nothing when run outside a task', function () {
|
||||
Task.info('foo')
|
||||
@@ -219,6 +256,84 @@ describe('Task', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('#set()', function () {
|
||||
const name = 'progress'
|
||||
const value = 10
|
||||
|
||||
it('throws when the task is not started', function () {
|
||||
const task = createTask()
|
||||
assert.throws(() => task.set(name, value), { message: /^task has not started yet\b/ })
|
||||
})
|
||||
|
||||
it(`emits an property message`, async function () {
|
||||
const task = createTask()
|
||||
await task.run(async () => {
|
||||
await Task.run(() => {
|
||||
task.set(name, value)
|
||||
|
||||
assertEvent(task, {
|
||||
name,
|
||||
type: 'property',
|
||||
value,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#start', function () {
|
||||
it('starts the task', function () {
|
||||
const task = createTask()
|
||||
task.start()
|
||||
|
||||
assertEvent(task, { type: 'start' })
|
||||
})
|
||||
|
||||
it('throws when the task has already started', function () {
|
||||
const task = createTask()
|
||||
task.start()
|
||||
|
||||
assert.throws(() => task.start(), { message: 'task has already started' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('#success()', function () {
|
||||
const result = {}
|
||||
|
||||
it('throws if the task has not started yet', function () {
|
||||
const task = createTask()
|
||||
|
||||
assert.throws(() => task.success(result), { message: /^task has not started yet\b/ })
|
||||
})
|
||||
|
||||
it('throws if the task is running', function () {
|
||||
const task = createTask()
|
||||
task.runInside(noop)
|
||||
|
||||
assert.throws(() => task.success(result), { code: 'ERR_ASSERTION' })
|
||||
})
|
||||
|
||||
it('throws if the task has already finished', function () {
|
||||
const task = createTask()
|
||||
task.start()
|
||||
task.success()
|
||||
|
||||
assert.throws(() => task.success(result), { code: 'ERR_ASSERTION' })
|
||||
})
|
||||
|
||||
it('finishes the task and mark it as successful', function () {
|
||||
const task = createTask()
|
||||
task.start()
|
||||
task.success(result)
|
||||
|
||||
assertEvent(task, {
|
||||
status: 'success',
|
||||
result,
|
||||
type: 'end',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.warning()', function () {
|
||||
it('does nothing when run outside a task', function () {
|
||||
Task.warning('foo')
|
||||
@@ -237,6 +352,30 @@ describe('Task', function () {
|
||||
})
|
||||
})
|
||||
|
||||
for (const type of ['info', 'warning']) {
|
||||
describe(`#${type}()`, function () {
|
||||
it('throws when the task is not started', function () {
|
||||
const task = createTask()
|
||||
assert.throws(() => task[type]('foo'), { message: /^task has not started yet\b/ })
|
||||
})
|
||||
|
||||
it(`emits an ${type} message`, async function () {
|
||||
const task = createTask()
|
||||
await task.run(async () => {
|
||||
await Task.run(() => {
|
||||
task[type]('foo')
|
||||
|
||||
assertEvent(task, {
|
||||
data: undefined,
|
||||
message: 'foo',
|
||||
type,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('#id', function () {
|
||||
it('can be set', function () {
|
||||
const task = createTask()
|
||||
|
||||
@@ -13,16 +13,13 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
|
||||
@@ -121,7 +121,8 @@ const xml = `
|
||||
</methodResponse>
|
||||
`
|
||||
|
||||
const tree = parseXml(xml)
|
||||
// do not normalize or trim whitespaces as it will break string values
|
||||
const tree = parseXml(xml, { normalize: false, trim: false })
|
||||
|
||||
// it can now be passed to the XML-RPC parser
|
||||
xmlRpcParser.parse(tree)
|
||||
|
||||
@@ -139,7 +139,8 @@ const xml = `
|
||||
</methodResponse>
|
||||
`
|
||||
|
||||
const tree = parseXml(xml)
|
||||
// do not normalize or trim whitespaces as it will break string values
|
||||
const tree = parseXml(xml, { normalize: false, trim: false })
|
||||
|
||||
// it can now be passed to the XML-RPC parser
|
||||
xmlRpcParser.parse(tree)
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
"node": ">=12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vates/xml": "^2.0.0",
|
||||
"test": "^3.3.0"
|
||||
"@vates/xml": "^2.0.0"
|
||||
},
|
||||
"exports": {
|
||||
"./formatter": "./formatter.js",
|
||||
@@ -28,6 +27,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const { parseXml } = require('@vates/xml/parse')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { xmlRpcFormatter } = require('@vates/xml-rpc/formatter')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
const test = require('node:test')
|
||||
|
||||
const methodCall = require('./methodCall.js')
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const { parseXml } = require('@vates/xml/parse')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { xmlRpcParser } = require('@vates/xml-rpc/parser')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
const test = require('node:test')
|
||||
|
||||
const methodCall = require('./methodCall.js')
|
||||
|
||||
|
||||
@@ -21,15 +21,12 @@
|
||||
"dependencies": {
|
||||
"sax": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
},
|
||||
"exports": {
|
||||
"./format": "./format.js",
|
||||
"./parse": "./parse.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ const { formatXml } = require('@vates/xml/format')
|
||||
const { join } = require('node:path')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
const test = require('node:test')
|
||||
|
||||
test('format()', function () {
|
||||
const xml = String(readFileSync(join(__dirname, 'data.xml')))
|
||||
|
||||
@@ -4,7 +4,7 @@ const { parseXml } = require('@vates/xml/parse')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
const test = require('node:test')
|
||||
|
||||
test('parse()', function () {
|
||||
const xml = String(readFileSync(join(__dirname, 'data.xml')))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('assert').strict
|
||||
const sinon = require('sinon')
|
||||
|
||||
|
||||
@@ -32,10 +32,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1"
|
||||
"sinon": "^18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert/strict')
|
||||
const { afterEach, describe, it } = require('test')
|
||||
const { afterEach, describe, it } = require('node:test')
|
||||
|
||||
const { AlteredRecordError, AuditCore, MissingRecordError, NULL_ID, Storage } = require('.')
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^2.1.0",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
@@ -26,8 +26,5 @@
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.48.1",
|
||||
"@xen-orchestra/fs": "^4.1.6",
|
||||
"@xen-orchestra/backups": "^0.57.0",
|
||||
"@xen-orchestra/fs": "^4.3.0",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "1.0.18",
|
||||
"version": "1.0.28",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
## Changing the default backup configuration
|
||||
|
||||
This library receives a backup configuration from `xo-server` or `xo-proxy`. This configuration is split between multiple properties:
|
||||
|
||||
- `config` contains values related to the general backup configuration
|
||||
- `config.defaultSettings`, `config.vm.defaultSettings` and `config.metadata.defaultSettings` contain values related to backup jobs default settings:
|
||||
- `config.vm.defaultSettings` is related to VM backup jobs (see [DEFAULT_XAPI_VM_SETTINGS](https://github.com/vatesfr/xen-orchestra/blob/568e14f7eec7c3ddf685d324ad4733a3ef577995/%40xen-orchestra/backups/_runners/VmsRemote.mjs#L15))
|
||||
- `config.metadata.defaultSettings` is related to metadata backup jobs (see [DEFAULT_METADATA_SETTINGS](https://github.com/vatesfr/xen-orchestra/blob/568e14f7eec7c3ddf685d324ad4733a3ef577995/%40xen-orchestra/backups/_runners/Metadata.mjs#L12))
|
||||
- `config.defaultSettings` is related to all backup jobs (see [DEFAULT_SETTINGS](https://github.com/vatesfr/xen-orchestra/blob/568e14f7eec7c3ddf685d324ad4733a3ef577995/%40xen-orchestra/backups/_runners/_Abstract.mjs#L7))
|
||||
- in case of duplicate value, `config.vm.defaultSettings` and `config.metadata.defaultSettings` will prevail over `config.defaultSettings`, and a defined job setting will prevail over any `defaultSettings`
|
||||
|
||||
@@ -49,26 +49,25 @@ export class HealthCheckVmBackup {
|
||||
}
|
||||
|
||||
// wait for the 'Running' event to be really stored in local xapi object cache
|
||||
|
||||
restoredVm = await xapi.waitObjectState(restoredVm.$ref, vm => vm.power_state === 'Running', {
|
||||
timeout: remainingTimeout,
|
||||
timeoutMessage: refOrUuid =>
|
||||
`local xapi did not get Running state for VM ${refOrUuid} after ${timeout / 1000} second`,
|
||||
})
|
||||
|
||||
const running = new Date()
|
||||
remainingTimeout -= running - started
|
||||
|
||||
if (remainingTimeout < 0) {
|
||||
throw new Error(`local xapi did not get Running state for VM ${restoredId} after ${timeout / 1000} second`)
|
||||
}
|
||||
// wait for the guest tool version to be defined
|
||||
await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, {
|
||||
// wait for the guest tools to be detected
|
||||
await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_detected, {
|
||||
timeout: remainingTimeout,
|
||||
timeoutMessage: refOrUuid =>
|
||||
`timeout reached while waiting for ${refOrUuid} to report the driver version through the Xen tools. Please check or update the Xen tools.`,
|
||||
})
|
||||
|
||||
const guestToolsReady = new Date()
|
||||
remainingTimeout -= guestToolsReady - running
|
||||
if (remainingTimeout < 0) {
|
||||
throw new Error(`local xapi did not get he guest tools check ${restoredId} after ${timeout / 1000} second`)
|
||||
}
|
||||
|
||||
if (waitForScript) {
|
||||
const startedRestoredVm = await xapi.waitObjectState(
|
||||
@@ -79,19 +78,10 @@ export class HealthCheckVmBackup {
|
||||
vm.xenstore_data['vm-data/xo-backup-health-check'] === 'failure'),
|
||||
{
|
||||
timeout: remainingTimeout,
|
||||
timeoutMessage: refOrUuid =>
|
||||
`timeout reached while waiting for ${refOrUuid} to report the startup script execution.`,
|
||||
}
|
||||
)
|
||||
const scriptOk = new Date()
|
||||
remainingTimeout -= scriptOk - guestToolsReady
|
||||
if (remainingTimeout < 0) {
|
||||
throw new Error(
|
||||
`Backup health check script did not update vm-data/xo-backup-health-check of ${restoredId} after ${
|
||||
timeout / 1000
|
||||
} second, got ${
|
||||
startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check']
|
||||
} instead of 'success' or 'failure'`
|
||||
)
|
||||
}
|
||||
|
||||
if (startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check'] !== 'success') {
|
||||
const message = startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check-error']
|
||||
|
||||
@@ -58,7 +58,7 @@ export class ImportVmBackup {
|
||||
async _reuseNearestSnapshot($defer, ignoredVdis) {
|
||||
const metadata = this._metadata
|
||||
const { mapVdisSrs } = this._importIncrementalVmSettings
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
|
||||
const streams = {}
|
||||
const metdataDir = dirname(metadata._filename)
|
||||
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
|
||||
@@ -79,8 +79,13 @@ export class ImportVmBackup {
|
||||
debug('found disks, wlll search its snapshots', { snapshots: xapiDisk.snapshots })
|
||||
for (const snapshotRef of xapiDisk.snapshots) {
|
||||
const snapshot = await this._xapi.getRecord('VDI', snapshotRef)
|
||||
debug('handling snapshot', { snapshot })
|
||||
|
||||
debug('handling snapshot', { snapshot })
|
||||
if (snapshot.type === 'cbt_metadata') {
|
||||
// disk without data can't be used as a base
|
||||
debug('cbt metadata snapshot, skip')
|
||||
continue
|
||||
}
|
||||
// take only the first snapshot
|
||||
if (snapshotCandidate && snapshotCandidate.snapshot_time < snapshot.snapshot_time) {
|
||||
debug('already got a better candidate')
|
||||
@@ -186,6 +191,7 @@ export class ImportVmBackup {
|
||||
version: '1.0.0',
|
||||
vifs,
|
||||
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
|
||||
vtpms,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,19 @@ Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/backu
|
||||
npm install --save @xen-orchestra/backups
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
## Changing the default backup configuration
|
||||
|
||||
This library receives a backup configuration from `xo-server` or `xo-proxy`. This configuration is split between multiple properties:
|
||||
|
||||
- `config` contains values related to the general backup configuration
|
||||
- `config.defaultSettings`, `config.vm.defaultSettings` and `config.metadata.defaultSettings` contain values related to backup jobs default settings:
|
||||
- `config.vm.defaultSettings` is related to VM backup jobs (see [DEFAULT_XAPI_VM_SETTINGS](https://github.com/vatesfr/xen-orchestra/blob/568e14f7eec7c3ddf685d324ad4733a3ef577995/%40xen-orchestra/backups/_runners/VmsRemote.mjs#L15))
|
||||
- `config.metadata.defaultSettings` is related to metadata backup jobs (see [DEFAULT_METADATA_SETTINGS](https://github.com/vatesfr/xen-orchestra/blob/568e14f7eec7c3ddf685d324ad4733a3ef577995/%40xen-orchestra/backups/_runners/Metadata.mjs#L12))
|
||||
- `config.defaultSettings` is related to all backup jobs (see [DEFAULT_SETTINGS](https://github.com/vatesfr/xen-orchestra/blob/568e14f7eec7c3ddf685d324ad4733a3ef577995/%40xen-orchestra/backups/_runners/_Abstract.mjs#L7))
|
||||
- in case of duplicate value, `config.vm.defaultSettings` and `config.metadata.defaultSettings` will prevail over `config.defaultSettings`, and a defined job setting will prevail over any `defaultSettings`
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -18,6 +18,7 @@ import fromEvent from 'promise-toolbox/fromEvent'
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import pDefer from 'promise-toolbox/defer'
|
||||
import pickBy from 'lodash/pickBy.js'
|
||||
import reduce from 'lodash/reduce.js'
|
||||
import tar from 'tar'
|
||||
import zlib from 'zlib'
|
||||
|
||||
@@ -106,10 +107,13 @@ export class RemoteAdapter {
|
||||
async *_getLvmLogicalVolumes(devicePath, pvId, vgName) {
|
||||
yield this._getLvmPhysicalVolume(devicePath, pvId && (await this._findPartition(devicePath, pvId)))
|
||||
|
||||
debug('activate LVM volume group', { vgName })
|
||||
await fromCallback(execFile, 'vgchange', ['-ay', vgName])
|
||||
try {
|
||||
debug('get LVM volume group name and path', { vgName })
|
||||
yield lvs(['lv_name', 'lv_path'], vgName)
|
||||
} finally {
|
||||
debug('deactivate LVM volume group', { vgName })
|
||||
await fromCallback(execFile, 'vgchange', ['-an', vgName])
|
||||
}
|
||||
}
|
||||
@@ -120,15 +124,22 @@ export class RemoteAdapter {
|
||||
args.push('-o', partition.start * 512, '--sizelimit', partition.size)
|
||||
}
|
||||
args.push('--show', '-f', devicePath)
|
||||
|
||||
debug('attach loop device', { devicePath, partition })
|
||||
const path = (await fromCallback(execFile, 'losetup', args)).trim()
|
||||
try {
|
||||
debug('list LVM physical volume', { path })
|
||||
await fromCallback(execFile, 'pvscan', ['--cache', path])
|
||||
|
||||
yield path
|
||||
} finally {
|
||||
try {
|
||||
const vgNames = await pvs('vg_name', path)
|
||||
|
||||
debug('deactivate LVM volume groups', { vgNames })
|
||||
await fromCallback(execFile, 'vgchange', ['-an', ...vgNames])
|
||||
} finally {
|
||||
debug('detach loop device', { path })
|
||||
await fromCallback(execFile, 'losetup', ['-d', path])
|
||||
}
|
||||
}
|
||||
@@ -149,6 +160,7 @@ export class RemoteAdapter {
|
||||
|
||||
const path = yield getTmpDir()
|
||||
const mount = options => {
|
||||
debug('mount device', { devicePath, mountPath: path })
|
||||
return fromCallback(execFile, 'mount', [
|
||||
`--options=${options.join(',')}`,
|
||||
`--source=${devicePath}`,
|
||||
@@ -166,6 +178,7 @@ export class RemoteAdapter {
|
||||
try {
|
||||
yield path
|
||||
} finally {
|
||||
debug('umount device', { devicePath, mountPath: path })
|
||||
await fromCallback(execFile, 'umount', ['--lazy', path])
|
||||
}
|
||||
}
|
||||
@@ -335,6 +348,8 @@ export class RemoteAdapter {
|
||||
|
||||
const diskPath = handler.getFilePath('/' + diskId)
|
||||
const mountDir = yield getTmpDir()
|
||||
|
||||
debug('mount VHD (vhdimount)', { diskPath, mountPath: mountDir })
|
||||
await fromCallback(execFile, 'vhdimount', [diskPath, mountDir])
|
||||
try {
|
||||
let max = 0
|
||||
@@ -356,6 +371,7 @@ export class RemoteAdapter {
|
||||
|
||||
yield `${mountDir}/${maxEntry}`
|
||||
} finally {
|
||||
debug('umount VHD (fusermount)', { diskPath, mountPath: mountDir })
|
||||
await fromCallback(execFile, 'fusermount', ['-uz', mountDir])
|
||||
}
|
||||
}
|
||||
@@ -728,7 +744,7 @@ export class RemoteAdapter {
|
||||
|
||||
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true } = {}) {
|
||||
const handler = this._handler
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
|
||||
const dir = dirname(metadata._filename)
|
||||
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
|
||||
|
||||
@@ -744,6 +760,7 @@ export class RemoteAdapter {
|
||||
version: '1.0.0',
|
||||
vifs,
|
||||
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
|
||||
vtpms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,6 +843,29 @@ export class RemoteAdapter {
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
#computeTotalBackupSizeRecursively(backups) {
|
||||
return reduce(
|
||||
backups,
|
||||
(prev, backup) => {
|
||||
const _backup = Array.isArray(backup) ? this.#computeTotalBackupSizeRecursively(backup) : backup
|
||||
return {
|
||||
onDisk: prev.onDisk + (_backup.onDisk ?? _backup.size),
|
||||
}
|
||||
},
|
||||
{ onDisk: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
async getTotalVmBackupSize() {
|
||||
return this.#computeTotalBackupSizeRecursively(await this.listAllVmBackups())
|
||||
}
|
||||
|
||||
async getTotalBackupSize() {
|
||||
const vmBackupSize = await this.getTotalVmBackupSize()
|
||||
// @TODO: add `getTotalXoBackupSize` and `getTotalPoolBackupSize` once `size` is implemented by fs
|
||||
return vmBackupSize
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(RemoteAdapter.prototype, {
|
||||
|
||||
@@ -23,7 +23,7 @@ createCachedLookup().patchGlobal()
|
||||
|
||||
const logger = createLogger('xo:backups:worker')
|
||||
catchGlobalErrors(logger)
|
||||
const { debug, info } = logger
|
||||
const { debug, info, warn } = logger
|
||||
|
||||
class BackupWorker {
|
||||
#config
|
||||
@@ -204,6 +204,13 @@ process.on('message', async message => {
|
||||
info('backup has ended')
|
||||
await ignoreErrors.call(backupWorker.debounceResource.flushAll())
|
||||
process.disconnect()
|
||||
|
||||
setTimeout(() => {
|
||||
warn('worker process did not exit automatically, forcing...')
|
||||
|
||||
// eslint-disable-next-line n/no-process-exit
|
||||
process.exit()
|
||||
}, 30e3).unref()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import test from 'test'
|
||||
import test from 'node:test'
|
||||
import { strict as assert } from 'node:assert'
|
||||
|
||||
import tmp from 'tmp'
|
||||
|
||||
@@ -3,7 +3,7 @@ import sum from 'lodash/sum.js'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { Constants, openVhd, VhdAbstract, VhdFile } from 'vhd-lib'
|
||||
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
|
||||
import { limitConcurrency } from 'limit-concurrency-decorator'
|
||||
import { mergeVhdChain } from 'vhd-lib/merge.js'
|
||||
@@ -121,7 +121,19 @@ export async function checkAliases(
|
||||
) {
|
||||
const aliasFound = []
|
||||
for (const alias of aliasPaths) {
|
||||
const target = await resolveVhdAlias(handler, alias)
|
||||
let target
|
||||
try {
|
||||
target = await resolveVhdAlias(handler, alias)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
logWarn('missing target of alias', { alias })
|
||||
if (remove) {
|
||||
logInfo('removing alias and non VHD target', { alias, target })
|
||||
await handler.unlink(target)
|
||||
await handler.unlink(alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVhdFile(target)) {
|
||||
logWarn('alias references non VHD target', { alias, target })
|
||||
@@ -179,7 +191,8 @@ export async function cleanVm(
|
||||
vmDir,
|
||||
{
|
||||
fixMetadata,
|
||||
remove,
|
||||
remove = false,
|
||||
removeTmp = remove,
|
||||
merge,
|
||||
mergeBlockConcurrency,
|
||||
mergeLimiter = defaultMergeLimiter,
|
||||
@@ -200,6 +213,10 @@ export async function cleanVm(
|
||||
|
||||
// remove broken VHDs
|
||||
await asyncMap(vhds, async path => {
|
||||
if (removeTmp && basename(path)[0] === '.') {
|
||||
logInfo('deleting temporary VHD', { path })
|
||||
return VhdAbstract.unlink(handler, path)
|
||||
}
|
||||
try {
|
||||
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
|
||||
if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
|
||||
|
||||
@@ -1,4 +1,136 @@
|
||||
// returns all entries but the last retention-th
|
||||
export function getOldEntries(retention, entries) {
|
||||
return entries === undefined ? [] : retention > 0 ? entries.slice(0, -retention) : entries
|
||||
import moment from 'moment-timezone'
|
||||
import assert from 'node:assert'
|
||||
|
||||
function instantiateTimezonedDateCreator(timezone) {
|
||||
return date => {
|
||||
const transformed = timezone ? moment.tz(date, timezone) : moment(date)
|
||||
assert.ok(transformed.isValid(), `date ${date} , timezone ${timezone} is invalid`)
|
||||
return transformed
|
||||
}
|
||||
}
|
||||
|
||||
const LTR_DEFINITIONS = {
|
||||
daily: {
|
||||
makeDateFormatter: ({ firstHourOfTheDay = 0, dateCreator } = {}) => {
|
||||
return date => {
|
||||
const copy = dateCreator(date)
|
||||
copy.hour(copy.hour() - firstHourOfTheDay)
|
||||
return copy.format('YYYY-MM-DD')
|
||||
}
|
||||
},
|
||||
},
|
||||
weekly: {
|
||||
makeDateFormatter: ({ firstDayOfWeek = 0 /* relative to timezone week start */, dateCreator } = {}) => {
|
||||
return date => {
|
||||
const copy = dateCreator(date)
|
||||
|
||||
copy.date(date.date() - firstDayOfWeek)
|
||||
// warning, the year in term of week may different from YYYY
|
||||
// since the computation of the first week of a year is timezone dependant
|
||||
return copy.format('gggg-WW')
|
||||
}
|
||||
},
|
||||
ancestor: 'daily',
|
||||
},
|
||||
monthly: {
|
||||
makeDateFormatter: ({ firstDayOfMonth = 0, dateCreator } = {}) => {
|
||||
return date => {
|
||||
const copy = dateCreator(date)
|
||||
copy.date(copy.date() - firstDayOfMonth)
|
||||
return copy.format('YYYY-MM')
|
||||
}
|
||||
},
|
||||
ancestor: 'weekly',
|
||||
},
|
||||
yearly: {
|
||||
makeDateFormatter: ({ firstDayOfYear = 0, dateCreator } = {}) => {
|
||||
return date => {
|
||||
const copy = dateCreator(date)
|
||||
copy.date(copy.date() - firstDayOfYear)
|
||||
return copy.format('YYYY')
|
||||
}
|
||||
},
|
||||
ancestor: 'monthly',
|
||||
},
|
||||
}
|
||||
|
||||
// returns all entries but the last retention-th
|
||||
/**
|
||||
* return the entries too old to be kept
|
||||
* if multiple entries are i the same time bucket : keep only the most recent one
|
||||
* if an entry is valid in any of the bucket OR the minRetentionCount : keep it
|
||||
* if a bucket is completly empty : it does not count as one, thus it may extend the retention
|
||||
* @returns Array<Backup>
|
||||
*/
|
||||
export function getOldEntries(minRetentionCount, entries, { longTermRetention = {}, timezone } = {}) {
|
||||
assert.strictEqual(
|
||||
typeof minRetentionCount,
|
||||
'number',
|
||||
`minRetentionCount must be a number, got ${JSON.stringify(minRetentionCount)}`
|
||||
)
|
||||
assert.strictEqual(
|
||||
minRetentionCount >= 0,
|
||||
true,
|
||||
`minRetentionCount must be a positive number, got ${JSON.stringify(minRetentionCount)}`
|
||||
)
|
||||
const dateBuckets = {}
|
||||
const dateCreator = instantiateTimezonedDateCreator(timezone)
|
||||
// only check buckets that have a retention set
|
||||
for (const [duration, { retention, settings }] of Object.entries(longTermRetention)) {
|
||||
assert.notStrictEqual(LTR_DEFINITIONS[duration], undefined, `Retention of type ${duration} is not defined`)
|
||||
assert.notStrictEqual(
|
||||
timezone,
|
||||
undefined,
|
||||
`timezone must defined for ltr, got ${JSON.stringify({ minRetentionCount, longTermRetention, timezone })}`
|
||||
)
|
||||
assert.strictEqual(
|
||||
typeof retention,
|
||||
'number',
|
||||
`retention of type ${duration} must be a number, got ${JSON.stringify(retention)}`
|
||||
)
|
||||
assert.strictEqual(
|
||||
retention > 0,
|
||||
true,
|
||||
`retention of type ${duration} must be a positive number, got ${JSON.stringify(retention)}`
|
||||
)
|
||||
dateBuckets[duration] = {
|
||||
remaining: retention,
|
||||
lastMatchingBucket: null,
|
||||
formatter: LTR_DEFINITIONS[duration].makeDateFormatter({ ...settings, dateCreator }),
|
||||
}
|
||||
}
|
||||
const nb = entries.length
|
||||
const oldEntries = []
|
||||
|
||||
for (let i = nb - 1; i >= 0; i--) {
|
||||
const entry = entries[i]
|
||||
const entryDate = dateCreator(entry.timestamp)
|
||||
let shouldBeKept = false
|
||||
for (const [duration, { remaining, lastMatchingBucket, formatter }] of Object.entries(dateBuckets)) {
|
||||
if (remaining === 0) {
|
||||
continue
|
||||
}
|
||||
const bucket = formatter(entryDate)
|
||||
if (lastMatchingBucket !== bucket) {
|
||||
if (lastMatchingBucket !== null) {
|
||||
assert.strictEqual(
|
||||
lastMatchingBucket > bucket,
|
||||
true,
|
||||
`entries must be sorted in asc order ${lastMatchingBucket} ${bucket}`
|
||||
)
|
||||
}
|
||||
shouldBeKept = true
|
||||
dateBuckets[duration].remaining -= 1
|
||||
dateBuckets[duration].lastMatchingBucket = bucket
|
||||
}
|
||||
}
|
||||
if (i >= nb - minRetentionCount) {
|
||||
shouldBeKept = true
|
||||
}
|
||||
if (!shouldBeKept) {
|
||||
oldEntries.push(entry)
|
||||
}
|
||||
}
|
||||
// we expect the entries to be in the right order
|
||||
return oldEntries.reverse()
|
||||
}
|
||||
|
||||
282
@xen-orchestra/backups/_getOldEntries.test.mjs
Normal file
282
@xen-orchestra/backups/_getOldEntries.test.mjs
Normal file
@@ -0,0 +1,282 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { getOldEntries } from './_getOldEntries.mjs'
|
||||
|
||||
describe('_getOldEntries() should succeed', () => {
|
||||
const tests = [
|
||||
{
|
||||
args: [
|
||||
1,
|
||||
[
|
||||
{ timestamp: 1, id: 1 },
|
||||
{ timestamp: 3, id: 2 },
|
||||
{ timestamp: 2, id: 3 },
|
||||
],
|
||||
],
|
||||
expectedIds: [1, 2],
|
||||
testLabel: 'should handle number based retention ',
|
||||
},
|
||||
|
||||
{
|
||||
args: [
|
||||
0,
|
||||
[
|
||||
{ timestamp: +new Date('2024-09-01 00:01:00'), id: 1 }, // too old
|
||||
{ timestamp: +new Date('2024-09-01 00:00:00'), id: 2 }, // too old
|
||||
{ timestamp: +new Date('2024-09-02 00:09:00'), id: 3 }, // too old in same day
|
||||
{ timestamp: +new Date('2024-09-02 00:10:00'), id: 4 },
|
||||
{ timestamp: +new Date('2024-09-03 00:09:00'), id: 5 },
|
||||
{ timestamp: +new Date('2024-09-04 00:09:00'), id: 6 }, // too old in same day
|
||||
{ timestamp: +new Date('2024-09-04 00:10:00'), id: 7 },
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
daily: { retention: 3 },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
expectedIds: [1, 2, 3, 6],
|
||||
testLabel: 'should handle day based retention ',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
0,
|
||||
[
|
||||
{ timestamp: +new Date('2024-09-01 00:01:00'), id: 1 }, // week n-3 too old
|
||||
{ timestamp: +new Date('2024-09-02 00:00:00'), id: 2 }, // week n-3 too old
|
||||
{ timestamp: +new Date('2024-09-03 00:09:00'), id: 3 }, // week n-2
|
||||
{ timestamp: +new Date('2024-09-04 00:09:00'), id: 4 }, // week n-2
|
||||
{ timestamp: +new Date('2024-09-05 00:09:00'), id: 5 }, // week n-2
|
||||
{ timestamp: +new Date('2024-09-06 00:09:00'), id: 6 }, // week n-2
|
||||
{ timestamp: +new Date('2024-09-07 00:09:00'), id: 7 }, // week n-2, most recent kept
|
||||
{ timestamp: +new Date('2024-09-09 00:09:00'), id: 8 }, // week n-1, too old
|
||||
{ timestamp: +new Date('2024-09-15 00:09:00'), id: 9 }, // week n-1 kept
|
||||
{ timestamp: +new Date('2024-09-22 00:09:00'), id: 10 }, // week n kept
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
weekly: { retention: 3 },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
expectedIds: [1, 2, 3, 4, 5, 6, 8],
|
||||
testLabel: 'should handle week based retention ',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
0,
|
||||
[
|
||||
{ timestamp: +new Date('2024-06-22 00:09:00'), id: 1 }, // too old
|
||||
{ timestamp: +new Date('2024-07-31 00:09:00'), id: 2 }, // first of july
|
||||
{ timestamp: +new Date('2024-08-01 00:09:00'), id: 3 }, // older of august
|
||||
{ timestamp: +new Date('2024-08-05 00:09:00'), id: 4 }, // older of august
|
||||
{ timestamp: +new Date('2024-08-07 00:09:00'), id: 5 }, // most recent of august
|
||||
{ timestamp: +new Date('2024-09-09 00:09:00'), id: 6 }, // older of september
|
||||
{ timestamp: +new Date('2024-09-15 00:09:00'), id: 7 }, // older of september
|
||||
{ timestamp: +new Date('2024-09-22 00:09:00'), id: 8 }, // most recent of september
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
monthly: { retention: 3 },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
expectedIds: [1, 3, 4, 6, 7],
|
||||
testLabel: 'should handle month based retention',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
0,
|
||||
[
|
||||
{ timestamp: +new Date('2023-05-18 00:09:00'), id: 1 }, // too old
|
||||
{ timestamp: +new Date('2024-06-15 00:09:00'), id: 2 }, // too old in same year
|
||||
{ timestamp: +new Date('2024-07-04 00:09:00'), id: 3 }, // too old
|
||||
{ timestamp: +new Date('2024-08-12 00:09:00'), id: 4 }, // too old
|
||||
{ timestamp: +new Date('2024-09-05 00:09:00'), id: 5 }, // too old
|
||||
{ timestamp: +new Date('2024-10-02 00:09:00'), id: 6 }, // new month,
|
||||
{ timestamp: +new Date('2024-11-01 00:09:00'), id: 7 }, // new month , week reached retention
|
||||
{ timestamp: +new Date('2024-12-17 00:09:00'), id: 8 }, // new week
|
||||
{ timestamp: +new Date('2024-12-24 00:09:00'), id: 9 }, // new week/month / year daily reach retention
|
||||
{ timestamp: +new Date('2025-01-01 00:09:00'), id: 10 }, // same day/week/month/year
|
||||
{ timestamp: +new Date('2025-01-01 00:10:00'), id: 11 }, // new day / week / month
|
||||
{ timestamp: +new Date('2025-12-31 00:09:00'), id: 12 }, // same day/week/month/year
|
||||
{ timestamp: +new Date('2025-12-31 00:09:00'), id: 13 }, // new month /year
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
daily: { retention: 2 },
|
||||
weekly: { retention: 4 },
|
||||
monthly: { retention: 5 },
|
||||
yearly: { retention: 2 },
|
||||
},
|
||||
timezone: 'Europe/Paris', // use a time zone here because week definition is timezone bound
|
||||
},
|
||||
],
|
||||
expectedIds: [1, 2, 3, 4, 5, 10, 12],
|
||||
testLabel: 'complete test ',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
0,
|
||||
[
|
||||
{ timestamp: +new Date('2024-09-01 00:01:00'), id: 1 }, // thrid day too old
|
||||
{ timestamp: +new Date('2024-09-01 00:10:00'), id: 2 }, // thrid day
|
||||
{ timestamp: +new Date('2024-09-01 00:20:00'), id: 3 }, // second day, too old
|
||||
{ timestamp: +new Date('2024-09-02 00:22:00'), id: 4 }, // second day too old
|
||||
{ timestamp: +new Date('2024-09-03 00:20:00'), id: 5 }, // second day in NZ
|
||||
{ timestamp: +new Date('2024-09-04 00:09:00'), id: 6 }, // same day in NZ
|
||||
{ timestamp: +new Date('2024-09-04 00:10:00'), id: 7 }, // most recent
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
daily: { retention: 3 },
|
||||
},
|
||||
timezone: 'Pacific/Auckland', // GMT +6
|
||||
},
|
||||
],
|
||||
expectedIds: [1, 2, 3, 6],
|
||||
testLabel: 'should handle timezone ',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { args, expectedIds, testLabel } of tests) {
|
||||
it(testLabel, () => {
|
||||
const oldEntries = getOldEntries.apply(null, args)
|
||||
assert.strictEqual(
|
||||
oldEntries.length,
|
||||
expectedIds.length,
|
||||
`different length , ${JSON.stringify({ oldEntries, expectedIds })}`
|
||||
)
|
||||
for (let i = 0; i < expectedIds.length; i++) {
|
||||
assert.strictEqual(
|
||||
oldEntries[i].id,
|
||||
expectedIds[i],
|
||||
`different id , ${JSON.stringify({ i, expectedIds, oldEntries })}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('_getOldEntries() should fail when called incorrectly', () => {
|
||||
const tests = [
|
||||
{
|
||||
args: [
|
||||
-1,
|
||||
[
|
||||
{ timestamp: 1, id: 1 },
|
||||
{ timestamp: 3, id: 2 },
|
||||
{ timestamp: 2, id: 3 },
|
||||
],
|
||||
],
|
||||
testLabel: 'negative min retention ',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
{ not: 'a number' },
|
||||
[
|
||||
{ timestamp: 1, id: 1 },
|
||||
{ timestamp: 3, id: 2 },
|
||||
{ timestamp: 2, id: 3 },
|
||||
],
|
||||
],
|
||||
testLabel: 'non number min retention ',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
4,
|
||||
[
|
||||
{ timestamp: 1, id: 1 },
|
||||
{ timestamp: 2, id: 2 },
|
||||
{ timestamp: 3, id: 3 },
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
invalidType: { retention: 3 },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
testLabel: 'invalid type ltr ',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
3,
|
||||
[
|
||||
{ timestamp: 1, id: 1 },
|
||||
{ timestamp: 3, id: 2 },
|
||||
{ timestamp: 2, id: 3 },
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
daily: { retention: 0 },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
testLabel: 'zero retention ltr ',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
1,
|
||||
[
|
||||
{ timestamp: 1, id: 1 },
|
||||
{ timestamp: 2, id: 2 },
|
||||
{ timestamp: 3, id: 3 },
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
daily: { retention: 'not a number' },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
testLabel: 'no timezone ltr ',
|
||||
},
|
||||
|
||||
{
|
||||
args: [
|
||||
5,
|
||||
[
|
||||
{ timestamp: +new Date('2024-09-01'), id: 1 },
|
||||
{ timestamp: +new Date('2024-09-03'), id: 3 },
|
||||
{ timestamp: +new Date('2024-09-02'), id: 2 },
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
daily: { retention: 5 },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
testLabel: 'unsorted entries ',
|
||||
},
|
||||
|
||||
{
|
||||
args: [
|
||||
5,
|
||||
[
|
||||
{ timestamp: 'notadate', id: 1 },
|
||||
{ timestamp: 'still not a date, but after', id: 3 },
|
||||
],
|
||||
{
|
||||
longTermRetention: {
|
||||
daily: { retention: 5 },
|
||||
},
|
||||
timezone: 'Europe/Paris',
|
||||
},
|
||||
],
|
||||
testLabel: 'broken date ',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { args, testLabel } of tests) {
|
||||
it(testLabel, () => {
|
||||
assert.throws(() => getOldEntries.apply(null, args), { code: 'ERR_ASSERTION' })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,16 +1,23 @@
|
||||
import Disposable from 'promise-toolbox/Disposable'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { join } from 'node:path'
|
||||
import { mkdir, rmdir } from 'node:fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const { debug } = createLogger('xo:backups:getTmpDir')
|
||||
|
||||
const MAX_ATTEMPTS = 3
|
||||
|
||||
export async function getTmpDir() {
|
||||
for (let i = 0; true; ++i) {
|
||||
const path = join(tmpdir(), Math.random().toString(36).slice(2))
|
||||
try {
|
||||
debug('creating directory', { path })
|
||||
await mkdir(path)
|
||||
return new Disposable(() => rmdir(path), path)
|
||||
return new Disposable(() => {
|
||||
debug('removing directory', { path })
|
||||
return rmdir(path)
|
||||
}, path)
|
||||
} catch (error) {
|
||||
if (i === MAX_ATTEMPTS) {
|
||||
throw error
|
||||
|
||||
@@ -1,52 +1,23 @@
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import omit from 'lodash/omit.js'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { CancelToken } from 'promise-toolbox'
|
||||
import { compareVersions } from 'compare-versions'
|
||||
import { createVhdStreamWithLength } from 'vhd-lib'
|
||||
import { defer } from 'golike-defer'
|
||||
|
||||
import { cancelableMap } from './_cancelableMap.mjs'
|
||||
import { Task } from './Task.mjs'
|
||||
import pick from 'lodash/pick.js'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the source VM
|
||||
export const TAG_BASE_DELTA = 'xo:base_delta'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
|
||||
//
|
||||
// added after the complete replication
|
||||
export const TAG_BACKUP_SR = 'xo:backup:sr'
|
||||
|
||||
// in other_config of VDIs of an incrementally replicated VM, contains the UUID of the source VDI
|
||||
export const TAG_COPY_SRC = 'xo:copy_of'
|
||||
import { BASE_DELTA_VDI, COPY_OF, VM_UUID } from './_otherConfig.mjs'
|
||||
|
||||
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
||||
|
||||
export async function exportIncrementalVm(
|
||||
vm,
|
||||
baseVm,
|
||||
{
|
||||
cancelToken = CancelToken.none,
|
||||
|
||||
// Sets of UUIDs of VDIs that must be exported as full.
|
||||
fullVdisRequired = new Set(),
|
||||
|
||||
disableBaseTags = false,
|
||||
nbdConcurrency = 1,
|
||||
preferNbd,
|
||||
} = {}
|
||||
baseVdis = {},
|
||||
{ cancelToken = CancelToken.none, nbdConcurrency = 1, preferNbd } = {}
|
||||
) {
|
||||
// refs of VM's VDIs → base's VDIs.
|
||||
const baseVdis = {}
|
||||
baseVm &&
|
||||
baseVm.$VBDs.forEach(vbd => {
|
||||
let vdi, snapshotOf
|
||||
if ((vdi = vbd.$VDI) && (snapshotOf = vdi.$snapshot_of) && !fullVdisRequired.has(snapshotOf.uuid)) {
|
||||
baseVdis[vdi.snapshot_of] = vdi
|
||||
}
|
||||
})
|
||||
|
||||
const streams = {}
|
||||
const vdis = {}
|
||||
@@ -67,25 +38,46 @@ export async function exportIncrementalVm(
|
||||
}
|
||||
|
||||
// Look for a snapshot of this vdi in the base VM.
|
||||
const baseVdi = baseVdis[vdi.snapshot_of]
|
||||
const baseVdi = baseVdis[vdi.$snapshot_of.uuid]
|
||||
|
||||
vdis[vdiRef] = {
|
||||
...vdi,
|
||||
other_config: {
|
||||
...vdi.other_config,
|
||||
[TAG_BASE_DELTA]: baseVdi && !disableBaseTags ? baseVdi.uuid : undefined,
|
||||
[BASE_DELTA_VDI]: baseVdi?.uuid,
|
||||
[VM_UUID]:
|
||||
vm.$snapshot_of?.uuid ?? // vm is a snapshot
|
||||
vm.uuid, // vm is a not snapshot
|
||||
},
|
||||
$snapshot_of$uuid: vdi.$snapshot_of?.uuid,
|
||||
$SR$uuid: vdi.$SR.uuid,
|
||||
}
|
||||
|
||||
streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
|
||||
baseRef: baseVdi?.$ref,
|
||||
cancelToken,
|
||||
format: 'vhd',
|
||||
nbdConcurrency,
|
||||
preferNbd,
|
||||
})
|
||||
try {
|
||||
streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
|
||||
baseRef: baseVdi?.$ref,
|
||||
cancelToken,
|
||||
format: 'vhd',
|
||||
nbdConcurrency,
|
||||
preferNbd,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.code === 'VDI_CANT_DO_DELTA') {
|
||||
// fall back to a base
|
||||
Task.info(`Can't do delta, will try to get a full stream`, { vdi })
|
||||
streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
|
||||
cancelToken,
|
||||
format: 'vhd',
|
||||
nbdConcurrency,
|
||||
preferNbd,
|
||||
})
|
||||
// only warn if the fall back succeed
|
||||
Task.warning(`Can't do delta with this vdi, transfer will be a full`, {
|
||||
vdi,
|
||||
})
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const suspendVdi = vm.$suspend_VDI
|
||||
@@ -112,6 +104,18 @@ export async function exportIncrementalVm(
|
||||
}
|
||||
})
|
||||
|
||||
const vtpms = await Promise.all(
|
||||
vm.$VTPMs.map(async vtpm => {
|
||||
let content
|
||||
try {
|
||||
content = await vm.$xapi.call('VTPM.get_contents', vtpm.$ref)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
return content
|
||||
})
|
||||
)
|
||||
|
||||
return Object.defineProperty(
|
||||
{
|
||||
version: '1.1.0',
|
||||
@@ -120,14 +124,8 @@ export async function exportIncrementalVm(
|
||||
vifs,
|
||||
vm: {
|
||||
...vm,
|
||||
other_config:
|
||||
baseVm && !disableBaseTags
|
||||
? {
|
||||
...vm.other_config,
|
||||
[TAG_BASE_DELTA]: baseVm.uuid,
|
||||
}
|
||||
: omit(vm.other_config, TAG_BASE_DELTA),
|
||||
},
|
||||
vtpms,
|
||||
},
|
||||
'streams',
|
||||
{
|
||||
@@ -205,7 +203,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
newVdi = await xapi.getRecord('VDI', await vdi.baseVdi.$clone())
|
||||
$defer.onFailure(() => newVdi.$destroy())
|
||||
|
||||
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
|
||||
await newVdi.update_other_config(COPY_OF, vdi.uuid)
|
||||
if (vdi.virtual_size > newVdi.virtual_size) {
|
||||
await newVdi.$callAsync('resize', vdi.virtual_size)
|
||||
}
|
||||
@@ -259,9 +257,6 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
if (typeof stream === 'function') {
|
||||
stream = await stream()
|
||||
}
|
||||
if (stream.length === undefined) {
|
||||
stream = await createVhdStreamWithLength(stream)
|
||||
}
|
||||
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
|
||||
await vdi.$importContent(stream, { cancelToken, format: 'vhd' })
|
||||
await xapi.setField('VDI', vdi.$ref, 'name_label', vdiRecords[id].name_label)
|
||||
@@ -299,6 +294,12 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
}
|
||||
}),
|
||||
])
|
||||
// recreate VTPMs
|
||||
await Promise.all(
|
||||
(incrementalVm.vtpms ?? []).map(async contents => {
|
||||
await xapi.VTPM_create({ VM: vmRef, contents })
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
incrementalVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
|
||||
|
||||
165
@xen-orchestra/backups/_otherConfig.mjs
Normal file
165
@xen-orchestra/backups/_otherConfig.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
import { formatDateTime } from '@xen-orchestra/xapi'
|
||||
import assert from 'node:assert/strict'
|
||||
// in `other_config` of an incrementally replicated VM or VDI
|
||||
// contains the UUID of the object used as a base for an incremental export
|
||||
// used to search for the replica of the base before applying a incremental replication
|
||||
export const BASE_DELTA_VDI = 'xo:base_delta_vdi'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
|
||||
//
|
||||
// added after the complete replication
|
||||
export const REPLICATED_TO_SR_UUID = 'xo:backup:sr'
|
||||
|
||||
// in other_config of VDIs of an incrementally replicated VM
|
||||
// contains the UUID of the source exported object (snapshot or VM)
|
||||
|
||||
export const COPY_OF = 'xo:copy_of'
|
||||
|
||||
export const DATETIME = 'xo:backup:datetime'
|
||||
|
||||
export const JOB_ID = 'xo:backup:job'
|
||||
export const SCHEDULE_ID = 'xo:backup:schedule'
|
||||
|
||||
// contains the number of delta in a chain, stored as a string
|
||||
export const DELTA_CHAIN_LENGTH = 'xo:backup:deltaChainLength'
|
||||
// contains the string true if this vdi has been exported successfully
|
||||
export const EXPORTED_SUCCESSFULLY = 'xo:backup:exported'
|
||||
|
||||
// the VM ( not the snapshot) uuid
|
||||
export const VM_UUID = 'xo:backup:vm'
|
||||
|
||||
async function listVdiRefs(xapi, vmRef) {
|
||||
return xapi.VM_getDisks(vmRef)
|
||||
}
|
||||
|
||||
async function applyToVmAndVdis(xapi, vmRef, fn) {
|
||||
const vdiRefs = await listVdiRefs(xapi, vmRef)
|
||||
return Promise.all([fn('VM', vmRef), ...vdiRefs.map(vdiRef => fn('VDI', vdiRef))])
|
||||
}
|
||||
|
||||
async function getDeltaChainLength(xapi, type, ref) {
|
||||
const otherConfig = await xapi.getField(type, ref, 'other_config')
|
||||
return Number(otherConfig[DELTA_CHAIN_LENGTH] ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* set the delta chain lenght ( number of delta since last base backup) to a VM and its associated VDIs
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @param {Number} length
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function setVmDeltaChainLength(xapi, vmRef, length) {
|
||||
return applyToVmAndVdis(xapi, vmRef, async (type, ref) => {
|
||||
await xapi.setFieldEntry(type, ref, 'other_config', DELTA_CHAIN_LENGTH, String(length))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the delta chain length of a VM and its associated VDIs
|
||||
* if there is a discrependcy, use, the highest value
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function getVmDeltaChainLength(xapi, vmRef) {
|
||||
const lengths = await applyToVmAndVdis(xapi, vmRef, async (type, ref) => getDeltaChainLength(xapi, type, ref))
|
||||
return Math.max(...lengths)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Reset the other_config field of a VM and its VDIs
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function resetVmOtherConfig(xapi, vmRef) {
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
|
||||
return xapi.setFieldEntries(type, ref, 'other_config', {
|
||||
[DATETIME]: null,
|
||||
[DELTA_CHAIN_LENGTH]: null,
|
||||
[EXPORTED_SUCCESSFULLY]: null,
|
||||
[JOB_ID]: null,
|
||||
[SCHEDULE_ID]: null,
|
||||
[VM_UUID]: null,
|
||||
// REPLICATED_TO_SR_UUID is not reset since we can replicate a replication
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* used to ensure compatibiliy with the previous snapshots that were having the config stored only into VM
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function populateVdisOtherConfig(xapi, vmRef) {
|
||||
const otherConfig = await xapi.getField('VM', vmRef, 'other_config')
|
||||
const {
|
||||
[DATETIME]: datetime,
|
||||
[DELTA_CHAIN_LENGTH]: chainLength,
|
||||
[EXPORTED_SUCCESSFULLY]: successfully,
|
||||
[JOB_ID]: jobId,
|
||||
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
||||
[SCHEDULE_ID]: scheduleId,
|
||||
[VM_UUID]: vmUuid,
|
||||
} = otherConfig
|
||||
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
||||
xapi.setFieldEntries(type, ref, 'other_config', {
|
||||
[DATETIME]: datetime,
|
||||
[DELTA_CHAIN_LENGTH]: chainLength,
|
||||
[EXPORTED_SUCCESSFULLY]: successfully,
|
||||
[JOB_ID]: jobId,
|
||||
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
||||
[SCHEDULE_ID]: scheduleId,
|
||||
[VM_UUID]: vmUuid,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* set the other_config key related to a backup of a VM and its associated VDIs
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @param {*} settings
|
||||
* @returns {PRomise}
|
||||
*/
|
||||
export async function setVmOtherConfig(xapi, vmRef, { timestamp, jobId, scheduleId, vmUuid, srUuid = null, ...other }) {
|
||||
assert.notEqual(timestamp, undefined)
|
||||
assert.notEqual(jobId, undefined)
|
||||
assert.notEqual(scheduleId, undefined)
|
||||
assert.notEqual(vmUuid, undefined)
|
||||
// srUuid is nullish for backup
|
||||
assert.equal(Object.keys(other).length, 0)
|
||||
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
||||
xapi.setFieldEntries(type, ref, 'other_config', {
|
||||
[REPLICATED_TO_SR_UUID]: srUuid,
|
||||
[DATETIME]: formatDateTime(timestamp),
|
||||
[JOB_ID]: jobId,
|
||||
[SCHEDULE_ID]: scheduleId,
|
||||
[VM_UUID]: vmUuid,
|
||||
})
|
||||
)
|
||||
}
|
||||
/**
|
||||
*
|
||||
* mark the export of he VM and its VDIs as successfull
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function markExportSuccessfull(xapi, vmRef) {
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
||||
xapi.setFieldEntry(type, ref, 'other_config', EXPORTED_SUCCESSFULLY, 'true')
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const noop = Function.prototype
|
||||
|
||||
const DEFAULT_XAPI_VM_SETTINGS = {
|
||||
bypassVdiChainsCheck: false,
|
||||
cbtDestroySnapshotData: false,
|
||||
checkpointSnapshot: false,
|
||||
concurrency: 2,
|
||||
copyRetention: 0,
|
||||
|
||||
@@ -29,6 +29,10 @@ export function forkStreamUnpipe(source) {
|
||||
if (source.forks === 0) {
|
||||
debug('no more forks, destroying original stream')
|
||||
source.destroy(new Error('no more consumers for this stream'))
|
||||
} else {
|
||||
// a combination of stream.unpipe, onReadable and onData may stall stream here
|
||||
// force it to flow again since we're piping it
|
||||
source.resume()
|
||||
}
|
||||
})
|
||||
return fork
|
||||
|
||||
@@ -2,39 +2,39 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
|
||||
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
|
||||
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
|
||||
import { watchStreamSize } from '../../_watchStreamSize.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
|
||||
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
|
||||
_getRemoteWriter() {
|
||||
return FullRemoteWriter
|
||||
}
|
||||
|
||||
_filterTransferList(transferList) {
|
||||
return transferList.filter(this._filterPredicate)
|
||||
}
|
||||
|
||||
async _run() {
|
||||
const transferList = await this._computeTransferList(({ mode }) => mode === 'full')
|
||||
|
||||
if (transferList.length > 0) {
|
||||
for (const metadata of transferList) {
|
||||
const stream = await this._sourceRemoteAdapter.readFullVmBackup(metadata)
|
||||
const sizeContainer = watchStreamSize(stream)
|
||||
for (const metadata of transferList) {
|
||||
const stream = await this._sourceRemoteAdapter.readFullVmBackup(metadata)
|
||||
const sizeContainer = watchStreamSize(stream)
|
||||
|
||||
// @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),
|
||||
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
|
||||
streamLength: stream.length,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
sizeContainer,
|
||||
}),
|
||||
'writer.run()'
|
||||
)
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
}
|
||||
} else {
|
||||
Task.info('No new data to upload for this VM')
|
||||
// @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),
|
||||
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
|
||||
streamLength: stream.length,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
sizeContainer,
|
||||
}),
|
||||
'writer.run()'
|
||||
)
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import assert from 'node:assert'
|
||||
import * as UUID from 'uuid'
|
||||
@@ -7,15 +9,25 @@ import mapValues from 'lodash/mapValues.js'
|
||||
import { AbstractRemote } from './_AbstractRemote.mjs'
|
||||
import { forkDeltaExport } from './_forkDeltaExport.mjs'
|
||||
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
import { Disposable } from 'promise-toolbox'
|
||||
import { openVhd } from 'vhd-lib'
|
||||
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
|
||||
|
||||
const { warn } = createLogger('xo:backups:Incrementalremote')
|
||||
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
_getRemoteWriter() {
|
||||
return IncrementalRemoteWriter
|
||||
}
|
||||
|
||||
// we'll transfer the full list if at least one backup should be transfered
|
||||
// to ensure we don't cut the delta chain
|
||||
_filterTransferList(transferList) {
|
||||
if (transferList.some(vmBackupMetadata => this._filterPredicate(vmBackupMetadata))) {
|
||||
return transferList
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async _selectBaseVm(metadata) {
|
||||
// for each disk , get the parent
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
@@ -37,11 +49,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
})
|
||||
|
||||
const presentBaseVdis = new Map(baseUuidToSrcVdi)
|
||||
await this._callWriters(
|
||||
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
|
||||
'writer.checkBaseVdis()',
|
||||
false
|
||||
)
|
||||
await this._callWriters(writer => writer.checkBaseVdis(presentBaseVdis), 'writer.checkBaseVdis()', false)
|
||||
// check if the parent vdi are present in all the remotes
|
||||
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
|
||||
if (!presentBaseVdis.has(baseUuid)) {
|
||||
@@ -53,50 +61,58 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
async _run() {
|
||||
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
|
||||
|
||||
if (transferList.length > 0) {
|
||||
for (const metadata of transferList) {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
await this._selectBaseVm(metadata)
|
||||
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
|
||||
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
|
||||
useChain: false,
|
||||
for (const metadata of transferList) {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
|
||||
useChain: false,
|
||||
})
|
||||
// don't trust metadata too much
|
||||
// recompute if it's a base backup
|
||||
// recompute if disks are differencing or not
|
||||
const isVhdDifferencing = {}
|
||||
|
||||
await asyncEach(Object.entries(incrementalExport.streams), async ([key, stream]) => {
|
||||
isVhdDifferencing[key] = await isVhdDifferencingDisk(stream)
|
||||
})
|
||||
const hasDifferencingDisk = Object.values(isVhdDifferencing).includes(true)
|
||||
if (metadata.isBase === hasDifferencingDisk) {
|
||||
warn(`Metadata isBase and real disk value are different`, {
|
||||
metadataIsBase: metadata.isBase,
|
||||
diskIsBase: !hasDifferencingDisk,
|
||||
isVhdDifferencing,
|
||||
})
|
||||
|
||||
const isVhdDifferencing = {}
|
||||
|
||||
await asyncEach(Object.entries(incrementalExport.streams), async ([key, stream]) => {
|
||||
isVhdDifferencing[key] = await isVhdDifferencingDisk(stream)
|
||||
})
|
||||
|
||||
incrementalExport.streams = mapValues(incrementalExport.streams, this._throttleStream)
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.transfer({
|
||||
deltaExport: forkDeltaExport(incrementalExport),
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
}),
|
||||
'writer.transfer()'
|
||||
)
|
||||
// this will update parent name with the needed alias
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.updateUuidAndChain({
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vdis: incrementalExport.vdis,
|
||||
}),
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
|
||||
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
}
|
||||
} else {
|
||||
Task.info('No new data to upload for this VM')
|
||||
metadata.isBase = !hasDifferencingDisk
|
||||
metadata.isVhdDifferencing = isVhdDifferencing
|
||||
await this._selectBaseVm(metadata)
|
||||
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
|
||||
|
||||
incrementalExport.streams = mapValues(incrementalExport.streams, this._throttleStream)
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.transfer({
|
||||
deltaExport: forkDeltaExport(incrementalExport),
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
}),
|
||||
'writer.transfer()'
|
||||
)
|
||||
// this will update parent name with the needed alias
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.updateUuidAndChain({
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vdis: incrementalExport.vdis,
|
||||
}),
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
|
||||
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { pipeline } from 'node:stream'
|
||||
import findLast from 'lodash/findLast.js'
|
||||
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
|
||||
import keyBy from 'lodash/keyBy.js'
|
||||
import mapValues from 'lodash/mapValues.js'
|
||||
@@ -15,9 +13,15 @@ import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs
|
||||
import { IncrementalXapiWriter } from '../_writers/IncrementalXapiWriter.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
import { watchStreamSize } from '../../_watchStreamSize.mjs'
|
||||
import {
|
||||
DATETIME,
|
||||
DELTA_CHAIN_LENGTH,
|
||||
EXPORTED_SUCCESSFULLY,
|
||||
setVmDeltaChainLength,
|
||||
markExportSuccessfull,
|
||||
} from '../../_otherConfig.mjs'
|
||||
|
||||
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
|
||||
@@ -30,17 +34,14 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
}
|
||||
|
||||
async _copy() {
|
||||
const baseVm = this._baseVm
|
||||
const baseVdis = this._baseVdis
|
||||
const vm = this._vm
|
||||
const exportedVm = this._exportedVm
|
||||
const fullVdisRequired = this._fullVdisRequired
|
||||
|
||||
const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
|
||||
|
||||
const isFull = Object.values(baseVdis).length === 0 || Object.values(baseVdis).some(_ => !_)
|
||||
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
|
||||
|
||||
const deltaExport = await exportIncrementalVm(exportedVm, baseVm, {
|
||||
fullVdisRequired,
|
||||
const deltaExport = await exportIncrementalVm(exportedVm, baseVdis, {
|
||||
nbdConcurrency: this._settings.nbdConcurrency,
|
||||
preferNbd: this._settings.preferNbd,
|
||||
})
|
||||
@@ -90,18 +91,15 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
|
||||
this._baseVm = exportedVm
|
||||
|
||||
if (baseVm !== undefined) {
|
||||
await exportedVm.update_other_config(
|
||||
'xo:backup:deltaChainLength',
|
||||
String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
|
||||
)
|
||||
if (isFull) {
|
||||
await setVmDeltaChainLength(this._xapi, exportedVm.$ref, 0)
|
||||
} else {
|
||||
await setVmDeltaChainLength(this._xapi, exportedVm.$ref, (this._deltaChainLength ?? 0) + 1)
|
||||
}
|
||||
|
||||
// not the case if offlineBackup
|
||||
if (exportedVm.is_a_snapshot) {
|
||||
await exportedVm.update_other_config('xo:backup:exported', 'true')
|
||||
await markExportSuccessfull(this._xapi, exportedVm.$ref)
|
||||
}
|
||||
|
||||
const size = Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0)
|
||||
@@ -119,43 +117,67 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
async _selectBaseVm() {
|
||||
const xapi = this._xapi
|
||||
|
||||
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
|
||||
if (baseVm === undefined) {
|
||||
debug('no base VM found')
|
||||
return
|
||||
// filter _jobSnapshotVdis to have only the last successfully exported vdi
|
||||
// compute delta chain length
|
||||
// fill baseUuidToSrcVdi with this data
|
||||
|
||||
const exportedVdis = this._jobSnapshotVdis.filter(
|
||||
_ => EXPORTED_SUCCESSFULLY in _.other_config && DATETIME in _.other_config
|
||||
)
|
||||
let lastSuccessfullBackup
|
||||
let lastExportedVdis = []
|
||||
for (const exportedVdi of exportedVdis) {
|
||||
if (lastSuccessfullBackup === undefined || lastSuccessfullBackup < exportedVdi.other_config[DATETIME]) {
|
||||
lastExportedVdis = []
|
||||
lastSuccessfullBackup = exportedVdi.other_config[DATETIME]
|
||||
}
|
||||
if (lastSuccessfullBackup === exportedVdi.other_config[DATETIME]) {
|
||||
lastExportedVdis.push(exportedVdi)
|
||||
}
|
||||
}
|
||||
|
||||
this._baseVdis = {}
|
||||
if (lastExportedVdis.length === 0) {
|
||||
debug('no base VDIS found', {
|
||||
jobLength: this._jobSnapshotVdis.length,
|
||||
lastSuccessfullBackup,
|
||||
exportedLength: exportedVdis.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
const deltaChainLength = Math.max(
|
||||
...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
|
||||
)
|
||||
const fullInterval = this._settings.fullInterval
|
||||
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
|
||||
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
|
||||
debug('not using base VM becaust fullInterval reached')
|
||||
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
|
||||
debug('not using base VM because fullInterval reached', {
|
||||
fullInterval,
|
||||
deltaChainLength,
|
||||
eq: fullInterval < deltaChainLength + 1,
|
||||
dc1: deltaChainLength + 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
|
||||
|
||||
// resolve full record
|
||||
baseVm = await xapi.getRecord('VM', baseVm.$ref)
|
||||
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
await asyncMap(await baseVm.$getDisks(), async baseRef => {
|
||||
const [baseUuid, snapshotOf] = await Promise.all([
|
||||
xapi.getField('VDI', baseRef, 'uuid'),
|
||||
xapi.getField('VDI', baseRef, 'snapshot_of'),
|
||||
])
|
||||
const baseUuidToSrcVdiUuid = new Map()
|
||||
for (const lastExportedVdi of lastExportedVdis) {
|
||||
const baseUuid = lastExportedVdi.uuid
|
||||
const snapshotOf = lastExportedVdi.snapshot_of
|
||||
const srcVdi = srcVdis[snapshotOf]
|
||||
if (srcVdi !== undefined) {
|
||||
baseUuidToSrcVdi.set(baseUuid, srcVdi.uuid)
|
||||
baseUuidToSrcVdiUuid.set(baseUuid, srcVdi.uuid)
|
||||
} else {
|
||||
debug('ignore snapshot VDI because no longer present on VM', {
|
||||
vdi: baseUuid,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const presentBaseVdis = new Map(baseUuidToSrcVdi)
|
||||
const presentBaseVdis = new Map(baseUuidToSrcVdiUuid)
|
||||
await this._callWriters(
|
||||
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
|
||||
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
|
||||
'writer.checkBaseVdis()',
|
||||
false
|
||||
)
|
||||
@@ -164,24 +186,22 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
debug('no base VM found')
|
||||
return
|
||||
}
|
||||
|
||||
const fullVdisRequired = new Set()
|
||||
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
|
||||
baseUuidToSrcVdiUuid.forEach((srcVdiUuid, baseUuid) => {
|
||||
if (presentBaseVdis.has(baseUuid)) {
|
||||
debug('found base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdiUuid,
|
||||
})
|
||||
this._baseVdis[srcVdiUuid] = lastExportedVdis.find(vdi => vdi.uuid === baseUuid)
|
||||
} else {
|
||||
debug('missing base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdiUuid,
|
||||
})
|
||||
fullVdisRequired.add(srcVdiUuid)
|
||||
// this will force a full for this vdi
|
||||
this._baseVdis[srcVdiUuid] = undefined
|
||||
}
|
||||
})
|
||||
|
||||
this._baseVm = baseVm
|
||||
this._fullVdisRequired = fullVdisRequired
|
||||
this._deltaChainLength = deltaChainLength
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user