Compare commits

..

43 Commits

Author SHA1 Message Date
b-Nollet
c11ed381c7 better warn message, changelog 2023-12-12 17:15:41 +01:00
b-Nollet
265b545c0c logging removed values 2023-12-11 15:43:33 +01:00
b-Nollet
86ccdd8f72 fix: error stored in remote url 2023-12-08 11:48:01 +01:00
Julien Fontanet
f0da94081b feat(gen-deps-list): detect duplicate packages
Prevents a bug where a second entry would override the previous one and possibly
decrease the release type (e.g. `major + patch → patch`).
2023-12-07 17:15:09 +01:00
Julien Fontanet
cd44a6e28c feat(eslint): enable require-atomic-updates rule 2023-12-07 17:05:21 +01:00
Julien Fontanet
70b09839c7 chore(xo-server): use @xen-orchestra/xapi/VM_import when possible 2023-12-07 16:50:45 +01:00
OlivierFL
12140143d2 feat(lite): added tooltip on CPU provisioning warning icon (#7223) 2023-12-07 09:07:15 +01:00
b-Nollet
e68236c9f2 docs(installation): update Debian & Fedora packages (#7207)
Fixes #7095
2023-12-06 15:39:50 +01:00
Julien Fontanet
8a1a0d76f7 chore: update dev deps 2023-12-06 11:09:54 +01:00
Mathieu
4a5bc5dccc feat(lite): override host address with 'master' query param (#7187) 2023-12-04 11:31:35 +01:00
MlssFrncJrg
0ccdfbd6f4 feat(xo-web/SR): improve forget SR modal message (#7155) 2023-12-04 09:33:50 +01:00
Mathieu
75af7668b5 fix(lite/changelog): fix xolite changelog (#7215) 2023-12-01 10:48:22 +01:00
Thierry Goettelmann
0b454fa670 feat(lite/VM): ability to migrate a VM (#7164) 2023-12-01 10:38:55 +01:00
Pierre Donias
2dcb5cb7cd feat(lite): 0.1.6 (#7213) 2023-11-30 16:01:06 +01:00
Thierry Goettelmann
a5aeeceb7f chore(lite): upgrade dependencies (#7170)
1. Since the project is built-only, all deps have been moved to `devDependencies`
2. TypeScript has been upgraded from 4.9 to 5.2
3. `engines.node` requirement was set to `>=8.10`. It has been updated to `>=18` to be aligned with deps requirements
2023-11-30 15:13:36 +01:00
Florent BEAUCHAMP
b2f2c3cbc4 feat: release 5.89.0 (#7212) 2023-11-30 13:57:49 +01:00
Florent BEAUCHAMP
0f7ac004ad feat: technical release (#7211) 2023-11-30 10:42:54 +01:00
Florent Beauchamp
7faa82a9c8 feat(xo-web): add UX for differential backup 2023-11-30 10:12:20 +01:00
Florent Beauchamp
4b3f60b280 feat(backups): implement differential restore
When restoring a backup, try to reuse data from an existing snapshot.
We use this snasphot and apply a reverse differential of the changes
between the backup and the snapshot

Prerequisite : a uninterrupted delta chain from the backup being
restored to a backup that still have its snapshot on the host
2023-11-30 10:12:20 +01:00
Florent Beauchamp
b29d5ba95c feat(vhd-lib): implement a limit in VhdSynthetic.fromVhdChain 2023-11-30 10:12:20 +01:00
Florent Beauchamp
408fc5af84 feat(vhd-lib): implement VhdNegative
it's a virtual Vhd that contains all the changes to be applied
to reset a child to its parent value
2023-11-30 10:12:20 +01:00
Florent BEAUCHAMP
2748aea4e9 feat: technical release (#7210) 2023-11-29 15:39:25 +01:00
Florent Beauchamp
a5acc7d267 fix(backups,xo-server): don't backup VMs created by Health Check 2023-11-29 14:46:05 +01:00
Florent Beauchamp
87a9fbe237 feat(xo-server,xo-web): don't backup VMs with xo:no-bak tag 2023-11-29 14:46:05 +01:00
Julien Fontanet
9d0b7242f0 fix(xapi-explore-sr): use xen-api@2.0.0 2023-11-29 14:42:50 +01:00
Julien Fontanet
20ec44c3b3 fix(xo-server/registerHttpRequestHandler): match on path
Instead of full URL, so that handlers can manage query string.
2023-11-29 14:42:50 +01:00
Julien Fontanet
6f68456bae feat(xo-server/registerHttpRequestHandler): returns teardown function
`unregisterHttpRequestHandler` is no longer useful and has been removed.
2023-11-29 14:42:50 +01:00
Florent BEAUCHAMP
b856c1a6b4 feat(xo-server,xo-web): show link to the SR for the garbage collector (coalesce) task (#7189)
See https://github.com/vatesfr/xen-orchestra/issues/5379#issuecomment-1765170973
2023-11-29 09:07:05 +01:00
Julien Fontanet
61e1f83a9f feat(xo-server/rest-api): possibility to import a VM 2023-11-28 17:54:10 +01:00
Mathieu
5820e19731 feat(xo-web/VM): display task information on VDI import (#7197) 2023-11-28 15:41:10 +01:00
Pierre Donias
cdb51f8fe3 chore(lite/settings): use FormSelect instead of select (#7206) 2023-11-28 14:46:25 +01:00
Florent BEAUCHAMP
57940e0a52 fix(backups): import on non default SR (#7209) 2023-11-28 14:35:08 +01:00
Florent BEAUCHAMP
6cc95efe51 feat: technical release (#7208) 2023-11-28 09:30:32 +01:00
Pierre Donias
b0ff2342ab chore(netbox): remove null-indexed entries from keyed-by collections (#7156) 2023-11-27 16:26:53 +01:00
Mathieu
0f67692be4 feat(xo-server/xostor): add XO tasks (#7201) 2023-11-27 16:11:53 +01:00
Julien Fontanet
865461bfb9 feat(xo-server/api): backupNg.{,un}mountPartition (#7176)
Manual method to mount a backup partition on the XOA.
2023-11-24 09:47:23 +01:00
Julien Fontanet
e108cb0990 feat(xo-server/rest-api): possibility to import in an existing VDI (#7199) 2023-11-23 17:07:40 +01:00
Florent BEAUCHAMP
c4535c6bae fix(fs/s3): enable md5 if object lock status is unknown (#7195)
From https://xcp-ng.org/forum/topic/7939/unable-to-connect-to-backblaze-b2/7?_=1700572613725
Following 796e2ab674 

User report it fixes the issue https://xcp-ng.org/forum/post/67633
2023-11-23 16:43:25 +01:00
Julien Fontanet
ad8eaaa771 feat(xo-cli): support REST PUT method 2023-11-23 16:30:03 +01:00
Julien Fontanet
9419cade3d feat(xo-server/rest-api): tags property can be updated 2023-11-23 16:30:03 +01:00
Julien Fontanet
272e6422bd chore(xapi/VM_import): typo snapshots → snapshot 2023-11-23 16:28:30 +01:00
Julien Fontanet
547908a8f9 chore(xo-server/proxy.checkHealth): call checkProxyHealth 2023-11-23 16:28:29 +01:00
Mathieu
8abfaa0bd5 feat(lite/VM): ability to export a VM (#7190) 2023-11-23 11:00:38 +01:00
121 changed files with 7961 additions and 2027 deletions

View File

@@ -68,6 +68,11 @@ module.exports = {
'no-console': ['error', { allow: ['warn', 'error'] }],
// this rule can prevent race condition bugs like parallel `a += await foo()`
//
// as it has a lots of false positive, it is only enabled as a warning for now
'require-atomic-updates': 'warn',
strict: 'error',
},
}

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.4",
"version": "0.1.5",
"engines": {
"node": ">=8.10"
},
@@ -23,7 +23,7 @@
"test": "node--test"
},
"dependencies": {
"@vates/multi-key-map": "^0.1.0",
"@vates/multi-key-map": "^0.2.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"ensure-array": "^1.0.0"

View File

@@ -22,7 +22,7 @@
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.6.1"
"vhd-lib": "^4.7.0"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -17,4 +17,14 @@ map.get(['foo', 'bar']) // 2
map.get(['bar', 'foo']) // 3
map.get([OBJ]) // 4
map.get([{}]) // undefined
map.delete([])
for (const [key, value] of map.entries() {
console.log(key, value)
}
for (const value of map.values()) {
console.log(value)
}
```

View File

@@ -35,6 +35,16 @@ map.get(['foo', 'bar']) // 2
map.get(['bar', 'foo']) // 3
map.get([OBJ]) // 4
map.get([{}]) // undefined
map.delete([])
for (const [key, value] of map.entries() {
console.log(key, value)
}
for (const value of map.values()) {
console.log(value)
}
```
## Contributions

View File

@@ -36,6 +36,23 @@ function del(node, i, keys) {
return node
}
function* entries(node, key) {
if (node !== undefined) {
if (node instanceof Node) {
const { value } = node
if (value !== undefined) {
yield [key, node.value]
}
for (const [childKey, child] of node.children.entries()) {
yield* entries(child, key.concat(childKey))
}
} else {
yield [key, node]
}
}
}
function get(node, i, keys) {
return i === keys.length
? node instanceof Node
@@ -69,6 +86,22 @@ function set(node, i, keys, value) {
return node
}
function* values(node) {
if (node !== undefined) {
if (node instanceof Node) {
const { value } = node
if (value !== undefined) {
yield node.value
}
for (const child of node.children.values()) {
yield* values(child)
}
} else {
yield node
}
}
}
exports.MultiKeyMap = class MultiKeyMap {
constructor() {
// each node is either a value or a Node if it contains children
@@ -79,6 +112,10 @@ exports.MultiKeyMap = class MultiKeyMap {
this._root = del(this._root, 0, keys)
}
entries() {
return entries(this._root, [])
}
get(keys) {
return get(this._root, 0, keys)
}
@@ -86,4 +123,8 @@ exports.MultiKeyMap = class MultiKeyMap {
set(keys, value) {
this._root = set(this._root, 0, keys, value)
}
values() {
return values(this._root)
}
}

View File

@@ -19,7 +19,7 @@ describe('MultiKeyMap', () => {
// reverse composite key
['bar', 'foo'],
]
const values = keys.map(() => ({}))
const values = keys.map(() => Math.random())
// set all values first to make sure they are all stored and not only the
// last one
@@ -27,6 +27,12 @@ describe('MultiKeyMap', () => {
map.set(key, values[i])
})
assert.deepEqual(
Array.from(map.entries()),
keys.map((key, i) => [key, values[i]])
)
assert.deepEqual(Array.from(map.values()), values)
keys.forEach((key, i) => {
// copy the key to make sure the array itself is not the key
assert.strictEqual(map.get(key.slice()), values[i])

View File

@@ -18,7 +18,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"version": "0.2.0",
"engines": {
"node": ">=8.10"
},

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "2.0.0",
"version": "2.0.1",
"engines": {
"node": ">=14.0"
},
@@ -24,7 +24,7 @@
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.3.6"
"xen-api": "^2.0.0"
},
"devDependencies": {
"tap": "^16.3.0",

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/backups": "^0.44.2",
"@xen-orchestra/fs": "^4.1.3",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.13",
"version": "1.0.14",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -4,7 +4,14 @@ import { formatFilenameDate } from './_filenameDate.mjs'
import { importIncrementalVm } from './_incrementalVm.mjs'
import { Task } from './Task.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { VhdNegative, VhdSynthetic } from 'vhd-lib'
import { decorateClass } from '@vates/decorate-with'
import { createLogger } from '@xen-orchestra/log'
import { dirname, join } from 'node:path'
import pickBy from 'lodash/pickBy.js'
import { defer } from 'golike-defer'
const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
async function resolveUuid(xapi, cache, uuid, type) {
if (uuid == null) {
return uuid
@@ -16,26 +23,199 @@ async function resolveUuid(xapi, cache, uuid, type) {
return cache.get(uuid)
}
export class ImportVmBackup {
constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses, mapVdisSrs = {} } = {} }) {
constructor({
adapter,
metadata,
srUuid,
xapi,
settings: { additionnalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false } = {},
}) {
this._adapter = adapter
this._importIncrementalVmSettings = { newMacAddresses, mapVdisSrs }
this._importIncrementalVmSettings = { additionnalVmTag, newMacAddresses, mapVdisSrs, useDifferentialRestore }
this._metadata = metadata
this._srUuid = srUuid
this._xapi = xapi
}
async #decorateIncrementalVmMetadata(backup) {
async #getPathOfVdiSnapshot(snapshotUuid) {
const metadata = this._metadata
if (this._pathToVdis === undefined) {
const backups = await this._adapter.listVmBackups(
this._metadata.vm.uuid,
({ mode, timestamp }) => mode === 'delta' && timestamp >= metadata.timestamp
)
const map = new Map()
for (const backup of backups) {
for (const [vdiRef, vdi] of Object.entries(backup.vdis)) {
map.set(vdi.uuid, backup.vhds[vdiRef])
}
}
this._pathToVdis = map
}
return this._pathToVdis.get(snapshotUuid)
}
async _reuseNearestSnapshot($defer, ignoredVdis) {
const metadata = this._metadata
const { mapVdisSrs } = this._importIncrementalVmSettings
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
const streams = {}
const metdataDir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
for (const [vdiRef, vdi] of Object.entries(vdis)) {
const vhdPath = join(metdataDir, vhds[vdiRef])
let xapiDisk
try {
xapiDisk = await this._xapi.getRecordByUuid('VDI', vdi.$snapshot_of$uuid)
} catch (err) {
// if this disk is not present anymore, fall back to default restore
warn(err)
}
let snapshotCandidate, backupCandidate
if (xapiDisk !== undefined) {
debug('found disks, wlll search its snapshots', { snapshots: xapiDisk.snapshots })
for (const snapshotRef of xapiDisk.snapshots) {
const snapshot = await this._xapi.getRecord('VDI', snapshotRef)
debug('handling snapshot', { snapshot })
// take only the first snapshot
if (snapshotCandidate && snapshotCandidate.snapshot_time < snapshot.snapshot_time) {
debug('already got a better candidate')
continue
}
// have a corresponding backup more recent than metadata ?
const pathToSnapshotData = await this.#getPathOfVdiSnapshot(snapshot.uuid)
if (pathToSnapshotData === undefined) {
debug('no backup linked to this snaphot')
continue
}
if (snapshot.$SR.uuid !== (mapVdisSrs[vdi.$snapshot_of$uuid] ?? this._srUuid)) {
debug('not restored on the same SR', { snapshotSr: snapshot.$SR.uuid, mapVdisSrs, srUuid: this._srUuid })
continue
}
debug('got a candidate', pathToSnapshotData)
snapshotCandidate = snapshot
backupCandidate = pathToSnapshotData
}
}
let stream
const backupWithSnapshotPath = join(metdataDir, backupCandidate ?? '')
if (vhdPath === backupWithSnapshotPath) {
// all the data are already on the host
debug('direct reuse of a snapshot')
stream = null
vdis[vdiRef].baseVdi = snapshotCandidate
// go next disk , we won't use this stream
continue
}
let disposableDescendants
const disposableSynthetic = await VhdSynthetic.fromVhdChain(this._adapter._handler, vhdPath)
// this will also clean if another disk of this VM backup fails
// if user really only need to restore non failing disks he can retry with ignoredVdis
let disposed = false
const disposeOnce = async () => {
if (!disposed) {
disposed = true
try {
await disposableDescendants?.dispose()
await disposableSynthetic?.dispose()
} catch (error) {
warn('openVhd: failed to dispose VHDs', { error })
}
}
}
$defer.onFailure(() => disposeOnce())
const parentVhd = disposableSynthetic.value
await parentVhd.readBlockAllocationTable()
debug('got vhd synthetic of parents', parentVhd.length)
if (snapshotCandidate !== undefined) {
try {
debug('will try to use differential restore', {
backupWithSnapshotPath,
vhdPath,
vdiRef,
})
disposableDescendants = await VhdSynthetic.fromVhdChain(this._adapter._handler, backupWithSnapshotPath, {
until: vhdPath,
})
const descendantsVhd = disposableDescendants.value
await descendantsVhd.readBlockAllocationTable()
debug('got vhd synthetic of descendants')
const negativeVhd = new VhdNegative(parentVhd, descendantsVhd)
debug('got vhd negative')
// update the stream with the negative vhd stream
stream = await negativeVhd.stream()
vdis[vdiRef].baseVdi = snapshotCandidate
} catch (err) {
// can be a broken VHD chain, a vhd chain with a key backup, ....
// not an irrecuperable error, don't dispose parentVhd, and fallback to full restore
warn(`can't use differential restore`, err)
disposableDescendants?.dispose()
}
}
// didn't make a negative stream : fallback to classic stream
if (stream === undefined) {
debug('use legacy restore')
stream = await parentVhd.stream()
}
stream.on('end', disposeOnce)
stream.on('close', disposeOnce)
stream.on('error', disposeOnce)
info('everything is ready, will transfer', stream.length)
streams[`${vdiRef}.vhd`] = stream
}
return {
streams,
vbds,
vdis,
version: '1.0.0',
vifs,
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
}
}
async #decorateIncrementalVmMetadata() {
const { additionnalVmTag, mapVdisSrs, useDifferentialRestore } = this._importIncrementalVmSettings
const ignoredVdis = new Set(
Object.entries(mapVdisSrs)
.filter(([_, srUuid]) => srUuid === null)
.map(([vdiUuid]) => vdiUuid)
)
let backup
if (useDifferentialRestore) {
backup = await this._reuseNearestSnapshot(ignoredVdis)
} else {
backup = await this._adapter.readIncrementalVmBackup(this._metadata, ignoredVdis)
}
const xapi = this._xapi
const cache = new Map()
const mapVdisSrRefs = {}
if (additionnalVmTag !== undefined) {
backup.vm.tags.push(additionnalVmTag)
}
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
}
const sr = await resolveUuid(xapi, cache, this._srUuid, 'SR')
const srRef = await resolveUuid(xapi, cache, this._srUuid, 'SR')
Object.values(backup.vdis).forEach(vdi => {
vdi.SR = mapVdisSrRefs[vdi.uuid] ?? sr.$ref
vdi.SR = mapVdisSrRefs[vdi.uuid] ?? srRef
})
return backup
}
@@ -46,7 +226,7 @@ export class ImportVmBackup {
const isFull = metadata.mode === 'full'
const sizeContainer = { size: 0 }
const { mapVdisSrs, newMacAddresses } = this._importIncrementalVmSettings
const { newMacAddresses } = this._importIncrementalVmSettings
let backup
if (isFull) {
backup = await adapter.readFullVmBackup(metadata)
@@ -54,12 +234,7 @@ export class ImportVmBackup {
} else {
assert.strictEqual(metadata.mode, 'delta')
const ignoredVdis = new Set(
Object.entries(mapVdisSrs)
.filter(([_, srUuid]) => srUuid === null)
.map(([vdiUuid]) => vdiUuid)
)
backup = await this.#decorateIncrementalVmMetadata(await adapter.readIncrementalVmBackup(metadata, ignoredVdis))
backup = await this.#decorateIncrementalVmMetadata()
Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer))
}
@@ -101,3 +276,5 @@ export class ImportVmBackup {
)
}
}
decorateClass(ImportVmBackup, { _reuseNearestSnapshot: defer })

View File

@@ -250,6 +250,10 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
// Import VDI contents.
cancelableMap(cancelToken, Object.entries(newVdis), async (cancelToken, [id, vdi]) => {
for (let stream of ensureArray(streams[`${id}.vhd`])) {
if (stream === null) {
// we restore a backup and reuse completly a local snapshot
continue
}
if (typeof stream === 'function') {
stream = await stream()
}

View File

@@ -96,6 +96,9 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
metadata,
srUuid,
xapi,
settings: {
additionnalVmTag: 'xo:no-bak=Health Check',
},
}).run()
const restoredVm = xapi.getObject(restoredId)
try {

View File

@@ -58,7 +58,7 @@ export const MixinXapiWriter = (BaseClass = Object) =>
)
}
const healthCheckVm = xapi.getObject(healthCheckVmRef) ?? (await xapi.waitObject(healthCheckVmRef))
await healthCheckVm.add_tag('xo:no-bak=Health Check')
await new HealthCheckVmBackup({
restoredVm: healthCheckVm,
xapi,

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.43.2",
"version": "0.44.2",
"engines": {
"node": ">=14.18"
},
@@ -23,12 +23,12 @@
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/disposable": "^0.1.5",
"@vates/fuse-vhd": "^2.0.0",
"@vates/nbd-client": "^2.0.0",
"@vates/nbd-client": "^2.0.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/fs": "^4.1.3",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"app-conf": "^2.3.0",
@@ -44,8 +44,8 @@
"proper-lockfile": "^4.1.2",
"tar": "^6.1.15",
"uuid": "^9.0.0",
"vhd-lib": "^4.6.1",
"xen-api": "^1.3.6",
"vhd-lib": "^4.7.0",
"xen-api": "^2.0.0",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -56,7 +56,7 @@
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^3.3.0"
"@xen-orchestra/xapi": "^4.0.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/cr-seed-cli",
"version": "0.2.0",
"version": "1.0.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/cr-seed-cli",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
@@ -18,7 +18,7 @@
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^1.3.6"
"xen-api": "^2.0.0"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.1.2",
"version": "4.1.3",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",

View File

@@ -33,7 +33,7 @@ import { pRetry } from 'promise-toolbox'
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PART_NUMBER = 10000
const MIN_PART_SIZE = 5 * 1024 * 1024
const { warn } = createLogger('xo:fs:s3')
const { debug, info, warn } = createLogger('xo:fs:s3')
export default class S3Handler extends RemoteHandlerAbstract {
#bucket
@@ -453,10 +453,18 @@ export default class S3Handler extends RemoteHandlerAbstract {
if (res.ObjectLockConfiguration?.ObjectLockEnabled === 'Enabled') {
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
// will automatically add the contentMD5 header to any upload to S3
debug(`Object Lock is enable, enable content md5 header`)
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
}
} catch (error) {
if (error.Code !== 'ObjectLockConfigurationNotFoundError' && error.$metadata.httpStatusCode !== 501) {
// maybe the account doesn't have enought privilege to query the object lock configuration
// be defensive and apply the md5 just in case
if (error.$metadata.httpStatusCode === 403) {
info(`s3 user doesnt have enough privilege to check for Object Lock, enable content MD5 header`)
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
} else if (error.Code === 'ObjectLockConfigurationNotFoundError' || error.$metadata.httpStatusCode === 501) {
info(`Object lock is not available or not configured, don't add the content MD5 header`)
} else {
throw error
}
}

View File

@@ -2,10 +2,17 @@
## **next**
- [VM/Action] Ability to migrate a VM from its view (PR [#7164](https://github.com/vatesfr/xen-orchestra/pull/7164))
- Ability to override host address with `master` URL query param (PR [#7187](https://github.com/vatesfr/xen-orchestra/pull/7187))
- Added tooltip on CPU provisioning warning icon (PR [#7223](https://github.com/vatesfr/xen-orchestra/pull/7223))
## **0.1.6** (2023-11-30)
- Explicit error if users attempt to connect from a slave host (PR [#7110](https://github.com/vatesfr/xen-orchestra/pull/7110))
- More compact UI (PR [#7159](https://github.com/vatesfr/xen-orchestra/pull/7159))
- Fix dashboard host patches list (PR [#7169](https://github.com/vatesfr/xen-orchestra/pull/7169))
- Ability to export selected VMs (PR [#7174](https://github.com/vatesfr/xen-orchestra/pull/7174))
- [VM/Action] Ability to export a VM from its view (PR [#7190](https://github.com/vatesfr/xen-orchestra/pull/7190))
## **0.1.5** (2023-11-07)

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.1.5",
"version": "0.1.6",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
@@ -10,57 +10,55 @@
"test": "yarn run type-check",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"devDependencies": {
"@fontsource/poppins": "^5.0.8",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/vue-fontawesome": "^3.0.1",
"@novnc/novnc": "^1.3.0",
"@types/d3-time-format": "^4.0.0",
"@types/file-saver": "^2.0.5",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.8",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@intlify/unplugin-vue-i18n": "^1.5.0",
"@limegrass/eslint-plugin-import-alias": "^1.1.0",
"@novnc/novnc": "^1.4.0",
"@rushstack/eslint-patch": "^1.5.1",
"@tsconfig/node18": "^18.2.2",
"@types/d3-time-format": "^4.0.3",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.11",
"@types/node": "^18.18.9",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.4.0",
"@vueuse/core": "^10.5.0",
"@vueuse/math": "^10.5.0",
"complex-matcher": "^0.7.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"echarts": "^5.4.3",
"eslint-plugin-vue": "^9.18.1",
"file-saver": "^2.0.5",
"highlight.js": "^11.6.0",
"human-format": "^1.1.0",
"highlight.js": "^11.9.0",
"human-format": "^1.2.0",
"iterable-backoff": "^0.1.0",
"json-rpc-2.0": "^1.3.0",
"json5": "^2.2.1",
"json-rpc-2.0": "^1.7.0",
"json5": "^2.2.3",
"limit-concurrency-decorator": "^0.5.0",
"lodash-es": "^4.17.21",
"make-error": "^1.3.6",
"marked": "^4.2.12",
"pinia": "^2.1.2",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.3.4",
"vue-echarts": "^6.2.3",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.1"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^0.10.0",
"@limegrass/eslint-plugin-import-alias": "^1.0.5",
"@rushstack/eslint-patch": "^1.1.0",
"@types/node": "^16.11.41",
"@vitejs/plugin-vue": "^4.2.3",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/tsconfig": "^0.1.3",
"eslint-plugin-vue": "^9.0.0",
"marked": "^9.1.5",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.19",
"postcss-custom-media": "^9.0.1",
"postcss-nested": "^6.0.0",
"typescript": "^4.9.3",
"vite": "^4.3.8",
"vue-tsc": "^1.6.5"
"pinia": "^2.1.7",
"placement.js": "^1.0.0-beta.5",
"postcss": "^8.4.31",
"postcss-custom-media": "^10.0.2",
"postcss-nested": "^6.0.1",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vue": "^3.3.8",
"vue-echarts": "^6.6.1",
"vue-i18n": "^9.6.5",
"vue-router": "^4.2.5",
"vue-tsc": "^1.8.22"
},
"private": true,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/lite",
@@ -76,6 +74,6 @@
},
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=8.10"
"node": ">=18"
}
}

View File

@@ -12,6 +12,7 @@
</RouterLink>
<slot />
<div class="right">
<PoolOverrideWarning as-tooltip />
<AccountButton />
</div>
</header>
@@ -19,6 +20,7 @@
<script lang="ts" setup>
import AccountButton from "@/components/AccountButton.vue";
import PoolOverrideWarning from "@/components/PoolOverrideWarning.vue";
import TextLogo from "@/components/TextLogo.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
@@ -51,6 +53,10 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
margin-left: 1rem;
vertical-align: middle;
}
.warning-not-current-pool {
font-size: 2.4rem;
}
}
.right {

View File

@@ -2,6 +2,7 @@
<div class="app-login form-container">
<form @submit.prevent="handleSubmit">
<img alt="XO Lite" src="../assets/logo-title.svg" />
<PoolOverrideWarning />
<p v-if="isHostIsSlaveErr(error)" class="error">
<UiIcon :icon="faExclamationCircle" />
{{ $t("login-only-on-master") }}
@@ -45,6 +46,7 @@ import FormCheckbox from "@/components/form/FormCheckbox.vue";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import LoginError from "@/components/LoginError.vue";
import PoolOverrideWarning from "@/components/PoolOverrideWarning.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { XenApiError } from "@/libs/xen-api/xen-api.types";

View File

@@ -0,0 +1,59 @@
<template>
<div
v-if="xenApi.isPoolOverridden"
class="warning-not-current-pool"
@click="xenApi.resetPoolMasterIp"
v-tooltip="
asTooltip && {
placement: 'right',
content: `
${$t('you-are-currently-on', [masterSessionStorage])}.
${$t('click-to-return-default-pool')}
`,
}
"
>
<div class="wrapper">
<UiIcon :icon="faWarning" />
<p v-if="!asTooltip">
<i18n-t keypath="you-are-currently-on">
<strong>{{ masterSessionStorage }}</strong>
</i18n-t>
<br />
{{ $t("click-to-return-default-pool") }}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { useSessionStorage } from "@vueuse/core";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
import { vTooltip } from "@/directives/tooltip.directive";
defineProps<{
asTooltip?: boolean;
}>();
const xenApi = useXenApiStore();
const masterSessionStorage = useSessionStorage("master", null);
</script>
<style lang="postcss" scoped>
.warning-not-current-pool {
color: var(--color-orange-world-base);
cursor: pointer;
.wrapper {
display: flex;
justify-content: center;
svg {
margin: auto 1rem;
}
}
}
</style>

View File

@@ -3,8 +3,14 @@
<UiCardTitle>
{{ $t("cpu-provisioning") }}
<template v-if="!hasError" #right>
<!-- TODO: add a tooltip for the warning icon -->
<UiStatusIcon v-if="state !== 'success'" :state="state" />
<UiStatusIcon
v-if="state !== 'success'"
v-tooltip="{
content: $t('cpu-provisioning-warning'),
placement: 'left',
}"
:state="state"
/>
</template>
</UiCardTitle>
<NoDataError v-if="hasError" />
@@ -37,11 +43,12 @@ import UiCard from "@/components/ui/UiCard.vue";
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { useVmMetricsCollection } from "@/stores/xen-api/vm-metrics.store";
import { vTooltip } from "@/directives/tooltip.directive";
import { percent } from "@/libs/utils";
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useVmMetricsCollection } from "@/stores/xen-api/vm-metrics.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { logicAnd } from "@vueuse/math";
import { computed } from "vue";

View File

@@ -11,7 +11,7 @@ import { useContext } from "@/composables/context.composable";
import { ColorContext, DisabledContext } from "@/context";
import type { Color } from "@/types";
import { IK_MODAL } from "@/types/injection-keys";
import { useMagicKeys, whenever } from "@vueuse/core/index";
import { useMagicKeys, whenever } from "@vueuse/core";
import { inject } from "vue";
const props = defineProps<{

View File

@@ -3,13 +3,13 @@
v-tooltip="
vmRefs.length > 0 &&
!isSomeExportable &&
$t('no-selected-vm-can-be-exported')
$t(isSingleAction ? 'vm-is-running' : 'no-selected-vm-can-be-exported')
"
:icon="faDisplay"
:disabled="isDisabled"
@click="openModal"
>
{{ $t("export-vms") }}
{{ $t(isSingleAction ? "export-vm" : "export-vms") }}
</MenuItem>
</template>
<script lang="ts" setup>
@@ -26,7 +26,10 @@ import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
const props = defineProps<{ vmRefs: XenApiVm["$ref"][] }>();
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
isSingleAction?: boolean;
}>();
const { getByOpaqueRefs, areSomeOperationAllowed } = useVmCollection();

View File

@@ -3,7 +3,11 @@
v-tooltip="
selectedRefs.length > 0 &&
!isMigratable &&
$t('no-selected-vm-can-be-migrated')
$t(
isSingleAction
? 'this-vm-cant-be-migrated'
: 'no-selected-vm-can-be-migrated'
)
"
:busy="isMigrating"
:disabled="isParentDisabled || !isMigratable"
@@ -28,6 +32,7 @@ import { computed } from "vue";
const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
isSingleAction?: boolean;
}>();
const { getByOpaqueRefs, isOperationPending, areSomeOperationAllowed } =

View File

@@ -26,7 +26,9 @@
/>
</template>
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
<VmActionExportItem :vm-refs="[vm.$ref]" is-single-action />
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
<VmActionMigrateItem :selected-refs="[vm.$ref]" is-single-action />
</AppMenu>
</template>
</TitleBar>
@@ -37,9 +39,11 @@ import AppMenu from "@/components/menu/AppMenu.vue";
import TitleBar from "@/components/TitleBar.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import VmActionMigrateItem from "@/components/vm/VmActionItems/VmActionMigrateItem.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";

View File

@@ -1,4 +1,4 @@
import type { HighlightResult, Language } from "highlight.js";
import type { HighlightResult } from "highlight.js";
import HLJS from "highlight.js/lib/core";
import cssLang from "highlight.js/lib/languages/css";
import jsonLang from "highlight.js/lib/languages/json";
@@ -19,10 +19,6 @@ export const highlight: (
ignoreIllegals?: boolean
) => HighlightResult = HLJS.highlight;
export const getLanguage: (
languageName: AcceptedLanguage
) => Language | undefined = HLJS.getLanguage;
export type AcceptedLanguage =
| "xml"
| "css"

View File

@@ -1,8 +1,5 @@
import {
type AcceptedLanguage,
getLanguage,
highlight,
} from "@/libs/highlight";
import { type AcceptedLanguage, highlight } from "@/libs/highlight";
import HLJS from "highlight.js/lib/core";
import { marked } from "marked";
enum VUE_TAG {
@@ -11,15 +8,26 @@ enum VUE_TAG {
STYLE = "vue-style",
}
function extractLang(lang: string | undefined): AcceptedLanguage | VUE_TAG {
if (lang === undefined) {
return "plaintext";
}
if (Object.values(VUE_TAG).includes(lang as VUE_TAG)) {
return lang as VUE_TAG;
}
if (HLJS.getLanguage(lang) !== undefined) {
return lang as AcceptedLanguage;
}
return "plaintext";
}
marked.use({
renderer: {
code(str: string, lang: AcceptedLanguage) {
const code = customHighlight(
str,
Object.values(VUE_TAG).includes(lang as VUE_TAG) || getLanguage(lang)
? lang
: "plaintext"
);
code(str, lang) {
const code = customHighlight(str, extractLang(lang));
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
},
},

View File

@@ -27,6 +27,7 @@
"cancel": "Cancel",
"change-state": "Change state",
"click-to-display-alarms": "Click to display alarms:",
"click-to-return-default-pool": "Click here to return to the default pool",
"close": "Close",
"coming-soon": "Coming soon!",
"community": "Community",
@@ -37,6 +38,7 @@
"console-unavailable": "Console unavailable",
"copy": "Copy",
"cpu-provisioning": "CPU provisioning",
"cpu-provisioning-warning": "The number of vCPUs allocated exceeds the number of physical CPUs available. System performance could be affected",
"cpu-usage": "CPU usage",
"dashboard": "Dashboard",
"delete": "Delete",
@@ -55,6 +57,7 @@
"export-n-vms": "Export 1 VM | Export {n} VMs",
"export-n-vms-manually": "Export 1 VM manually | Export {n} VMs manually",
"export-table-to": "Export table to {type}",
"export-vm": "Export VM",
"export-vms": "Export VMs",
"export-vms-manually-information": "Some VM exports were not able to start automatically, probably due to your browser settings. To export them, you should click on each one. (Alternatively, copy the link as well.)",
"fetching-fresh-data": "Fetching fresh data",
@@ -176,6 +179,7 @@
"theme-auto": "Auto",
"theme-dark": "Dark",
"theme-light": "Light",
"this-vm-cant-be-migrated": "This VM can't be migrated",
"top-#": "Top {n}",
"total-cpus": "Total CPUs",
"total-free": "Total free",
@@ -188,5 +192,6 @@
"vm-is-running": "The VM is running",
"vms": "VMs",
"xo-lite-under-construction": "XOLite is under construction",
"you-are-currently-on": "You are currently on: {0}",
"zstd": "zstd"
}

View File

@@ -27,6 +27,7 @@
"cancel": "Annuler",
"change-state": "Changer l'état",
"click-to-display-alarms": "Cliquer pour afficher les alarmes :",
"click-to-return-default-pool": "Cliquer ici pour revenir au pool par défaut",
"close": "Fermer",
"coming-soon": "Bientôt disponible !",
"community": "Communauté",
@@ -37,6 +38,7 @@
"console-unavailable": "Console indisponible",
"copy": "Copier",
"cpu-provisioning": "Provisionnement CPU",
"cpu-provisioning-warning": "Le nombre de vCPU alloués dépasse le nombre de CPU physique disponible. Les performances du système pourraient être affectées",
"cpu-usage": "Utilisation CPU",
"dashboard": "Tableau de bord",
"delete": "Supprimer",
@@ -55,6 +57,7 @@
"export-n-vms": "Exporter 1 VM | Exporter {n} VMs",
"export-n-vms-manually": "Exporter 1 VM manuellement | Exporter {n} VMs manuellement",
"export-table-to": "Exporter le tableau en {type}",
"export-vm": "Exporter la VM",
"export-vms": "Exporter les VMs",
"export-vms-manually-information": "Certaines exportations de VMs n'ont pas pu démarrer automatiquement, peut-être en raison des paramètres du navigateur. Pour les exporter, vous devrez cliquer sur chacune d'entre elles. (Ou copier le lien.)",
"fetching-fresh-data": "Récupération de données à jour",
@@ -176,6 +179,7 @@
"theme-auto": "Auto",
"theme-dark": "Sombre",
"theme-light": "Clair",
"this-vm-cant-be-migrated": "Cette VM ne peut pas être migrée",
"top-#": "Top {n}",
"total-cpus": "Total CPUs",
"total-free": "Total libre",
@@ -188,5 +192,6 @@
"vm-is-running": "La VM est en cours d'exécution",
"vms": "VMs",
"xo-lite-under-construction": "XOLite est en construction",
"you-are-currently-on": "Vous êtes actuellement sur : {0}",
"zstd": "zstd"
}

View File

@@ -1,8 +1,10 @@
import XapiStats from "@/libs/xapi-stats";
import XenApi from "@/libs/xen-api/xen-api";
import { useLocalStorage } from "@vueuse/core";
import { useLocalStorage, useSessionStorage, whenever } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref, watchEffect } from "vue";
import { useRouter } from "vue-router";
import { useRoute } from "vue-router";
const HOST_URL = import.meta.env.PROD
? window.origin
@@ -15,7 +17,27 @@ enum STATUS {
}
export const useXenApiStore = defineStore("xen-api", () => {
const xenApi = new XenApi(HOST_URL);
// undefined not correctly handled. See https://github.com/vueuse/vueuse/issues/3595
const masterSessionStorage = useSessionStorage<null | string>("master", null);
const router = useRouter();
const route = useRoute();
whenever(
() => route.query.master,
async (newMaster) => {
masterSessionStorage.value = newMaster as string;
await router.replace({ query: { ...route.query, master: undefined } });
window.location.reload();
}
);
const hostUrl = new URL(HOST_URL);
if (masterSessionStorage.value !== null) {
hostUrl.hostname = masterSessionStorage.value;
}
const isPoolOverridden = hostUrl.origin !== new URL(HOST_URL).origin;
const xenApi = new XenApi(hostUrl.origin);
const xapiStats = new XapiStats(xenApi);
const storedSessionId = useLocalStorage<string | undefined>(
"sessionId",
@@ -75,14 +97,21 @@ export const useXenApiStore = defineStore("xen-api", () => {
status.value = STATUS.DISCONNECTED;
}
function resetPoolMasterIp() {
masterSessionStorage.value = null;
window.location.reload();
}
return {
isConnected,
isConnecting,
isPoolOverridden,
connect,
reconnect,
disconnect,
getXapi,
getXapiStats,
currentSessionId,
resetPoolMasterIp,
};
});

View File

@@ -135,23 +135,15 @@
</UiCard>
<UiCard class="group">
<UiCardTitle>{{ $t("language") }}</UiCardTitle>
<UiKeyValueList>
<UiKeyValueRow>
<template #value>
<FormWidget class="full-length" :before="faEarthAmericas">
<select v-model="$i18n.locale">
<option
:value="locale"
v-for="locale in $i18n.availableLocales"
:key="locale"
>
{{ locales[locale].name ?? locale }}
</option>
</select>
</FormWidget>
</template>
</UiKeyValueRow>
</UiKeyValueList>
<FormSelect :before="faEarthAmericas" v-model="$i18n.locale">
<option
:value="locale"
v-for="locale in $i18n.availableLocales"
:key="locale"
>
{{ locales[locale].name ?? locale }}
</option>
</FormSelect>
</UiCard>
</div>
</template>
@@ -174,7 +166,7 @@ import {
faGear,
faCheck,
} from "@fortawesome/free-solid-svg-icons";
import FormWidget from "@/components/FormWidget.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import TitleBar from "@/components/TitleBar.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiKeyValueList from "@/components/ui/UiKeyValueList.vue";
@@ -249,8 +241,4 @@ h5 {
}
}
}
.full-length {
width: 100%;
}
</style>

View File

@@ -1,5 +1,5 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"extends": ["@tsconfig/node18/tsconfig.json", "@vue/tsconfig/tsconfig.json"],
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
"compilerOptions": {
"composite": true,

View File

@@ -1,10 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/stories/**/*"],
"compilerOptions": {
"experimentalDecorators": true,
"lib": ["ES2019", "ES2020.Intl", "dom"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.26.38",
"version": "0.26.41",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -30,15 +30,15 @@
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/disposable": "^0.1.5",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/backups": "^0.44.2",
"@xen-orchestra/fs": "^4.1.3",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.14.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/xapi": "^3.3.0",
"@xen-orchestra/xapi": "^4.0.0",
"ajv": "^8.0.3",
"app-conf": "^2.3.0",
"async-iterator-to-stream": "^1.1.0",
@@ -60,7 +60,7 @@
"source-map-support": "^0.5.16",
"stoppable": "^1.0.6",
"xdg-basedir": "^5.1.0",
"xen-api": "^1.3.6",
"xen-api": "^2.0.0",
"xo-common": "^0.8.0"
},
"devDependencies": {

View File

@@ -43,7 +43,7 @@
"pw": "^0.0.4",
"xdg-basedir": "^4.0.0",
"xo-lib": "^0.11.1",
"xo-vmdk-to-vhd": "^2.5.6"
"xo-vmdk-to-vhd": "^2.5.7"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -1,7 +1,7 @@
{
"license": "ISC",
"private": false,
"version": "0.3.0",
"version": "0.3.1",
"name": "@xen-orchestra/vmware-explorer",
"dependencies": {
"@vates/node-vsphere-soap": "^2.0.0",
@@ -10,7 +10,7 @@
"@xen-orchestra/log": "^0.6.0",
"lodash": "^4.17.21",
"node-fetch": "^3.3.0",
"vhd-lib": "^4.6.1"
"vhd-lib": "^4.7.0"
},
"engines": {
"node": ">=14"

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/xapi",
"version": "3.3.0",
"version": "4.0.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
@@ -16,7 +16,7 @@
},
"main": "./index.mjs",
"peerDependencies": {
"xen-api": "^1.3.6"
"xen-api": "^2.0.0"
},
"scripts": {
"postversion": "npm publish --access public",
@@ -25,7 +25,7 @@
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/decorate-with": "^2.0.0",
"@vates/nbd-client": "^2.0.0",
"@vates/nbd-client": "^2.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"d3-time-format": "^4.1.0",
@@ -34,7 +34,7 @@
"json-rpc-protocol": "^0.13.2",
"lodash": "^4.17.15",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.6.1",
"vhd-lib": "^4.7.0",
"xo-common": "^0.8.0"
},
"private": false,

View File

@@ -491,7 +491,7 @@ class Vm {
exportedVmRef = await this.VM_snapshot(vmRef, { cancelToken, name_label: `[XO Export] ${vm.name_label}` })
destroySnapshot = () =>
this.VM_destroy(exportedVmRef).catch(error => {
warn('VM_export: failed to destroy snapshots', {
warn('VM_export: failed to destroy snapshot', {
error,
snapshotRef: exportedVmRef,
vmRef,

View File

@@ -1,7 +1,61 @@
# ChangeLog
## **5.89.0** (2023-11-30)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Highlights
- [Restore] Show source remote and restoration time on a restored VM (PR [#7186](https://github.com/vatesfr/xen-orchestra/pull/7186))
- [Backup/Import] Show disk import status during Incremental Replication or restoration of Incremental Backup (PR [#7171](https://github.com/vatesfr/xen-orchestra/pull/7171))
- [VM/Console] Add a message to indicate that the console view has been [disabled](https://support.citrix.com/article/CTX217766/how-to-disable-the-console-for-the-vm-in-xencenter) for this VM [#6319](https://github.com/vatesfr/xen-orchestra/issues/6319) (PR [#7161](https://github.com/vatesfr/xen-orchestra/pull/7161))
- [REST API] `tags` property can be updated (PR [#7196](https://github.com/vatesfr/xen-orchestra/pull/7196))
- [REST API] A VDI export can now be imported in an existing VDI (PR [#7199](https://github.com/vatesfr/xen-orchestra/pull/7199))
- [REST API] Support VM import using the XVA format
- [File Restore] API method `backupNg.mountPartition` to manually mount a backup disk on the XOA
- [Backup] Implement differential restore (PR [#7202](https://github.com/vatesfr/xen-orchestra/pull/7202))
- [VM/Disks] Display task information when importing VDIs (PR [#7197](https://github.com/vatesfr/xen-orchestra/pull/7197))
- [VM Creation] Added ISO option in new VM form when creating from template with a disk [#3464](https://github.com/vatesfr/xen-orchestra/issues/3464) (PR [#7166](https://github.com/vatesfr/xen-orchestra/pull/7166))
- [Task] Show the related SR on the Garbage Collector Task ( vdi coalescing) (PR [#7189](https://github.com/vatesfr/xen-orchestra/pull/7189))
### Enhancements
- [Netbox] Ability to synchronize XO users as Netbox tenants (PR [#7158](https://github.com/vatesfr/xen-orchestra/pull/7158))
- [Backup] Don't backup VM with tag xo:no-bak (PR [#7173](https://github.com/vatesfr/xen-orchestra/pull/7173))
### Bug fixes
- [Backup/Restore] In case of snapshot with memory, create the suspend VDI on the correct SR instead of the default one
- [Import/ESXi] Handle `Cannot read properties of undefined (reading 'perDatastoreUsage')` error when importing VM without storage (PR [#7168](https://github.com/vatesfr/xen-orchestra/pull/7168))
- [Export/OVA] Handle export with resulting disk larger than 8.2GB (PR [#7183](https://github.com/vatesfr/xen-orchestra/pull/7183))
- [Self Service] Fix error displayed after adding a VM to a resource set (PR [#7144](https://github.com/vatesfr/xen-orchestra/pull/7144))
- [Backup/HealthCheck] Don't backup VM created by health check when using smart mode (PR [#7173](https://github.com/vatesfr/xen-orchestra/pull/7173))
### Released packages
- vhd-lib 4.7.0
- @vates/multi-key-map 0.2.0
- @vates/disposable 0.1.5
- @xen-orchestra/fs 4.1.3
- xen-api 2.0.0
- @vates/nbd-client 2.0.1
- @xen-orchestra/xapi 4.0.0
- @xen-orchestra/backups 0.44.2
- @xen-orchestra/backups-cli 1.0.14
- @xen-orchestra/cr-seed-cli 1.0.0
- @xen-orchestra/proxy 0.26.41
- xo-vmdk-to-vhd 2.5.7
- @xen-orchestra/vmware-explorer 0.3.1
- xapi-explore-sr 0.4.2
- xo-cli 0.22.0
- xo-server 5.129.0
- xo-server-netbox 1.4.0
- xo-web 5.130.0
## **5.88.2** (2023-11-13)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Enhancement
- [REST API] Add `users` collection
@@ -13,8 +67,6 @@
## **5.88.1** (2023-11-07)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Bug fixes
- [Netbox] Fix VMs' `site` property being unnecessarily updated on some versions of Netbox (PR [#7145](https://github.com/vatesfr/xen-orchestra/pull/7145))
@@ -83,8 +135,6 @@
## **5.87.0** (2023-09-29)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Highlights
- [Patches] Support new XenServer Updates system. See [our documentation](https://xen-orchestra.com/docs/updater.html#xenserver-updates). (PR [#7044](https://github.com/vatesfr/xen-orchestra/pull/7044))

View File

@@ -7,20 +7,13 @@
> Users must be able to say: “Nice enhancement, I'm eager to test it”
- [Netbox] Ability to synchronize XO users as Netbox tenants (PR [#7158](https://github.com/vatesfr/xen-orchestra/pull/7158))
- [VM/Console] Add a message to indicate that the console view has been [disabled](https://support.citrix.com/article/CTX217766/how-to-disable-the-console-for-the-vm-in-xencenter) for this VM [#6319](https://github.com/vatesfr/xen-orchestra/issues/6319) (PR [#7161](https://github.com/vatesfr/xen-orchestra/pull/7161))
- [Restore] Show source remote and restoration time on a restored VM (PR [#7186](https://github.com/vatesfr/xen-orchestra/pull/7186))
- [Backup/Import] Show disk import status during Incremental Replication or restoration of Incremental Backup (PR [#7171](https://github.com/vatesfr/xen-orchestra/pull/7171))
- [VM Creation] Added ISO option in new VM form when creating from template with a disk [#3464](https://github.com/vatesfr/xen-orchestra/issues/3464) (PR [#7166](https://github.com/vatesfr/xen-orchestra/pull/7166))
- [Forget SR] Changed the modal message and added a confirmation text to be sure the action is understood by the user [#7148](https://github.com/vatesfr/xen-orchestra/issues/7148) (PR [#7155](https://github.com/vatesfr/xen-orchestra/pull/7155))
### Bug fixes
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [Backup/Restore] In case of snapshot with memory, create the suspend VDI on the correct SR instead of the default one
- [Import/ESXi] Handle `Cannot read properties of undefined (reading 'perDatastoreUsage')` error when importing VM without storage (PR [#7168](https://github.com/vatesfr/xen-orchestra/pull/7168))
- [Export/OVA] Handle export with resulting disk larger than 8.2GB (PR [#7183](https://github.com/vatesfr/xen-orchestra/pull/7183))
- [Self Service] Fix error displayed after adding a VM to a resource set (PR [#7144](https://github.com/vatesfr/xen-orchestra/pull/7144))
- [Remotes] Prevents the "connection failed" alert from continuing to appear after successfull connection
### Packages to release
@@ -38,14 +31,6 @@
<!--packages-start-->
- @vates/nbd-client patch
- @xen-orchestra/backups minor
- @xen-orchestra/cr-seed-cli major
- @xen-orchestra/vmware-explorer patch
- xen-api major
- xo-server patch
- xo-server-netbox minor
- xo-vmdk-to-vhd patch
- xo-web minor
- xo-remote-parser patch
<!--packages-end-->

View File

@@ -106,13 +106,13 @@ XO needs the following packages to be installed. Redis is used as a database by
For example, on Debian/Ubuntu:
```sh
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils nfs-common
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils nfs-common ntfs-3g
```
On Fedora/CentOS like:
```sh
dnf install redis libpng-devel git libvhdi-tools lvm2 cifs-utils make automake gcc gcc-c++
dnf install redis libpng-devel git lvm2 cifs-utils make automake gcc gcc-c++ nfs-utils ntfs-3g
```
### Make sure Redis is running

View File

@@ -123,7 +123,7 @@ Content-Type: application/x-ndjson
## Properties update
> This feature is restricted to `name_label` and `name_description` at the moment.
> This feature is restricted to `name_label`, `name_description` and `tags` at the moment.
```sh
curl \
@@ -135,6 +135,30 @@ curl \
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac'
```
### Collections
For collection properties, like `tags`, it can be more practical to touch a single item without impacting the others.
An item can be created with `PUT <collection>/<item id>` and can be destroyed with `DELETE <collection>/<item id>`.
Adding a tag:
```sh
curl \
-X PUT \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/tags/My%20tag'
```
Removing a tag:
```sh
curl \
-X DELETE \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/tags/My%20tag'
```
## VM and VDI destruction
For a VM:
@@ -175,9 +199,45 @@ curl \
> myDisk.vhd
```
## VM Import
A VM can be imported by posting to `/rest/v0/pools/:id/vms`.
```sh
curl \
-X POST \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
-T myDisk.raw \
'https://xo.example.org/rest/v0/pools/355ee47d-ff4c-4924-3db2-fd86ae629676/vms?sr=357bd56c-71f9-4b2a-83b8-3451dec04b8f' \
| cat
```
The `sr` query parameter can be used to specify on which SR the VM should be imported, if not specified, the default SR will be used.
> Note: the final `| cat` ensures cURL's standard output is not a TTY, which is necessary for upload stats to be dislayed.
## VDI Import
A VHD or a raw export can be imported on an SR to create a new VDI at `/rest/v0/srs/<sr uuid>/vdis`.
### Existing VDI
A VHD or a raw export can be imported in an existing VDI respectively at `/rest/v0/vdis/<uuid>.vhd` and `/rest/v0/vdis/<uuid>.raw`.
> Note: the size of the VDI must match exactly the size of VDI that was previously exported.
```sh
curl \
-X PUT \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
-T myDisk.vhd \
'https://xo.example.org/rest/v0/vdis/1a269782-ea93-4c4c-897a-475365f7b674.vhd' \
| cat
```
> Note: the final `| cat` ensures cURL's standard output is not a TTY, which is necessary for upload stats to be dislayed.
### New VDI
An export can also be imported on an SR to create a new VDI at `/rest/v0/srs/<sr uuid>/vdis`.
```sh
curl \

View File

@@ -23,7 +23,7 @@
"node": ">=10"
},
"dependencies": {
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/fs": "^4.1.3",
"cli-progress": "^3.1.0",
"exec-promise": "^0.7.0",
"getopts": "^2.2.3",
@@ -31,7 +31,7 @@
"lodash": "^4.17.21",
"promise-toolbox": "^0.21.0",
"uuid": "^9.0.0",
"vhd-lib": "^4.6.1"
"vhd-lib": "^4.7.0"
},
"scripts": {
"postversion": "npm publish",

View File

@@ -0,0 +1,184 @@
'use strict'
const { VhdAbstract, VhdNegative } = require('..')
const { describe, it } = require('test')
const assert = require('assert/strict')
const { unpackHeader, unpackFooter } = require('./_utils')
const { createHeader, createFooter } = require('../_createFooterHeader')
const _computeGeometryForSize = require('../_computeGeometryForSize')
const { FOOTER_SIZE, DISK_TYPES } = require('../_constants')
const VHD_BLOCK_LENGTH = 2 * 1024 * 1024
class VhdMock extends VhdAbstract {
#blockUsed
#header
#footer
get header() {
return this.#header
}
get footer() {
return this.#footer
}
constructor(header, footer, blockUsed = new Set()) {
super()
this.#header = header
this.#footer = footer
this.#blockUsed = blockUsed
}
containsBlock(blockId) {
return this.#blockUsed.has(blockId)
}
readBlock(blockId, onlyBitmap = false) {
const bitmap = Buffer.alloc(512, 255) // bitmap are full of bit 1
const data = Buffer.alloc(2 * 1024 * 1024, 0) // empty are full of bit 0
data.writeUint8(blockId)
return {
id: blockId,
bitmap,
data,
buffer: Buffer.concat([bitmap, data]),
}
}
readBlockAllocationTable() {}
readHeaderAndFooter() {}
_readParentLocatorData(id) {}
}
describe('vhd negative', async () => {
it(`throws when uid aren't chained `, () => {
const length = 10e8
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
const geometry = _computeGeometryForSize(length)
let footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const parent = new VhdMock(header, footer)
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const child = new VhdMock(header, footer)
assert.throws(() => new VhdNegative(parent, child), { message: 'NOT_CHAINED' })
})
it('throws when size changed', () => {
const childLength = 10e8
const parentLength = 10e8 + 1
let header = unpackHeader(createHeader(parentLength / VHD_BLOCK_LENGTH))
let geometry = _computeGeometryForSize(parentLength)
let footer = unpackFooter(
createFooter(parentLength, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const parent = new VhdMock(header, footer)
header = unpackHeader(createHeader(childLength / VHD_BLOCK_LENGTH))
geometry = _computeGeometryForSize(childLength)
header.parentUuid = footer.uuid
footer = unpackFooter(
createFooter(childLength, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const child = new VhdMock(header, footer)
assert.throws(() => new VhdNegative(parent, child), { message: 'GEOMETRY_CHANGED' })
})
it('throws when child is not differencing', () => {
const length = 10e8
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
const geometry = _computeGeometryForSize(length)
let footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const parent = new VhdMock(header, footer)
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
header.parentUuid = footer.uuid
footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
)
const child = new VhdMock(header, footer)
assert.throws(() => new VhdNegative(parent, child), { message: 'CHILD_NOT_DIFFERENCING' })
})
it(`throws when writing into vhd negative `, async () => {
const length = 10e8
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
const geometry = _computeGeometryForSize(length)
let footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const parent = new VhdMock(header, footer)
const parentUuid = footer.uuid
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
header.parentUuid = parentUuid
footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const child = new VhdMock(header, footer)
const vhd = new VhdNegative(parent, child)
// await assert.rejects( ()=> vhd.writeFooter())
assert.throws(() => vhd.writeHeader())
assert.throws(() => vhd.writeBlockAllocationTable())
assert.throws(() => vhd.writeEntireBlock())
assert.throws(() => vhd.mergeBlock(), { message: `can't coalesce block into a vhd negative` })
})
it('normal case', async () => {
const length = 10e8
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
let geometry = _computeGeometryForSize(length)
let footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
)
const parent = new VhdMock(header, footer, new Set([1, 3]))
const parentUuid = footer.uuid
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
header.parentUuid = parentUuid
geometry = _computeGeometryForSize(length)
footer = unpackFooter(
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
)
const childUuid = footer.uuid
const child = new VhdMock(header, footer, new Set([2, 3]))
const vhd = new VhdNegative(parent, child)
assert.equal(vhd.header.parentUuid.equals(childUuid), true)
assert.equal(vhd.footer.diskType, DISK_TYPES.DIFFERENCING)
await vhd.readBlockAllocationTable()
await vhd.readHeaderAndFooter()
await vhd.readParentLocator(0)
assert.equal(vhd.header.parentUuid, childUuid)
assert.equal(vhd.footer.diskType, DISK_TYPES.DIFFERENCING)
assert.equal(vhd.containsBlock(1), false)
assert.equal(vhd.containsBlock(2), true)
assert.equal(vhd.containsBlock(3), true)
assert.equal(vhd.containsBlock(4), false)
const expected = [0, 1, 0, 3, 0]
const expectedBitmap = Buffer.alloc(512, 255) // bitmap must always be full of bit 1
for (let index = 0; index < 5; index++) {
if (vhd.containsBlock(index)) {
const { id, data, bitmap } = await vhd.readBlock(index)
assert.equal(index, id)
assert.equal(expectedBitmap.equals(bitmap), true)
assert.equal(data.readUInt8(0), expected[index])
} else {
assert.equal([2, 3].includes(index), false)
}
}
})
})

View File

@@ -0,0 +1,84 @@
'use strict'
const UUID = require('uuid')
const { DISK_TYPES } = require('../_constants')
const { VhdAbstract } = require('./VhdAbstract')
const { computeBlockBitmapSize } = require('./_utils')
const assert = require('node:assert')
/**
* Build an incremental VHD which can be applied to a child to revert to the state of its parent.
* @param {*} parent
* @param {*} descendant
*/
class VhdNegative extends VhdAbstract {
#parent
#child
get header() {
// we want to have parent => child => negative
// where => means " is the parent of "
return {
...this.#parent.header,
parentUuid: this.#child.footer.uuid,
}
}
get footer() {
// by construct a negative vhd is differencing disk
return {
...this.#parent.footer,
diskType: DISK_TYPES.DIFFERENCING,
}
}
constructor(parent, child) {
super()
this.#parent = parent
this.#child = child
assert.strictEqual(UUID.stringify(child.header.parentUuid), UUID.stringify(parent.footer.uuid), 'NOT_CHAINED')
assert.strictEqual(child.footer.diskType, DISK_TYPES.DIFFERENCING, 'CHILD_NOT_DIFFERENCING')
// we don't want to handle alignment and missing block for now
// last block may contains partly empty data when changing size
assert.strictEqual(child.footer.currentSize, parent.footer.currentSize, 'GEOMETRY_CHANGED')
}
async readBlockAllocationTable() {
return Promise.all([this.#parent.readBlockAllocationTable(), this.#child.readBlockAllocationTable()])
}
containsBlock(blockId) {
return this.#child.containsBlock(blockId)
}
async readHeaderAndFooter() {
return Promise.all([this.#parent.readHeaderAndFooter(), this.#child.readHeaderAndFooter()])
}
async readBlock(blockId, onlyBitmap = false) {
// only read the content of the first vhd containing this block
if (this.#parent.containsBlock(blockId)) {
return this.#parent.readBlock(blockId, onlyBitmap)
}
const bitmap = Buffer.alloc(computeBlockBitmapSize(this.header.blockSize), 255) // bitmap are full of bit 1
const data = Buffer.alloc(this.header.blockSize, 0) // empty are full of bit 0
return {
id: blockId,
bitmap,
data,
buffer: Buffer.concat([bitmap, data]),
}
}
mergeBlock(child, blockId) {
throw new Error(`can't coalesce block into a vhd negative`)
}
_readParentLocatorData(id) {
return this.#parent._readParentLocatorData(id)
}
}
exports.VhdNegative = VhdNegative

View File

@@ -120,7 +120,8 @@ const VhdSynthetic = class VhdSynthetic extends VhdAbstract {
}
// add decorated static method
VhdSynthetic.fromVhdChain = Disposable.factory(async function* fromVhdChain(handler, childPath) {
// until is not included in the result , the chain will stop at its child
VhdSynthetic.fromVhdChain = Disposable.factory(async function* fromVhdChain(handler, childPath, { until } = {}) {
let vhdPath = childPath
let vhd
const vhds = []
@@ -128,8 +129,11 @@ VhdSynthetic.fromVhdChain = Disposable.factory(async function* fromVhdChain(hand
vhd = yield openVhd(handler, vhdPath)
vhds.unshift(vhd) // from oldest to most recent
vhdPath = resolveRelativeFromFile(vhdPath, vhd.header.parentUnicodeName)
} while (vhd.footer.diskType !== DISK_TYPES.DYNAMIC)
} while (vhd.footer.diskType !== DISK_TYPES.DYNAMIC && vhdPath !== until)
if (until !== undefined && vhdPath !== until) {
throw new Error(`Didn't find ${until} as a parent of ${childPath}`)
}
const synthetic = new VhdSynthetic(vhds)
await synthetic.readHeaderAndFooter()
yield synthetic

View File

@@ -2,6 +2,10 @@
const { dirname, resolve } = require('path')
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
const resolveRelativeFromFile = (file, path) => {
if (file.startsWith('/')) {
return resolve(dirname(file), path)
}
return resolve('/', dirname(file), path).slice(1)
}
module.exports = resolveRelativeFromFile

View File

@@ -13,4 +13,5 @@ exports.VhdAbstract = require('./Vhd/VhdAbstract').VhdAbstract
exports.VhdDirectory = require('./Vhd/VhdDirectory').VhdDirectory
exports.VhdFile = require('./Vhd/VhdFile').VhdFile
exports.VhdSynthetic = require('./Vhd/VhdSynthetic').VhdSynthetic
exports.VhdNegative = require('./Vhd/VhdNegative').VhdNegative
exports.Constants = require('./_constants')

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "vhd-lib",
"version": "4.6.1",
"version": "4.7.0",
"license": "AGPL-3.0-or-later",
"description": "Primitives for VHD file handling",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-lib",
@@ -20,7 +20,7 @@
"@vates/read-chunk": "^1.2.0",
"@vates/stream-reader": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/fs": "^4.1.3",
"@xen-orchestra/log": "^0.6.0",
"async-iterator-to-stream": "^1.0.2",
"decorator-synchronized": "^0.6.0",
@@ -33,7 +33,7 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/fs": "^4.1.3",
"execa": "^5.0.0",
"get-stream": "^6.0.0",
"rimraf": "^5.0.1",

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xapi-explore-sr",
"version": "0.4.1",
"version": "0.4.2",
"license": "ISC",
"description": "Display the list of VDIs (unmanaged and snapshots included) of a SR",
"keywords": [
@@ -40,7 +40,7 @@
"human-format": "^1.0.0",
"lodash": "^4.17.4",
"pw": "^0.0.4",
"xen-api": "^1.3.6"
"xen-api": "^2.0.0"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -1,111 +0,0 @@
```js
import { Xapi } from 'xen-api'
// bare-bones XAPI client
const xapi = new Xapi({
// URL to a host belonging to the XCP-ng/XenServer pool we want to connect to
url: 'https://xen1.company.net',
// credentials used to connect to this XAPI
auth: {
user: 'root',
password: 'important secret password',
},
// if true, only side-effects free calls will be allowed
readOnly: false,
})
// ensure that the connection is working
await xapi.checkConnection()
// call a XAPI method
//
// see available methods there: https://xapi-project.github.io/xen-api/
const result = await xapi.call(
// name of the method
'VM.snapshot',
// list of params
[vm.$ref, 'My new snapshot'],
// options
{
// AbortSignal that can be used to stop the call
//
// Note: this will not stop/rollback the side-effects of the call
signal,
}
)
// after a call (or checkConnection) has succeed, the following properties are available
// list of classes available on this XAPI
xapi.classes
// timestamp of the last reply from XAPI
xapi.lastReply
// pool record of this XAPI
xapi.pool
// secret identifier of the current session
//
// it might become obsolete, in that case, it will be automatically renewed by the next call
xapi.sessionId
// invalidate the session identifier
await xapi.logOut()
```
```js
import { Proxy } from 'xen-api/proxy'
const proxy = new Proxy(xapi)
await proxy.VM.snapshot()
```
```js
import { Events } from 'xen-api/events'
const events = new Events(xapi)
// ensure that all events until now have been received and processed
await events.barrier()
// watch events on tasks and wait for a task to finish
const task = await events.waitTask(taskRef, { signal })
// for long running actions, it's better to use an async call which will are based on tasks
const result = await events.asyncCall(method)
const stop = events.watch(
// class that we are interested in
//
// use `*` for all classes
'pool',
// called each time a new event for this class has been received
//
// https://xapi-project.github.io/xen-api/classes/event.html
event => {
stop()
}
)
// when wanting to really stop watching all events, simply remove all watchers
events.clear()
```
```js
import { Cache } from 'xen-api/events'
const cache = new Cache(watcher)
const host = await cache.get('host', 'OpaqueRef:1c3f19c8-f80a-464d-9c48-a2c19d4e4fc3')
const vm = await cache.getByUuid('VM', '355ee47d-ff4c-4924-3db2-fd86ae629676')
cache.clear()
```

32
packages/xen-api/_Ref.mjs Normal file
View File

@@ -0,0 +1,32 @@
const EMPTY = 'OpaqueRef:NULL'
const PREFIX = 'OpaqueRef:'
export default {
// Reference to use to indicate it's not pointing to an object
EMPTY,
// Whether this value is a reference (probably) pointing to an object
isNotEmpty(val) {
return val !== EMPTY && typeof val === 'string' && val.startsWith(PREFIX)
},
// Whether this value looks like a reference
is(val) {
return (
typeof val === 'string' &&
(val.startsWith(PREFIX) ||
// 2019-02-07 - JFT: even if `value` should not be an empty string for
// a ref property, an user had the case on XenServer 7.0 on the CD VBD
// of a VM created by XenCenter
val === '' ||
// 2021-03-08 - JFT: there is an bug in XCP-ng/XenServer which leads to
// some refs to be `Ref:*` instead of being rewritten
//
// We'll consider them as empty refs in this lib to avoid issues with
// _wrapRecord.
//
// See https://github.com/xapi-project/xen-api/issues/4338
val.startsWith('Ref:'))
)
},
}

View File

@@ -0,0 +1,30 @@
import { BaseError } from 'make-error'
export default class XapiError extends BaseError {
static wrap(error) {
let code, params
if (Array.isArray(error)) {
// < XenServer 7.3
;[code, ...params] = error
} else {
code = error.message
params = error.data
if (!Array.isArray(params)) {
params = []
}
}
return new XapiError(code, params)
}
constructor(code, params) {
super(`${code}(${params.join(', ')})`)
this.code = code
this.params = params
// slots than can be assigned later
this.call = undefined
this.url = undefined
this.task = undefined
}
}

View File

@@ -0,0 +1,3 @@
import debug from 'debug'
export default debug('xen-api')

View File

@@ -0,0 +1,22 @@
import { Cancel } from 'promise-toolbox'
import XapiError from './_XapiError.mjs'
export default task => {
const { status } = task
if (status === 'cancelled') {
return Promise.reject(new Cancel('task canceled'))
}
if (status === 'failure') {
const error = XapiError.wrap(task.error_info)
error.task = task
return Promise.reject(error)
}
if (status === 'success') {
// the result might be:
// - empty string
// - an opaque reference
// - an XML-RPC value
return Promise.resolve(task.result)
}
}

View File

@@ -0,0 +1,3 @@
const SUFFIX = '.get_all_records'
export default method => method.endsWith(SUFFIX)

View File

@@ -0,0 +1,6 @@
const RE = /^[^.]+\.get_/
export default function isReadOnlyCall(method, args) {
const n = args.length
return (n === 0 || (n === 1 && typeof args[0] === 'string')) && RE.test(method)
}

View File

@@ -0,0 +1,8 @@
export default (setting, defaultValue) =>
setting === undefined
? () => defaultValue
: typeof setting === 'function'
? setting
: typeof setting === 'object'
? method => setting[method] ?? setting['*'] ?? defaultValue
: () => setting

View File

@@ -0,0 +1,26 @@
const URL_RE = /^(?:(https?:)\/*)?(?:(([^:]*)(?::([^@]*))?)@)?(\[[^\]]+\]|[^:/]+)(?::([0-9]+))?(\/[^?#]*)?$/
export default url => {
const matches = URL_RE.exec(url)
if (matches === null) {
throw new Error('invalid URL: ' + url)
}
const [, protocol = 'https:', auth, username = '', password = '', hostname, port, pathname = '/'] = matches
const parsedUrl = {
protocol,
hostname,
port,
pathname,
// compat with url.parse
auth,
}
if (username !== '') {
parsedUrl.username = decodeURIComponent(username)
}
if (password !== '') {
parsedUrl.password = decodeURIComponent(password)
}
return parsedUrl
}

View File

@@ -0,0 +1,50 @@
import t from 'tap'
import parseUrl from './_parseUrl.mjs'
const data = {
'xcp.company.lan': {
hostname: 'xcp.company.lan',
pathname: '/',
protocol: 'https:',
},
'[::1]': {
hostname: '[::1]',
pathname: '/',
protocol: 'https:',
},
'http://username:password@xcp.company.lan': {
auth: 'username:password',
hostname: 'xcp.company.lan',
password: 'password',
pathname: '/',
protocol: 'http:',
username: 'username',
},
'https://username@xcp.company.lan': {
auth: 'username',
hostname: 'xcp.company.lan',
pathname: '/',
protocol: 'https:',
username: 'username',
},
}
t.test('invalid url', function (t) {
t.throws(() => parseUrl(''))
t.end()
})
for (const url of Object.keys(data)) {
t.test(url, function (t) {
const parsed = parseUrl(url)
for (const key of Object.keys(parsed)) {
if (parsed[key] === undefined) {
delete parsed[key]
}
}
t.same(parsed, data[url])
t.end()
})
}

View File

@@ -0,0 +1,17 @@
import mapValues from 'lodash/mapValues.js'
export default function replaceSensitiveValues(value, replacement) {
function helper(value, name) {
if (name === 'password' && typeof value === 'string') {
return replacement
}
if (typeof value !== 'object' || value === null) {
return value
}
return Array.isArray(value) ? value.map(helper) : mapValues(value, helper)
}
return helper(value)
}

View File

@@ -0,0 +1,130 @@
/* eslint-disable no-console */
import blocked from 'blocked'
import createDebug from 'debug'
import filter from 'lodash/filter.js'
import find from 'lodash/find.js'
import L from 'lodash'
import minimist from 'minimist'
import pw from 'pw'
import { asCallback, fromCallback, fromEvent } from 'promise-toolbox'
import { diff } from 'jest-diff'
import { getBoundPropertyDescriptor } from 'bind-property-descriptor'
import { start as createRepl } from 'repl'
// ===================================================================
function askPassword(prompt = 'Password: ') {
if (prompt) {
process.stdout.write(prompt)
}
return new Promise(resolve => {
pw(resolve)
})
}
const { getPrototypeOf, ownKeys } = Reflect
function getAllBoundDescriptors(object) {
const descriptors = { __proto__: null }
let current = object
do {
ownKeys(current).forEach(key => {
if (!(key in descriptors)) {
descriptors[key] = getBoundPropertyDescriptor(current, key, object)
}
})
} while ((current = getPrototypeOf(current)) !== null)
return descriptors
}
// ===================================================================
const usage = 'Usage: xen-api <url> [<user> [<password>]]'
export async function main(createClient) {
const opts = minimist(process.argv.slice(2), {
string: ['proxy', 'session-id', 'transport'],
boolean: ['allow-unauthorized', 'help', 'read-only', 'verbose'],
alias: {
'allow-unauthorized': 'au',
debounce: 'd',
help: 'h',
proxy: 'p',
'read-only': 'ro',
verbose: 'v',
transport: 't',
},
})
if (opts.help) {
return usage
}
if (opts.verbose) {
// Does not work perfectly.
//
// https://github.com/visionmedia/debug/pull/156
createDebug.enable('xen-api,xen-api:*')
}
let auth
if (opts._.length > 1) {
const [, user, password = await askPassword()] = opts._
auth = { user, password }
} else if (opts['session-id'] !== undefined) {
auth = { sessionId: opts['session-id'] }
}
{
const debug = createDebug('xen-api:perf')
blocked(ms => {
debug('blocked for %sms', ms | 0)
})
}
const xapi = createClient({
url: opts._[0],
allowUnauthorized: opts.au,
auth,
debounce: opts.debounce != null ? +opts.debounce : null,
httpProxy: opts.proxy,
readOnly: opts.ro,
syncStackTraces: true,
transport: opts.transport || undefined,
})
await xapi.connect()
const repl = createRepl({
prompt: `${xapi._humanId}> `,
})
{
const ctx = repl.context
ctx.xapi = xapi
ctx.diff = (a, b) => console.log('%s', diff(a, b))
ctx.find = predicate => find(xapi.objects.all, predicate)
ctx.findAll = predicate => filter(xapi.objects.all, predicate)
ctx.L = L
Object.defineProperties(ctx, getAllBoundDescriptors(xapi))
}
// Make the REPL waits for promise completion.
repl.eval = (evaluate => (cmd, context, filename, cb) => {
asCallback.call(
fromCallback(cb => {
evaluate.call(repl, cmd, context, filename, cb)
}).then(value => (Array.isArray(value) ? Promise.all(value) : value)),
cb
)
})(repl.eval)
await fromEvent(repl, 'exit')
try {
await xapi.disconnect()
} catch (error) {}
}
/* eslint-enable no-console */

6
packages/xen-api/cli.mjs Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env node
import { createClient } from './index.mjs'
import { main } from './cli-lib.mjs'
main(createClient).catch(console.error.bind(console, 'FATAL'))

View File

@@ -1,115 +0,0 @@
const EVENT_TIMEOUT = 60e3
export class Watcher {
#abortController
#typeWatchers = new Map()
classes = new Map()
xapi
constructor(xapi) {
this.xapi = xapi
}
async asyncCall(method, params, { signal }) {
const taskRef = await this.xapi.call('Async.' + method, params, { signal })
return new Promise((resolve, reject) => {
const stop = this.watch(
'task',
taskRef,
task => {
const { status } = task
if (status === 'success') {
stop()
resolve(task.status)
} else if (status === 'cancelled' || status === 'failure') {
stop()
reject(task.error_info)
}
},
{ signal }
)
})
}
async #start() {
const { xapi } = this
const { signal } = this.#abortController
const watchers = this.#typeWatchers
let token = await xapi.call('event.inject', 'pool', xapi.pool.$ref)
while (true) {
signal.throwIfRequested()
const result = await xapi.call({ signal }, 'event.from', this.classes, token, EVENT_TIMEOUT)
for (const event of result.events) {
}
}
this.#abortController = undefined
}
start() {
if (this.#abortController !== undefined) {
throw new Error('already started')
}
this.#abortController = new AbortController()
this.#start()
}
stop() {
if (this.#abortController === undefined) {
throw new Error('already stopped')
}
this.#abortController.abort()
}
}
export class Cache {
// contains records indexed by type + ref
//
// plain records when retrieved by events
//
// promises to record when retrieved by a get_record call (might be a rejection if the record does not exist)
#recordCache = new Map()
#watcher
constructor(watcher) {
this.#watcher = watcher
}
async #get(type, ref) {
let record
try {
record = await this.#watcher.xapi.call(`${type}.get_record`, ref)
} catch (error) {
if (error.code !== 'HANDLE_INVALID') {
throw error
}
record = Promise.reject(error)
}
this.#recordCache.set(type, Promise.resolve(record))
return record
}
async get(type, ref) {
const cache = this.#recordCache
const key = type + ref
let record = cache.get(key)
if (record === undefined) {
record = this.#get(type, ref)
cache.set(key, record)
}
return record
}
async getByUuid(type, uuid) {
return this.get(type, await this.#watcher.xapi.call(`${type}.get_by_uuid`, uuid))
}
}
exports.Cache = Cache

View File

@@ -0,0 +1,5 @@
'use strict'
module.exports = {
ignorePatterns: ['*'],
}

View File

@@ -0,0 +1,3 @@
if (process.env.DEBUG === undefined) {
process.env.DEBUG = 'xen-api'
}

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
import './env.mjs'
import createProgress from 'progress-stream'
import createTop from 'process-top'
import getopts from 'getopts'
import { defer } from 'golike-defer'
import { CancelToken } from 'promise-toolbox'
import { createClient } from '../index.mjs'
import { createOutputStream, formatProgress, pipeline, resolveRecord, throttle } from './utils.mjs'
defer(async ($defer, rawArgs) => {
const {
raw,
throttle: bps,
_: args,
} = getopts(rawArgs, {
boolean: 'raw',
alias: {
raw: 'r',
throttle: 't',
},
})
if (args.length < 2) {
return console.log('Usage: export-vdi [--raw] <XS URL> <VDI identifier> [<VHD file>]')
}
const xapi = createClient({
allowUnauthorized: true,
url: args[0],
watchEvents: false,
})
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
const vdi = await resolveRecord(xapi, 'VDI', args[1])
// https://xapi-project.github.io/xen-api/snapshots.html#downloading-a-disk-or-snapshot
const exportStream = await xapi.getResource(token, '/export_raw_vdi/', {
query: {
format: raw ? 'raw' : 'vhd',
vdi: vdi.$ref,
},
})
console.warn('Export task:', exportStream.headers['task-id'])
const top = createTop()
const progressStream = createProgress()
$defer(
clearInterval,
setInterval(() => {
console.warn('\r %s | %s', top.toString(), formatProgress(progressStream.progress()))
}, 1e3)
)
await pipeline(exportStream, progressStream, throttle(bps), createOutputStream(args[2]))
})(process.argv.slice(2)).catch(console.error.bind(console, 'error'))

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import './env.mjs'
import createProgress from 'progress-stream'
import getopts from 'getopts'
import { defer } from 'golike-defer'
import { CancelToken } from 'promise-toolbox'
import { createClient } from '../index.mjs'
import { createOutputStream, formatProgress, pipeline, resolveRecord } from './utils.mjs'
defer(async ($defer, rawArgs) => {
const {
gzip,
zstd,
_: args,
} = getopts(rawArgs, {
boolean: ['gzip', 'zstd'],
})
if (args.length < 2) {
return console.log('Usage: export-vm <XS URL> <VM identifier> [<XVA file>]')
}
const xapi = createClient({
allowUnauthorized: true,
url: args[0],
watchEvents: false,
})
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
// https://xapi-project.github.io/xen-api/importexport.html
const exportStream = await xapi.getResource(token, '/export/', {
query: {
ref: (await resolveRecord(xapi, 'VM', args[1])).$ref,
use_compression: zstd ? 'zstd' : gzip ? 'true' : 'false',
},
})
console.warn('Export task:', exportStream.headers['task-id'])
await pipeline(
exportStream,
createProgress({ time: 1e3 }, p => console.warn(formatProgress(p))),
createOutputStream(args[2])
)
})(process.argv.slice(2)).catch(console.error.bind(console, 'error'))

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
import './env.mjs'
import getopts from 'getopts'
import { defer } from 'golike-defer'
import { CancelToken } from 'promise-toolbox'
import { createVhdStreamWithLength } from 'vhd-lib'
import { createClient } from '../index.mjs'
import { createInputStream, resolveRef } from './utils.mjs'
defer(async ($defer, argv) => {
const opts = getopts(argv, { boolean: ['events', 'raw', 'remove-length'], string: ['sr', 'vdi'] })
const url = opts._[0]
if (url === undefined) {
return console.log(
'Usage: import-vdi [--events] [--raw] [--sr <SR identifier>] [--vdi <VDI identifier>] <XS URL> [<VHD file>]'
)
}
const { raw, sr, vdi } = opts
const createVdi = vdi === ''
if (createVdi) {
if (sr === '') {
throw 'requires either --vdi or --sr'
}
if (!raw) {
throw 'creating a VDI requires --raw'
}
} else if (sr !== '') {
throw '--vdi and --sr are mutually exclusive'
}
const xapi = createClient({
allowUnauthorized: true,
url,
watchEvents: opts.events && ['task'],
})
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
let input = createInputStream(opts._[1])
$defer.onFailure(() => input.destroy())
let vdiRef
if (createVdi) {
vdiRef = await xapi.call('VDI.create', {
name_label: 'xen-api/import-vdi',
other_config: {},
read_only: false,
sharable: false,
SR: await resolveRef(xapi, 'SR', sr),
type: 'user',
virtual_size: input.length,
})
$defer.onFailure(() => xapi.call('VDI.destroy', vdiRef))
} else {
vdiRef = await resolveRef(xapi, 'VDI', vdi)
}
if (opts['remove-length']) {
delete input.length
console.log('length removed')
} else if (!raw && input.length === undefined) {
input = await createVhdStreamWithLength(input)
}
// https://xapi-project.github.io/xen-api/snapshots.html#uploading-a-disk-or-snapshot
const result = await xapi.putResource(token, input, '/import_raw_vdi/', {
query: {
format: raw ? 'raw' : 'vhd',
vdi: vdiRef,
},
})
if (result !== undefined) {
console.log(result)
}
})(process.argv.slice(2)).catch(console.error.bind(console, 'Fatal:'))

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
import './env.mjs'
import { defer } from 'golike-defer'
import { CancelToken } from 'promise-toolbox'
import { createClient } from '../index.mjs'
import { createInputStream, resolveRef } from './utils.mjs'
defer(async ($defer, args) => {
if (args.length < 1) {
return console.log('Usage: import-vm <XS URL> [<XVA file>] [<SR identifier>]')
}
const xapi = createClient({
allowUnauthorized: true,
url: args[0],
watchEvents: false,
})
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
// https://xapi-project.github.io/xen-api/importexport.html
await xapi.putResource(token, createInputStream(args[1]), '/import/', {
query: args[2] && { sr_id: await resolveRef(xapi, 'SR', args[2]) },
})
})(process.argv.slice(2)).catch(console.error.bind(console, 'error'))

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env node
import 'source-map-support/register.js'
import forEach from 'lodash/forEach.js'
import size from 'lodash/size.js'
import { createClient } from '../index.mjs'
// ===================================================================
if (process.argv.length < 3) {
throw new Error('Usage: log-events <XS URL>')
}
// ===================================================================
// Creation
const xapi = createClient({
allowUnauthorized: true,
url: process.argv[2],
})
// ===================================================================
// Method call
xapi.connect().then(() => {
xapi
.call('VM.get_all_records')
.then(function (vms) {
console.log('%s VMs fetched', size(vms))
})
.catch(function (error) {
console.error(error)
})
})
// ===================================================================
// Objects
const objects = xapi.objects
objects.on('add', objects => {
forEach(objects, object => {
console.log('+ %s: %s', object.$type, object.$id)
})
})
objects.on('update', objects => {
forEach(objects, object => {
console.log('± %s: %s', object.$type, object.$id)
})
})
objects.on('remove', objects => {
forEach(objects, (value, id) => {
console.log('- %s', id)
})
})

2647
packages/xen-api/examples/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"dependencies": {
"getopts": "^2.2.3",
"golike-defer": "^0.5.1",
"human-format": "^0.11.0",
"lodash": "^4.17.21",
"process-top": "^1.2.0",
"progress-stream": "^2.0.0",
"promise-toolbox": "^0.19.2",
"readable-stream": "^4.4.2",
"source-map-support": "^0.5.21",
"throttle": "^1.0.3",
"vhd-lib": "^4.7.0"
}
}

View File

@@ -0,0 +1,75 @@
import { createReadStream, createWriteStream, statSync } from 'fs'
import { fromCallback } from 'promise-toolbox'
import { PassThrough, pipeline as Pipeline } from 'readable-stream'
import humanFormat from 'human-format'
import Throttle from 'throttle'
import Ref from '../_Ref.mjs'
export const createInputStream = path => {
if (path === undefined || path === '-') {
return process.stdin
}
const { size } = statSync(path)
const stream = createReadStream(path)
stream.length = size
return stream
}
export const createOutputStream = path => {
if (path !== undefined && path !== '-') {
return createWriteStream(path)
}
// introduce a through stream because stdout is not a normal stream!
const stream = new PassThrough()
stream.pipe(process.stdout)
return stream
}
const formatSizeOpts = { scale: 'binary', unit: 'B' }
const formatSize = bytes => humanFormat(bytes, formatSizeOpts)
export const formatProgress = p => {
return [
formatSize(p.transferred),
' / ',
formatSize(p.length),
' | ',
p.runtime,
's / ',
p.eta,
's | ',
formatSize(p.speed),
'/s',
].join('')
}
export const pipeline = (...streams) => {
return fromCallback(cb => {
streams = streams.filter(_ => _ != null)
streams.push(cb)
Pipeline.apply(undefined, streams)
})
}
const resolveRef = (xapi, type, refOrUuidOrNameLabel) =>
Ref.is(refOrUuidOrNameLabel)
? refOrUuidOrNameLabel
: xapi.call(`${type}.get_by_uuid`, refOrUuidOrNameLabel).catch(() =>
xapi.call(`${type}.get_by_name_label`, refOrUuidOrNameLabel).then(refs => {
if (refs.length === 1) {
return refs[0]
}
throw new Error(`no single match for ${type} with name label ${refOrUuidOrNameLabel}`)
})
)
export const resolveRecord = async (xapi, type, refOrUuidOrNameLabel) =>
xapi.getRecord(type, await resolveRef(xapi, type, refOrUuidOrNameLabel))
export { resolveRef }
export const throttle = opts => (opts != null ? new Throttle(opts) : undefined)

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xen-api",
"version": "1.3.6",
"version": "2.0.0",
"license": "ISC",
"description": "Connector to the Xen API",
"keywords": [

View File

@@ -0,0 +1,3 @@
import makeError from 'make-error'
export default makeError('UnsupportedTransport')

View File

@@ -0,0 +1,25 @@
// Prepare values before passing them to the XenAPI:
//
// - cast integers to strings
export default function prepare(param) {
if (Number.isInteger(param)) {
return String(param)
}
if (typeof param !== 'object' || param === null) {
return param
}
if (Array.isArray(param)) {
return param.map(prepare)
}
const values = {}
Object.keys(param).forEach(key => {
const value = param[key]
if (value !== undefined) {
values[key] = prepare(value)
}
})
return values
}

View File

@@ -0,0 +1,35 @@
import jsonRpc from './json-rpc.mjs'
import UnsupportedTransport from './_UnsupportedTransport.mjs'
import xmlRpc from './xml-rpc.mjs'
const factories = [jsonRpc, xmlRpc]
const { length } = factories
export default opts => {
let i = 0
let call
function create() {
const current = factories[i++](opts)
if (i < length) {
const currentI = i
call = (method, args) =>
current(method, args).catch(error => {
if (error instanceof UnsupportedTransport) {
if (currentI === i) {
// not changed yet
create()
}
return call(method, args)
}
throw error
})
} else {
call = current
}
}
create()
return (method, args) => call(method, args)
}

View File

@@ -0,0 +1,11 @@
import auto from './auto.mjs'
import jsonRpc from './json-rpc.mjs'
import xmlRpc from './xml-rpc.mjs'
export default {
__proto__: null,
auto,
'json-rpc': jsonRpc,
'xml-rpc': xmlRpc,
}

View File

@@ -0,0 +1,37 @@
import httpRequestPlus from 'http-request-plus'
import { format, parse } from 'json-rpc-protocol'
import XapiError from '../_XapiError.mjs'
import UnsupportedTransport from './_UnsupportedTransport.mjs'
// https://github.com/xenserver/xenadmin/blob/0df39a9d83cd82713f32d24704852a0fd57b8a64/XenModel/XenAPI/Session.cs#L403-L433
export default ({ secureOptions, url, agent }) => {
url = new URL('./jsonrpc', Object.assign(new URL('http://localhost'), url))
return async function (method, args) {
const res = await httpRequestPlus(url, {
...secureOptions,
body: format.request(0, method, args),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
method: 'POST',
agent,
})
// content-type is `text/xml` on old hosts where JSON-RPC is unsupported
if (res.headers['content-type'] !== 'application/json') {
throw new UnsupportedTransport()
}
const response = parse(await res.text())
if (response.type === 'response') {
return response.result
}
throw XapiError.wrap(response.error)
}
}

View File

@@ -0,0 +1,45 @@
import xmlrpc from 'xmlrpc'
import { promisify } from 'promise-toolbox'
import XapiError from '../_XapiError.mjs'
import prepareXmlRpcParams from './_prepareXmlRpcParams.mjs'
const logError = error => {
if (error.res) {
console.error('XML-RPC Error: %s (response status %s)', error.message, error.res.statusCode)
console.error('%s', error.body)
}
throw error
}
const parseResult = result => {
const status = result.Status
// Return the plain result if it does not have a valid XAPI
// format.
if (status === undefined) {
return result
}
if (status !== 'Success') {
throw XapiError.wrap(result.ErrorDescription)
}
return result.Value
}
export default ({ secureOptions, url: { hostnameRaw, pathname, port, protocol }, agent }) => {
const secure = protocol === 'https:'
const client = (secure ? xmlrpc.createSecureClient : xmlrpc.createClient)({
...(secure ? secureOptions : undefined),
agent,
host: hostnameRaw,
pathname,
port,
})
const call = promisify(client.methodCall, client)
return (method, args) => call(method, prepareXmlRpcParams(args)).then(parseResult, logError)
}

View File

@@ -50,6 +50,7 @@ Usage:
Examples:
xo-cli rest del tasks/<task id>
xo-cli rest del vms/<vm id>/tags/<tag>
xo-cli rest get <collection> [fields=<fields>] [filter=<filter>] [limit=<limit>]
List objects in a REST API collection.
@@ -122,6 +123,18 @@ Usage:
Examples:
xo-cli rest post tasks/<task id>/actions/abort
xo-cli rest post vms/<VM UUID>/actions/snapshot name_label='My snapshot'
xo-cli rest put <collection>/<item id> <name>=<value>...
Put a item in a collection
<collection>/<item id>
Full path of the item to add
<name>=<value>...
Properties of the item
Examples:
xo-cli rest put vms/<vm id>/tags/<tag>
```
#### Register your XO instance

View File

@@ -68,6 +68,7 @@ Usage:
Examples:
xo-cli rest del tasks/<task id>
xo-cli rest del vms/<vm id>/tags/<tag>
xo-cli rest get <collection> [fields=<fields>] [filter=<filter>] [limit=<limit>]
List objects in a REST API collection.
@@ -140,6 +141,18 @@ Usage:
Examples:
xo-cli rest post tasks/<task id>/actions/abort
xo-cli rest post vms/<VM UUID>/actions/snapshot name_label='My snapshot'
xo-cli rest put <collection>/<item id> <name>=<value>...
Put a item in a collection
<collection>/<item id>
Full path of the item to add
<name>=<value>...
Properties of the item
Examples:
xo-cli rest put vms/<vm id>/tags/<tag>
```
#### Register your XO instance

View File

@@ -274,6 +274,7 @@ const help = wrap(
Examples:
$name rest del tasks/<task id>
$name rest del vms/<vm id>/tags/<tag>
$name rest get <collection> [fields=<fields>] [filter=<filter>] [limit=<limit>]
List objects in a REST API collection.
@@ -347,6 +348,18 @@ const help = wrap(
$name rest post tasks/<task id>/actions/abort
$name rest post vms/<VM UUID>/actions/snapshot name_label='My snapshot'
$name rest put <collection>/<item id> <name>=<value>...
Put a item in a collection
<collection>/<item id>
Full path of the item to add
<name>=<value>...
Properties of the item
Examples:
$name rest put vms/<vm id>/tags/<tag>
$name v$version`.replace(/<([^>]+)>|\$(\w+)/g, function (_, arg, key) {
if (arg) {
return '<' + chalk.yellow(arg) + '>'

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-cli",
"version": "0.21.0",
"version": "0.22.0",
"license": "AGPL-3.0-or-later",
"description": "Basic CLI for Xen-Orchestra",
"keywords": [

View File

@@ -112,6 +112,18 @@ const COMMANDS = {
return stripPrefix(await response.text())
},
async put([path, ...params]) {
const response = await this.exec(path, {
body: JSON.stringify(parseParams(params)),
headers: {
'content-type': 'application/json',
},
method: 'PUT',
})
return stripPrefix(await response.text())
},
}
export async function rest(args) {

View File

@@ -21,6 +21,7 @@
"node": ">=6"
},
"dependencies": {
"@xen-orchestra/log": "^0.6.0",
"lodash": "^4.13.1",
"url-parse": "^1.4.7"
},

View File

@@ -4,6 +4,9 @@ import trim from 'lodash/trim'
import trimStart from 'lodash/trimStart'
import queryString from 'querystring'
import urlParser from 'url-parse'
import { createLogger } from '@xen-orchestra/log'
const { warn } = createLogger('xo:xo-remote-parser')
const NFS_RE = /^([^:]+):(?:(\d+):)?([^:?]+)(\?[^?]*)?$/
const SMB_RE = /^([^:]+):(.+)@([^@]+)\\\\([^\0?]+)(?:\0([^?]*))?(\?[^?]*)?$/
@@ -15,6 +18,17 @@ const parseOptionList = (optionList = '') => {
optionList = optionList.substring(1)
}
const parsed = queryString.parse(optionList)
// bugfix for persisting error notification
if ('error' in parsed) {
warn('Deleting "error" value in url query, resave your remote to clear this values', { error: parsed.error })
delete parsed.error
}
if ('name' in parsed) {
warn('Deleting "name" value in url query, resave your remote to clear this values', { name: parsed.name })
delete parsed.name
}
Object.keys(parsed).forEach(key => {
const val = parsed[key]
// some incorrect values have been saved in users database (introduced by #6270)

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-netbox",
"version": "1.3.3",
"version": "1.4.0",
"license": "AGPL-3.0-or-later",
"description": "Synchronizes pools managed by Xen Orchestra with Netbox",
"keywords": [

View File

@@ -381,6 +381,7 @@ class Netbox {
await this.#request(`/virtualization/clusters/?type_id=${nbClusterType.id}`),
'custom_fields.uuid'
)
delete allNbClusters.null
const nbClusters = pick(allNbClusters, xoPools)
const clustersToCreate = []
@@ -549,7 +550,9 @@ class Netbox {
// Then make them objects to map the Netbox VMs to their XO VMs
// { VM UUID → Netbox VM }
const allNbVms = keyBy(allNbVmsList, 'custom_fields.uuid')
delete allNbVms.null
const nbVms = keyBy(nbVmsList, 'custom_fields.uuid')
delete nbVms.null
// Used for name deduplication
// Start by storing the names of the VMs that have been created manually in
@@ -667,6 +670,7 @@ class Netbox {
const nbIfsList = await this.#request(`/virtualization/interfaces/?${clusterFilter}`)
// { ID → Interface }
const nbIfs = keyBy(nbIfsList, 'custom_fields.uuid')
delete nbIfs.null
const ifsToDelete = []
const ifsToUpdate = []
@@ -910,7 +914,10 @@ class Netbox {
}
}
Object.assign(nbVms, keyBy(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate2)))
Object.assign(
nbVms,
keyBy(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate2), 'custom_fields.uuid')
)
log.info(`Done synchronizing ${xoPools.length} pools with Netbox`, { pools: xoPools })
}

View File

@@ -76,6 +76,8 @@ defaultSignInPage = '/signin'
throttlingDelay = '2 seconds'
[backups]
autoUnmountPartitionDelay = '24h'
disableMergeWorker = false
# Mode to use for newly created backup directories

View File

@@ -119,7 +119,7 @@ Content-Type: application/x-ndjson
## Properties update
> This feature is restricted to `name_label` and `name_description` at the moment.
> This feature is restricted to `name_label`, `name_description` and `tags` at the moment.
```sh
curl \
@@ -131,6 +131,30 @@ curl \
'https://xo.company.lan/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac'
```
### Collections
For collection properties, like `tags`, it can be more practical to touch a single item without impacting the others.
An item can be created with `PUT <collection>/<item id>` and can be destroyed with `DELETE <collection>/<item id>`.
Adding a tag:
```sh
curl \
-X PUT \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
'https://xo.company.lan/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/tags/My%20tag'
```
Removing a tag:
```sh
curl \
-X DELETE \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
'https://xo.company.lan/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/tags/My%20tag'
```
## VM destruction
```sh
@@ -156,6 +180,23 @@ curl \
> myVM.xva
```
## VM Import
A VM can be imported by posting to `/rest/v0/pools/:id/vms`.
```sh
curl \
-X POST \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
-T myDisk.raw \
'https://xo.company.lan/rest/v0/pools/355ee47d-ff4c-4924-3db2-fd86ae629676/vms?sr=357bd56c-71f9-4b2a-83b8-3451dec04b8f' \
| cat
```
The `sr` query parameter can be used to specify on which SR the VM should be imported, if not specified, the default SR will be used.
> Note: the final `| cat` ensures cURL's standard output is not a TTY, which is necessary for upload stats to be dislayed.
## VDI destruction
```sh
@@ -178,7 +219,26 @@ curl \
## VDI Import
A VHD or a raw export can be imported on an SR to create a new VDI at `/rest/v0/srs/<sr uuid>/vdis`.
### Existing VDI
A VHD or a raw export can be imported in an existing VDI respectively at `/rest/v0/vdis/<uuid>.vhd` and `/rest/v0/vdis/<uuid>.raw`.
> Note: the size of the VDI must match exactly the size of VDI that was previously exported.
```sh
curl \
-X PUT \
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
-T myDisk.vhd \
'https://xo.company.lan/rest/v0/vdis/1a269782-ea93-4c4c-897a-475365f7b674.vhd' \
| cat
```
> Note: the final `| cat` ensures cURL's standard output is not a TTY, which is necessary for upload stats to be dislayed.
### New VDI
An export can also be imported on an SR to create a new VDI at `/rest/v0/srs/<sr uuid>/vdis`.
```sh
curl \

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "xo-server",
"version": "5.126.0",
"version": "5.129.0",
"license": "AGPL-3.0-or-later",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -33,26 +33,26 @@
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/disposable": "^0.1.5",
"@vates/event-listeners-manager": "^1.0.1",
"@vates/multi-key-map": "^0.1.0",
"@vates/multi-key-map": "^0.2.0",
"@vates/otp": "^1.0.0",
"@vates/parse-duration": "^0.1.1",
"@vates/predicates": "^1.1.0",
"@vates/read-chunk": "^1.2.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/backups": "^0.44.2",
"@xen-orchestra/cron": "^1.0.6",
"@xen-orchestra/defined": "^0.0.1",
"@xen-orchestra/emit-async": "^1.0.0",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/fs": "^4.1.3",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.14.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/template": "^0.1.0",
"@xen-orchestra/vmware-explorer": "^0.3.0",
"@xen-orchestra/xapi": "^3.3.0",
"@xen-orchestra/vmware-explorer": "^0.3.1",
"@xen-orchestra/xapi": "^4.0.0",
"ajv": "^8.0.3",
"app-conf": "^2.3.0",
"async-iterator-to-stream": "^1.0.1",
@@ -127,15 +127,15 @@
"unzipper": "^0.10.5",
"uuid": "^9.0.0",
"value-matcher": "^0.2.0",
"vhd-lib": "^4.6.1",
"vhd-lib": "^4.7.0",
"ws": "^8.2.3",
"xdg-basedir": "^5.1.0",
"xen-api": "^1.3.6",
"xen-api": "^2.0.0",
"xo-acl-resolver": "^0.4.1",
"xo-collection": "^0.5.0",
"xo-common": "^0.8.0",
"xo-remote-parser": "^0.9.2",
"xo-vmdk-to-vhd": "^2.5.6"
"xo-vmdk-to-vhd": "^2.5.7"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -277,6 +277,10 @@ importVmBackup.params = {
sr: {
type: 'string',
},
useDifferentialRestore: {
type: 'boolean',
optional: true
}
}
export function checkBackup({ id, settings, sr }) {
@@ -380,3 +384,47 @@ fetchFiles.params = {
type: 'string',
},
}
export function listMountedPartitions() {
return this.listMountedPartitions()
}
listMountedPartitions.permission = 'admin'
export function mountPartition({ remote, disk, partition }) {
return this.mountPartition(remote, disk, partition)
}
mountPartition.permission = 'admin'
mountPartition.params = {
disk: {
type: 'string',
},
partition: {
optional: true,
type: 'string',
},
remote: {
type: 'string',
},
}
export function unmountPartition({ remote, disk, partition }) {
return this.unmountPartition(remote, disk, partition)
}
unmountPartition.permission = 'admin'
unmountPartition.params = {
disk: {
type: 'string',
},
partition: {
optional: true,
type: 'string',
},
remote: {
type: 'string',
},
}

View File

@@ -181,7 +181,7 @@ getApplianceUpdaterState.params = {
export async function checkHealth({ id }) {
try {
await this.callProxyMethod(id, 'system.getServerVersion')
await this.checkProxyHealth(id)
return {
success: true,
}

View File

@@ -63,7 +63,7 @@ export async function copyVm({ vm, sr }) {
const input = await srcXapi.VM_export(vm._xapiRef)
// eslint-disable-next-line no-console
console.log('import full VM...')
await tgtXapi.VM_destroy((await tgtXapi.importVm(input, { srId: sr })).$ref)
await tgtXapi.VM_destroy(await tgtXapi.VM_import(input, sr._xapiRef))
}
}

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