Compare commits
378 Commits
florent-fi
...
better-HOS
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33eb080484 | ||
|
|
6da0aa376f | ||
|
|
1ab5503558 | ||
|
|
4b9db257fd | ||
|
|
96f83d92fc | ||
|
|
7c7ee7fb9b | ||
|
|
2bd3d57f8a | ||
|
|
3f1c41a4f7 | ||
|
|
ef819f4d53 | ||
|
|
23189ed8f9 | ||
|
|
6b8a345241 | ||
|
|
b3cc168571 | ||
|
|
00b740c549 | ||
|
|
472bececb1 | ||
|
|
4fe9a4eb59 | ||
|
|
d331cd934a | ||
|
|
f6e1b95711 | ||
|
|
edec412bc7 | ||
|
|
e142bacb67 | ||
|
|
915e4b66a3 | ||
|
|
ee47a361b1 | ||
|
|
3ab2dad19b | ||
|
|
635c6db83a | ||
|
|
d2a13f531a | ||
|
|
e44857c023 | ||
|
|
749cdd011b | ||
|
|
4fde005b7f | ||
|
|
0a975fc0cc | ||
|
|
f5b7c59203 | ||
|
|
c775559912 | ||
|
|
4b9116ed72 | ||
|
|
178501e252 | ||
|
|
c9391abfd9 | ||
|
|
f7a7d9e52d | ||
|
|
5a65087aeb | ||
|
|
3cfeb8f492 | ||
|
|
0498e2d679 | ||
|
|
19563d0b3c | ||
|
|
4cc1d98a42 | ||
|
|
cd408c1687 | ||
|
|
546859531b | ||
|
|
857e467672 | ||
|
|
5de5a80eee | ||
|
|
5f1333b2a0 | ||
|
|
5e9548edbc | ||
|
|
e42edf2164 | ||
|
|
7e8c524a43 | ||
|
|
8d6ba86118 | ||
|
|
dc27317d0b | ||
|
|
0b8032c7a0 | ||
|
|
d3e18a81aa | ||
|
|
4707573c57 | ||
|
|
218e2deef4 | ||
|
|
47afff3bab | ||
|
|
24ce400429 | ||
|
|
ceac341016 | ||
|
|
7830c89e66 | ||
|
|
fa6ca01de9 | ||
|
|
746bff55a8 | ||
|
|
ec81e90153 | ||
|
|
74c9d06d91 | ||
|
|
3cfbd8b70c | ||
|
|
046d9b80bb | ||
|
|
516fda2a3a | ||
|
|
bc5c103cec | ||
|
|
98f0c068ad | ||
|
|
28e0a5a3a3 | ||
|
|
5f9d6db761 | ||
|
|
9bec52074f | ||
|
|
dfa5009a9b | ||
|
|
fbb5c47358 | ||
|
|
f2ca67a7f4 | ||
|
|
3a9af92571 | ||
|
|
b26148bf62 | ||
|
|
7e27e0bda8 | ||
|
|
94df05566a | ||
|
|
efa46414fd | ||
|
|
311623d71a | ||
|
|
89894b0f08 | ||
|
|
ed590eeb41 | ||
|
|
8956902c52 | ||
|
|
720d9649eb | ||
|
|
ce95041821 | ||
|
|
9cd84ac834 | ||
|
|
eddd6880f2 | ||
|
|
e7f9111ab5 | ||
|
|
0eb2ee72a4 | ||
|
|
1aa4fac528 | ||
|
|
3f07f7ef41 | ||
|
|
886ff2cd70 | ||
|
|
ed09608952 | ||
|
|
6aa5ec6eb6 | ||
|
|
eee4981d4c | ||
|
|
ab2c0f905a | ||
|
|
4eca3d169e | ||
|
|
e69944eaaf | ||
|
|
bc987b2dda | ||
|
|
244b150385 | ||
|
|
3acbc08ec5 | ||
|
|
1ce7e5d8a4 | ||
|
|
7813fe232a | ||
|
|
1b273071b2 | ||
|
|
43236f25ad | ||
|
|
5f2b1acfea | ||
|
|
b820dcd73f | ||
|
|
0a412c0ef7 | ||
|
|
db75f8046c | ||
|
|
7bdd1a4a3a | ||
|
|
b7d39105e8 | ||
|
|
f8b5dbbba5 | ||
|
|
c99120bd24 | ||
|
|
b9ff3db9b0 | ||
|
|
8ce1b4bf71 | ||
|
|
0b41a2b132 | ||
|
|
575ed92593 | ||
|
|
d3fe37b879 | ||
|
|
8318cb991b | ||
|
|
9779fb1d29 | ||
|
|
43b4e8c924 | ||
|
|
756a206a9e | ||
|
|
324ff44886 | ||
|
|
04913cabba | ||
|
|
c67b550435 | ||
|
|
88bf60b8c2 | ||
|
|
08fd2beb52 | ||
|
|
a870813ac5 | ||
|
|
c31f0100cb | ||
|
|
bc7fc750f0 | ||
|
|
9cd1784667 | ||
|
|
fbc7baa9a4 | ||
|
|
a4e5cf3914 | ||
|
|
e14c177f39 | ||
|
|
b5b53c636c | ||
|
|
33b45d2eda | ||
|
|
4c9bb71626 | ||
|
|
e9aa88e637 | ||
|
|
e7c7c2ce3e | ||
|
|
686e7e9527 | ||
|
|
86e390f70f | ||
|
|
e04352a577 | ||
|
|
ee4e7620b5 | ||
|
|
975dc4f314 | ||
|
|
dfff96ace1 | ||
|
|
f01171bb8b | ||
|
|
8f248f8d14 | ||
|
|
ae815a80b1 | ||
|
|
925514b0a3 | ||
|
|
45639e5a5c | ||
|
|
17ab78f064 | ||
|
|
c43a3343d8 | ||
|
|
095e40d1dd | ||
|
|
a400ef823f | ||
|
|
7684fbd5ec | ||
|
|
cd64e8dc28 | ||
|
|
dbba918b79 | ||
|
|
88dbe35c4b | ||
|
|
4a61fedf97 | ||
|
|
110181679b | ||
|
|
b0a0092a73 | ||
|
|
a6a975ce40 | ||
|
|
a73eb82ac6 | ||
|
|
702cade0d7 | ||
|
|
367b86bc7a | ||
|
|
7c5e823805 | ||
|
|
0975863d98 | ||
|
|
6e6886a6ba | ||
|
|
8bf11d6f6a | ||
|
|
6c97ccafd5 | ||
|
|
72ff66ebde | ||
|
|
b3d0da7392 | ||
|
|
a1c0d82889 | ||
|
|
fbd708d1b5 | ||
|
|
710ce965a1 | ||
|
|
2ee1535095 | ||
|
|
471fbef6ef | ||
|
|
ab63709104 | ||
|
|
0924184358 | ||
|
|
6e38cf2bc1 | ||
|
|
2183422eb5 | ||
|
|
3842f5b16d | ||
|
|
1dad6b37ef | ||
|
|
7023db2264 | ||
|
|
bf194f5c85 | ||
|
|
bcdaa37f8a | ||
|
|
a5f8900d23 | ||
|
|
3c684c7f45 | ||
|
|
cd0f4baa15 | ||
|
|
58f3050344 | ||
|
|
ab110bb0df | ||
|
|
636025c1b9 | ||
|
|
c28fa78963 | ||
|
|
ecb66fb9f3 | ||
|
|
f8e4192d34 | ||
|
|
3d15cd57c4 | ||
|
|
2e633f0df4 | ||
|
|
833b814fd5 | ||
|
|
da52d905b6 | ||
|
|
a2054c24d8 | ||
|
|
599db80467 | ||
|
|
6075a3307e | ||
|
|
9af06996c0 | ||
|
|
72c386cdce | ||
|
|
f6d4771603 | ||
|
|
15d06c591e | ||
|
|
0a07765027 | ||
|
|
81e0d06604 | ||
|
|
809314e516 | ||
|
|
cbe37b2ccd | ||
|
|
14fcbb6b8b | ||
|
|
bac976144e | ||
|
|
7677620645 | ||
|
|
c8597bd363 | ||
|
|
899be12418 | ||
|
|
31975b6737 | ||
|
|
315549e588 | ||
|
|
457886d538 | ||
|
|
8d02b206e0 | ||
|
|
45e5b94b4f | ||
|
|
ec866b4a61 | ||
|
|
2e8e2522e5 | ||
|
|
96bd46c3a8 | ||
|
|
797ed9ac51 | ||
|
|
7089dcddff | ||
|
|
8ee93dfe9c | ||
|
|
88f160820f | ||
|
|
b61b3e75c1 | ||
|
|
a1bac51f7b | ||
|
|
fa986fb4cb | ||
|
|
aa27b3be64 | ||
|
|
97d94b7952 | ||
|
|
96eb793298 | ||
|
|
b4f15de7be | ||
|
|
ae5726b836 | ||
|
|
692e72a78a | ||
|
|
ff24364bb6 | ||
|
|
b60a1958b6 | ||
|
|
f6a2b505db | ||
|
|
38aacdbd7d | ||
|
|
089b877cc5 | ||
|
|
81e55dcf77 | ||
|
|
58dd44bf5d | ||
|
|
3aa6669fd9 | ||
|
|
c10601d905 | ||
|
|
e15be7ebd3 | ||
|
|
b465a91cd3 | ||
|
|
f304a46bea | ||
|
|
6756faa1cc | ||
|
|
73fd7c7d54 | ||
|
|
60eda9ec69 | ||
|
|
a979c29a15 | ||
|
|
8f25082917 | ||
|
|
9375b1c8bd | ||
|
|
422a22a767 | ||
|
|
249f638495 | ||
|
|
6cf5e10195 | ||
|
|
b78a946458 | ||
|
|
e8a5694d51 | ||
|
|
514fa72ee2 | ||
|
|
e9ca13aa12 | ||
|
|
57f1ec6716 | ||
|
|
02e32cc9b9 | ||
|
|
902abd5d94 | ||
|
|
53380802ec | ||
|
|
af5d8d02b6 | ||
|
|
7abba76f03 | ||
|
|
79b22057d9 | ||
|
|
366daef718 | ||
|
|
a5ff0ba799 | ||
|
|
c2c6febb88 | ||
|
|
f119c72a7f | ||
|
|
8aee897d23 | ||
|
|
729db5c662 | ||
|
|
61c46df7bf | ||
|
|
9b1a04338d | ||
|
|
d307134d22 | ||
|
|
5bc44363f9 | ||
|
|
68c4fac3ab | ||
|
|
6ad9245019 | ||
|
|
763cf771fb | ||
|
|
3160b08637 | ||
|
|
f8949958a3 | ||
|
|
8b7ac07d2d | ||
|
|
044df9adba | ||
|
|
040139f4cc | ||
|
|
7b73bb9df0 | ||
|
|
24c8370daa | ||
|
|
029c4921d7 | ||
|
|
3a74c71f1a | ||
|
|
6022a1bbaa | ||
|
|
4e88c993f7 | ||
|
|
c9a61f467c | ||
|
|
e6a5f42f63 | ||
|
|
a373823eea | ||
|
|
b5e010eac8 | ||
|
|
50ffe58655 | ||
|
|
07eb3b59b3 | ||
|
|
5177b5e142 | ||
|
|
3c984e21cd | ||
|
|
aa2b27e22b | ||
|
|
14a7f00c90 | ||
|
|
56f98601bd | ||
|
|
027a8c675e | ||
|
|
bdaba9a767 | ||
|
|
4e9090f60d | ||
|
|
73b445d371 | ||
|
|
75bfc283af | ||
|
|
727de19b89 | ||
|
|
5d605d1bd7 | ||
|
|
ffdd1dfd6f | ||
|
|
d45418eb29 | ||
|
|
6ccc9d1ade | ||
|
|
93069159dd | ||
|
|
8c4780131f | ||
|
|
02ae8bceda | ||
|
|
bb10bbc945 | ||
|
|
478d88e97f | ||
|
|
6fb397a729 | ||
|
|
18dae34778 | ||
|
|
243566e936 | ||
|
|
87f4fd675d | ||
|
|
dec6b59a9f | ||
|
|
e51baedf7f | ||
|
|
530da14e24 | ||
|
|
02da7c272f | ||
|
|
a07c5418e9 | ||
|
|
c080db814b | ||
|
|
3abe13c006 | ||
|
|
fb331c0a2c | ||
|
|
19ea78afc5 | ||
|
|
2096c782e3 | ||
|
|
79a6a8a10c | ||
|
|
5a933bad93 | ||
|
|
7e302fd1cb | ||
|
|
cf9f0da6e5 | ||
|
|
10ac23e265 | ||
|
|
dc2e1cba1f | ||
|
|
7bfd190c22 | ||
|
|
c3bafeb468 | ||
|
|
7bacd781cf | ||
|
|
ee005c3679 | ||
|
|
315f54497a | ||
|
|
e30233347b | ||
|
|
56d4a7f01e | ||
|
|
7c110eebd8 | ||
|
|
39394f8c09 | ||
|
|
3283130dfc | ||
|
|
3146a591d0 | ||
|
|
e478b1ec04 | ||
|
|
7bc4d14f46 | ||
|
|
f3eeeef389 | ||
|
|
8d69208197 | ||
|
|
2c689af1a9 | ||
|
|
cb2a34c765 | ||
|
|
465c8f9009 | ||
|
|
8ea4c1c1fd | ||
|
|
ba0f7df9e8 | ||
|
|
e47dd723b0 | ||
|
|
fca6e2f6bf | ||
|
|
faa7ba6f24 | ||
|
|
fc2dbbe3ee | ||
|
|
cc98b81825 | ||
|
|
eb4a7069d4 | ||
|
|
4f65d9214e | ||
|
|
4d3c8ee63c | ||
|
|
e41c1b826a | ||
|
|
644bb48135 | ||
|
|
c9809285f6 | ||
|
|
5704949f4d | ||
|
|
a19e00fbc0 | ||
|
|
470a9b3e27 | ||
|
|
ace31dc566 | ||
|
|
ed252276cb | ||
|
|
26d0ff3c9a | ||
|
|
ff806a3ff9 | ||
|
|
949b17dee6 | ||
|
|
b1fdc68623 | ||
|
|
f502facfd1 | ||
|
|
bf0a74d709 |
35
.eslintrc.js
35
.eslintrc.js
@@ -1,5 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
extends: ['plugin:eslint-comments/recommended', 'standard', 'standard-jsx', 'prettier'],
|
||||
extends: ['plugin:eslint-comments/recommended', 'plugin:n/recommended', 'standard', 'standard-jsx', 'prettier'],
|
||||
globals: {
|
||||
__DEV__: true,
|
||||
$Dict: true,
|
||||
@@ -15,11 +17,40 @@ module.exports = {
|
||||
{
|
||||
files: ['cli.{,c,m}js', '*-cli.{,c,m}js', '**/*cli*/**/*.{,c,m}js'],
|
||||
rules: {
|
||||
'n/no-process-exit': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.mjs'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.spec.{,c,m}js'],
|
||||
rules: {
|
||||
'n/no-unsupported-features/node-builtins': [
|
||||
'error',
|
||||
{
|
||||
version: '>=16',
|
||||
},
|
||||
],
|
||||
'n/no-unsupported-features/es-syntax': [
|
||||
'error',
|
||||
{
|
||||
version: '>=16',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
parserOptions: {
|
||||
ecmaVersion: 13,
|
||||
sourceType: 'script',
|
||||
},
|
||||
|
||||
rules: {
|
||||
// disabled because XAPI objects are using camel case
|
||||
camelcase: ['off'],
|
||||
@@ -34,5 +65,7 @@ module.exports = {
|
||||
'lines-between-class-members': 'off',
|
||||
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
|
||||
strict: 'error',
|
||||
},
|
||||
}
|
||||
|
||||
16
.flowconfig
16
.flowconfig
@@ -1,16 +0,0 @@
|
||||
[ignore]
|
||||
<PROJECT_ROOT>/node_modules/.*
|
||||
|
||||
[include]
|
||||
|
||||
[libs]
|
||||
|
||||
[lints]
|
||||
|
||||
[options]
|
||||
esproposal.decorators=ignore
|
||||
esproposal.optional_chaining=enable
|
||||
include_warnings=true
|
||||
module.use_strict=true
|
||||
|
||||
[strict]
|
||||
25
.github/ISSUE_TEMPLATE/bug_report.md
vendored
25
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -4,14 +4,26 @@ about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'status: triaging :triangular_flag_on_post:, type: bug :bug:'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**XOA or XO from the sources?**
|
||||
|
||||
If XOA:
|
||||
|
||||
- which release channel? (`stable` vs `latest`)
|
||||
- please consider creating a support ticket in [your dedicated support area](https://xen-orchestra.com/#!/member/support)
|
||||
|
||||
If XO from the sources:
|
||||
|
||||
- Don't forget to [read this first](https://xen-orchestra.com/docs/community.html)
|
||||
- As well as follow [this guide](https://xen-orchestra.com/docs/community.html#report-a-bug)
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
@@ -23,11 +35,12 @@ A clear and concise description of what you expected to happen.
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- Node: [e.g. 16.12.1]
|
||||
- xo-server: [e.g. 5.82.3]
|
||||
- xo-web: [e.g. 5.87.0]
|
||||
- hypervisor: [e.g. XCP-ng 8.2.0]
|
||||
**Environment (please provide the following information):**
|
||||
|
||||
- Node: [e.g. 16.12.1]
|
||||
- xo-server: [e.g. 5.82.3]
|
||||
- xo-web: [e.g. 5.87.0]
|
||||
- hypervisor: [e.g. XCP-ng 8.2.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
13
.github/workflows/push.yml
vendored
Normal file
13
.github/workflows/push.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: CI
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: satackey/action-docker-layer-caching@v0.0.11
|
||||
# Ignore the failure of a step and avoid terminating the job.
|
||||
continue-on-error: true
|
||||
- run: docker-compose -f docker/docker-compose.dev.yml build
|
||||
- run: docker-compose -f docker/docker-compose.dev.yml up
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,4 @@
|
||||
/_book/
|
||||
/coverage/
|
||||
/node_modules/
|
||||
/lerna-debug.log
|
||||
/lerna-debug.log.*
|
||||
@@ -11,8 +10,6 @@
|
||||
/packages/*/dist/
|
||||
/packages/*/node_modules/
|
||||
|
||||
/@xen-orchestra/proxy/src/app/mixins/index.mjs
|
||||
|
||||
/packages/vhd-cli/src/commands/index.js
|
||||
|
||||
/packages/xen-api/examples/node_modules/
|
||||
@@ -35,3 +32,7 @@ pnpm-debug.log.*
|
||||
yarn-error.log
|
||||
yarn-error.log.*
|
||||
.env
|
||||
|
||||
# code coverage
|
||||
.nyc_output/
|
||||
coverage/
|
||||
|
||||
23
.travis.yml
23
.travis.yml
@@ -1,23 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 14
|
||||
|
||||
# Use containers.
|
||||
# http://docs.travis-ci.com/user/workers/container-based-infrastructure/
|
||||
sudo: false
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- qemu-utils
|
||||
- blktap-utils
|
||||
- vmdk-stream-converter
|
||||
|
||||
before_install:
|
||||
- curl -o- -L https://yarnpkg.com/install.sh | bash
|
||||
- export PATH="$HOME/.yarn/bin:$PATH"
|
||||
|
||||
cache:
|
||||
yarn: true
|
||||
|
||||
script:
|
||||
- yarn run travis-tests
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
exports.coalesceCalls = function (fn) {
|
||||
let promise
|
||||
const clean = () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { coalesceCalls } = require('./')
|
||||
|
||||
@@ -46,3 +46,20 @@ const f = compose(
|
||||
[add2, mul3]
|
||||
)
|
||||
```
|
||||
|
||||
Functions can receive extra parameters:
|
||||
|
||||
```js
|
||||
const isIn = (value, min, max) => min <= value && value <= max
|
||||
|
||||
// Only compatible when `fns` is passed as an array!
|
||||
const f = compose([
|
||||
[add, 2],
|
||||
[isIn, 3, 10],
|
||||
])
|
||||
|
||||
console.log(f(1))
|
||||
// → true
|
||||
```
|
||||
|
||||
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.
|
||||
@@ -65,6 +65,23 @@ const f = compose(
|
||||
)
|
||||
```
|
||||
|
||||
Functions can receive extra parameters:
|
||||
|
||||
```js
|
||||
const isIn = (value, min, max) => min <= value && value <= max
|
||||
|
||||
// Only compatible when `fns` is passed as an array!
|
||||
const f = compose([
|
||||
[add, 2],
|
||||
[isIn, 3, 10],
|
||||
])
|
||||
|
||||
console.log(f(1))
|
||||
// → true
|
||||
```
|
||||
|
||||
> Note: if the first function is defined with extra parameters, it will only receive the first value passed to the composed function, instead of all the parameters.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -4,11 +4,13 @@ const defaultOpts = { async: false, right: false }
|
||||
|
||||
exports.compose = function compose(opts, fns) {
|
||||
if (Array.isArray(opts)) {
|
||||
fns = opts
|
||||
fns = opts.slice() // don't mutate passed array
|
||||
opts = defaultOpts
|
||||
} else if (typeof opts === 'object') {
|
||||
opts = Object.assign({}, defaultOpts, opts)
|
||||
if (!Array.isArray(fns)) {
|
||||
if (Array.isArray(fns)) {
|
||||
fns = fns.slice() // don't mutate passed array
|
||||
} else {
|
||||
fns = Array.prototype.slice.call(arguments, 1)
|
||||
}
|
||||
} else {
|
||||
@@ -20,6 +22,24 @@ exports.compose = function compose(opts, fns) {
|
||||
if (n === 0) {
|
||||
throw new TypeError('at least one function must be passed')
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const entry = fns[i]
|
||||
if (Array.isArray(entry)) {
|
||||
const fn = entry[0]
|
||||
const args = entry.slice()
|
||||
args[0] = undefined
|
||||
fns[i] = function composeWithArgs(value) {
|
||||
args[0] = value
|
||||
try {
|
||||
return fn.apply(this, args)
|
||||
} finally {
|
||||
args[0] = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (n === 1) {
|
||||
return fns[0]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { compose } = require('./')
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"engines": {
|
||||
"node": ">=7.6"
|
||||
},
|
||||
|
||||
86
@vates/decorate-with/.USAGE.md
Normal file
86
@vates/decorate-with/.USAGE.md
Normal file
@@ -0,0 +1,86 @@
|
||||
### `decorateWith(fn, ...args)`
|
||||
|
||||
Creates a new ([legacy](https://babeljs.io/docs/en/babel-plugin-syntax-decorators#legacy)) method decorator from a function decorator, for instance, allows using Lodash's functions as decorators:
|
||||
|
||||
```js
|
||||
import { decorateWith } from '@vates/decorate-with'
|
||||
|
||||
class Foo {
|
||||
@decorateWith(lodash.debounce, 150)
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `decorateClass(class, map)`
|
||||
|
||||
Decorates a number of accessors and methods directly, without using the decorator syntax:
|
||||
|
||||
```js
|
||||
import { decorateClass } from '@vates/decorate-with'
|
||||
|
||||
class Foo {
|
||||
get bar() {
|
||||
// body
|
||||
}
|
||||
|
||||
set bar(value) {
|
||||
// body
|
||||
}
|
||||
|
||||
baz() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
|
||||
decorateClass(Foo, {
|
||||
// getter and/or setter
|
||||
bar: {
|
||||
// without arguments
|
||||
get: lodash.memoize,
|
||||
|
||||
// with arguments
|
||||
set: [lodash.debounce, 150],
|
||||
},
|
||||
|
||||
// method (with or without arguments)
|
||||
baz: lodash.curry,
|
||||
})
|
||||
```
|
||||
|
||||
The decorated class is returned, so you can export it directly.
|
||||
|
||||
To apply multiple transforms to an accessor/method, you can either call `decorateClass` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
|
||||
|
||||
```js
|
||||
decorateClass(Foo, {
|
||||
baz: compose([
|
||||
[lodash.debounce, 150]
|
||||
lodash.curry,
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
### `perInstance(fn, ...args)`
|
||||
|
||||
Helper to decorate the method by instance instead of for the whole class.
|
||||
|
||||
This is often necessary for caching or deduplicating calls.
|
||||
|
||||
```js
|
||||
import { perInstance } from '@vates/decorateWith'
|
||||
|
||||
class Foo {
|
||||
@decorateWith(perInstance, lodash.memoize)
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Because it's a normal function, it can also be used with `decorateClass`, with `compose` or even by itself.
|
||||
|
||||
### `decorateMethodsWith(class, map)`
|
||||
|
||||
> Deprecated alias for [`decorateClass(class, map)`](#decorateclassclass-map).
|
||||
@@ -31,15 +31,19 @@ class Foo {
|
||||
}
|
||||
```
|
||||
|
||||
### `decorateMethodsWith(class, map)`
|
||||
### `decorateClass(class, map)`
|
||||
|
||||
Decorates a number of methods directly, without using the decorator syntax:
|
||||
Decorates a number of accessors and methods directly, without using the decorator syntax:
|
||||
|
||||
```js
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
import { decorateClass } from '@vates/decorate-with'
|
||||
|
||||
class Foo {
|
||||
bar() {
|
||||
get bar() {
|
||||
// body
|
||||
}
|
||||
|
||||
set bar(value) {
|
||||
// body
|
||||
}
|
||||
|
||||
@@ -48,17 +52,57 @@ class Foo {
|
||||
}
|
||||
}
|
||||
|
||||
decorateMethodsWith(Foo, {
|
||||
// without arguments
|
||||
bar: lodash.curry,
|
||||
decorateClass(Foo, {
|
||||
// getter and/or setter
|
||||
bar: {
|
||||
// without arguments
|
||||
get: lodash.memoize,
|
||||
|
||||
// with arguments
|
||||
baz: [lodash.debounce, 150],
|
||||
// with arguments
|
||||
set: [lodash.debounce, 150],
|
||||
},
|
||||
|
||||
// method (with or without arguments)
|
||||
baz: lodash.curry,
|
||||
})
|
||||
```
|
||||
|
||||
The decorated class is returned, so you can export it directly.
|
||||
|
||||
To apply multiple transforms to an accessor/method, you can either call `decorateClass` multiple times or use [`@vates/compose`](https://www.npmjs.com/package/@vates/compose):
|
||||
|
||||
```js
|
||||
decorateClass(Foo, {
|
||||
baz: compose([
|
||||
[lodash.debounce, 150]
|
||||
lodash.curry,
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
### `perInstance(fn, ...args)`
|
||||
|
||||
Helper to decorate the method by instance instead of for the whole class.
|
||||
|
||||
This is often necessary for caching or deduplicating calls.
|
||||
|
||||
```js
|
||||
import { perInstance } from '@vates/decorateWith'
|
||||
|
||||
class Foo {
|
||||
@decorateWith(perInstance, lodash.memoize)
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Because it's a normal function, it can also be used with `decorateClass`, with `compose` or even by itself.
|
||||
|
||||
### `decorateMethodsWith(class, map)`
|
||||
|
||||
> Deprecated alias for [`decorateClass(class, map)`](#decorateclassclass-map).
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
### `decorateWith(fn, ...args)`
|
||||
|
||||
Creates a new ([legacy](https://babeljs.io/docs/en/babel-plugin-syntax-decorators#legacy)) method decorator from a function decorator, for instance, allows using Lodash's functions as decorators:
|
||||
|
||||
```js
|
||||
import { decorateWith } from '@vates/decorate-with'
|
||||
|
||||
class Foo {
|
||||
@decorateWith(lodash.debounce, 150)
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `decorateMethodsWith(class, map)`
|
||||
|
||||
Decorates a number of methods directly, without using the decorator syntax:
|
||||
|
||||
```js
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
|
||||
class Foo {
|
||||
bar() {
|
||||
// body
|
||||
}
|
||||
|
||||
baz() {
|
||||
// body
|
||||
}
|
||||
}
|
||||
|
||||
decorateMethodsWith(Foo, {
|
||||
// without arguments
|
||||
bar: lodash.curry,
|
||||
|
||||
// with arguments
|
||||
baz: [lodash.debounce, 150],
|
||||
})
|
||||
```
|
||||
|
||||
The decorated class is returned, so you can export it directly.
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
exports.decorateWith = function decorateWith(fn, ...args) {
|
||||
return (target, name, descriptor) => ({
|
||||
...descriptor,
|
||||
@@ -7,15 +9,40 @@ exports.decorateWith = function decorateWith(fn, ...args) {
|
||||
|
||||
const { getOwnPropertyDescriptor, defineProperty } = Object
|
||||
|
||||
exports.decorateMethodsWith = function decorateMethodsWith(klass, map) {
|
||||
function applyDecorator(decorator, value) {
|
||||
return typeof decorator === 'function' ? decorator(value) : decorator[0](value, ...decorator.slice(1))
|
||||
}
|
||||
|
||||
exports.decorateClass = exports.decorateMethodsWith = function decorateClass(klass, map) {
|
||||
const { prototype } = klass
|
||||
for (const name of Object.keys(map)) {
|
||||
const descriptor = getOwnPropertyDescriptor(prototype, name)
|
||||
const { value } = descriptor
|
||||
|
||||
const decorator = map[name]
|
||||
descriptor.value = typeof decorator === 'function' ? decorator(value) : decorator[0](value, ...decorator.slice(1))
|
||||
const descriptor = getOwnPropertyDescriptor(prototype, name)
|
||||
if (typeof decorator === 'function' || Array.isArray(decorator)) {
|
||||
descriptor.value = applyDecorator(decorator, descriptor.value)
|
||||
} else {
|
||||
const { get, set } = decorator
|
||||
if (get !== undefined) {
|
||||
descriptor.get = applyDecorator(get, descriptor.get)
|
||||
}
|
||||
if (set !== undefined) {
|
||||
descriptor.set = applyDecorator(set, descriptor.set)
|
||||
}
|
||||
}
|
||||
|
||||
defineProperty(prototype, name, descriptor)
|
||||
}
|
||||
return klass
|
||||
}
|
||||
|
||||
exports.perInstance = function perInstance(fn, decorator, ...args) {
|
||||
const map = new WeakMap()
|
||||
return function () {
|
||||
let decorated = map.get(this)
|
||||
if (decorated === undefined) {
|
||||
decorated = decorator(fn, ...args)
|
||||
map.set(this, decorated)
|
||||
}
|
||||
return decorated.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
152
@vates/decorate-with/index.spec.js
Normal file
152
@vates/decorate-with/index.spec.js
Normal file
@@ -0,0 +1,152 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const { describe, it } = require('tap').mocha
|
||||
|
||||
const { decorateClass, decorateWith, decorateMethodsWith, perInstance } = require('./')
|
||||
|
||||
const identity = _ => _
|
||||
|
||||
describe('decorateWith', () => {
|
||||
it('works', () => {
|
||||
const expectedArgs = [Math.random(), Math.random()]
|
||||
const expectedFn = Function.prototype
|
||||
const newFn = () => {}
|
||||
|
||||
const decorator = decorateWith(function wrapper(fn, ...args) {
|
||||
assert.deepStrictEqual(fn, expectedFn)
|
||||
assert.deepStrictEqual(args, expectedArgs)
|
||||
|
||||
return newFn
|
||||
}, ...expectedArgs)
|
||||
|
||||
const descriptor = {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: expectedFn,
|
||||
writable: true,
|
||||
}
|
||||
assert.deepStrictEqual(decorator({}, 'foo', descriptor), {
|
||||
...descriptor,
|
||||
value: newFn,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('decorateClass', () => {
|
||||
it('works', () => {
|
||||
class C {
|
||||
foo() {}
|
||||
bar() {}
|
||||
get baz() {}
|
||||
// eslint-disable-next-line accessor-pairs
|
||||
set qux(_) {}
|
||||
}
|
||||
|
||||
const expectedArgs = [Math.random(), Math.random()]
|
||||
const P = C.prototype
|
||||
|
||||
const descriptors = Object.getOwnPropertyDescriptors(P)
|
||||
|
||||
const newFoo = () => {}
|
||||
const newBar = () => {}
|
||||
const newGetBaz = () => {}
|
||||
const newSetQux = _ => {}
|
||||
|
||||
decorateClass(C, {
|
||||
foo(fn) {
|
||||
assert.strictEqual(arguments.length, 1)
|
||||
assert.strictEqual(fn, P.foo)
|
||||
return newFoo
|
||||
},
|
||||
bar: [
|
||||
function (fn, ...args) {
|
||||
assert.strictEqual(fn, P.bar)
|
||||
assert.deepStrictEqual(args, expectedArgs)
|
||||
return newBar
|
||||
},
|
||||
...expectedArgs,
|
||||
],
|
||||
baz: {
|
||||
get(fn) {
|
||||
assert.strictEqual(arguments.length, 1)
|
||||
assert.strictEqual(fn, descriptors.baz.get)
|
||||
return newGetBaz
|
||||
},
|
||||
},
|
||||
qux: {
|
||||
set: [
|
||||
function (fn, ...args) {
|
||||
assert.strictEqual(fn, descriptors.qux.set)
|
||||
assert.deepStrictEqual(args, expectedArgs)
|
||||
return newSetQux
|
||||
},
|
||||
...expectedArgs,
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const newDescriptors = Object.getOwnPropertyDescriptors(P)
|
||||
assert.deepStrictEqual(newDescriptors.foo, { ...descriptors.foo, value: newFoo })
|
||||
assert.deepStrictEqual(newDescriptors.bar, { ...descriptors.bar, value: newBar })
|
||||
assert.deepStrictEqual(newDescriptors.baz, { ...descriptors.baz, get: newGetBaz })
|
||||
assert.deepStrictEqual(newDescriptors.qux, { ...descriptors.qux, set: newSetQux })
|
||||
})
|
||||
|
||||
it('throws if using an accessor decorator for a method', function () {
|
||||
assert.throws(() =>
|
||||
decorateClass(
|
||||
class {
|
||||
foo() {}
|
||||
},
|
||||
{ foo: { get: identity, set: identity } }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if using a method decorator for an accessor', function () {
|
||||
assert.throws(() =>
|
||||
decorateClass(
|
||||
class {
|
||||
get foo() {}
|
||||
},
|
||||
{ foo: identity }
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('decorateMethodsWith is an alias of decorateClass', function () {
|
||||
assert.strictEqual(decorateMethodsWith, decorateClass)
|
||||
})
|
||||
|
||||
describe('perInstance', () => {
|
||||
it('works', () => {
|
||||
let calls = 0
|
||||
|
||||
const expectedArgs = [Math.random(), Math.random()]
|
||||
const expectedFn = Function.prototype
|
||||
function wrapper(fn, ...args) {
|
||||
assert.strictEqual(fn, expectedFn)
|
||||
assert.deepStrictEqual(args, expectedArgs)
|
||||
const i = ++calls
|
||||
return () => i
|
||||
}
|
||||
|
||||
const wrapped = perInstance(expectedFn, wrapper, ...expectedArgs)
|
||||
|
||||
// decorator is not called before decorated called
|
||||
assert.strictEqual(calls, 0)
|
||||
|
||||
const o1 = {}
|
||||
const o2 = {}
|
||||
|
||||
assert.strictEqual(wrapped.call(o1), 1)
|
||||
|
||||
// the same decorated function is returned for the same instance
|
||||
assert.strictEqual(wrapped.call(o1), 1)
|
||||
|
||||
// a new decorated function is returned for another instance
|
||||
assert.strictEqual(wrapped.call(o2), 2)
|
||||
})
|
||||
})
|
||||
@@ -20,11 +20,15 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "tap"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { createDebounceResource } = require('./debounceResource')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const ensureArray = require('ensure-array')
|
||||
const { MultiKeyMap } = require('@vates/multi-key-map')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { deduped } = require('./deduped')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
class Node {
|
||||
constructor(value) {
|
||||
this.children = new Map()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { MultiKeyMap } = require('./')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const ms = require('ms')
|
||||
|
||||
exports.parseDuration = value => {
|
||||
|
||||
57
@vates/predicates/.USAGE.md
Normal file
57
@vates/predicates/.USAGE.md
Normal file
@@ -0,0 +1,57 @@
|
||||
`undefined` predicates are ignored and `undefined` is returned if all predicates are `undefined`, this permits the most efficient composition:
|
||||
|
||||
```js
|
||||
const compositePredicate = every(undefined, some(predicate2, undefined))
|
||||
|
||||
// ends up as
|
||||
|
||||
const compositePredicate = predicate2
|
||||
```
|
||||
|
||||
Predicates can also be passed wrapped in an array:
|
||||
|
||||
```js
|
||||
const compositePredicate = every([predicate1, some([predicate2, predicate3])])
|
||||
```
|
||||
|
||||
`this` and all arguments are passed to the nested predicates.
|
||||
|
||||
### `every(predicates)`
|
||||
|
||||
> Returns a predicate that returns `true` iff every predicate returns `true`.
|
||||
|
||||
```js
|
||||
const isBetween3And7 = every(
|
||||
n => n >= 3,
|
||||
n => n <= 7
|
||||
)
|
||||
|
||||
isBetween3And10(0)
|
||||
// → false
|
||||
|
||||
isBetween3And10(5)
|
||||
// → true
|
||||
|
||||
isBetween3And10(10)
|
||||
// → false
|
||||
```
|
||||
|
||||
### `some(predicates)`
|
||||
|
||||
> Returns a predicate that returns `true` iff some predicate returns `true`.
|
||||
|
||||
```js
|
||||
const isAliceOrBob = some(
|
||||
name => name === 'Alice',
|
||||
name => name === 'Bob'
|
||||
)
|
||||
|
||||
isAliceOrBob('Alice')
|
||||
// → true
|
||||
|
||||
isAliceOrBob('Bob')
|
||||
// → true
|
||||
|
||||
isAliceOrBob('Oscar')
|
||||
// → false
|
||||
```
|
||||
1
@vates/predicates/.npmignore
Symbolic link
1
@vates/predicates/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
90
@vates/predicates/README.md
Normal file
90
@vates/predicates/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/predicates
|
||||
|
||||
[](https://npmjs.org/package/@vates/predicates)  [](https://bundlephobia.com/result?p=@vates/predicates) [](https://npmjs.org/package/@vates/predicates)
|
||||
|
||||
> Utilities to compose predicates
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/predicates):
|
||||
|
||||
```
|
||||
> npm install --save @vates/predicates
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
`undefined` predicates are ignored and `undefined` is returned if all predicates are `undefined`, this permits the most efficient composition:
|
||||
|
||||
```js
|
||||
const compositePredicate = every(undefined, some(predicate2, undefined))
|
||||
|
||||
// ends up as
|
||||
|
||||
const compositePredicate = predicate2
|
||||
```
|
||||
|
||||
Predicates can also be passed wrapped in an array:
|
||||
|
||||
```js
|
||||
const compositePredicate = every([predicate1, some([predicate2, predicate3])])
|
||||
```
|
||||
|
||||
`this` and all arguments are passed to the nested predicates.
|
||||
|
||||
### `every(predicates)`
|
||||
|
||||
> Returns a predicate that returns `true` iff every predicate returns `true`.
|
||||
|
||||
```js
|
||||
const isBetween3And7 = every(
|
||||
n => n >= 3,
|
||||
n => n <= 7
|
||||
)
|
||||
|
||||
isBetween3And10(0)
|
||||
// → false
|
||||
|
||||
isBetween3And10(5)
|
||||
// → true
|
||||
|
||||
isBetween3And10(10)
|
||||
// → false
|
||||
```
|
||||
|
||||
### `some(predicates)`
|
||||
|
||||
> Returns a predicate that returns `true` iff some predicate returns `true`.
|
||||
|
||||
```js
|
||||
const isAliceOrBob = some(
|
||||
name => name === 'Alice',
|
||||
name => name === 'Bob'
|
||||
)
|
||||
|
||||
isAliceOrBob('Alice')
|
||||
// → true
|
||||
|
||||
isAliceOrBob('Bob')
|
||||
// → true
|
||||
|
||||
isAliceOrBob('Oscar')
|
||||
// → false
|
||||
```
|
||||
|
||||
## 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)
|
||||
71
@vates/predicates/index.js
Normal file
71
@vates/predicates/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
'use strict'
|
||||
|
||||
const {
|
||||
isArray,
|
||||
prototype: { filter },
|
||||
} = Array
|
||||
|
||||
class InvalidPredicate extends TypeError {
|
||||
constructor(value) {
|
||||
super('not a valid predicate')
|
||||
this.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function isDefinedPredicate(value) {
|
||||
if (value === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof value !== 'function') {
|
||||
throw new InvalidPredicate(value)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function handleArgs() {
|
||||
let predicates
|
||||
if (!(arguments.length === 1 && isArray((predicates = arguments[0])))) {
|
||||
predicates = arguments
|
||||
}
|
||||
return filter.call(predicates, isDefinedPredicate)
|
||||
}
|
||||
|
||||
exports.every = function every() {
|
||||
const predicates = handleArgs.apply(this, arguments)
|
||||
const n = predicates.length
|
||||
if (n === 0) {
|
||||
return
|
||||
}
|
||||
if (n === 1) {
|
||||
return predicates[0]
|
||||
}
|
||||
return function everyPredicate() {
|
||||
for (let i = 0; i < n; ++i) {
|
||||
if (!predicates[i].apply(this, arguments)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
exports.some = function some() {
|
||||
const predicates = handleArgs.apply(this, arguments)
|
||||
const n = predicates.length
|
||||
if (n === 0) {
|
||||
return
|
||||
}
|
||||
if (n === 1) {
|
||||
return predicates[0]
|
||||
}
|
||||
return function somePredicate() {
|
||||
for (let i = 0; i < n; ++i) {
|
||||
if (predicates[i].apply(this, arguments)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
65
@vates/predicates/index.spec.js
Normal file
65
@vates/predicates/index.spec.js
Normal file
@@ -0,0 +1,65 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert/strict')
|
||||
const { describe, it } = require('tap').mocha
|
||||
|
||||
const { every, some } = require('./')
|
||||
|
||||
const T = () => true
|
||||
const F = () => false
|
||||
|
||||
const testArgsHandling = fn => {
|
||||
it('returns undefined if all predicates are undefined', () => {
|
||||
assert.equal(fn(undefined), undefined)
|
||||
assert.equal(fn([undefined]), undefined)
|
||||
})
|
||||
|
||||
it('returns the predicate if only a single one is passed', () => {
|
||||
assert.equal(fn(undefined, T), T)
|
||||
assert.equal(fn([undefined, T]), T)
|
||||
})
|
||||
|
||||
it('throws if it receives a non-predicate', () => {
|
||||
const error = new TypeError('not a valid predicate')
|
||||
error.value = 3
|
||||
assert.throws(() => fn(3), error)
|
||||
})
|
||||
|
||||
it('forwards this and arguments to predicates', () => {
|
||||
const thisArg = 'qux'
|
||||
const args = ['foo', 'bar', 'baz']
|
||||
const predicate = function () {
|
||||
assert.equal(this, thisArg)
|
||||
assert.deepEqual(Array.from(arguments), args)
|
||||
}
|
||||
fn(predicate, predicate).apply(thisArg, args)
|
||||
})
|
||||
}
|
||||
|
||||
const runTests = (fn, truthTable) =>
|
||||
it('works', () => {
|
||||
truthTable.forEach(([result, ...predicates]) => {
|
||||
assert.equal(fn(...predicates)(), result)
|
||||
assert.equal(fn(predicates)(), result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('every', () => {
|
||||
testArgsHandling(every)
|
||||
runTests(every, [
|
||||
[true, T, T],
|
||||
[false, T, F],
|
||||
[false, F, T],
|
||||
[false, F, F],
|
||||
])
|
||||
})
|
||||
|
||||
describe('some', () => {
|
||||
testArgsHandling(some)
|
||||
runTests(some, [
|
||||
[true, T, T],
|
||||
[true, T, F],
|
||||
[true, F, T],
|
||||
[false, F, F],
|
||||
])
|
||||
})
|
||||
40
@vates/predicates/package.json
Normal file
40
@vates/predicates/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/predicates",
|
||||
"description": "Utilities to compose predicates",
|
||||
"keywords": [
|
||||
"and",
|
||||
"combine",
|
||||
"compose",
|
||||
"every",
|
||||
"function",
|
||||
"functions",
|
||||
"or",
|
||||
"predicate",
|
||||
"predicates",
|
||||
"some"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/predicates",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/predicates",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "tap"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.0.1"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const readChunk = (stream, size) =>
|
||||
size === 0
|
||||
? Promise.resolve(Buffer.alloc(0))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { Readable } = require('stream')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const mapKeys = (object, iteratee) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const wrapCall = (fn, arg, thisArg) => {
|
||||
try {
|
||||
return Promise.resolve(fn.call(thisArg, arg))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { asyncMapSettled } = require('./')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
// type MaybePromise<T> = Promise<T> | T
|
||||
//
|
||||
// declare export function asyncMap<T1, T2>(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'))
|
||||
@@ -1,12 +1,14 @@
|
||||
import assert from 'assert'
|
||||
import hash from 'object-hash'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { decorateWith } from '@vates/decorate-with'
|
||||
import { defer } from 'golike-defer'
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const hash = require('object-hash')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { decorateClass } = require('@vates/decorate-with')
|
||||
const { defer } = require('golike-defer')
|
||||
|
||||
const log = createLogger('xo:audit-core')
|
||||
|
||||
export class Storage {
|
||||
exports.Storage = class Storage {
|
||||
constructor() {
|
||||
this._lock = Promise.resolve()
|
||||
}
|
||||
@@ -29,7 +31,7 @@ const ID_TO_ALGORITHM = {
|
||||
5: 'sha256',
|
||||
}
|
||||
|
||||
export class AlteredRecordError extends Error {
|
||||
class AlteredRecordError extends Error {
|
||||
constructor(id, nValid, record) {
|
||||
super('altered record')
|
||||
|
||||
@@ -38,8 +40,9 @@ export class AlteredRecordError extends Error {
|
||||
this.record = record
|
||||
}
|
||||
}
|
||||
exports.AlteredRecordError = AlteredRecordError
|
||||
|
||||
export class MissingRecordError extends Error {
|
||||
class MissingRecordError extends Error {
|
||||
constructor(id, nValid) {
|
||||
super('missing record')
|
||||
|
||||
@@ -47,8 +50,10 @@ export class MissingRecordError extends Error {
|
||||
this.nValid = nValid
|
||||
}
|
||||
}
|
||||
exports.MissingRecordError = MissingRecordError
|
||||
|
||||
export const NULL_ID = 'nullId'
|
||||
const NULL_ID = 'nullId'
|
||||
exports.NULL_ID = NULL_ID
|
||||
|
||||
const HASH_ALGORITHM_ID = '5'
|
||||
const createHash = (data, algorithmId = HASH_ALGORITHM_ID) =>
|
||||
@@ -57,13 +62,12 @@ const createHash = (data, algorithmId = HASH_ALGORITHM_ID) =>
|
||||
excludeKeys: key => key === 'id',
|
||||
})}`
|
||||
|
||||
export class AuditCore {
|
||||
class AuditCore {
|
||||
constructor(storage) {
|
||||
assert.notStrictEqual(storage, undefined)
|
||||
this._storage = storage
|
||||
}
|
||||
|
||||
@decorateWith(defer)
|
||||
async add($defer, subject, event, data) {
|
||||
const time = Date.now()
|
||||
$defer(await this._storage.acquireLock())
|
||||
@@ -148,7 +152,6 @@ export class AuditCore {
|
||||
}
|
||||
}
|
||||
|
||||
@decorateWith(defer)
|
||||
async deleteRangeAndRewrite($defer, newest, oldest) {
|
||||
assert.notStrictEqual(newest, undefined)
|
||||
assert.notStrictEqual(oldest, undefined)
|
||||
@@ -189,3 +192,9 @@ export class AuditCore {
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AuditCore = AuditCore
|
||||
|
||||
decorateClass(AuditCore, {
|
||||
add: defer,
|
||||
deleteRangeAndRewrite: defer,
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-env jest */
|
||||
'use strict'
|
||||
|
||||
import { AlteredRecordError, AuditCore, MissingRecordError, NULL_ID, Storage } from '.'
|
||||
const assert = require('assert/strict')
|
||||
const { afterEach, describe, it } = require('tap').mocha
|
||||
|
||||
const { AlteredRecordError, AuditCore, MissingRecordError, NULL_ID, Storage } = require('.')
|
||||
|
||||
const asyncIteratorToArray = async asyncIterator => {
|
||||
const array = []
|
||||
@@ -72,7 +75,7 @@ const auditCore = new AuditCore(db)
|
||||
const storeAuditRecords = async () => {
|
||||
await Promise.all(DATA.map(data => auditCore.add(...data)))
|
||||
const records = await asyncIteratorToArray(auditCore.getFrom())
|
||||
expect(records.length).toBe(DATA.length)
|
||||
assert.equal(records.length, DATA.length)
|
||||
return records
|
||||
}
|
||||
|
||||
@@ -83,10 +86,11 @@ describe('auditCore', () => {
|
||||
const [newestRecord, deletedRecord] = await storeAuditRecords()
|
||||
|
||||
const nValidRecords = await auditCore.checkIntegrity(NULL_ID, newestRecord.id)
|
||||
expect(nValidRecords).toBe(DATA.length)
|
||||
assert.equal(nValidRecords, DATA.length)
|
||||
|
||||
await db.del(deletedRecord.id)
|
||||
await expect(auditCore.checkIntegrity(NULL_ID, newestRecord.id)).rejects.toEqual(
|
||||
await assert.rejects(
|
||||
auditCore.checkIntegrity(NULL_ID, newestRecord.id),
|
||||
new MissingRecordError(deletedRecord.id, 1)
|
||||
)
|
||||
})
|
||||
@@ -97,7 +101,8 @@ describe('auditCore', () => {
|
||||
alteredRecord.event = ''
|
||||
await db.put(alteredRecord)
|
||||
|
||||
await expect(auditCore.checkIntegrity(NULL_ID, newestRecord.id)).rejects.toEqual(
|
||||
await assert.rejects(
|
||||
auditCore.checkIntegrity(NULL_ID, newestRecord.id),
|
||||
new AlteredRecordError(alteredRecord.id, 1, alteredRecord)
|
||||
)
|
||||
})
|
||||
@@ -107,8 +112,8 @@ describe('auditCore', () => {
|
||||
|
||||
await auditCore.deleteFrom(secondRecord.id)
|
||||
|
||||
expect(await db.get(firstRecord.id)).toBe(undefined)
|
||||
expect(await db.get(secondRecord.id)).toBe(undefined)
|
||||
assert.equal(await db.get(firstRecord.id), undefined)
|
||||
assert.equal(await db.get(secondRecord.id), undefined)
|
||||
|
||||
await auditCore.checkIntegrity(secondRecord.id, thirdRecord.id)
|
||||
})
|
||||
@@ -9,28 +9,14 @@
|
||||
},
|
||||
"version": "0.2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=14"
|
||||
},
|
||||
"main": "dist/",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
"dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/",
|
||||
"postversion": "npm publish --access public",
|
||||
"prebuild": "rimraf dist/",
|
||||
"predev": "yarn run prebuild",
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.4",
|
||||
"@babel/core": "^7.7.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.8.0",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.0",
|
||||
"@babel/preset-env": "^7.7.4",
|
||||
"cross-env": "^7.0.2",
|
||||
"rimraf": "^3.0.0"
|
||||
"test": "tap --lines 67 --functions 92 --branches 52 --statements 67"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^0.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
@@ -40,5 +26,8 @@
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
"node": ">=8.3"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const getopts = require('getopts')
|
||||
|
||||
const { version } = require('./package.json')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { dirname } = require('path')
|
||||
|
||||
const fs = require('promise-toolbox/promisifyAll')(require('fs'))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const filenamify = require('filenamify')
|
||||
const get = require('lodash/get')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const groupBy = require('lodash/groupBy')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { createHash } = require('crypto')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict'
|
||||
|
||||
require('./_composeCommands')({
|
||||
'clean-vms': {
|
||||
get main() {
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.16.0",
|
||||
"@xen-orchestra/fs": "^0.19.1",
|
||||
"@xen-orchestra/backups": "^0.21.0",
|
||||
"@xen-orchestra/fs": "^1.0.0",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.20.0"
|
||||
"promise-toolbox": "^0.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.10.1"
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
|
||||
const Disposable = require('promise-toolbox/Disposable.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const Disposable = require('promise-toolbox/Disposable')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const { compileTemplate } = require('@xen-orchestra/template')
|
||||
const { limitConcurrency } = require('limit-concurrency-decorator')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
|
||||
exports.DurablePartition = class DurablePartition {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
|
||||
const { formatFilenameDate } = require('./_filenameDate.js')
|
||||
@@ -6,9 +8,9 @@ const { Task } = require('./Task.js')
|
||||
const { watchStreamSize } = require('./_watchStreamSize.js')
|
||||
|
||||
exports.ImportVmBackup = class ImportVmBackup {
|
||||
constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses } = {} }) {
|
||||
constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses, mapVdisSrs = {} } = {} }) {
|
||||
this._adapter = adapter
|
||||
this._importDeltaVmSettings = { newMacAddresses }
|
||||
this._importDeltaVmSettings = { newMacAddresses, mapVdisSrs }
|
||||
this._metadata = metadata
|
||||
this._srUuid = srUuid
|
||||
this._xapi = xapi
|
||||
@@ -28,7 +30,12 @@ exports.ImportVmBackup = class ImportVmBackup {
|
||||
} else {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
|
||||
backup = await adapter.readDeltaVmBackup(metadata)
|
||||
const ignoredVdis = new Set(
|
||||
Object.entries(this._importDeltaVmSettings.mapVdisSrs)
|
||||
.filter(([_, srUuid]) => srUuid === null)
|
||||
.map(([vdiUuid]) => vdiUuid)
|
||||
)
|
||||
backup = await adapter.readDeltaVmBackup(metadata, ignoredVdis)
|
||||
Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
|
||||
const Disposable = require('promise-toolbox/Disposable.js')
|
||||
const fromCallback = require('promise-toolbox/fromCallback.js')
|
||||
const fromEvent = require('promise-toolbox/fromEvent.js')
|
||||
const pDefer = require('promise-toolbox/defer.js')
|
||||
const Disposable = require('promise-toolbox/Disposable')
|
||||
const fromCallback = require('promise-toolbox/fromCallback')
|
||||
const fromEvent = require('promise-toolbox/fromEvent')
|
||||
const pDefer = require('promise-toolbox/defer')
|
||||
const groupBy = require('lodash/groupBy.js')
|
||||
const pickBy = require('lodash/pickBy.js')
|
||||
const { dirname, join, normalize, resolve } = require('path')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdSynthetic } = require('vhd-lib')
|
||||
const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = require('vhd-lib')
|
||||
const { deduped } = require('@vates/disposable/deduped.js')
|
||||
const { decorateMethodsWith } = require('@vates/decorate-with')
|
||||
const { compose } = require('@vates/compose')
|
||||
const { execFile } = require('child_process')
|
||||
const { readdir, stat } = require('fs-extra')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
@@ -67,10 +73,11 @@ const debounceResourceFactory = factory =>
|
||||
}
|
||||
|
||||
class RemoteAdapter {
|
||||
constructor(handler, { debounceResource = res => res, dirMode } = {}) {
|
||||
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression } = {}) {
|
||||
this._debounceResource = debounceResource
|
||||
this._dirMode = dirMode
|
||||
this._handler = handler
|
||||
this._vhdDirectoryCompression = vhdDirectoryCompression
|
||||
}
|
||||
|
||||
get handler() {
|
||||
@@ -86,9 +93,6 @@ class RemoteAdapter {
|
||||
return partition
|
||||
}
|
||||
|
||||
_getLvmLogicalVolumes = Disposable.factory(this._getLvmLogicalVolumes)
|
||||
_getLvmLogicalVolumes = deduped(this._getLvmLogicalVolumes, (devicePath, pvId, vgName) => [devicePath, pvId, vgName])
|
||||
_getLvmLogicalVolumes = debounceResourceFactory(this._getLvmLogicalVolumes)
|
||||
async *_getLvmLogicalVolumes(devicePath, pvId, vgName) {
|
||||
yield this._getLvmPhysicalVolume(devicePath, pvId && (await this._findPartition(devicePath, pvId)))
|
||||
|
||||
@@ -100,9 +104,6 @@ class RemoteAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
_getLvmPhysicalVolume = Disposable.factory(this._getLvmPhysicalVolume)
|
||||
_getLvmPhysicalVolume = deduped(this._getLvmPhysicalVolume, (devicePath, partition) => [devicePath, partition?.id])
|
||||
_getLvmPhysicalVolume = debounceResourceFactory(this._getLvmPhysicalVolume)
|
||||
async *_getLvmPhysicalVolume(devicePath, partition) {
|
||||
const args = []
|
||||
if (partition !== undefined) {
|
||||
@@ -123,9 +124,6 @@ class RemoteAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
_getPartition = Disposable.factory(this._getPartition)
|
||||
_getPartition = deduped(this._getPartition, (devicePath, partition) => [devicePath, partition?.id])
|
||||
_getPartition = debounceResourceFactory(this._getPartition)
|
||||
async *_getPartition(devicePath, partition) {
|
||||
const options = ['loop', 'ro']
|
||||
|
||||
@@ -178,7 +176,6 @@ class RemoteAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
_usePartitionFiles = Disposable.factory(this._usePartitionFiles)
|
||||
async *_usePartitionFiles(diskId, partitionId, paths) {
|
||||
const path = yield this.getPartition(diskId, partitionId)
|
||||
|
||||
@@ -190,6 +187,22 @@ class RemoteAdapter {
|
||||
return files
|
||||
}
|
||||
|
||||
// check if we will be allowed to merge a a vhd created in this adapter
|
||||
// with the vhd at path `path`
|
||||
async isMergeableParent(packedParentUid, path) {
|
||||
return await Disposable.use(openVhd(this.handler, path), vhd => {
|
||||
// this baseUuid is not linked with this vhd
|
||||
if (!vhd.footer.uuid.equals(packedParentUid)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isVhdDirectory = vhd instanceof VhdDirectory
|
||||
return isVhdDirectory
|
||||
? this.#useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
|
||||
: !this.#useVhdDirectory()
|
||||
})
|
||||
}
|
||||
|
||||
fetchPartitionFiles(diskId, partitionId, paths) {
|
||||
const { promise, reject, resolve } = pDefer()
|
||||
Disposable.use(
|
||||
@@ -212,8 +225,8 @@ class RemoteAdapter {
|
||||
async deleteDeltaVmBackups(backups) {
|
||||
const handler = this._handler
|
||||
|
||||
// unused VHDs will be detected by `cleanVm`
|
||||
await asyncMapSettled(backups, ({ _filename }) => VhdAbstract.unlink(handler, _filename))
|
||||
// this will delete the json, unused VHDs will be detected by `cleanVm`
|
||||
await asyncMapSettled(backups, ({ _filename }) => handler.unlink(_filename))
|
||||
}
|
||||
|
||||
async deleteMetadataBackup(backupId) {
|
||||
@@ -243,22 +256,42 @@ class RemoteAdapter {
|
||||
)
|
||||
}
|
||||
|
||||
async deleteVmBackup(filename) {
|
||||
const metadata = JSON.parse(String(await this._handler.readFile(filename)))
|
||||
metadata._filename = filename
|
||||
deleteVmBackup(file) {
|
||||
return this.deleteVmBackups([file])
|
||||
}
|
||||
|
||||
if (metadata.mode === 'delta') {
|
||||
await this.deleteDeltaVmBackups([metadata])
|
||||
} else if (metadata.mode === 'full') {
|
||||
await this.deleteFullVmBackups([metadata])
|
||||
} else {
|
||||
throw new Error(`no deleter for backup mode ${metadata.mode}`)
|
||||
async deleteVmBackups(files) {
|
||||
const { delta, full, ...others } = groupBy(await asyncMap(files, file => this.readVmBackupMetadata(file)), 'mode')
|
||||
|
||||
const unsupportedModes = Object.keys(others)
|
||||
if (unsupportedModes.length !== 0) {
|
||||
throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
delta !== undefined && this.deleteDeltaVmBackups(delta),
|
||||
full !== undefined && this.deleteFullVmBackups(full),
|
||||
])
|
||||
|
||||
const dirs = new Set(files.map(file => dirname(file)))
|
||||
for (const dir of dirs) {
|
||||
// don't merge in main process, unused VHDs will be merged in the next backup run
|
||||
await this.cleanVm(dir, { remove: true, onLog: warn })
|
||||
}
|
||||
}
|
||||
|
||||
getDisk = Disposable.factory(this.getDisk)
|
||||
getDisk = deduped(this.getDisk, diskId => [diskId])
|
||||
getDisk = debounceResourceFactory(this.getDisk)
|
||||
#getCompressionType() {
|
||||
return this._vhdDirectoryCompression
|
||||
}
|
||||
|
||||
#useVhdDirectory() {
|
||||
return this.handler.type === 's3'
|
||||
}
|
||||
|
||||
#useAlias() {
|
||||
return this.#useVhdDirectory()
|
||||
}
|
||||
|
||||
async *getDisk(diskId) {
|
||||
const handler = this._handler
|
||||
|
||||
@@ -295,7 +328,6 @@ class RemoteAdapter {
|
||||
// - `<partitionId>`: partitioned disk
|
||||
// - `<pvId>/<vgName>/<lvName>`: LVM on a partitioned disk
|
||||
// - `/<vgName>/lvName>`: LVM on a raw disk
|
||||
getPartition = Disposable.factory(this.getPartition)
|
||||
async *getPartition(diskId, partitionId) {
|
||||
const devicePath = yield this.getDisk(diskId)
|
||||
if (partitionId === undefined) {
|
||||
@@ -312,13 +344,10 @@ class RemoteAdapter {
|
||||
return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
|
||||
}
|
||||
|
||||
// this function will be the one where we plug the logic of the storage format by fs type/user settings
|
||||
|
||||
// if the file is named .vhd => vhd
|
||||
// if the file is named alias.vhd => alias to a vhd
|
||||
// if we use alias on this remote, we have to name the file alias.vhd
|
||||
getVhdFileName(baseName) {
|
||||
if (this._handler.type === 's3') {
|
||||
return `${baseName}.alias.vhd` // we want an alias to a vhddirectory
|
||||
if (this.#useAlias()) {
|
||||
return `${baseName}.alias.vhd`
|
||||
}
|
||||
return `${baseName}.vhd`
|
||||
}
|
||||
@@ -327,9 +356,14 @@ class RemoteAdapter {
|
||||
const handler = this._handler
|
||||
|
||||
const backups = { __proto__: null }
|
||||
await asyncMap(await handler.list(BACKUP_DIR), async vmUuid => {
|
||||
const vmBackups = await this.listVmBackups(vmUuid)
|
||||
backups[vmUuid] = vmBackups
|
||||
await asyncMap(await handler.list(BACKUP_DIR), async entry => {
|
||||
// ignore hidden and lock files
|
||||
if (entry[0] !== '.' && !entry.endsWith('.lock')) {
|
||||
const vmBackups = await this.listVmBackups(entry)
|
||||
if (vmBackups.length !== 0) {
|
||||
backups[entry] = vmBackups
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return backups
|
||||
@@ -469,11 +503,12 @@ class RemoteAdapter {
|
||||
|
||||
async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
|
||||
const handler = this._handler
|
||||
let dataPath = path
|
||||
|
||||
if (path.endsWith('.alias.vhd')) {
|
||||
await createVhdDirectoryFromStream(handler, `${dirname(path)}/data/${uuidv4()}.vhd`, input, {
|
||||
if (this.#useVhdDirectory()) {
|
||||
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
||||
await createVhdDirectoryFromStream(handler, dataPath, input, {
|
||||
concurrency: 16,
|
||||
compression: this.#getCompressionType(),
|
||||
async validator() {
|
||||
await input.task
|
||||
return validator.apply(this, arguments)
|
||||
@@ -481,7 +516,7 @@ class RemoteAdapter {
|
||||
})
|
||||
await VhdAbstract.createAlias(handler, path, dataPath)
|
||||
} else {
|
||||
await this.outputStream(dataPath, input, { checksum, validator })
|
||||
await this.outputStream(path, input, { checksum, validator })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,8 +536,8 @@ class RemoteAdapter {
|
||||
|
||||
// if it's a path : open all hierarchy of parent
|
||||
if (typeof paths === 'string') {
|
||||
let vhd,
|
||||
vhdPath = paths
|
||||
let vhd
|
||||
let vhdPath = paths
|
||||
do {
|
||||
const disposable = await openVhd(handler, vhdPath)
|
||||
vhd = disposable.value
|
||||
@@ -542,14 +577,15 @@ class RemoteAdapter {
|
||||
return stream
|
||||
}
|
||||
|
||||
async readDeltaVmBackup(metadata) {
|
||||
async readDeltaVmBackup(metadata, ignoredVdis) {
|
||||
const handler = this._handler
|
||||
const { vbds, vdis, vhds, vifs, vm } = metadata
|
||||
const { vbds, vhds, vifs, vm } = metadata
|
||||
const dir = dirname(metadata._filename)
|
||||
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
|
||||
|
||||
const streams = {}
|
||||
await asyncMapSettled(Object.keys(vdis), async id => {
|
||||
streams[`${id}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[id]))
|
||||
await asyncMapSettled(Object.keys(vdis), async ref => {
|
||||
streams[`${ref}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[ref]))
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -582,4 +618,30 @@ Object.assign(RemoteAdapter.prototype, {
|
||||
isValidXva,
|
||||
})
|
||||
|
||||
decorateMethodsWith(RemoteAdapter, {
|
||||
_getLvmLogicalVolumes: compose([
|
||||
Disposable.factory,
|
||||
[deduped, (devicePath, pvId, vgName) => [devicePath, pvId, vgName]],
|
||||
debounceResourceFactory,
|
||||
]),
|
||||
|
||||
_getLvmPhysicalVolume: compose([
|
||||
Disposable.factory,
|
||||
[deduped, (devicePath, partition) => [devicePath, partition?.id]],
|
||||
debounceResourceFactory,
|
||||
]),
|
||||
|
||||
_getPartition: compose([
|
||||
Disposable.factory,
|
||||
[deduped, (devicePath, partition) => [devicePath, partition?.id]],
|
||||
debounceResourceFactory,
|
||||
]),
|
||||
|
||||
_usePartitionFiles: Disposable.factory,
|
||||
|
||||
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
|
||||
|
||||
getPartition: Disposable.factory,
|
||||
})
|
||||
|
||||
exports.RemoteAdapter = RemoteAdapter
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
|
||||
const { PATH_DB_DUMP } = require('./_PoolMetadataBackup.js')
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const CancelToken = require('promise-toolbox/CancelToken.js')
|
||||
'use strict'
|
||||
|
||||
const CancelToken = require('promise-toolbox/CancelToken')
|
||||
const Zone = require('node-zone')
|
||||
|
||||
const logAfterEnd = () => {
|
||||
@@ -7,6 +9,8 @@ const logAfterEnd = () => {
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
const serializeErrors = errors => (Array.isArray(errors) ? errors.map(serializeError) : errors)
|
||||
|
||||
// Create a serializable object from an error.
|
||||
//
|
||||
// Otherwise some fields might be non-enumerable and missing from logs.
|
||||
@@ -15,6 +19,7 @@ const serializeError = error =>
|
||||
? {
|
||||
...error, // Copy enumerable properties.
|
||||
code: error.code,
|
||||
errors: serializeErrors(error.errors), // supports AggregateError
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
|
||||
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const findLast = require('lodash/findLast.js')
|
||||
const groupBy = require('lodash/groupBy.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const keyBy = require('lodash/keyBy.js')
|
||||
const mapValues = require('lodash/mapValues.js')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { decorateMethodsWith } = require('@vates/decorate-with')
|
||||
const { defer } = require('golike-defer')
|
||||
const { formatDateTime } = require('@xen-orchestra/xapi')
|
||||
|
||||
@@ -21,6 +24,13 @@ const { watchStreamSize } = require('./_watchStreamSize.js')
|
||||
|
||||
const { debug, warn } = createLogger('xo:backups:VmBackup')
|
||||
|
||||
class AggregateError extends Error {
|
||||
constructor(errors, message) {
|
||||
super(message)
|
||||
this.errors = errors
|
||||
}
|
||||
}
|
||||
|
||||
const asyncEach = async (iterable, fn, thisArg = iterable) => {
|
||||
for (const item of iterable) {
|
||||
await fn.call(thisArg, item)
|
||||
@@ -34,8 +44,14 @@ const forkDeltaExport = deltaExport =>
|
||||
},
|
||||
})
|
||||
|
||||
exports.VmBackup = class VmBackup {
|
||||
class VmBackup {
|
||||
constructor({ config, getSnapshotNameLabel, job, remoteAdapters, remotes, schedule, settings, srs, vm }) {
|
||||
if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
|
||||
// don't match replicated VMs created by this very job otherwise they
|
||||
// will be replicated again and again
|
||||
throw new Error('cannot backup a VM created by this very job')
|
||||
}
|
||||
|
||||
this.config = config
|
||||
this.job = job
|
||||
this.remoteAdapters = remoteAdapters
|
||||
@@ -119,16 +135,18 @@ exports.VmBackup = class VmBackup {
|
||||
return
|
||||
}
|
||||
|
||||
const errors = []
|
||||
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
|
||||
try {
|
||||
await fn(writer)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
this.delete(writer)
|
||||
warn(warnMessage, { error, writer: writer.constructor.name })
|
||||
}
|
||||
})
|
||||
if (writers.size === 0) {
|
||||
throw new Error('all targets have failed, step: ' + warnMessage)
|
||||
throw new AggregateError(errors, 'all targets have failed, step: ' + warnMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,13 +351,16 @@ exports.VmBackup = class VmBackup {
|
||||
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
await asyncMap(await baseVm.$getDisks(), async baseRef => {
|
||||
const snapshotOf = await xapi.getField('VDI', baseRef, 'snapshot_of')
|
||||
const [baseUuid, snapshotOf] = await Promise.all([
|
||||
xapi.getField('VDI', baseRef, 'uuid'),
|
||||
xapi.getField('VDI', baseRef, 'snapshot_of'),
|
||||
])
|
||||
const srcVdi = srcVdis[snapshotOf]
|
||||
if (srcVdi !== undefined) {
|
||||
baseUuidToSrcVdi.set(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
|
||||
baseUuidToSrcVdi.set(baseUuid, srcVdi)
|
||||
} else {
|
||||
debug('no base VDI found', {
|
||||
vdi: srcVdi.uuid,
|
||||
debug('ignore snapshot VDI because no longer present on VM', {
|
||||
vdi: baseUuid,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -351,6 +372,11 @@ exports.VmBackup = class VmBackup {
|
||||
false
|
||||
)
|
||||
|
||||
if (presentBaseVdis.size === 0) {
|
||||
debug('no base VM found')
|
||||
return
|
||||
}
|
||||
|
||||
const fullVdisRequired = new Set()
|
||||
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
|
||||
if (presentBaseVdis.has(baseUuid)) {
|
||||
@@ -371,7 +397,6 @@ exports.VmBackup = class VmBackup {
|
||||
this._fullVdisRequired = fullVdisRequired
|
||||
}
|
||||
|
||||
run = defer(this.run)
|
||||
async run($defer) {
|
||||
const settings = this._settings
|
||||
assert(
|
||||
@@ -419,3 +444,8 @@ exports.VmBackup = class VmBackup {
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.VmBackup = VmBackup
|
||||
|
||||
decorateMethodsWith(VmBackup, {
|
||||
run: defer,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
|
||||
const { DIR_XO_CONFIG_BACKUPS } = require('./RemoteAdapter.js')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
exports.isMetadataFile = filename => filename.endsWith('.json')
|
||||
exports.isVhdFile = filename => filename.endsWith('.vhd')
|
||||
exports.isXvaFile = filename => filename.endsWith('.xva')
|
||||
exports.isXvaSumFile = filename => filename.endsWith('.xva.cheksum')
|
||||
exports.isXvaSumFile = filename => filename.endsWith('.xva.checksum')
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use strict'
|
||||
|
||||
require('@xen-orchestra/log/configure.js').catchGlobalErrors(
|
||||
require('@xen-orchestra/log').createLogger('xo:backups:worker')
|
||||
)
|
||||
|
||||
const Disposable = require('promise-toolbox/Disposable.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const Disposable = require('promise-toolbox/Disposable')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const { compose } = require('@vates/compose')
|
||||
const { createDebounceResource } = require('@vates/disposable/debounceResource.js')
|
||||
const { decorateMethodsWith } = require('@vates/decorate-with')
|
||||
const { deduped } = require('@vates/disposable/deduped.js')
|
||||
const { getHandler } = require('@xen-orchestra/fs')
|
||||
const { parseDuration } = require('@vates/parse-duration')
|
||||
@@ -58,11 +61,6 @@ class BackupWorker {
|
||||
}).run()
|
||||
}
|
||||
|
||||
getAdapter = Disposable.factory(this.getAdapter)
|
||||
getAdapter = deduped(this.getAdapter, remote => [remote.url])
|
||||
getAdapter = compose(this.getAdapter, function (resource) {
|
||||
return this.debounceResource(resource)
|
||||
})
|
||||
async *getAdapter(remote) {
|
||||
const handler = getHandler(remote, this.#remoteOptions)
|
||||
await handler.sync()
|
||||
@@ -70,17 +68,13 @@ class BackupWorker {
|
||||
yield new RemoteAdapter(handler, {
|
||||
debounceResource: this.debounceResource,
|
||||
dirMode: this.#config.dirMode,
|
||||
vhdDirectoryCompression: this.#config.vhdDirectoryCompression,
|
||||
})
|
||||
} finally {
|
||||
await handler.forget()
|
||||
}
|
||||
}
|
||||
|
||||
getXapi = Disposable.factory(this.getXapi)
|
||||
getXapi = deduped(this.getXapi, ({ url }) => [url])
|
||||
getXapi = compose(this.getXapi, function (resource) {
|
||||
return this.debounceResource(resource)
|
||||
})
|
||||
async *getXapi({ credentials: { username: user, password }, ...opts }) {
|
||||
const xapi = new Xapi({
|
||||
...this.#xapiOptions,
|
||||
@@ -102,6 +96,30 @@ class BackupWorker {
|
||||
}
|
||||
}
|
||||
|
||||
decorateMethodsWith(BackupWorker, {
|
||||
getAdapter: compose([
|
||||
Disposable.factory,
|
||||
[deduped, remote => [remote.url]],
|
||||
[
|
||||
compose,
|
||||
function (resource) {
|
||||
return this.debounceResource(resource)
|
||||
},
|
||||
],
|
||||
]),
|
||||
|
||||
getXapi: compose([
|
||||
Disposable.factory,
|
||||
[deduped, xapi => [xapi.url]],
|
||||
[
|
||||
compose,
|
||||
function (resource) {
|
||||
return this.debounceResource(resource)
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
// Received message:
|
||||
//
|
||||
// Message {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const cancelable = require('promise-toolbox/cancelable.js')
|
||||
const CancelToken = require('promise-toolbox/CancelToken.js')
|
||||
'use strict'
|
||||
|
||||
const cancelable = require('promise-toolbox/cancelable')
|
||||
const CancelToken = require('promise-toolbox/CancelToken')
|
||||
|
||||
// Similar to `Promise.all` + `map` but pass a cancel token to the callback
|
||||
//
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const rimraf = require('rimraf')
|
||||
@@ -9,6 +11,8 @@ const crypto = require('crypto')
|
||||
const { RemoteAdapter } = require('./RemoteAdapter')
|
||||
const { VHDFOOTER, VHDHEADER } = require('./tests.fixtures.js')
|
||||
const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
|
||||
const { checkAliases } = require('./_cleanVm')
|
||||
const { dirname, basename } = require('path')
|
||||
|
||||
let tempDir, adapter, handler, jobId, vdiId, basePath
|
||||
|
||||
@@ -35,7 +39,11 @@ const uniqueId = () => crypto.randomBytes(16).toString('hex')
|
||||
async function generateVhd(path, opts = {}) {
|
||||
let vhd
|
||||
|
||||
const dataPath = opts.useAlias ? path + '.data' : path
|
||||
let dataPath = path
|
||||
if (opts.useAlias) {
|
||||
await handler.mkdir(dirname(path) + '/data/')
|
||||
dataPath = dirname(path) + '/data/' + basename(path)
|
||||
}
|
||||
if (opts.mode === 'directory') {
|
||||
await handler.mkdir(dataPath)
|
||||
vhd = new VhdDirectory(handler, dataPath)
|
||||
@@ -162,7 +170,7 @@ test('it remove backup meta data referencing a missing vhd in delta backup', asy
|
||||
`${basePath}/deleted.vhd`, // in metadata but not in vhds
|
||||
`${basePath}/orphan.vhd`,
|
||||
`${basePath}/child.vhd`,
|
||||
// abandonned.json is not here
|
||||
// abandonned.vhd is not here anymore
|
||||
],
|
||||
}),
|
||||
{ flags: 'w' }
|
||||
@@ -178,7 +186,7 @@ test('it merges delta of non destroyed chain', async () => {
|
||||
`metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
size: 209920,
|
||||
size: 12000, // a size too small
|
||||
vhds: [
|
||||
`${basePath}/grandchild.vhd`, // grand child should not be merged
|
||||
`${basePath}/child.vhd`,
|
||||
@@ -204,20 +212,25 @@ test('it merges delta of non destroyed chain', async () => {
|
||||
},
|
||||
})
|
||||
|
||||
let loggued = ''
|
||||
let loggued = []
|
||||
const onLog = message => {
|
||||
loggued += message + '\n'
|
||||
loggued.push(message)
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, onLog })
|
||||
expect(loggued).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused\n`)
|
||||
loggued = ''
|
||||
expect(loggued[0]).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused`)
|
||||
expect(loggued[1]).toEqual(`incorrect size in metadata: 12000 instead of 209920`)
|
||||
|
||||
loggued = []
|
||||
await adapter.cleanVm('/', { remove: true, merge: true, onLog })
|
||||
const [unused, merging] = loggued.split('\n')
|
||||
const [unused, merging] = loggued
|
||||
expect(unused).toEqual(`the parent /${basePath}/orphan.vhd of the child /${basePath}/child.vhd is unused`)
|
||||
expect(merging).toEqual(`merging /${basePath}/child.vhd into /${basePath}/orphan.vhd`)
|
||||
|
||||
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
|
||||
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
|
||||
// size should be the size of children + grand children after the merge
|
||||
expect(metadata.size).toEqual(209920)
|
||||
|
||||
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
|
||||
// only check deletion
|
||||
const remainingVhds = await handler.list(basePath)
|
||||
expect(remainingVhds.length).toEqual(2)
|
||||
@@ -231,11 +244,7 @@ test('it finish unterminated merge ', async () => {
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
size: 209920,
|
||||
vhds: [
|
||||
`${basePath}/orphan.vhd`, // grand child should not be merged
|
||||
`${basePath}/child.vhd`,
|
||||
// orphan is not here, he should be merged in child
|
||||
],
|
||||
vhds: [`${basePath}/orphan.vhd`, `${basePath}/child.vhd`],
|
||||
})
|
||||
)
|
||||
|
||||
@@ -261,7 +270,6 @@ test('it finish unterminated merge ', async () => {
|
||||
})
|
||||
)
|
||||
|
||||
// a unfinished merging
|
||||
await adapter.cleanVm('/', { remove: true, merge: true })
|
||||
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
|
||||
|
||||
@@ -274,12 +282,17 @@ test('it finish unterminated merge ', async () => {
|
||||
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
|
||||
// the message an resulting files should be identical to the output with vhd files which is tested independantly
|
||||
|
||||
describe('tests mulitple combination ', () => {
|
||||
describe('tests multiple combination ', () => {
|
||||
for (const useAlias of [true, false]) {
|
||||
for (const vhdMode of ['file', 'directory']) {
|
||||
test(`alias : ${useAlias}, mode: ${vhdMode}`, async () => {
|
||||
// a broken VHD
|
||||
const brokenVhdDataPath = basePath + useAlias ? 'broken.data' : 'broken.vhd'
|
||||
if (useAlias) {
|
||||
await handler.mkdir(basePath + '/data')
|
||||
}
|
||||
|
||||
const brokenVhdDataPath = basePath + (useAlias ? '/data/broken.vhd' : '/broken.vhd')
|
||||
|
||||
if (vhdMode === 'directory') {
|
||||
await handler.mkdir(brokenVhdDataPath)
|
||||
} else {
|
||||
@@ -300,6 +313,7 @@ describe('tests mulitple combination ', () => {
|
||||
parentUid: crypto.randomBytes(16),
|
||||
},
|
||||
})
|
||||
|
||||
// an ancestor of a vhd present in metadata
|
||||
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, {
|
||||
useAlias,
|
||||
@@ -362,22 +376,29 @@ describe('tests mulitple combination ', () => {
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
await adapter.cleanVm('/', { remove: true, merge: true })
|
||||
|
||||
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
|
||||
// size should be the size of children + grand children + clean after the merge
|
||||
expect(metadata.size).toEqual(vhdMode === 'file' ? 314880 : undefined)
|
||||
|
||||
// broken vhd, non referenced, abandonned should be deleted ( alias and data)
|
||||
// ancestor and child should be merged
|
||||
// grand child and clean vhd should not have changed
|
||||
const survivors = await handler.list(basePath)
|
||||
// console.log(survivors)
|
||||
if (useAlias) {
|
||||
const dataSurvivors = await handler.list(basePath + '/data')
|
||||
// the goal of the alias : do not move a full folder
|
||||
expect(survivors).toContain('ancestor.vhd.data')
|
||||
expect(survivors).toContain('grandchild.vhd.data')
|
||||
expect(survivors).toContain('cleanAncestor.vhd.data')
|
||||
expect(dataSurvivors).toContain('ancestor.vhd')
|
||||
expect(dataSurvivors).toContain('grandchild.vhd')
|
||||
expect(dataSurvivors).toContain('cleanAncestor.vhd')
|
||||
expect(survivors).toContain('clean.vhd.alias.vhd')
|
||||
expect(survivors).toContain('child.vhd.alias.vhd')
|
||||
expect(survivors).toContain('grandchild.vhd.alias.vhd')
|
||||
expect(survivors.length).toEqual(6)
|
||||
expect(survivors.length).toEqual(4) // the 3 ok + data
|
||||
expect(dataSurvivors.length).toEqual(3) // the 3 ok + data
|
||||
} else {
|
||||
expect(survivors).toContain('clean.vhd')
|
||||
expect(survivors).toContain('child.vhd')
|
||||
@@ -388,3 +409,31 @@ describe('tests mulitple combination ', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('it cleans orphan merge states ', async () => {
|
||||
await handler.writeFile(`${basePath}/.orphan.vhd.merge.json`, '')
|
||||
|
||||
await adapter.cleanVm('/', { remove: true })
|
||||
|
||||
expect(await handler.list(basePath)).toEqual([])
|
||||
})
|
||||
|
||||
test('check Aliases should work alone', async () => {
|
||||
await handler.mkdir('vhds')
|
||||
await handler.mkdir('vhds/data')
|
||||
await generateVhd(`vhds/data/ok.vhd`)
|
||||
await VhdAbstract.createAlias(handler, 'vhds/ok.alias.vhd', 'vhds/data/ok.vhd')
|
||||
|
||||
await VhdAbstract.createAlias(handler, 'vhds/missingData.alias.vhd', 'vhds/data/nonexistent.vhd')
|
||||
|
||||
await generateVhd(`vhds/data/missingalias.vhd`)
|
||||
|
||||
await checkAliases(['vhds/missingData.alias.vhd', 'vhds/ok.alias.vhd'], 'vhds/data', { remove: true, handler })
|
||||
|
||||
// only ok have suvived
|
||||
const alias = (await handler.list('vhds')).filter(f => f.endsWith('.vhd'))
|
||||
expect(alias.length).toEqual(1)
|
||||
|
||||
const data = await handler.list('vhds/data')
|
||||
expect(data.length).toEqual(1)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const sum = require('lodash/sum')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
|
||||
const { isVhdAlias, resolveVhdAlias } = require('vhd-lib/aliases')
|
||||
const { dirname, resolve } = require('path')
|
||||
const { DISK_TYPES } = Constants
|
||||
const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
|
||||
@@ -10,6 +13,24 @@ const { limitConcurrency } = require('limit-concurrency-decorator')
|
||||
const { Task } = require('./Task.js')
|
||||
const { Disposable } = require('promise-toolbox')
|
||||
|
||||
// checking the size of a vhd directory is costly
|
||||
// 1 Http Query per 1000 blocks
|
||||
// we only check size of all the vhd are VhdFiles
|
||||
function shouldComputeVhdsSize(vhds) {
|
||||
return vhds.every(vhd => vhd instanceof VhdFile)
|
||||
}
|
||||
|
||||
const computeVhdsSize = (handler, vhdPaths) =>
|
||||
Disposable.use(
|
||||
vhdPaths.map(vhdPath => openVhd(handler, vhdPath)),
|
||||
async vhds => {
|
||||
if (shouldComputeVhdsSize(vhds)) {
|
||||
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
|
||||
return sum(sizes)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// chain is an array of VHDs from child to parent
|
||||
//
|
||||
// the whole chain will be merged into parent, parent will be renamed to child
|
||||
@@ -64,7 +85,6 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
)
|
||||
|
||||
clearInterval(handle)
|
||||
|
||||
await Promise.all([
|
||||
VhdAbstract.rename(handler, parent, child),
|
||||
asyncMap(children.slice(0, -1), child => {
|
||||
@@ -82,10 +102,11 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
const INTERRUPTED_VHDS_REG = /^(?:(.+)\/)?\.(.+)\.merge.json$/
|
||||
const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
|
||||
const listVhds = async (handler, vmDir) => {
|
||||
const vhds = []
|
||||
const interruptedVhds = new Set()
|
||||
const vhds = new Set()
|
||||
const aliases = {}
|
||||
const interruptedVhds = new Map()
|
||||
|
||||
await asyncMap(
|
||||
await handler.list(`${vmDir}/vdis`, {
|
||||
@@ -100,25 +121,77 @@ const listVhds = async (handler, vmDir) => {
|
||||
async vdiDir => {
|
||||
const list = await handler.list(vdiDir, {
|
||||
filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
|
||||
prependDir: true,
|
||||
})
|
||||
|
||||
aliases[vdiDir] = list.filter(vhd => isVhdAlias(vhd)).map(file => `${vdiDir}/${file}`)
|
||||
list.forEach(file => {
|
||||
const res = INTERRUPTED_VHDS_REG.exec(file)
|
||||
if (res === null) {
|
||||
vhds.push(file)
|
||||
vhds.add(`${vdiDir}/${file}`)
|
||||
} else {
|
||||
const [, dir, file] = res
|
||||
interruptedVhds.add(`${dir}/${file}`)
|
||||
interruptedVhds.set(`${vdiDir}/${res[1]}`, `${vdiDir}/${file}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return { vhds, interruptedVhds }
|
||||
return { vhds, interruptedVhds, aliases }
|
||||
}
|
||||
|
||||
async function checkAliases(aliasPaths, targetDataRepository, { handler, onLog = noop, remove = false }) {
|
||||
const aliasFound = []
|
||||
for (const path of aliasPaths) {
|
||||
const target = await resolveVhdAlias(handler, path)
|
||||
|
||||
if (!isVhdFile(target)) {
|
||||
onLog(`Alias ${path} references a non vhd target: ${target}`)
|
||||
if (remove) {
|
||||
await handler.unlink(target)
|
||||
await handler.unlink(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const { dispose } = await openVhd(handler, target)
|
||||
try {
|
||||
await dispose()
|
||||
} catch (e) {
|
||||
// error during dispose should not trigger a deletion
|
||||
}
|
||||
} catch (error) {
|
||||
onLog(`target ${target} of alias ${path} is missing or broken`, { error })
|
||||
if (remove) {
|
||||
try {
|
||||
await VhdAbstract.unlink(handler, path)
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
onLog(`Error while deleting target ${target} of alias ${path}`, { error: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
aliasFound.push(resolve('/', target))
|
||||
}
|
||||
|
||||
const entries = await handler.list(targetDataRepository, {
|
||||
ignoreMissing: true,
|
||||
prependDir: true,
|
||||
})
|
||||
|
||||
entries.forEach(async entry => {
|
||||
if (!aliasFound.includes(entry)) {
|
||||
onLog(`the Vhd ${entry} is not referenced by a an alias`)
|
||||
if (remove) {
|
||||
await VhdAbstract.unlink(handler, entry)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
exports.checkAliases = checkAliases
|
||||
|
||||
const defaultMergeLimiter = limitConcurrency(1)
|
||||
|
||||
exports.cleanVm = async function cleanVm(
|
||||
@@ -129,17 +202,16 @@ exports.cleanVm = async function cleanVm(
|
||||
|
||||
const handler = this._handler
|
||||
|
||||
const vhds = new Set()
|
||||
const vhdsToJSons = new Set()
|
||||
const vhdParents = { __proto__: null }
|
||||
const vhdChildren = { __proto__: null }
|
||||
|
||||
const vhdsList = await listVhds(handler, vmDir)
|
||||
const { vhds, interruptedVhds, aliases } = await listVhds(handler, vmDir)
|
||||
|
||||
// remove broken VHDs
|
||||
await asyncMap(vhdsList.vhds, async path => {
|
||||
await asyncMap(vhds, async path => {
|
||||
try {
|
||||
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !vhdsList.interruptedVhds.has(path) }), vhd => {
|
||||
vhds.add(path)
|
||||
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
|
||||
if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
|
||||
const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
|
||||
vhdParents[path] = parent
|
||||
@@ -154,6 +226,7 @@ exports.cleanVm = async function cleanVm(
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
vhds.delete(path)
|
||||
onLog(`error while checking the VHD with path ${path}`, { error })
|
||||
if (error?.code === 'ERR_ASSERTION' && remove) {
|
||||
onLog(`deleting broken ${path}`)
|
||||
@@ -162,7 +235,28 @@ exports.cleanVm = async function cleanVm(
|
||||
}
|
||||
})
|
||||
|
||||
// @todo : add check for data folder of alias not referenced in a valid alias
|
||||
// remove interrupted merge states for missing VHDs
|
||||
for (const interruptedVhd of interruptedVhds.keys()) {
|
||||
if (!vhds.has(interruptedVhd)) {
|
||||
const statePath = interruptedVhds.get(interruptedVhd)
|
||||
interruptedVhds.delete(interruptedVhd)
|
||||
|
||||
onLog('orphan merge state', {
|
||||
mergeStatePath: statePath,
|
||||
missingVhdPath: interruptedVhd,
|
||||
})
|
||||
if (remove) {
|
||||
onLog(`deleting orphan merge state ${statePath}`)
|
||||
await handler.unlink(statePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if alias are correct
|
||||
// check if all vhd in data subfolder have a corresponding alias
|
||||
await asyncMap(Object.keys(aliases), async dir => {
|
||||
await checkAliases(aliases[dir], `${dir}/data`, { handler, onLog, remove })
|
||||
})
|
||||
|
||||
// remove VHDs with missing ancestors
|
||||
{
|
||||
@@ -202,7 +296,7 @@ exports.cleanVm = async function cleanVm(
|
||||
await Promise.all(deletions)
|
||||
}
|
||||
|
||||
const jsons = []
|
||||
const jsons = new Set()
|
||||
const xvas = new Set()
|
||||
const xvaSums = []
|
||||
const entries = await handler.list(vmDir, {
|
||||
@@ -210,7 +304,7 @@ exports.cleanVm = async function cleanVm(
|
||||
})
|
||||
entries.forEach(path => {
|
||||
if (isMetadataFile(path)) {
|
||||
jsons.push(path)
|
||||
jsons.add(path)
|
||||
} else if (isXvaFile(path)) {
|
||||
xvas.add(path)
|
||||
} else if (isXvaSumFile(path)) {
|
||||
@@ -232,22 +326,25 @@ exports.cleanVm = async function cleanVm(
|
||||
// compile the list of unused XVAs and VHDs, and remove backup metadata which
|
||||
// reference a missing XVA/VHD
|
||||
await asyncMap(jsons, async json => {
|
||||
const metadata = JSON.parse(await handler.readFile(json))
|
||||
let metadata
|
||||
try {
|
||||
metadata = JSON.parse(await handler.readFile(json))
|
||||
} catch (error) {
|
||||
onLog(`failed to read metadata file ${json}`, { error })
|
||||
jsons.delete(json)
|
||||
return
|
||||
}
|
||||
|
||||
const { mode } = metadata
|
||||
let size
|
||||
if (mode === 'full') {
|
||||
const linkedXva = resolve('/', vmDir, metadata.xva)
|
||||
|
||||
if (xvas.has(linkedXva)) {
|
||||
unusedXvas.delete(linkedXva)
|
||||
|
||||
size = await handler.getSize(linkedXva).catch(error => {
|
||||
onLog(`failed to get size of ${json}`, { error })
|
||||
})
|
||||
} else {
|
||||
onLog(`the XVA linked to the metadata ${json} is missing`)
|
||||
if (remove) {
|
||||
onLog(`deleting incomplete backup ${json}`)
|
||||
jsons.delete(json)
|
||||
await handler.unlink(json)
|
||||
}
|
||||
}
|
||||
@@ -256,50 +353,25 @@ exports.cleanVm = async function cleanVm(
|
||||
const { vhds } = metadata
|
||||
return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
|
||||
})()
|
||||
|
||||
const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
|
||||
|
||||
// FIXME: find better approach by keeping as much of the backup as
|
||||
// possible (existing disks) even if one disk is missing
|
||||
if (linkedVhds.every(_ => vhds.has(_))) {
|
||||
if (missingVhds.length === 0) {
|
||||
linkedVhds.forEach(_ => unusedVhds.delete(_))
|
||||
|
||||
// checking the size of a vhd directory is costly
|
||||
// 1 Http Query per 1000 blocks
|
||||
// we only check size of all the vhd are VhdFiles
|
||||
|
||||
const shouldComputeSize = linkedVhds.every(vhd => vhd instanceof VhdFile)
|
||||
if (shouldComputeSize) {
|
||||
try {
|
||||
await Disposable.use(Disposable.all(linkedVhds.map(vhdPath => openVhd(handler, vhdPath))), async vhds => {
|
||||
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
|
||||
size = sum(sizes)
|
||||
})
|
||||
} catch (error) {
|
||||
onLog(`failed to get size of ${json}`, { error })
|
||||
}
|
||||
}
|
||||
linkedVhds.forEach(path => {
|
||||
vhdsToJSons[path] = json
|
||||
})
|
||||
} else {
|
||||
onLog(`Some VHDs linked to the metadata ${json} are missing`)
|
||||
onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
|
||||
if (remove) {
|
||||
onLog(`deleting incomplete backup ${json}`)
|
||||
jsons.delete(json)
|
||||
await handler.unlink(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadataSize = metadata.size
|
||||
if (size !== undefined && metadataSize !== size) {
|
||||
onLog(`incorrect size in metadata: ${metadataSize ?? 'none'} instead of ${size}`)
|
||||
|
||||
// don't update if the the stored size is greater than found files,
|
||||
// it can indicates a problem
|
||||
if (fixMetadata && (metadataSize === undefined || metadataSize < size)) {
|
||||
try {
|
||||
metadata.size = size
|
||||
await handler.writeFile(json, JSON.stringify(metadata), { flags: 'w' })
|
||||
} catch (error) {
|
||||
onLog(`failed to update size in backup metadata ${json}`, { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: parallelize by vm/job/vdi
|
||||
@@ -347,9 +419,9 @@ exports.cleanVm = async function cleanVm(
|
||||
})
|
||||
|
||||
// merge interrupted VHDs
|
||||
vhdsList.interruptedVhds.forEach(parent => {
|
||||
for (const parent of interruptedVhds.keys()) {
|
||||
vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
|
||||
})
|
||||
}
|
||||
|
||||
Object.values(vhdChainsToMerge).forEach(chain => {
|
||||
if (chain !== undefined) {
|
||||
@@ -358,9 +430,15 @@ exports.cleanVm = async function cleanVm(
|
||||
})
|
||||
}
|
||||
|
||||
const doMerge = () => {
|
||||
const promise = asyncMap(toMerge, async chain => limitedMergeVhdChain(chain, { handler, onLog, remove, merge }))
|
||||
return merge ? promise.then(sizes => ({ size: sum(sizes) })) : promise
|
||||
const metadataWithMergedVhd = {}
|
||||
const doMerge = async () => {
|
||||
await asyncMap(toMerge, async chain => {
|
||||
const merged = await limitedMergeVhdChain(chain, { handler, onLog, remove, merge })
|
||||
if (merged !== undefined) {
|
||||
const metadataPath = vhdsToJSons[chain[0]] // all the chain should have the same metada file
|
||||
metadataWithMergedVhd[metadataPath] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
@@ -385,6 +463,52 @@ exports.cleanVm = async function cleanVm(
|
||||
}),
|
||||
])
|
||||
|
||||
// update size for delta metadata with merged VHD
|
||||
// check for the other that the size is the same as the real file size
|
||||
|
||||
await asyncMap(jsons, async metadataPath => {
|
||||
const metadata = JSON.parse(await handler.readFile(metadataPath))
|
||||
|
||||
let fileSystemSize
|
||||
const merged = metadataWithMergedVhd[metadataPath] !== undefined
|
||||
|
||||
const { mode, size, vhds, xva } = metadata
|
||||
|
||||
try {
|
||||
if (mode === 'full') {
|
||||
// a full backup : check size
|
||||
const linkedXva = resolve('/', vmDir, xva)
|
||||
fileSystemSize = await handler.getSize(linkedXva)
|
||||
} else if (mode === 'delta') {
|
||||
const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
|
||||
fileSystemSize = await computeVhdsSize(handler, linkedVhds)
|
||||
|
||||
// the size is not computed in some cases (e.g. VhdDirectory)
|
||||
if (fileSystemSize === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// don't warn if the size has changed after a merge
|
||||
if (!merged && fileSystemSize !== size) {
|
||||
onLog(`incorrect size in metadata: ${size ?? 'none'} instead of ${fileSystemSize}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
onLog(`failed to get size of ${metadataPath}`, { error })
|
||||
return
|
||||
}
|
||||
|
||||
// systematically update size after a merge
|
||||
if ((merged || fixMetadata) && size !== fileSystemSize) {
|
||||
metadata.size = fileSystemSize
|
||||
try {
|
||||
await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
|
||||
} catch (error) {
|
||||
onLog(`failed to update size in backup metadata ${metadataPath} after merge`, { error })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// boolean whether some VHDs were merged (or should be merged)
|
||||
merge: toMerge.length !== 0,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use strict'
|
||||
|
||||
const compareVersions = require('compare-versions')
|
||||
const find = require('lodash/find.js')
|
||||
const groupBy = require('lodash/groupBy.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const omit = require('lodash/omit.js')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { CancelToken } = require('promise-toolbox')
|
||||
@@ -9,6 +11,8 @@ const { createVhdStreamWithLength } = require('vhd-lib')
|
||||
const { defer } = require('golike-defer')
|
||||
|
||||
const { cancelableMap } = require('./_cancelableMap.js')
|
||||
const { Task } = require('./Task.js')
|
||||
const { pick } = require('lodash')
|
||||
|
||||
const TAG_BASE_DELTA = 'xo:base_delta'
|
||||
exports.TAG_BASE_DELTA = TAG_BASE_DELTA
|
||||
@@ -17,6 +21,17 @@ const TAG_COPY_SRC = 'xo:copy_of'
|
||||
exports.TAG_COPY_SRC = TAG_COPY_SRC
|
||||
|
||||
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
||||
const resolveUuid = async (xapi, cache, uuid, type) => {
|
||||
if (uuid == null) {
|
||||
return uuid
|
||||
}
|
||||
let ref = cache.get(uuid)
|
||||
if (ref === undefined) {
|
||||
ref = await xapi.call(`${type}.get_by_uuid`, uuid)
|
||||
cache.set(uuid, ref)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
exports.exportDeltaVm = async function exportDeltaVm(
|
||||
vm,
|
||||
@@ -165,6 +180,12 @@ exports.importDeltaVm = defer(async function importDeltaVm(
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new Map()
|
||||
const mapVdisSrRefs = {}
|
||||
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
|
||||
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
|
||||
}
|
||||
|
||||
const baseVdis = {}
|
||||
baseVm &&
|
||||
baseVm.$VBDs.forEach(vbd => {
|
||||
@@ -179,19 +200,25 @@ exports.importDeltaVm = defer(async function importDeltaVm(
|
||||
let suspendVdi
|
||||
if (vmRecord.power_state === 'Suspended') {
|
||||
const vdi = vdiRecords[vmRecord.suspend_VDI]
|
||||
suspendVdi = await xapi.getRecord(
|
||||
'VDI',
|
||||
await xapi.VDI_create({
|
||||
...vdi,
|
||||
other_config: {
|
||||
...vdi.other_config,
|
||||
[TAG_BASE_DELTA]: undefined,
|
||||
[TAG_COPY_SRC]: vdi.uuid,
|
||||
},
|
||||
sr: mapVdisSrs[vdi.uuid] ?? sr.$ref,
|
||||
if (vdi === undefined) {
|
||||
Task.warning('Suspend VDI not available for this suspended VM', {
|
||||
vm: pick(vmRecord, 'uuid', 'name_label'),
|
||||
})
|
||||
)
|
||||
$defer.onFailure(() => suspendVdi.$destroy())
|
||||
} else {
|
||||
suspendVdi = await xapi.getRecord(
|
||||
'VDI',
|
||||
await xapi.VDI_create({
|
||||
...vdi,
|
||||
other_config: {
|
||||
...vdi.other_config,
|
||||
[TAG_BASE_DELTA]: undefined,
|
||||
[TAG_COPY_SRC]: vdi.uuid,
|
||||
},
|
||||
sr: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
|
||||
})
|
||||
)
|
||||
$defer.onFailure(() => suspendVdi.$destroy())
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Create the VM.
|
||||
@@ -255,7 +282,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
|
||||
[TAG_BASE_DELTA]: undefined,
|
||||
[TAG_COPY_SRC]: vdi.uuid,
|
||||
},
|
||||
SR: mapVdisSrs[vdi.uuid] ?? sr.$ref,
|
||||
SR: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
|
||||
})
|
||||
)
|
||||
$defer.onFailure(() => newVdi.$destroy())
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
exports.extractIdsFromSimplePattern = function extractIdsFromSimplePattern(pattern) {
|
||||
if (pattern === undefined) {
|
||||
return []
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { utcFormat, utcParse } = require('d3-time-format')
|
||||
|
||||
// Format a date in ISO 8601 in a safe way to be used in filenames
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const eos = require('end-of-stream')
|
||||
const { PassThrough } = require('stream')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
// returns all entries but the last retention-th
|
||||
exports.getOldEntries = function getOldEntries(retention, entries) {
|
||||
return entries === undefined ? [] : retention > 0 ? entries.slice(0, -retention) : entries
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const Disposable = require('promise-toolbox/Disposable.js')
|
||||
'use strict'
|
||||
|
||||
const Disposable = require('promise-toolbox/Disposable')
|
||||
const { join } = require('path')
|
||||
const { mkdir, rmdir } = require('fs-extra')
|
||||
const { tmpdir } = require('os')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const BACKUP_DIR = 'xo-vm-backups'
|
||||
exports.BACKUP_DIR = BACKUP_DIR
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
|
||||
const isGzipFile = async (handler, fd) => {
|
||||
const COMPRESSED_MAGIC_NUMBERS = [
|
||||
// https://tools.ietf.org/html/rfc1952.html#page-5
|
||||
const magicNumber = Buffer.allocUnsafe(2)
|
||||
Buffer.from('1F8B', 'hex'),
|
||||
|
||||
assert.strictEqual((await handler.read(fd, magicNumber, 0)).bytesRead, magicNumber.length)
|
||||
return magicNumber[0] === 31 && magicNumber[1] === 139
|
||||
// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#zstandard-frames
|
||||
Buffer.from('28B52FFD', 'hex'),
|
||||
]
|
||||
const MAGIC_NUMBER_MAX_LENGTH = Math.max(...COMPRESSED_MAGIC_NUMBERS.map(_ => _.length))
|
||||
|
||||
const isCompressedFile = async (handler, fd) => {
|
||||
const header = Buffer.allocUnsafe(MAGIC_NUMBER_MAX_LENGTH)
|
||||
assert.strictEqual((await handler.read(fd, header, 0)).bytesRead, header.length)
|
||||
|
||||
for (const magicNumber of COMPRESSED_MAGIC_NUMBERS) {
|
||||
if (magicNumber.compare(header, 0, magicNumber.length) === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: better check?
|
||||
@@ -43,8 +58,8 @@ async function isValidXva(path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (await isGzipFile(handler, fd))
|
||||
? true // gzip files cannot be validated at this time
|
||||
return (await isCompressedFile(handler, fd))
|
||||
? true // compressed files cannot be validated at this time
|
||||
: await isValidTar(handler, size, fd)
|
||||
} finally {
|
||||
handler.closeFile(fd).catch(noop)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const fromCallback = require('promise-toolbox/fromCallback.js')
|
||||
'use strict'
|
||||
|
||||
const fromCallback = require('promise-toolbox/fromCallback')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { createParser } = require('parse-pairs')
|
||||
const { execFile } = require('child_process')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const fromCallback = require('promise-toolbox/fromCallback.js')
|
||||
'use strict'
|
||||
|
||||
const fromCallback = require('promise-toolbox/fromCallback')
|
||||
const { createParser } = require('parse-pairs')
|
||||
const { execFile } = require('child_process')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
exports.watchStreamSize = function watchStreamSize(stream, container = { size: 0 }) {
|
||||
stream.on('data', data => {
|
||||
container.size += data.length
|
||||
|
||||
52
@xen-orchestra/backups/docs/Metadata backups/README.md
Normal file
52
@xen-orchestra/backups/docs/Metadata backups/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
- [File structure on remote](#file-structure-on-remote)
|
||||
- [Structure of `metadata.json`](#structure-of-metadatajson)
|
||||
- [Task logs](#task-logs)
|
||||
- [During backup](#during-backup)
|
||||
|
||||
## File structure on remote
|
||||
|
||||
```
|
||||
<remote>
|
||||
├─ xo-config-backups
|
||||
│ └─ <schedule ID>
|
||||
│ └─ <YYYYMMDD>T<HHmmss>
|
||||
│ ├─ metadata.json
|
||||
│ └─ data.json
|
||||
└─ xo-pool-metadata-backups
|
||||
└─ <schedule ID>
|
||||
└─ <pool UUID>
|
||||
└─ <YYYYMMDD>T<HHmmss>
|
||||
├─ metadata.json
|
||||
└─ data
|
||||
```
|
||||
|
||||
## Structure of `metadata.json`
|
||||
|
||||
```ts
|
||||
interface Metadata {
|
||||
jobId: String
|
||||
jobName: String
|
||||
scheduleId: String
|
||||
scheduleName: String
|
||||
timestamp: number
|
||||
pool?: Pool
|
||||
poolMaster?: Host
|
||||
}
|
||||
```
|
||||
|
||||
## Task logs
|
||||
|
||||
### During backup
|
||||
|
||||
```
|
||||
job.start(data: { reportWhen: ReportWhen })
|
||||
├─ task.start(data: { type: 'pool', id: string, pool?: Pool, poolMaster?: Host })
|
||||
│ ├─ task.start(data: { type: 'remote', id: string })
|
||||
│ │ └─ task.end
|
||||
│ └─ task.end
|
||||
├─ task.start(data: { type: 'xo' })
|
||||
│ ├─ task.start(data: { type: 'remote', id: string })
|
||||
│ │ └─ task.end
|
||||
│ └─ task.end
|
||||
└─ job.end
|
||||
```
|
||||
97
@xen-orchestra/backups/docs/VM backups/README.md
Normal file
97
@xen-orchestra/backups/docs/VM backups/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
- [File structure on remote](#file-structure-on-remote)
|
||||
- [Attributes](#attributes)
|
||||
- [Of created snapshots](#of-created-snapshots)
|
||||
- [Of created VMs and snapshots](#of-created-vms-and-snapshots)
|
||||
- [Of created VMs](#of-created-vms)
|
||||
- [Task logs](#task-logs)
|
||||
- [During backup](#during-backup)
|
||||
- [During restoration](#during-restoration)
|
||||
|
||||
## File structure on remote
|
||||
|
||||
```
|
||||
<remote>
|
||||
└─ xo-vm-backups
|
||||
├─ index.json // TODO
|
||||
└─ <VM UUID>
|
||||
├─ index.json // TODO
|
||||
├─ vdis
|
||||
│ └─ <job UUID>
|
||||
│ └─ <VDI UUID>
|
||||
│ ├─ index.json // TODO
|
||||
│ └─ <YYYYMMDD>T<HHmmss>.vhd
|
||||
├─ <YYYYMMDD>T<HHmmss>.json // backup metadata
|
||||
├─ <YYYYMMDD>T<HHmmss>.xva
|
||||
└─ <YYYYMMDD>T<HHmmss>.xva.checksum
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
### Of created snapshots
|
||||
|
||||
- `other_config`:
|
||||
- `xo:backup:deltaChainLength` = n (number of delta copies/replicated since a full)
|
||||
- `xo:backup:exported` = 'true' (added at the end of the backup)
|
||||
|
||||
### Of created VMs and snapshots
|
||||
|
||||
- `other_config`:
|
||||
- `xo:backup:datetime`: format is UTC %Y%m%dT%H:%M:%SZ
|
||||
- from snapshots: snapshot.snapshot_time
|
||||
- with offline backup: formatDateTime(Date.now())
|
||||
- `xo:backup:job` = job.id
|
||||
- `xo:backup:schedule` = schedule.id
|
||||
- `xo:backup:vm` = vm.uuid
|
||||
|
||||
### Of created VMs
|
||||
|
||||
- `name_label`: `${original name} - ${job name} - (${safeDateFormat(backup timestamp)})`
|
||||
- tag:
|
||||
- copy in delta mode: `Continuous Replication`
|
||||
- copy in full mode: `Disaster Recovery`
|
||||
- imported from backup: `restored from backup`
|
||||
- `blocked_operations.start`: message
|
||||
- for copies/replications only, added after complete transfer
|
||||
- `other_config[xo:backup:sr]` = sr.uuid
|
||||
|
||||
## Task logs
|
||||
|
||||
### During backup
|
||||
|
||||
```
|
||||
job.start(data: { mode: Mode, reportWhen: ReportWhen })
|
||||
├─ task.info(message: 'vms', data: { vms: string[] })
|
||||
├─ task.warning(message: string)
|
||||
├─ task.start(data: { type: 'VM', id: string })
|
||||
│ ├─ task.warning(message: string)
|
||||
│ ├─ task.start(message: 'snapshot')
|
||||
│ │ └─ task.end
|
||||
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string })
|
||||
│ │ ├─ task.warning(message: string)
|
||||
│ │ ├─ task.start(message: 'transfer')
|
||||
│ │ │ ├─ task.warning(message: string)
|
||||
│ │ │ └─ task.end(result: { size: number })
|
||||
│ │ │
|
||||
│ │ │ // in case of full backup, DR and CR
|
||||
│ │ ├─ task.start(message: 'clean')
|
||||
│ │ │ ├─ task.warning(message: string)
|
||||
│ │ │ └─ task.end
|
||||
│ │ │
|
||||
│ │ │ // in case of delta backup
|
||||
│ │ ├─ task.start(message: 'merge')
|
||||
│ │ │ ├─ task.warning(message: string)
|
||||
│ │ │ └─ task.end(result: { size: number })
|
||||
│ │ │
|
||||
│ │ └─ task.end
|
||||
│ └─ task.end
|
||||
└─ job.end
|
||||
```
|
||||
|
||||
### During restoration
|
||||
|
||||
```
|
||||
task.start(message: 'restore', data: { jobId: string, srId: string, time: number })
|
||||
├─ task.start(message: 'transfer')
|
||||
│ └─ task.end(result: { id: string, size: number })
|
||||
└─ task.end
|
||||
```
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const mapValues = require('lodash/mapValues.js')
|
||||
const { dirname } = require('path')
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict'
|
||||
|
||||
const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { getSyncedHandler } = require('@xen-orchestra/fs')
|
||||
@@ -41,13 +43,32 @@ const main = Disposable.wrap(async function* main(args) {
|
||||
let taskFiles
|
||||
while ((taskFiles = await listRetry()) !== undefined) {
|
||||
const taskFileBasename = min(taskFiles)
|
||||
const previousTaskFile = join(CLEAN_VM_QUEUE, taskFileBasename)
|
||||
const taskFile = join(CLEAN_VM_QUEUE, '_' + taskFileBasename)
|
||||
|
||||
// move this task to the end
|
||||
await handler.rename(join(CLEAN_VM_QUEUE, taskFileBasename), taskFile)
|
||||
try {
|
||||
await handler.rename(previousTaskFile, taskFile)
|
||||
} catch (error) {
|
||||
// this error occurs if the task failed too many times (i.e. too many `_` prefixes)
|
||||
// there is nothing more that can be done
|
||||
if (error.code === 'ENAMETOOLONG') {
|
||||
await handler.unlink(previousTaskFile)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const vmDir = getVmBackupDir(String(await handler.readFile(taskFile)))
|
||||
await adapter.cleanVm(vmDir, { merge: true, onLog: info, remove: true })
|
||||
try {
|
||||
await adapter.cleanVm(vmDir, { merge: true, onLog: info, remove: true })
|
||||
} catch (error) {
|
||||
// consider the clean successful if the VM dir is missing
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
handler.unlink(taskFile).catch(error => warn('deleting task failure', { error }))
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { join, resolve } = require('path')
|
||||
const { spawn } = require('child_process')
|
||||
const { check } = require('proper-lockfile')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.16.0",
|
||||
"version": "0.21.0",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
@@ -16,11 +16,12 @@
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/compose": "^2.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^0.19.1",
|
||||
"@xen-orchestra/fs": "^1.0.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^4.0.1",
|
||||
@@ -32,15 +33,18 @@
|
||||
"lodash": "^4.17.20",
|
||||
"node-zone": "^0.4.0",
|
||||
"parse-pairs": "^1.1.0",
|
||||
"promise-toolbox": "^0.20.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"pump": "^3.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vhd-lib": "^2.0.1",
|
||||
"vhd-lib": "^3.1.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^3.0.2",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^0.8.4"
|
||||
"@xen-orchestra/xapi": "^0.10.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { DIR_XO_CONFIG_BACKUPS, DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
|
||||
|
||||
exports.parseMetadataBackupId = function parseMetadataBackupId(backupId) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { fork } = require('child_process')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
// a valid footer of a 2
|
||||
exports.VHDFOOTER = {
|
||||
cookie: 'conectix',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const map = require('lodash/map.js')
|
||||
const mapValues = require('lodash/mapValues.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
@@ -24,6 +26,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
const { handler } = this._adapter
|
||||
const backup = this._backup
|
||||
const adapter = this._adapter
|
||||
|
||||
const backupDir = getVmBackupDir(backup.vm.uuid)
|
||||
const vdisDir = `${backupDir}/vdis/${backup.job.id}`
|
||||
@@ -35,13 +38,18 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
|
||||
prependDir: true,
|
||||
})
|
||||
const packedBaseUuid = packUuid(baseUuid)
|
||||
await asyncMap(vhds, async path => {
|
||||
try {
|
||||
await checkVhdChain(handler, path)
|
||||
await Disposable.use(
|
||||
openVhd(handler, path),
|
||||
vhd => (found = found || vhd.footer.uuid.equals(packUuid(baseUuid)))
|
||||
)
|
||||
// Warning, this should not be written as found = found || await adapter.isMergeableParent(packedBaseUuid, path)
|
||||
//
|
||||
// since all the checks of a path are done in parallel, found would be containing
|
||||
// only the last answer of isMergeableParent which is probably not the right one
|
||||
// this led to the support tickets https://help.vates.fr/#ticket/zoom/4751 , 4729, 4665 and 4300
|
||||
|
||||
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
|
||||
found = found || isMergeable
|
||||
} catch (error) {
|
||||
warn('checkBaseVdis', { error })
|
||||
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const { formatDateTime } = require('@xen-orchestra/xapi')
|
||||
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict'
|
||||
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
const { getOldEntries } = require('../_getOldEntries.js')
|
||||
const { getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user