Compare commits

..

3 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
e84bf15650 better handling of undefined 2025-11-27 09:47:41 +00:00
Florent BEAUCHAMP
60e286a70d refactor: use getObjectsByType instead of direct indexes access 2025-11-27 09:46:59 +00:00
Florent BEAUCHAMP
c7a16a3f7a chore(perf-alert): remove compilation and simplify code 2025-11-26 17:28:26 +00:00
1001 changed files with 28356 additions and 41268 deletions

View File

@@ -233,26 +233,7 @@ module.exports = {
'@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
'@intlify/vue-i18n/no-missing-keys-in-other-locales': [
'error',
{
ignoreLocales: [
'de',
'fa',
'cs',
'es',
'sv',
'it',
'ru',
'uk',
'nl',
'pt-BR',
'da',
'ko',
'nb-NO',
'ja',
'fi',
'pl',
],
},
{ ignoreLocales: ['de', 'fa', 'cs', 'es', 'sv', 'it', 'ru', 'uk', 'nl', 'pt_BR', 'da'] },
],
},
},

View File

@@ -19,12 +19,12 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.6.6",
"@xen-orchestra/fs": "^4.6.4",
"@xen-orchestra/log": "^0.7.1",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.14.7"
"vhd-lib": "^4.14.4"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"

View File

@@ -127,6 +127,6 @@ export class NbdDisk extends RandomAccessDisk {
}
const blockStart = index * this.getBlockSize()
const blockEnd = (index + 1) * this.getBlockSize()
return this.#dataMap.some(({ offset, length }) => offset + length > blockStart && offset < blockEnd)
return this.#dataMap.some(({ offset, length }) => offset + length > blockStart && blockEnd >= offset)
}
}

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "3.2.3",
"version": "3.2.1",
"engines": {
"node": ">=14.0"
},
@@ -22,10 +22,10 @@
"@vates/async-each": "^1.0.1",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/disk-transform": "^1.2.1",
"@xen-orchestra/disk-transform": "^1.2.0",
"@xen-orchestra/log": "^0.7.1",
"promise-toolbox": "^0.21.0",
"xen-api": "^4.7.6"
"xen-api": "^4.7.5"
},
"devDependencies": {
"tap": "^18.7.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@vates/types",
"private": false,
"version": "1.19.0",
"version": "1.15.0",
"main": "./dist/index.mjs",
"exports": {
".": {
@@ -9,7 +9,6 @@
"types": "./dist/index.d.mts"
},
"./xo": "./dist/xo.d.mts",
"./xo-app": "./dist/xo-app.d.mts",
"./xen-api": "./dist/xen-api.d.mts",
"./common": {
"default": "./dist/common.mjs",

View File

@@ -1,6 +1,5 @@
export * from './common.mjs'
export * from './xen-api.mjs'
export * from './xo.mjs'
export * from './xo-app.mjs'
export * from './lib/xen-orchestra-xapi.mjs'
export * from './lib/complex-matcher.mjs'

View File

@@ -1,33 +1,28 @@
import {
WrappedXenApiRecord,
XenApiHost,
XenApiNetwork,
XenApiNetworkWrapped,
XenApiRecord,
XenApiSr,
XenApiTask,
XenApiVbd,
XenApiVdi,
XenApiVif,
XenApiVm,
XenApiVmWrapped,
XenApiVtpm,
} from '../xen-api.mjs'
import type { OPAQUE_REF_NULL, SUPPORTED_VDI_FORMAT, VBD_MODE, VBD_TYPE, VIF_LOCKING_MODE } from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
XoGpuGroup,
XoVgpuType,
XoHost,
XoNetwork,
XoPif,
XoSr,
XoUser,
XoVdi,
XoVgpuType,
XoVm,
XoVmTemplate,
XoVif,
} from '../xo.mjs'
import type { SUPPORTED_VDI_FORMAT } from '../common.mjs'
export type XcpPatches = {
changelog?: {
@@ -62,7 +57,6 @@ export interface Xapi {
call: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
barrier<T extends XenApiRecord>(ref: T['$ref']): Promise<Extract<WrappedXenApiRecord, { $ref: T['$ref'] }>>
getField<T extends XenApiRecord, K extends keyof T>(
type: Extract<WrappedXenApiRecord, T>['$type'],
ref: T['$ref'],
@@ -85,21 +79,7 @@ export interface Xapi {
}
): Promise<XenApiNetworkWrapped>
deleteNetwork(id: XoNetwork['id']): Promise<void>
deleteVif(vifId: XoVif['id']): Promise<void>
exportVmOva(vmRef: XenApiVm['$ref']): Promise<PassThrough>
migrateVm(
vmId: XoVm['id'],
hostXapi: Xapi,
hostId: XoHost['id'],
opts?: {
force?: boolean
mapVdisSrs?: Record<XoVdi['id'], XoSr['id']>
mapVifsNetworks?: Record<XoVif['id'], XoNetwork['id']>
migrationNetworkId?: XoNetwork['id']
sr?: XoSr['id']
bypassAssert?: boolean
}
): Promise<void>
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
resumeVm(id: XoVm['id']): Promise<void>
@@ -192,32 +172,11 @@ export interface Xapi {
gpuGroup?: XoGpuGroup['id']
copyHostBiosStrings?: boolean
hvmBootFirmware?: 'uefi' | 'bios'
secureBoot?: boolean
},
checkLimits?: boolean,
creatorId?: XoUser['id'],
opts?: { destroyAllVifs: boolean }
): Promise<XenApiVmWrapped>
moveVdi(
vdiId: XenApiVdi['$ref'] | XoVdi['id'],
srId: XenApiSr['$ref'] | XoSr['id'],
opts?: { _failOnCbtError?: boolean }
): Promise<XenApiVdi>
VBD_create(params: {
bootable?: boolean
empty?: boolean
mode?: VBD_MODE
other_config?: Record<string, string>
qos_algorithm_params?: Record<string, string>
qos_algorithm_type?: string
type?: VBD_TYPE
unpluggable?: boolean
userdevice?: string
VDI: XenApiVdi['$ref'] | OPAQUE_REF_NULL
VM: XenApiVm['$ref']
}): Promise<XenApiVbd['$ref']>
VBD_destroy(vbdRef: XenApiVbd['$ref']): Promise<void>
VBD_unplug(vbdRef: XenApiVbd['$ref']): Promise<void>
VDI_destroy(vdiRef: XenApiVdi['$ref']): Promise<void>
VDI_destroyCloudInitConfig(vdiRef: XenApiVdi['$ref'], opts?: { timeLimit?: number }): Promise<void>
VDI_exportContent(
@@ -229,24 +188,6 @@ export interface Xapi {
stream: Readable,
opts: { cancelToken?: unknown; format: SUPPORTED_VDI_FORMAT }
): Promise<void>
VIF_create(
options: {
currently_attached?: boolean
device?: string
ipv4_allowed?: string[]
ipv6_allowed?: string[]
locking_mode?: VIF_LOCKING_MODE
MTU?: number
network: XenApiNetwork['$ref']
other_config?: Record<string, string>
qos_algorithm_params?: Record<string, string>
qos_algorithm_type?: string
VM: XenApiVm['$ref']
},
extraOptions?: {
MAC?: string
}
): Promise<XenApiVif['$ref']>
VM_createCloudInitConfig(
vmRef: XenApiVm['$ref'],
cloudConfig: string,
@@ -266,5 +207,4 @@ export interface Xapi {
params?: { host?: XenApiHost; query?: Record<string, unknown>; task?: boolean | XenApiTask['$ref'] }
): Promise<{ body: Readable }>
isHyperThreadingEnabled(hostId: XoHost['id']): Promise<boolean | null>
VTPM_create(params: { VM: XenApiVm['$ref']; is_unique?: boolean; contents?: string }): Promise<XenApiVtpm['$ref']>
}

View File

@@ -1008,7 +1008,6 @@ export interface XenApiMessage {
timestamp: string
uuid: string
}
export type XenApiMessageWrapped = WrapperXenApi<XenApiMessage, 'message'>
export interface XenApiSecret {
$ref: Branded<'secret'>

View File

@@ -1,257 +0,0 @@
import { EventEmitter } from 'node:stream'
import type {
AnyXoBackupJob,
AnyXoJob,
AnyXoLog,
XapiXoRecord,
XoAuthenticationToken,
XoBackupRepository,
XoConfigBackupArchive,
XoGroup,
XoHost,
XoPool,
XoPoolBackupArchive,
XoProxy,
XoSchedule,
XoServer,
XoTask,
XoUser,
XoVm,
XoVmBackupArchive,
} from './xo.mjs'
import { VatesTask } from './lib/vates-task.mjs'
import {
Xapi,
XapiHostStats,
XapiPoolStats,
XapiStatsGranularity,
XapiVmStats,
XenApiGpuGroupWrapped,
XenApiHostWrapped,
XenApiMessageWrapped,
XenApiNetworkWrapped,
XenApiPbdWrapped,
XenApiPciWrapped,
XenApiPgpuWrapped,
XenApiPifWrapped,
XenApiPoolWrapped,
XenApiSmWrapped,
XenApiSrWrapped,
XenApiVbdWrapped,
XenApiVdiWrapped,
XenApiVgpuTypeWrapped,
XenApiVgpuWrapped,
XenApiVifWrapped,
XenApiVmWrapped,
XenApiVtpmWrapped,
} from './index.mjs'
export type XapiConnection = Xapi & {
status: string
pool?: { uuid: string }
sessionId: string
_url?: { protocol: string; hostname: string; port?: string }
}
type XapiRecordByXapiXoRecord = {
gpuGroup: XenApiGpuGroupWrapped
host: XenApiHostWrapped
message: XenApiMessageWrapped
network: XenApiNetworkWrapped
PBD: XenApiPbdWrapped
PCI: XenApiPciWrapped
PGPU: XenApiPgpuWrapped
PIF: XenApiPifWrapped
pool: XenApiPoolWrapped
SR: XenApiSrWrapped
SM: XenApiSmWrapped
VBD: XenApiVbdWrapped
VDI: XenApiVdiWrapped
'VDI-snapshot': XenApiVdiWrapped
'VDI-unmanaged': XenApiVdiWrapped
vgpu: XenApiVgpuWrapped
vgpuType: XenApiVgpuTypeWrapped
VIF: XenApiVifWrapped
VM: XenApiVmWrapped
'VM-controller': XenApiVmWrapped
'VM-snapshot': XenApiVmWrapped
'VM-template': XenApiVmWrapped
VTPM: XenApiVtpmWrapped
}
export type XoApp = {
config: {
get<T = string>(path: string): T
getOptional(path: string): Record<string, string> | undefined
getOptionalDuration(path: string): number | undefined
getGuiRoutes(): Promise<{
default: {
url: string
path: string
}
v5?: {
url: string
path: string
}
v6?: {
url: string
path: string
}
[key: string]:
| undefined
| {
url: string
path: string
}
}>
}
objects: EventEmitter & {
allIndexes: {
type: {
getEventEmitterByType(type: XapiXoRecord['type']): EventEmitter
}
}
}
tasks: EventEmitter & {
abort(id: XoTask['id'], reason?: string): Promise<void>
clearLogs(): Promise<void>
create: (params: { name: string; objectId?: string; type?: string }) => VatesTask
deleteLog(id: XoTask['id']): Promise<void>
get(id: XoTask['id']): Promise<XoTask>
list(opts?: { filter?: string | ((obj: XoTask) => boolean); limit?: number }): AsyncGenerator<XoTask>
watch(id: XoTask['id'], cb: (task: XoTask) => void): Promise<() => void>
}
apiContext: {
user?: XoUser
permission?: XoUser['permission'] | 'none' | null
}
// methods ------------
addUserToGroup: (userId: XoUser['id'], groupId: XoGroup['id']) => Promise<void>
authenticateUser: (
credentials: { token?: string; username?: string; password?: string },
userData?: { ip?: string },
opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean }
) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }>
/* Throw if no authorization */
checkFeatureAuthorization(featureCode: string): Promise<void>
/* connect a server (XCP-ng/XenServer) */
connectXenServer(id: XoServer['id']): Promise<void>
createAuthenticationToken(opts: {
client?: {
id?: string
[key: string]: unknown
}
description?: string
expiresIn?: string | number
userId: XoUser['id']
}): Promise<XoAuthenticationToken>
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
deleteGroup(id: XoGroup['id']): Promise<void>
deleteUser(id: XoUser['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
/* disconnect a server (XCP-ng/XenServer) */
disconnectXenServer(id: XoServer['id']): Promise<void>
getAllGroups(): Promise<XoGroup[]>
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoBackupJob['type']>(type: T): Promise<Extract<AnyXoBackupJob, { type: T }>[]>
getAllJobs(type?: string): Promise<AnyXoJob[]>
getProxy(id: XoProxy['id']): Promise<XoProxy>
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]>
getAllRemotesInfo(): Promise<
Record<
XoBackupRepository['id'],
{
size?: number
used?: number
available?: number
encryption?: {
algorithm: string
isLegacy: boolean
recommanded: string
}
}
>
>
getAllSchedules(): Promise<XoSchedule[]>
getAllUsers(): Promise<XoUser[]>
getAllXenServers(): Promise<XoServer[]>
getAuthenticationTokensForUser(userId: XoUser['id']): Promise<XoAuthenticationToken[]>
getBackupNgLogs(): Promise<Record<string, AnyXoLog>>
getBackupNgLogs(id: AnyXoLog['id']): Promise<AnyXoLog>
getBackupNgLogsSorted(opts: {
after?: number
before?: number
filter: (log: AnyXoLog) => boolean
limit?: number
}): Promise<AnyXoLog[]>
getGroup(id: XoGroup['id']): Promise<XoGroup>
getHVSupportedVersions: undefined | (() => Promise<{ [key: XoHost['productBrand']]: string }>)
getJob<T extends AnyXoJob>(id: T['id']): Promise<T>
getObject: <T extends XapiXoRecord>(id: T['id'], type?: T['type'] | T['type'][]) => T
getObjectsByType: <T extends XapiXoRecord>(
type: T['type'],
opts?: { filter?: string | ((obj: T) => boolean); limit?: number }
) => Record<T['id'], T> | undefined
getTotalBackupSizeOnRemote(id: XoBackupRepository['id']): Promise<{ onDisk: number }>
getSchedule(id: XoSchedule['id']): Promise<XoSchedule>
getUser: (id: XoUser['id']) => Promise<XoUser>
getXapi(maybeId: XapiXoRecord['id'] | XapiXoRecord): Xapi
getXapiHostStats: (hostId: XoHost['id'], granularity?: XapiStatsGranularity) => Promise<XapiHostStats>
getXapiObject: <T extends XapiXoRecord>(
maybeId: T['id'] | T,
type: T['type'] | T['type'][]
) => XapiRecordByXapiXoRecord[T['type']]
getXapiPoolStats(poolId: XoPool['id'], granularity?: XapiStatsGranularity): Promise<XapiPoolStats>
getXapiVmStats: (vmId: XoVm['id'], granularity?: XapiStatsGranularity) => Promise<XapiVmStats>
getXenServer(id: XoServer['id']): Promise<XoServer>
hasFeatureAuthorization(featureCode: string): Promise<boolean>
hasObject<T extends XapiXoRecord>(id: T['id'], type: T['type']): boolean
listMetadataBackups(backupRepositoryIds: XoBackupRepository['id'][]): Promise<{
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
}>
listVmBackupsNg(
backupRepositoryIds: XoBackupRepository['id'][],
opts?: { _forceRefresh?: boolean; vmId: XoVm['id'] }
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]>>>
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
registerXenServer(
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
allowUnauthorized?: XoServer['allowUnauthorized']
password: string
readOnly?: XoServer['readOnly']
}
): Promise<XoServer>
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask }): Promise<void>
rollingPoolUpdate(pool: XoPool, opts?: { rebootVm?: boolean; parentTask?: VatesTask }): Promise<void>
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
/** Remove a server from the DB (XCP-ng/XenServer) */
unregisterXenServer(id: XoServer['id']): Promise<void>
updateUser(
id: XoUser['id'],
updates: {
/**
* @deprecated
*/
email?: string
authProviders?: Record<string, string>
name?: string
password?: string
permission?: string
preferences?: Record<string, string>
}
): Promise<void>
updateGroup(
id: XoGroup['id'],
updates: {
name?: string
}
): void
getAllXapis(): Record<string, XapiConnection>
getObjects(opts?: { filter?: Record<string, unknown>; limit?: number }): Record<string, XapiXoRecord>
}

View File

@@ -439,6 +439,7 @@ export type XoPool = BaseXapiXo & {
tags: string[]
type: 'pool'
vtpmSupported: boolean
xosanPackInstallationTime: number | null
zstdSupported: boolean
}
@@ -632,28 +633,11 @@ export type XoTask = {
end?: number
id: Branded<'task'>
infos?: { data: unknown; message: string }[]
progress?: number
properties: {
method?: string
name?: string
objectId?: string
objectType?:
| XapiXoRecord['type']
| 'backup'
| 'backup-archive'
| 'backup-job'
| 'backup-log'
| 'backup-repository'
| 'group'
| 'proxy'
| 'restore'
| 'restore-log'
| 'schedule'
| 'server'
| 'task'
| 'user'
params?: Record<string, unknown>
progress?: number
type?: string
userId?: string
[key: string]: unknown | undefined

View File

@@ -8,8 +8,8 @@
"dependencies": {
"@vates/async-each": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.68.2",
"@xen-orchestra/fs": "^4.6.6",
"@xen-orchestra/backups": "^0.67.0",
"@xen-orchestra/fs": "^4.6.4",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -28,7 +28,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.1.7",
"version": "1.1.6",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -11,7 +11,6 @@ import pickBy from 'lodash/pickBy.js'
import { defer } from 'golike-defer'
import { NegativeDisk } from '@xen-orchestra/disk-transform'
import { openDiskChain } from './disks/openDiskChain.mjs'
import { resetVmOtherConfig } from './_otherConfig.mjs'
const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
async function resolveUuid(xapi, cache, uuid, type) {
@@ -271,7 +270,6 @@ export class ImportVmBackup {
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
),
xapi.call('VM.set_name_description', vmRef, desc),
resetVmOtherConfig(xapi, vmRef),
])
return {

View File

@@ -18,7 +18,7 @@ import groupBy from 'lodash/groupBy.js'
import pDefer from 'promise-toolbox/defer'
import pickBy from 'lodash/pickBy.js'
import reduce from 'lodash/reduce.js'
import * as tar from 'tar'
import tar from 'tar'
import zlib from 'zlib'
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
@@ -31,7 +31,7 @@ import { listPartitions, LVM_PARTITION_TYPE_MBR, LVM_PARTITION_TYPE_GPT } from '
import { lvs, pvs } from './_lvm.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { RemoteVhdDisk } from './disks/RemoteVhdDisk.mjs'
import { RemoteVhd } from './disks/RemoteVhd.mjs'
import { openDiskChain } from './disks/openDiskChain.mjs'
import { toVhdStream, writeToVhdDirectory } from 'vhd-lib/disk-consumer/index.mjs'
import { ReadAhead } from '@xen-orchestra/disk-transform'
@@ -44,7 +44,7 @@ const IMMUTABILITY_METADATA_FILENAME = '/immutability.json'
const { debug, warn } = createLogger('xo:backups:RemoteAdapter')
export const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const noop = Function.prototype
@@ -613,11 +613,6 @@ export class RemoteAdapter {
// if cache is missing or broken => regenerate it and return
async _readCacheListVmBackups(vmUuid) {
// immutable remote can't use any caching
// since the cache file may be non modifiable
if (this._handler.isImmutable()) {
return this.#getCacheableDataListVmBackups(`${BACKUP_DIR}/${vmUuid}`)
}
const path = this.#getVmBackupsCache(vmUuid)
const cache = await this._readCache(path)
@@ -714,7 +709,7 @@ export class RemoteAdapter {
})
} else {
const stream = await toVhdStream(disk)
await this.outputStream(path, stream, { validator, checksum: false })
await this.outputStream(path, stream, { validator })
await validator(path)
}
}
@@ -744,7 +739,7 @@ export class RemoteAdapter {
if (useChain) {
disk = await openDiskChain({ handler, path })
} else {
disk = new RemoteVhdDisk({ handler, path })
disk = new RemoteVhd({ handler, path })
await disk.init()
}
disk = new ReadAhead(disk)

View File

@@ -1,5 +1,4 @@
export const isMetadataFile = filename => filename.endsWith('.json')
export const isVhdFile = filename => filename.endsWith('.vhd')
export const isVhdSumFile = filename => filename.endsWith('.vhd.checksum')
export const isXvaFile = filename => filename.endsWith('.xva')
export const isXvaSumFile = filename => filename.endsWith('.xva.checksum')

View File

@@ -235,7 +235,7 @@ test('it merges delta of non destroyed chain', async () => {
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
// size should be the size of children + grand children after the merge
assert.equal(metadata.size, 104448)
assert.equal(metadata.size, 104960)
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
@@ -402,12 +402,13 @@ describe('tests multiple combination ', { concurrency: 1 }, () => {
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
// size should be the size of children + grand children + clean after the merge
assert.deepEqual(metadata.size, 4404224)
assert.deepEqual(metadata.size, vhdMode === 'file' ? 6502400 : 6501888)
// broken vhd, non referenced, abandoned should be deleted ( alias and data)
// ancestor and child should be merged
// grand child and clean vhd should not have changed
const survivors = await handler.list(basePath)
// console.log(survivors)
if (useAlias) {
const dataSurvivors = await handler.list(basePath + '/data')
// the goal of the alias : do not move a full folder

View File

@@ -4,11 +4,9 @@ import { asyncMap } from '@xen-orchestra/async-map'
import { Constants, openVhd, VhdAbstract, VhdFile } from 'vhd-lib'
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
import { basename, dirname, resolve } from 'node:path'
import { isMetadataFile, isVhdFile, isVhdSumFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
import { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
import { limitConcurrency } from 'limit-concurrency-decorator'
import { RemoteVhdDisk } from './disks/RemoteVhdDisk.mjs'
import { RemoteVhdDiskChain } from './disks/RemoteVhdDiskChain.mjs'
import { MergeRemoteDisk } from './disks/MergeRemoteDisk.mjs'
import { mergeVhdChain } from 'vhd-lib/merge.js'
import { Task } from './Task.mjs'
import { Disposable } from 'promise-toolbox'
@@ -53,15 +51,7 @@ async function _mergeVhdChain(handler, chain, { logInfo, remove, mergeBlockConcu
}
}, 10e3)
try {
const parentDisk = new RemoteVhdDisk({ handler, path: chain.shift() })
const childDisks = []
for (const path of chain) {
childDisks.push(new RemoteVhdDisk({ handler, path }))
}
const childDiskChain = new RemoteVhdDiskChain({ disks: childDisks })
const mergeRemoteDisk = new MergeRemoteDisk(handler, {
return await mergeVhdChain(handler, chain, {
logInfo,
mergeBlockConcurrency,
onProgress({ done: d, total: t }) {
@@ -70,14 +60,6 @@ async function _mergeVhdChain(handler, chain, { logInfo, remove, mergeBlockConcu
},
removeUnused: remove,
})
const isResumingMerge = await mergeRemoteDisk.isResuming(parentDisk)
await parentDisk.init({ force: isResumingMerge })
await childDiskChain.init({ force: isResumingMerge })
const result = await mergeRemoteDisk.merge(parentDisk, childDiskChain)
return result
} finally {
clearInterval(handle)
}
@@ -86,12 +68,10 @@ async function _mergeVhdChain(handler, chain, { logInfo, remove, mergeBlockConcu
const noop = Function.prototype
const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
const listVhds = async (handler, vmDir, logWarn) => {
const vhds = new Set()
const aliases = {}
const interruptedVhds = new Map()
const checksums = new Set()
await asyncMap(
await handler.list(`${vmDir}/vdis`, {
@@ -105,15 +85,11 @@ const listVhds = async (handler, vmDir, logWarn) => {
}),
async vdiDir => {
const list = await handler.list(vdiDir, {
filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file) || isVhdSumFile(file),
filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
})
aliases[vdiDir] = list.filter(vhd => isVhdAlias(vhd)).map(file => `${vdiDir}/${file}`)
await asyncMap(list, async file => {
if (isVhdSumFile(file)) {
checksums.add(`${vdiDir}/${file}`)
return
}
const res = INTERRUPTED_VHDS_REG.exec(file)
if (res === null) {
vhds.add(`${vdiDir}/${file}`)
@@ -135,7 +111,7 @@ const listVhds = async (handler, vmDir, logWarn) => {
)
)
return { vhds, interruptedVhds, aliases, checksums }
return { vhds, interruptedVhds, aliases }
}
export async function checkAliases(
@@ -252,16 +228,7 @@ export async function cleanVm(
const vhdParents = { __proto__: null }
const vhdChildren = { __proto__: null }
const { vhds, interruptedVhds, aliases, checksums } = await listVhds(handler, vmDir, logWarn)
// from 5.110 to 5.113 we computed checksum for vhd file
// but never used nor removed them
await asyncMap(checksums, async path => {
if (remove) {
logInfo('deleting checksum file ', { path })
return handler.unlink(path)
}
})
const { vhds, interruptedVhds, aliases } = await listVhds(handler, vmDir, logWarn)
// remove broken VHDs
await asyncMap(vhds, async path => {
@@ -539,14 +506,14 @@ export async function cleanVm(
const metadataWithMergedVhd = {}
const doMerge = async () => {
await asyncMap(toMerge, async chain => {
const { finalDiskSize } = await limitedMergeVhdChain(handler, chain, {
const { finalVhdSize } = await limitedMergeVhdChain(handler, chain, {
logInfo,
logWarn,
remove,
mergeBlockConcurrency,
})
const metadataPath = vhdsToJSons[chain[chain.length - 1]] // all the chain should have the same metadata file
metadataWithMergedVhd[metadataPath] = (metadataWithMergedVhd[metadataPath] ?? 0) + finalDiskSize
metadataWithMergedVhd[metadataPath] = (metadataWithMergedVhd[metadataPath] ?? 0) + finalVhdSize
})
}

View File

@@ -1,58 +1,5 @@
// @ts-check
import moment from 'moment-timezone'
import assert from 'node:assert'
/**
* @typedef {import('moment-timezone').Moment} Moment
*/
/**
* @typedef {Object} LTRSettings
* @property {number} [firstHourOfTheDay] - First hour of the day (for daily retention)
* @property {number} [firstDayOfWeek] - First day of the week (for weekly retention)
* @property {number} [firstDayOfMonth] - First day of the month (for monthly retention)
* @property {number} [firstDayOfYear] - First day of the year (for yearly retention)
*/
/**
* @typedef {Object} LTRConfig
* @property {number} retention - Number of time buckets to retain
* @property {LTRSettings} [settings] - Optional settings for the retention period
*/
/**
* @typedef {Object} LongTermRetention
* @property {LTRConfig} [daily] - Daily retention configuration
* @property {LTRConfig} [weekly] - Weekly retention configuration
* @property {LTRConfig} [monthly] - Monthly retention configuration
* @property {LTRConfig} [yearly] - Yearly retention configuration
*/
/**
* @typedef {Object} Entry
* @property {number | undefined} timestamp - Unix timestamp of the entry
* @property {string} id
* @property {*} [key] - Any other properties
*/
/**
* @typedef {Object} DateBucket
* @property {number} remaining - Number of remaining buckets to fill
* @property {string|null} lastMatchingBucket - Last bucket key that was matched
* @property {(date: Moment) => string} formatter - Function to format date into bucket key
* @property {Object.<string, Entry>} entries - Map of bucket keys to entries
*/
/**
* @typedef {Object} LTRDefinition
* @property {(options: {firstHourOfTheDay?: number, firstDayOfWeek?: number, firstDayOfMonth?: number, firstDayOfYear?: number, dateCreator: (date: Moment) => Moment}) => (date: Moment) => string} makeDateFormatter
* @property {string} [ancestor] - Name of the parent retention period
*/
/**
* Creates a function that converts dates to moment objects in a specific timezone
* @param {string|undefined} timezone - IANA timezone string (e.g., 'America/New_York')
* @returns {(date: Moment | number) => Moment} Function that creates timezoned moment objects
*/
function instantiateTimezonedDateCreator(timezone) {
return date => {
@@ -62,12 +9,9 @@ function instantiateTimezonedDateCreator(timezone) {
}
}
/**
* @type {Object.<string, LTRDefinition>}
*/
const LTR_DEFINITIONS = {
daily: {
makeDateFormatter: ({ firstHourOfTheDay = 0, dateCreator }) => {
makeDateFormatter: ({ firstHourOfTheDay = 0, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.hour(copy.hour() - firstHourOfTheDay)
@@ -76,11 +20,11 @@ const LTR_DEFINITIONS = {
},
},
weekly: {
makeDateFormatter: ({ firstDayOfWeek = 0 /* relative to timezone week start */, dateCreator }) => {
makeDateFormatter: ({ firstDayOfWeek = 0 /* relative to timezone week start */, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.date(copy.date() - firstDayOfWeek)
copy.date(date.date() - firstDayOfWeek)
// warning, the year in term of week may different from YYYY
// since the computation of the first week of a year is timezone dependent
return copy.format('gggg-ww')
@@ -89,7 +33,7 @@ const LTR_DEFINITIONS = {
ancestor: 'daily',
},
monthly: {
makeDateFormatter: ({ firstDayOfMonth = 0, dateCreator }) => {
makeDateFormatter: ({ firstDayOfMonth = 0, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.date(copy.date() - firstDayOfMonth)
@@ -99,7 +43,7 @@ const LTR_DEFINITIONS = {
ancestor: 'weekly',
},
yearly: {
makeDateFormatter: ({ firstDayOfYear = 0, dateCreator }) => {
makeDateFormatter: ({ firstDayOfYear = 0, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.date(copy.date() - firstDayOfYear)
@@ -109,23 +53,27 @@ const LTR_DEFINITIONS = {
ancestor: 'monthly',
},
}
/**
* Groups entries into date buckets based on long-term retention configuration
* @template {Entry} T
* @param {Array<T>} entries - Array of entries sorted in descending order by timestamp
* @param {LongTermRetention} longTermRetention - Configuration for retention periods
* @param {string | undefined} timezone - IANA timezone string
* @returns {Object.<string, DateBucket>} Map of retention period names to their date buckets
*/
export function getLtrEntries(entries, longTermRetention, timezone) {
/**
* @type {Object.<string, DateBucket>}
*/
// returns all entries but the last retention-th
/**
* return the entries too old to be kept
* if multiple entries are i the same time bucket : keep only the most recent one
* if an entry is valid in any of the bucket OR the minRetentionCount : keep it
* if a bucket is completely empty : it does not count as one, thus it may extend the retention
* @returns Array<Backup>
*/
export function getOldEntries(minRetentionCount, entries, { longTermRetention = {}, timezone } = {}) {
assert.strictEqual(
typeof minRetentionCount,
'number',
`minRetentionCount must be a number, got ${JSON.stringify(minRetentionCount)}`
)
assert.strictEqual(
minRetentionCount >= 0,
true,
`minRetentionCount must be a positive number, got ${JSON.stringify(minRetentionCount)}`
)
const dateBuckets = {}
if (!longTermRetention || Object.keys(longTermRetention).length === 0) {
return {}
}
const dateCreator = instantiateTimezonedDateCreator(timezone)
// only check buckets that have a retention set
for (const [duration, { retention, settings }] of Object.entries(longTermRetention)) {
@@ -133,7 +81,7 @@ export function getLtrEntries(entries, longTermRetention, timezone) {
assert.notStrictEqual(
timezone,
undefined,
`timezone must defined for ltr, got ${JSON.stringify({ longTermRetention, timezone })}`
`timezone must defined for ltr, got ${JSON.stringify({ minRetentionCount, longTermRetention, timezone })}`
)
assert.strictEqual(
typeof retention,
@@ -153,92 +101,45 @@ export function getLtrEntries(entries, longTermRetention, timezone) {
}
}
const nb = entries.length
const minDurationEntries = []
let previousTimestamp = -1
for (let i = nb - 1; i >= 0; i--) {
const entry = entries[i]
// force it so the compiler is sure we have a timestamp
if (entry.timestamp === undefined) {
assert.notStrictEqual(
entry.timestamp,
undefined,
`Can't compute long term retention if entries don't have a timestamp`
if (entry.timestamp !== undefined) {
// we go through the entries from the last (most recent) to the first (oldest)
assert.ok(
previousTimestamp === -1 || entry.timestamp < previousTimestamp,
`entries must be sorted in desc order ${new Date(entry.timestamp)} , previous : > ${new Date(previousTimestamp)} `
)
continue
}
// we go through the entries from the last (most recent) to the first (oldest)
assert.ok(
previousTimestamp === -1 || entry.timestamp < previousTimestamp,
`entries must be sorted in desc order ${new Date(entry.timestamp)} , previous : > ${new Date(previousTimestamp)} `
)
previousTimestamp = entry.timestamp
const entryDate = dateCreator(entry.timestamp)
for (const [duration, { remaining, lastMatchingBucket, formatter }] of Object.entries(dateBuckets)) {
const bucket = formatter(entryDate)
if (lastMatchingBucket !== bucket) {
if (remaining === 0) {
continue
previousTimestamp = entry.timestamp
const entryDate = dateCreator(entry.timestamp)
for (const [duration, { remaining, lastMatchingBucket, formatter }] of Object.entries(dateBuckets)) {
const bucket = formatter(entryDate)
if (lastMatchingBucket !== bucket) {
if (remaining === 0) {
continue
}
dateBuckets[duration].lastMatchingBucket = bucket
dateBuckets[duration].remaining -= 1
}
dateBuckets[duration].lastMatchingBucket = bucket
dateBuckets[duration].remaining -= 1
dateBuckets[duration].entries[bucket] = entry
}
dateBuckets[duration].entries[bucket] = entry
} else {
// replicated VM entries or snapshot retention don't have a timestamp
// but also can't have LTR
assert.deepStrictEqual(
longTermRetention,
{},
"Can't compute long term retention if entries don't have a timestamp"
)
}
// also keep it on retention
if (i >= nb - minRetentionCount) {
minDurationEntries.push(entry)
}
}
return dateBuckets
}
/**
* return the status of an entry
* @param {Array<Entry>} entries
* @param {Entry} entry
* @param {LongTermRetention} longTermRetention - Configuration for retention periods
* @param {string | undefined} timezone - IANA timezone string
* @returns {Array<{duration: string, dateBucket:string}>}
*/
export function getEntryStatus(entries, entry, longTermRetention, timezone) {
const dateBuckets = getLtrEntries(entries, longTermRetention, timezone)
const entryBuckets = []
for (const [duration, { entries }] of Object.entries(dateBuckets)) {
for (const [dateBucket, bucketEntry] of Object.entries(entries)) {
if (entry.id === bucketEntry.id) {
entryBuckets.push({ duration, dateBucket })
}
}
}
return entryBuckets
}
/**
* return the entries too old to be kept
* if multiple entries are in the same time bucket: keep only the oldest one
* if an entry is valid in any of the bucket OR the minRetentionCount: keep it
* if a bucket is completely empty: it does not count as one, thus it may extend the retention
* if an entry is within the most recents minRetentionCount entries : keep it
*
* @param {number} minRetentionCount - Minimum number of most recent entries to always keep
* @param {Array<Entry>} entries - Array of entries sorted in descending order by timestamp
* @param {Object} options - Additional options
* @param {LongTermRetention} [options.longTermRetention={}] - Configuration for retention periods
* @param {string | undefined} [options.timezone] - IANA timezone string
* @returns {Array<Entry>} Array of entries that can be removed
*/
export function getOldEntries(minRetentionCount, entries, { longTermRetention = {}, timezone } = {}) {
assert.strictEqual(
typeof minRetentionCount,
'number',
`minRetentionCount must be a number, got ${JSON.stringify(minRetentionCount)}`
)
assert.ok(
minRetentionCount >= 0,
`minRetentionCount must be a positive number, got ${JSON.stringify(minRetentionCount)}`
)
const dateBuckets = getLtrEntries(entries, longTermRetention, timezone)
const kept = new Set(entries.filter((_, index) => index >= entries.length - minRetentionCount))
/**
* @type {Set<Entry>}
*/
const kept = new Set(minDurationEntries)
for (const { entries } of Object.values(dateBuckets)) {
for (const entry of Object.values(entries)) {
kept.add(entry)
@@ -246,7 +147,7 @@ export function getOldEntries(minRetentionCount, entries, { longTermRetention =
}
// ensure order is the same as the source
const oldEntries = entries.filter((entry, index) => {
const oldEntries = entries.filter(entry => {
return !kept.has(entry)
})

View File

@@ -22,6 +22,7 @@ describe('_getOldEntries() should succeed', () => {
expectedIds: [1],
testLabel: 'should handle number based retention without timestamp ',
},
{
args: [
0,

View File

@@ -291,16 +291,10 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
await xapi.VTPM_create({ VM: vmRef, contents })
})
)
const vm = await xapi.getRecord('VM', vmRef)
await Promise.all([
vmRecord.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', vmRecord.name_label),
// correctly unlock the VM and reapply the target blocked operations
vm.update_blocked_operations({
start: null,
start_on: null,
...vmRecord.blocked_operations,
}),
incrementalVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', incrementalVm.vm.name_label),
])
return vmRef

View File

@@ -70,8 +70,7 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
/**
*
* Reset the other_config field related to backups of a VM and its VDIs
*
* Reset the other_config field of a VM and its VDIs
*
* @param {Xapi} xapi
* @param {String} vmRef
@@ -91,6 +90,39 @@ export function resetVmOtherConfig(xapi, vmRef) {
})
}
/**
*
* used to ensure compatibility with the previous snapshots that were having the config stored only into VM
*
* @param {Xapi} xapi
* @param {String} vmRef
* @returns {Promise}
*/
export async function populateVdisOtherConfig(xapi, vmRef) {
const otherConfig = await xapi.getField('VM', vmRef, 'other_config')
const {
[DATETIME]: datetime,
[DELTA_CHAIN_LENGTH]: chainLength,
[EXPORTED_SUCCESSFULLY]: successfully,
[JOB_ID]: jobId,
[REPLICATED_TO_SR_UUID]: replicatedTo,
[SCHEDULE_ID]: scheduleId,
[VM_UUID]: vmUuid,
} = otherConfig
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
xapi.setFieldEntries(type, ref, 'other_config', {
[DATETIME]: datetime,
[DELTA_CHAIN_LENGTH]: chainLength,
[EXPORTED_SUCCESSFULLY]: successfully,
[JOB_ID]: jobId,
[REPLICATED_TO_SR_UUID]: replicatedTo,
[SCHEDULE_ID]: scheduleId,
[VM_UUID]: vmUuid,
})
)
}
/**
*
* set the other_config key related to a backup of a VM and its associated VDIs

View File

@@ -2,11 +2,10 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriters() {
return [FullRemoteWriter, AggregatedFullRemoteWriter]
_getRemoteWriter() {
return FullRemoteWriter
}
_filterTransferList(transferList) {

View File

@@ -5,14 +5,12 @@ import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { FullXapiWriter } from '../_writers/FullXapiWriter.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AbstractXapi } from './_AbstractXapi.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
import { AggregatedFullXapiWriter } from '../_writers/AggregatedFullXapiWriter.mjs'
const { debug } = createLogger('xo:backups:FullXapiVmBackup')
export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [FullRemoteWriter, FullXapiWriter, AggregatedFullRemoteWriter, AggregatedFullXapiWriter]
return [FullRemoteWriter, FullXapiWriter]
}
async _mustDoSnapshot() {
@@ -66,16 +64,9 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
const vdi = await this._xapi.getRecord('VDI', vdiRef)
// the size a of fully allocated vdi will be virtual_size exactly, it's a gross over evaluation
// of the real stream size in general, since a disk is rarely completely full
// of the real stream size in general, since a disk is never completely full
// vdi.physical_size seems to underevaluate a lot the real disk usage of a VDI, as of 2023-10-30
// xva files are tar archive with data cut in 1MB blocks
// each block is signed with a hash (in xxhash format)
// each of these blocks + hash add one tar header entry in the file
const XVA_BLOCK_SIZE = 1024 * 1024
const nbBlocks = Math.ceil(vdi.virtual_size / XVA_BLOCK_SIZE)
const headersSize = nbBlocks * (512 /* file header */ + 512 /* xxhash header */ + 512) /* xhxhash size */
maxStreamLength += XVA_BLOCK_SIZE * nbBlocks + headersSize
maxStreamLength += vdi.virtual_size
}
const sizeContainer = watchStreamSize(stream)

View File

@@ -11,12 +11,11 @@ import { Disposable } from 'promise-toolbox'
import { openVhd } from 'vhd-lib'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
import { SynchronizedDisk, ThrottledDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
const { warn } = createLogger('xo:backups:Incrementalremote')
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriters() {
return [IncrementalRemoteWriter, AggregatedIncrementalRemoteWriter]
_getRemoteWriter() {
return IncrementalRemoteWriter
}
// we'll transfer the full list if at least one backup should be transferred

View File

@@ -15,19 +15,12 @@ import {
markExportSuccessfull,
} from '../../_otherConfig.mjs'
import { ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
import { AggregatedIncrementalXapiWriter } from '../_writers/AggregatedIncrementalXapiWriter.mjs'
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [
IncrementalRemoteWriter,
IncrementalXapiWriter,
AggregatedIncrementalRemoteWriter,
AggregatedIncrementalXapiWriter,
]
return [IncrementalRemoteWriter, IncrementalXapiWriter]
}
async _mustDoSnapshot() {

View File

@@ -45,41 +45,26 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
const writers = new Set()
this._writers = writers
const [BackupWriter, AggregratedBackupWriter] = this._getRemoteWriters()
if (settings.distributeBackups) {
const RemoteWriter = this._getRemoteWriter()
extractIdsFromSimplePattern(job.remotes).forEach(remoteId => {
const adapter = remoteAdapters[remoteId]
const targetSettings = {
...settings,
...allSettings[remoteId],
}
writers.add(
new AggregratedBackupWriter({
adapters: remoteAdapters,
BackupWriter,
new RemoteWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid,
settings,
remoteId,
settings: targetSettings,
})
)
} else {
extractIdsFromSimplePattern(job.remotes).forEach(remoteId => {
const adapter = remoteAdapters[remoteId]
const targetSettings = {
...settings,
...allSettings[remoteId],
}
writers.add(
new BackupWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid,
remoteId,
settings: targetSettings,
})
)
})
}
})
const { filter } = job
if (filter === undefined) {
this._filterPredicate = () => true
@@ -118,10 +103,17 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
}
if (transferList.length > 0) {
const filteredTransferList = this._filterTransferList(transferList)
return { transferList: filteredTransferList, filtered: true }
if (filteredTransferList.length > 0) {
return filteredTransferList
} else {
Task.info('This VM is excluded by the job filter')
return []
}
} else {
return { transferList, filtered: false }
Task.info('No new data to upload for this VM')
}
return []
}
/**
@@ -135,21 +127,12 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
Object.values(this.remoteAdapters).map(remoteAdapter => remoteAdapter.listVmBackups(this._vmUuid, vmPredicate))
)
const sourceBackupByJobId = groupBy(sourceBackups, 'jobId')
const transferObjectsList = await Promise.all(
const transferByJobs = await Promise.all(
Object.values(sourceBackupByJobId).map(vmBackupsByJob =>
this.#computeTransferListPerJob(vmBackupsByJob, remotesBackups)
)
)
const transferList = transferObjectsList.map(transferObject => transferObject.transferList).flat(1)
if (transferList.length === 0 && Object.values(sourceBackupByJobId).length > 0) {
if (transferObjectsList.some(transferObject => transferObject.filtered)) {
// Happens if any job transfer list wasn't empty before filter and is empty after filter.
Task.info('This VM is excluded by the job filter')
} else {
Task.info('No new data to upload for this VM')
}
}
return transferList
return transferByJobs.flat(1)
}
async run($defer) {

View File

@@ -1,20 +1,21 @@
import assert from 'node:assert'
import groupBy from 'lodash/groupBy.js'
import { createLogger } from '@xen-orchestra/log'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { asyncMap } from '@xen-orchestra/async-map'
import { asyncEach } from '@vates/async-each'
import { decorateMethodsWith } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { Task } from '../../Task.mjs'
import { Abstract } from './_Abstract.mjs'
import { DATETIME, JOB_ID, SCHEDULE_ID, VM_UUID, resetVmOtherConfig, setVmOtherConfig } from '../../_otherConfig.mjs'
const { warn, info } = createLogger('xo:backups:AbstractXapi')
const TEMP_SNAPSHOT_NAME = 'xo-backup-temp-snapshot-name'
import {
DATETIME,
JOB_ID,
SCHEDULE_ID,
populateVdisOtherConfig,
resetVmOtherConfig,
setVmOtherConfig,
} from '../../_otherConfig.mjs'
export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
constructor({
@@ -82,84 +83,48 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
const writers = new Set()
this._writers = writers
const [BackupWriter, ReplicationWriter, AggregratedBackupWriter, AggregratedReplicationWriter] =
this._getWriters()
const [BackupWriter, ReplicationWriter] = this._getWriters()
const allSettings = job.settings
if (settings.exportRetention > 0) {
if (settings.distributeBackups) {
Object.entries(remoteAdapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(
new AggregratedBackupWriter({
adapters: remoteAdapters,
BackupWriter,
new BackupWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
settings,
remoteId,
settings: targetSettings,
})
)
} else {
Object.entries(remoteAdapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(
new BackupWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
remoteId,
settings: targetSettings,
})
)
}
})
}
}
if (settings.copyRetention) {
if (settings.distributeReplications) {
})
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(
new AggregratedReplicationWriter({
new ReplicationWriter({
config,
healthCheckSr,
job,
ReplicationWriter,
scheduleId: schedule.id,
vmUuid: vm.uuid,
srs,
settings,
sr,
settings: targetSettings,
})
)
} else {
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(
new ReplicationWriter({
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
sr,
settings: targetSettings,
})
)
}
})
}
}
})
}
}
@@ -195,7 +160,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
ignoredVdisTag: '[NOBAK]',
name_label: TEMP_SNAPSHOT_NAME,
name_label: this._getSnapshotNameLabel(vm),
unplugVusbs: true,
})
this.timestamp = Date.now()
@@ -205,9 +170,6 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
scheduleId: this.scheduleId,
vmUuid: vm.uuid,
})
const snapshot = await xapi.getRecord('VM', snapshotRef)
await snapshot.set_name_label(this._getSnapshotNameLabel(vm))
// reload data to ensure it is up to date with the new name label
this._exportedVm = await xapi.getRecord('VM', snapshotRef)
return this._exportedVm.uuid
})
@@ -217,113 +179,43 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
}
}
// handle snapshot by VDI since snapshot of cbtDestroySnapshotData jobs
// aren't attached to any VM
// look at all the vdi snapshots ( snapshot_of not empty )
// with the same vm_uuid and job_uuid
// for cbt_metadata list them unconditionnaly to remove older one
// for other: only list them if they are attached to a VM snapshot
// and if this vm snapshot is also part of the backup
// ensure they are attached to only one vm snapshot
// ensure any VM-snapshot harvested by this has all its disk harvested (no mix of vdi snapshot from this job and not)
async _fetchJobSnapshots() {
const jobId = this._jobId
const vmRef = this._vm.$ref
const xapi = this._xapi
const vdiCandidates = {}
// to ensure compatibility with snapshots older than CBT implementation
// update vdi data to ensure the vdi are correctly fetched in _jobSnapshotVdis
// remove by then end of 2024
const vmSnapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
const vmSnapshotsOtherConfig = await asyncMap(vmSnapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
Object.values(xapi.objects.indexes.type.VDI)
.filter(_ => !!_) // filter nullish
.filter(({ other_config, $snapshot_of }) => {
return $snapshot_of !== undefined && other_config[JOB_ID] === jobId && other_config[VM_UUID] === this._vm.uuid
})
.forEach(vdi => {
vdiCandidates[vdi.uuid] = vdi
})
// check that user snapshots are clean
for (const vdi of Object.values(vdiCandidates)) {
// cbt metadata are always considered linked to a backup job
// if they have the right other_config
if (vdi.type === 'cbt_metadata') {
continue
}
const vbds = vdi.$VBDs
.filter(({ $VM }) => !!$VM) // filter empty VMs
.filter(({ $VM }) => $VM.is_control_domain === false) // do not handle control domain
if (vbds.length === 0) {
// orphan vdi snapshot
info(
`disk snapshot ${vdi.name_label} is orphan or attached only to control domain,
it will be removed at the end of a successful backup run`,
{ vdi, attachedto: vdi.$VBDs.map(vbd => vbd?.$VM) }
)
continue
}
const userVms = vbds.map(({ $VM }) => $VM)
if (vbds.length > 1) {
warn(
`vdi ${vdi.name_label} (${vdi.uuid}) is linked to multipe vms : ${userVms.map(({ name_label, uuid }) => `${name_label} ${uuid}`).join(', ')}.
This disk snapshot will be excluded from the backup cleaning`,
{ vdi, userVms }
)
delete vdiCandidates[vdi.uuid]
continue
const vmSnapshots = []
vmSnapshotsOtherConfig.forEach((other_config, i) => {
if (other_config[JOB_ID] === jobId) {
vmSnapshots.push({ other_config, $ref: vmSnapshotsRef[i] })
}
})
await Promise.all(vmSnapshots.map(snapshot => populateVdisOtherConfig(xapi, snapshot.$ref)))
// end of compatibility handling
const vm = vbds[0].$VM
// xapi has some issue regarding vdi snapshot attached to non snapshot VM
// it can also be some user action forcibely linking a vdi snapshot to a vm
// => we exclude these from the backup processing
if (vm.$snapshot_of === undefined) {
warn(
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a non snapshot vm ${vm.name_label} ${vm.uuid}.
This disk snapshot will be excluded from the backup cleaning`,
{ vdi, vm }
)
delete vdiCandidates[vdi.uuid]
continue
// handle snapshot by VDI
this._jobSnapshotVdis = []
const srcVdis = await xapi.getRecords('VDI', await this._vm.$getDisks())
for (const srcVdi of srcVdis) {
const snapshots = await xapi.getRecords('VDI', srcVdi.snapshots)
for (const snapshot of snapshots) {
// only keep the snapshot related to this backup job
// and only if the job is still using purge snapshot data or if the disk
// is not a cbt metadata disk ( expect a type: user for normal disks)
if (
snapshot.other_config[JOB_ID] === jobId &&
(this._settings.cbtDestroySnapshotData || snapshot.type !== 'cbt_metadata')
) {
this._jobSnapshotVdis.push(snapshot)
}
}
// vdi is attached only to a snapshot that is not a backup snapshot
// we don't check scheduleId since we are looking for all the snapshot of this job
// => excludes from the list to be cleared
if (
vm.other_config[DATETIME] !== vdi.other_config[DATETIME] ||
vm.other_config[JOB_ID] !== vdi.other_config[JOB_ID] ||
vm.other_config[VM_UUID] !== vdi.other_config[VM_UUID]
) {
warn(
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a snapshot vm ${vm.name_label} ${vm.uuid} out of this backup job scope.
This disk snapshot will be excluded from the backup cleaning`,
{ vdi, vm }
)
delete vdiCandidates[vdi.uuid]
continue
}
// check if all the disks of these VM snapshot have been harvested
// if not => remove it from the list to ensure we won't half destroy VM later
vm.$VBDs
.filter(({ $VDI }) => !!$VDI) // filter missing keys
.filter(({ $VDI }) => $VDI && vdiCandidates[$VDI.uuid] === undefined)
.forEach(({ $VDI: outOfSnapshotsVdi, ...other }) => {
warn(
`vdi ${vdi.name_label} ${vdi.uuid} is recognized as a snapshot of the backup job,
linked to vm ${vm.name_label} ${vm.uuid} but vdi ${outOfSnapshotsVdi.name_label} ${outOfSnapshotsVdi.uuid}
is not linked to the job. This disk snapshot will be excluded from the backup cleaning`,
{ vdi, vm, vbds, outOfSnapshotsVdi }
)
// this will be called multiple time but it is not really an issue
delete vdiCandidates[vdi.uuid]
})
}
this._jobSnapshotVdis = Object.values(vdiCandidates)
}
async _removeUnusedSnapshots() {
@@ -363,47 +255,28 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
return
}
const vdis = snapshotPerDatetime[datetime]
let vm
let vmRef
// if there is an attached VM => destroy the VM (Non CBT backups)
for (const vdi of vdis) {
const vbds = vdi.$VBDs.filter(({ $VM }) => $VM.is_control_domain === false)
if (vbds.length > 0) {
// only one VM linked to this vdi
// this will throw error for VDI still attached to control domain
// since we won't be able to remove an attached VDI
assert.strictEqual(vbds.length, 1, 'VDI must be free or attached to exactly one VM')
const vdiVm = vbds[0].$VM
if (vdiVm.$snapshot_of === undefined) {
// don't delete a VM (especially a control domain)
warn(
`VM ${vdiVm.uuid} (${vdiVm.name_label}) linked to VDI ${vdi.uuid} (${vdi.name_label}) should be a snapshot`
)
throw new Error(`VM must be a snapshot`)
}
if (vm !== undefined && vm.$ref !== vdiVm.$ref) {
// this VDI is attached to another VM than the other vdi of
// this batch
// in doubt, do not delete anything
warn("_removeUnusedSnapshots don't handle vdi related to multiple VMs ", {
vm1: {
label: vm.name_label,
id: vm.$id,
},
vm2: {
label: vdiVm.name_label,
id: vdiVm.$id,
},
vdis: vdis.map(({ name_label, $id }) => ({ name_label, $id })),
})
throw new Error(
`_removeUnusedSnapshots don't handle vdi related to multiple VMs ${vm.name_label} and ${vdiVm.name_label}`
)
}
vm = vdiVm
const vm = vbds[0].$VM
assert.strictEqual(vm.is_a_snapshot, true, `VM must be a snapshot`) // don't delete a VM (especially a control domain)
const vmRefVdi = vm.$ref
// same vm than other vdi of the same batch
assert.ok(
vmRef === undefined || vmRef === vmRefVdi,
'_removeUnusedSnapshots don t handle vdi related to multiple VMs '
)
vmRef = vmRefVdi
}
}
if (vm?.$ref !== undefined) {
return xapi.VM_destroy(vm.$ref)
if (vmRef !== undefined) {
return xapi.VM_destroy(vmRef)
} else {
return asyncMap(
vdis.map(async ({ $ref }) => {
@@ -413,11 +286,6 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
}
})
})
// list and remove the snapshot were the jobs failed between
// makesnapshot and update_other_config
const snapshots = this._vm.$snapshots.filter(_ => !!_).filter(({ name_label }) => name_label === TEMP_SNAPSHOT_NAME)
await asyncEach(snapshots, snapshot => snapshot.$destroy())
}
async _removeSnapshotData() {
@@ -425,9 +293,8 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
// going back to a previous version of XO not supporting CBT will create a full backup
// this will only do something after snapshot and transfer
if (
this._exportedVm !== undefined &&
// don't modify the VM
this._exportedVm.$snapshot_of !== undefined &&
this._exportedVm?.is_a_snapshot &&
// user don't want to keep the snapshot data
this._settings.snapshotRetention === 0 &&
// preferNbd is not a guarantee that the backup used NBD, depending on the network configuration,

View File

@@ -1,38 +0,0 @@
import { AbstractAggregatedRemoteWriter } from './_AbstractAggregatedRemoteWriter.mjs'
export class AggregatedFullRemoteWriter extends AbstractAggregatedRemoteWriter {
async deleteOldBackupsOnAdapter(adapter, backups) {
await adapter.deleteFullVmBackups(backups)
}
// take the lock on every adapters with this VM
// cleanup if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
await this.setOldBackups()
if (this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
}
async run(props) {
const mainAdapter = await this.computeAdapterCandidate()
this.mainWriter = this.getWriterByAdapter(mainAdapter)
return this.mainWriter.run(props)
}
// cleanup if deleteFirst is NOT selected
// release the lock on every adapters with this VM
async afterBackup() {
if (!this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -1,31 +0,0 @@
import { AbstractAggregatedXapiWriter } from './_AbstractAggregatedXapiWriter.mjs'
export class AggregatedFullXapiWriter extends AbstractAggregatedXapiWriter {
// delete replica if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
this.setOldReplicaList()
if (this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
}
async run(props) {
const mainSr = await this.computeSRCandidate()
this.mainWriter = this.getWriterBySr(mainSr)
await this.mainWriter.run(props)
}
// delete replica if not using deleteFirst
async afterBackup() {
if (!this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -1,78 +0,0 @@
import { AbstractAggregatedRemoteWriter } from './_AbstractAggregatedRemoteWriter.mjs'
export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteWriter {
async deleteOldBackupsOnAdapter(adapter, backups) {
await adapter.deleteDeltaVmBackups(backups)
}
/**
*
* @param {Map<string,string>} baseUuidToSrcVdi
*/
async checkBaseVdis(baseUuidToSrcVdi) {
let selectedCopy = new Map()
for (const writer of this.writers) {
const copy = new Map(baseUuidToSrcVdi)
await writer.checkBaseVdis(copy)
if (copy.size > 0) {
// there can be multiple candidates
// we use the last one in this case
this.mainWriter = writer
selectedCopy = copy
}
}
// update the parameter with our best candidate
// that can be an empty map
baseUuidToSrcVdi.forEach(key => {
if (!selectedCopy.has(key)) {
baseUuidToSrcVdi.delete(key)
}
})
}
// take the lock on every adapters with this VM
// cleanup if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
await this.setOldBackups()
if (this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
}
async prepare({ isFull }) {
if (this.mainWriter === undefined) {
// use the remote with the most empty space for starting a new chain
const mainAdapter = await this.computeAdapterCandidate()
this.mainWriter = this.getWriterByAdapter(mainAdapter)
}
await Promise.all(this.writers.map(writer => writer.prepare({ isFull })))
}
// write only on the main writer
transfer({ isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
return this.mainWriter.transfer({ isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot })
}
// chain only on the main writer
updateUuidAndChain({ isVhdDifferencing, vdis }) {
return this.mainWriter.updateUuidAndChain({ isVhdDifferencing, vdis })
}
// remove the backups and remove the entries
async cleanup() {
if (!this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
await Promise.all(this.writers.map(writer => writer.cleanup()))
}
// cleanVM on all, since we may want to delete a chani
// that is not on the main writer
async afterBackup() {
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -1,85 +0,0 @@
import { AbstractAggregatedXapiWriter } from './_AbstractAggregatedXapiWriter.mjs'
import { createLogger } from '@xen-orchestra/log'
const { debug } = createLogger('xo:backups:AggregatedIncrementalXapiWriter')
export class AggregatedIncrementalXapiWriter extends AbstractAggregatedXapiWriter {
/**
*
* @param {Map<string,string>} baseUuidToSrcVdi
*/
async checkBaseVdis(baseUuidToSrcVdi) {
debug('checkBaseVdis', { baseUuidToSrcVdi })
let selectedCopy = new Map()
for (const writer of this.writers) {
const copy = new Map(baseUuidToSrcVdi)
await writer.checkBaseVdis(copy)
if (copy.size > 0) {
debug('checkBaseVdis found a mainwriter candidate ', writer._sr.name_label)
// there can be multiple candidates
// we use the last one in this case
this.mainWriter = writer
selectedCopy = copy
} else {
debug(writer._sr.name_label, 'is not a delta candidate')
}
}
// update the parameter with our best candidate
// that can be an empty map
baseUuidToSrcVdi.forEach(key => {
if (!selectedCopy.has(key)) {
baseUuidToSrcVdi.delete(key)
}
})
debug('checkBaseVdis', { baseUuidToSrcVdi })
}
// take the lock on every adapters with this VM
// cleanup if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
this.setOldReplicaList()
if (this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
}
async prepare(args) {
if (this.mainWriter === undefined) {
debug('no mainwriter found, fallback to a new SR')
// use the SR with the most empty space for starting a new chain
const mainSr = await this.computeSRCandidate()
this.mainWriter = this.getWriterBySr(mainSr)
}
await Promise.all(this.writers.map(writer => writer.prepare(args)))
}
// write only on the main writer
transfer(args) {
return this.mainWriter.transfer(args)
}
// chain only on the main writer
updateUuidAndChain(args) {
return this.mainWriter.updateUuidAndChain(args)
}
// remove the backups and remove the entries
async cleanup() {
debug('cleanup')
if (!this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
await Promise.all(this.writers.map(writer => writer.cleanup()))
}
// cleanVM on all, since we may want to delete a chani
// that is not on the main writer
async afterBackup() {
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -59,8 +59,8 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
xva: './' + dataBasename,
}
const { skipDeleteOldEntries, deleteFirst } = settings
if (!skipDeleteOldEntries && deleteFirst) {
const { deleteFirst } = settings
if (deleteFirst) {
await deleteOldBackups()
}
@@ -73,10 +73,9 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
return { size: sizeContainer.size }
})
metadata.size = sizeContainer.size
metadata.tags = await this.getLongTermRetentionTags(metadata)
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadata)
if (!skipDeleteOldEntries && !deleteFirst) {
if (!deleteFirst) {
await deleteOldBackups()
}

View File

@@ -46,8 +46,8 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
const oldVms = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vm.uuid))
const deleteOldBackups = () => asyncMapSettled(oldVms, vm => xapi.VM_destroy(vm.$ref))
const { deleteFirst, skipDeleteOldEntries, _warmMigration } = settings
if (!skipDeleteOldEntries && deleteFirst) {
const { deleteFirst, _warmMigration } = settings
if (deleteFirst) {
await deleteOldBackups()
}
@@ -85,7 +85,7 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
}),
])
if (!skipDeleteOldEntries && !deleteFirst) {
if (!deleteFirst) {
await deleteOldBackups()
}
}

View File

@@ -114,13 +114,13 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
oldEntries.length = maxMergedDeltasPerRun
}
if (settings.deleteFirst && !settings.skipDeleteOldEntries) {
if (settings.deleteFirst) {
await this._deleteOldEntries()
}
}
async cleanup() {
if (!this._settings.deleteFirst && !this._settings.skipDeleteOldEntries) {
if (!this._settings.deleteFirst) {
await this._deleteOldEntries()
}
}
@@ -244,7 +244,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
return { size }
})
metadataContent.tags = await this.getLongTermRetentionTags(metadataContent)
metadataContent.size = size // @todo return exactly the size written by this writer
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadataContent)

View File

@@ -1,6 +1,7 @@
import { asyncMapSettled } from '@xen-orchestra/async-map'
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { importIncrementalVm } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
@@ -8,18 +9,8 @@ import { Task } from '../../Task.mjs'
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
import { listReplicatedVms } from './_listReplicatedVms.mjs'
import {
COPY_OF,
setVmOtherConfig,
BASE_DELTA_VDI,
JOB_ID,
SCHEDULE_ID,
REPLICATED_TO_SR_UUID,
DATETIME,
VM_UUID,
} from '../../_otherConfig.mjs'
import { COPY_OF, setVmOtherConfig, BASE_DELTA_VDI } from '../../_otherConfig.mjs'
import assert from 'node:assert'
import { formatFilenameDate } from '../../_filenameDate.mjs'
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
@@ -83,7 +74,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vmUuid))
if (settings.deleteFirst && settings.skipDeleteOldEntries) {
if (settings.deleteFirst) {
// we want to keep the baseVM when copying a delta
// even if we want to keep only one after
let mostRecentEntry
@@ -96,37 +87,19 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
}
async cleanup() {
if (!this._settings.skipDeleteOldEntries) {
await this._deleteOldEntries()
}
await this._deleteOldEntries()
}
async _deleteOldEntries() {
return asyncMapSettled(this._oldEntries, vm => vm.$destroy({ bypassBlockedOperation: true }))
return asyncMapSettled(this._oldEntries, vm => vm.$destroy())
}
#decorateVmMetadata(backup, timestamp) {
#decorateVmMetadata(backup) {
const { _warmMigration } = this._settings
const sr = this._sr
const vm = backup.vm
const job = this._job
const scheduleId = this._scheduleId
vm.name_label = `${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`
// update other_config data as soon as possible to ensure the next job
// will be able to detect any partial transfer and lean them
vm.other_config[COPY_OF] = vm.uuid
vm.other_config[JOB_ID] = job.id
vm.other_config[SCHEDULE_ID] = scheduleId
vm.other_config[REPLICATED_TO_SR_UUID] = sr.uuid
// set the timestamp in the past to ensure any incomplete VM will be deleted on next run
vm.other_config[DATETIME] = formatFilenameDate(0)
vm.blocked_operations = {
start: 'Start operation for this vm is blocked, clone it if you want to use it.',
start_on: 'Start operation for this vm is blocked, clone it if you want to use it.',
}
if (!_warmMigration) {
vm.tags.push('Continuous Replication')
}
@@ -145,11 +118,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
Object.values(backup.vdis).forEach(vdi => {
vdi.other_config[COPY_OF] = vdi.uuid
vdi.other_config[JOB_ID] = job.id
vdi.other_config[SCHEDULE_ID] = scheduleId
vdi.other_config[REPLICATED_TO_SR_UUID] = sr.uuid
vdi.other_config[VM_UUID] = vm.uuid
if (sourceVdiUuids.length > 0) {
const baseReplicatedTo = replicatedVdis.filter(
replicatedVdi => replicatedVdi.other_config[COPY_OF] === vdi.other_config[BASE_DELTA_VDI]
@@ -176,11 +144,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
const sr = this._sr
const job = this._job
const scheduleId = this._scheduleId
const { uuid: srUuid, $xapi: xapi } = sr
let targetVmRef
await Task.run({ name: 'transfer' }, async () => {
targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr)
targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport), sr)
// size is mandatory to ensure the task have the right data
return {
size: Object.values(deltaExport.disks).reduce(
@@ -197,8 +166,15 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
!_warmMigration &&
targetVm.ha_restart_priority !== '' &&
Promise.all([targetVm.set_ha_restart_priority(''), targetVm.add_tags('HA disabled')]),
targetVm.set_name_label(`${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`),
asyncMap(['start', 'start_on'], op =>
targetVm.update_blocked_operations(
op,
'Start operation for this vm is blocked, clone it if you want to use it.'
)
),
setVmOtherConfig(xapi, targetVmRef, {
timestamp, // updated at the end to mark the transfer as complete
timestamp,
jobId: job.id,
scheduleId,
vmUuid: vm.uuid,

View File

@@ -1,157 +0,0 @@
import { asyncEach } from '@vates/async-each'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { compareTimestamp } from '../../RemoteAdapter.mjs'
import { createLogger } from '@xen-orchestra/log'
const { debug } = createLogger('xo:backups:AbstractAggregatedRemoteWriter')
export const DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY = 2
export class AbstractAggregatedRemoteWriter {
#BackupWriter
#adapters
get adapters() {
return this.#adapters
}
mainWriter
#props
get props() {
return this.#props
}
#oldBackups
#writers = []
get writers() {
return this.#writers
}
constructor({ adapters, BackupWriter, ...props }) {
debug('instantiate AggregatedFullRemoteWriter')
if (Object.keys(adapters).length < 1) {
throw new Error('AggregatedRemoteWriter need at least one backup repository to work ')
}
this.#props = props
this.#adapters = adapters
this.#BackupWriter = BackupWriter
}
getWriterByAdapter(adapter) {
for (const writer of this.#writers) {
if (writer._adapter === adapter) {
return writer
}
}
throw new Error("Can't find writer for adapter")
}
/**
* select the best adapter candidate to write this backup
* if all the adapter infos expose an available info use the one with the more free space
* if none of the adapter exposes this info : choose at random
* if mixed : throw an error
* @returns {RemoteAdapter}
*/
async computeAdapterCandidate() {
const adapters = Object.values(this.#adapters)
const infos = await Promise.all(
adapters.map(async adapter => {
const infos = await adapter._handler.getInfo()
return { adapter, infos }
})
)
const withLimit = infos.filter(({ infos: { available } }) => available > 0).length
if (withLimit !== 0 && withLimit !== infos.length) {
throw new Error(`an Aggregate remote can't use a mix of limited and unlimited storages`)
}
let candidates
if (withLimit === 0) {
debug('no storage limit , choose at random')
candidates = Object.values(this.adapters)
} else {
debug('storage limit , choose at random between those with the max space avilable')
let maxSpace = 0
infos.forEach(({ infos: { available } }) => {
if (available > maxSpace) {
maxSpace = available
}
})
// candidates are the adapter with the same free space , equal to the max free space
candidates = infos.filter(({ infos }) => infos.available === maxSpace).map(({ adapter }) => adapter)
}
const adapterCandidate = candidates[Math.floor(Math.random() * candidates.length)]
debug('adapter candidate is selected ', adapterCandidate._handler._remote.id)
return adapterCandidate
}
async setupWriters() {
debug('Setup writers ')
const settings = this.#props.settings
const allSettings = this.#props.job.settings
Object.entries(this.#adapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
skipDeleteOldEntries: true, // delete is handled globally
}
if (targetSettings.exportRetention !== 0) {
const writer = new this.#BackupWriter({
...this.#props,
adapter,
remoteId,
settings: targetSettings,
})
this.#writers.push(writer)
}
})
}
async getEntriesPerAdapter(adapter) {
const scheduleId = this.#props.scheduleId
const vmUuid = this.#props.vmUuid
return (await adapter.listVmBackups(vmUuid, _ => _.scheduleId === scheduleId)).map(entry => ({
...entry,
adapter,
}))
}
async setOldBackups() {
const entriesPerAdapter = await Promise.all(
Object.values(this.#adapters).map(adapter => this.getEntriesPerAdapter(adapter))
)
const entries = entriesPerAdapter
.flat(1)
.filter(_ => !!_)
.sort(compareTimestamp)
const settings = this.#props.settings
this.#oldBackups = getOldEntries(settings.exportRetention - 1, entries, {
longTermRetention: settings.longTermRetention,
timezone: settings.timezone,
})
debug(`got ${this.#oldBackups.length} old backup(s) to delete `)
}
async deleteOldBackupsOnAdapter(adapter, backup) {
throw new Error('not implemented')
}
async deleteOldBackups() {
const byAdapters = new Map()
this.#oldBackups.forEach(({ adapter, ...backup }) => {
const current = byAdapters.has(adapter) ? byAdapters.get(adapter) : []
current.push(backup)
byAdapters.set(adapter, current)
})
await asyncEach(
byAdapters.entries(),
async ([adapter, backups]) => {
debug(`delete ${backups.length} old backup(s) to delete from ${adapter._handler._remote.id}`)
await this.deleteOldBackupsOnAdapter(adapter, backups)
},
{
concurrency: this.#props.deleteConcurrency ?? DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY,
}
)
}
}

View File

@@ -1,114 +0,0 @@
import { getOldEntries } from '../../_getOldEntries.mjs'
import { createLogger } from '@xen-orchestra/log'
import { compareReplicatedVmDatetime, listReplicatedVms } from './_listReplicatedVms.mjs'
import { asyncMapSettled } from '@xen-orchestra/async-map'
const { debug } = createLogger('xo:backups:AbstractAggregatedXapiWriter')
export class AbstractAggregatedXapiWriter {
#ReplicationWriter
#storageRepositories
#writers = []
get writers() {
return this.#writers
}
mainWriter
#props
get props() {
return this.#props
}
#oldVmReplicaList = []
constructor({ srs, ReplicationWriter, ...props }) {
debug('instantiate AbstractAggregatedXapiWriter')
if (srs.length < 1) {
throw new Error('AggregatedXapiWriter need at least one storage repository to work ')
}
this.#props = props
this.#ReplicationWriter = ReplicationWriter
this.#storageRepositories = srs
}
getWriterBySr(storageRepository) {
for (const writer of this.#writers) {
if (writer._sr === storageRepository) {
return writer
}
}
throw new Error("Can't find writer for storage repository")
}
async computeSRCandidate() {
let maxSpace = 0
this.#storageRepositories.forEach(({ physical_utilisation, physical_size }) => {
if (physical_size - physical_utilisation > maxSpace) {
maxSpace = physical_size - physical_utilisation
}
})
debug(`computeSRCandidate found the max free space ${maxSpace} for vm${this.#props.vmUuid}`)
const srCandidates = this.#storageRepositories.filter(
({ physical_utilisation, physical_size }) => maxSpace === physical_size - physical_utilisation
)
// one at random from the sr with the most free space
const candidate = srCandidates[Math.floor(Math.random() * srCandidates.length)]
debug(
`computeSRCandidate found ${srCandidates.length} sr with ${maxSpace} free size, and chose ${candidate.name_label} for vm${this.#props.vmUuid}`
)
return candidate
}
setOldReplicaList() {
debug(`setOldReplicaList for vm ${this.#props.vmUuid}`)
const scheduleId = this.#props.scheduleId
const vmUuid = this.#props.vmUuid
const settings = this.#props.settings
const replicatedVms = this.#storageRepositories
.map(sr => {
return listReplicatedVms(sr.$xapi, scheduleId, sr.uuid, vmUuid)
})
.flat(1)
.filter(_ => !!_)
.sort(compareReplicatedVmDatetime)
this.#oldVmReplicaList = getOldEntries(settings.copyRetention - 1, replicatedVms)
debug(
`setOldReplicaList found ${replicatedVms.length} replica, and will delete ${this.#oldVmReplicaList.length} (retention ${settings.copyRetention}) for vm ${this.#props.vmUuid}`
)
}
async deleteOldReplicas() {
debug(`deleteOldReplicas will delete ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
// destroy the VM on the right xapi
await asyncMapSettled(this.#oldVmReplicaList, async vm => {
debug('will delete old replica', vm.name_label)
await vm.$xapi.VM_destroy(vm.$ref)
})
debug(`deleteOldReplicas deleted ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
}
async setupWriters() {
debug('Setup writers ')
const settings = this.#props.settings
const allSettings = this.#props.job.settings
this.#storageRepositories.forEach(storageRepository => {
const targetSettings = {
...settings,
...allSettings[storageRepository.uuid],
skipDeleteOldEntries: true, // delete is handled globally
}
if (targetSettings.copyRetention !== 0) {
const writer = new this.#ReplicationWriter({
...this.#props,
sr: storageRepository,
settings: targetSettings,
})
this.#writers.push(writer)
}
})
}
}

View File

@@ -1,3 +1,6 @@
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
export class AbstractWriter {
constructor({ config, healthCheckSr, job, vmUuid, scheduleId, settings }) {
this._config = config
@@ -13,4 +16,14 @@ export class AbstractWriter {
afterBackup() {}
healthCheck(sr) {}
_isAlreadyTransferred(timestamp) {
const vmUuid = this._vmUuid
const adapter = this._adapter
const backupDir = getVmBackupDir(vmUuid)
try {
const actualMetadata = JSON.parse(adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`))
return actualMetadata
} catch (error) {}
}
}

View File

@@ -9,7 +9,6 @@ import { ImportVmBackup } from '../../ImportVmBackup.mjs'
import { Task } from '../../Task.mjs'
import * as MergeWorker from '../../merge-worker/index.mjs'
import ms from 'ms'
import { getEntryStatus } from '../../_getOldEntries.mjs'
const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
@@ -48,19 +47,6 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
}
}
async getLongTermRetentionTags(currentEntry) {
const settings = this._settings
const scheduleId = this._scheduleId
const vmUuid = this._vmUuid
const adapter = this._adapter
const entries = await adapter.listVmBackups(vmUuid, _ => _.scheduleId === scheduleId)
entries.push(currentEntry)
const buckets = getEntryStatus(entries, currentEntry, settings.longTermRetention, settings.timezone)
return buckets.map(({ duration, dateBucket }) => `${duration}=${dateBucket}`)
}
async beforeBackup() {
const { handler } = this._adapter
const vmBackupDir = this._vmBackupDir
@@ -130,7 +116,7 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
xapi,
}).run()
} finally {
await xapi.VM_destroy(restoredVm.$ref, { bypassBlockedOperation: true })
await xapi.VM_destroy(restoredVm.$ref)
}
}
)

View File

@@ -72,7 +72,7 @@ export const MixinXapiWriter = (BaseClass = Object) =>
xapi,
}).run()
} finally {
healthCheckVmRef && (await xapi.VM_destroy(healthCheckVmRef, { bypassBlockedOperation: true }))
healthCheckVmRef && (await xapi.VM_destroy(healthCheckVmRef))
}
}
)

View File

@@ -5,18 +5,10 @@ const getReplicatedVmDatetime = vm => {
return datetime
}
export const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
/**
*
* @param {Xapi} xapi
* @param {string} scheduleOrJobId
* @param {string} srUuid
* @param {string} vmUuid
* @returns {Array<XoVm>}
*/
export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
const all = xapi.objects.indexes.type.VM
const { all } = xapi.objects
const vms = {}
for (const key in all) {
const object = all[key]
@@ -28,7 +20,9 @@ export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
'start' in object.blocked_operations &&
(oc[JOB_ID] === scheduleOrJobId || oc[SCHEDULE_ID] === scheduleOrJobId) &&
oc[REPLICATED_TO_SR_UUID] === srUuid &&
oc[VM_UUID] === vmUuid
(oc[VM_UUID] === vmUuid ||
// 2018-03-28, JFT: to catch VMs replicated before this fix
oc[VM_UUID] === undefined)
) {
vms[object.$id] = object
}

View File

@@ -1,317 +0,0 @@
// @ts-check
/**
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
*/
import assert from 'assert'
import { createLogger } from '@xen-orchestra/log'
import { basename, dirname } from 'path'
import { asyncEach } from '@vates/async-each'
// @ts-ignore
const { warn } = createLogger('remote-disk:merge')
/**
* @typedef {Object} MergeState
* @property {{ uuid: string }} child
* @property {{ uuid: string }} parent
* @property {number} currentBlock
* @property {number} mergedDataSize
* @property {'mergeBlocks' | 'cleanup'} step
* @property {number} diskSize
*/
export class MergeRemoteDisk {
/**
* @type {MergeState}
*/
#state = {
child: { uuid: '0' },
parent: { uuid: '0' },
currentBlock: 0,
mergedDataSize: 0,
step: 'mergeBlocks',
diskSize: 0,
}
/**
* @type {string}
*/
#statePath = ''
/**
* @type {boolean}
*/
#isResuming
/**
* @type {Logger | Function}
*/
#logInfo
/**
* @type {number}
*/
#mergeBlockConcurrency
/**
* @type {Function}
*/
#onProgress
/**
* @type {boolean}
*/
#removeUnused
/**
* @type {number}
*/
#writeStateDelay
/**
* @type {number}
*/
#lastStateWrittenAt
/**
* @type {FileAccessor}
*/
#handler
/**
* @param {FileAccessor} handler
* @param {Object} params
* @param {Function} params.onProgress
* @param {Logger | Function} params.logInfo
* @param {boolean} params.removeUnused
* @param {number} params.mergeBlockConcurrency
* @param {number} params.writeStateDelay
*/
constructor(
handler,
{
onProgress = () => {},
logInfo = () => {},
removeUnused = false,
mergeBlockConcurrency = 2,
writeStateDelay = 10e3,
}
) {
this.#handler = handler
this.#logInfo = logInfo
this.#onProgress = onProgress
this.#removeUnused = removeUnused
this.#mergeBlockConcurrency = mergeBlockConcurrency
this.#writeStateDelay = writeStateDelay
this.#isResuming = false
this.#lastStateWrittenAt = 0
}
/**
* @param {RemoteDisk} parentDisk
* @returns {Promise<boolean>} isResuming
*/
async isResuming(parentDisk) {
try {
await this.#handler.readFile(
dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
)
return true
} catch (error) {
return false
}
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async merge(parentDisk, childDisk) {
this.#statePath = dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
try {
const mergeStateContent = await this.#handler.readFile(this.#statePath)
this.#state = JSON.parse(mergeStateContent)
// work-around a bug introduce in 97d94b795
//
// currentBlock could be `null` due to the JSON.stringify of a `NaN` value
if (this.#state?.currentBlock === null) this.#state.currentBlock = 0
this.#isResuming = true
} catch (error) {
// @ts-ignore
if (error.code !== 'ENOENT') {
warn('problem while checking the merge state', { error })
}
}
try {
/* eslint-disable no-fallthrough */
switch (this.#state?.step ?? 'mergeBlocks') {
case 'mergeBlocks':
await this.#step_mergeBlocks(parentDisk, childDisk)
case 'cleanup':
await this.#step_cleanup(parentDisk, childDisk)
return this.#cleanup(parentDisk, childDisk, true)
default:
warn(`Step ${this.#state?.step} is unknown`, { state: this.#state })
}
/* eslint-enable no-fallthrough */
} catch (error) {
await this.#cleanup(parentDisk, childDisk, false)
throw error
}
}
async #writeState() {
try {
await this.#handler.writeFile(this.#statePath, JSON.stringify(this.#state), { flags: 'w' })
} catch (err) {
warn('failed to write merge state', { error: err })
}
}
async #writeStateThrottled() {
const now = Date.now()
if (now - this.#lastStateWrittenAt > this.#writeStateDelay) {
this.#lastStateWrittenAt = now
await this.#writeState()
}
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async #step_mergeBlocks(parentDisk, childDisk) {
const getMaxBlockCount = childDisk.getMaxBlockCount()
if (this.#isResuming) {
const alreadyMergedBlocks = []
for (let blockId = 0; blockId < this.#state.currentBlock; blockId++) {
if (childDisk.hasBlock(blockId)) {
alreadyMergedBlocks.push(blockId)
}
}
parentDisk.setAllocatedBlocks(alreadyMergedBlocks)
} else {
this.#state.child = { uuid: childDisk.getUuid() ?? 0 }
this.#state.parent = { uuid: parentDisk.getUuid() ?? 0 }
// Finds first allocated block for the 2 following loops
while (this.#state.currentBlock < getMaxBlockCount && !childDisk.hasBlock(this.#state.currentBlock)) {
++this.#state.currentBlock
}
await this.#writeState()
}
await this.#mergeBlocks(parentDisk, childDisk)
await parentDisk.flushMetadata(childDisk)
parentDisk.mergeMetadata(childDisk)
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async #mergeBlocks(parentDisk, childDisk) {
this.#mergeBlockConcurrency =
(await parentDisk.canMergeConcurently()) && (await childDisk.canMergeConcurently())
? this.#mergeBlockConcurrency
: 1
const toMerge = []
for (let block = this.#state.currentBlock; block < childDisk.getMaxBlockCount(); block++) {
if (childDisk.hasBlock(block)) {
toMerge.push(block)
}
}
const nBlocks = toMerge.length
this.#onProgress({ total: nBlocks, done: 0 })
const merging = new Set()
let counter = 0
await asyncEach(
toMerge,
async blockId => {
merging.add(blockId)
const blockSize = await parentDisk.mergeBlock(childDisk, blockId, this.#isResuming)
this.#state.mergedDataSize += blockSize
this.#state.currentBlock = Math.min(...merging) - 1
merging.delete(blockId)
this.#onProgress({ total: nBlocks, done: counter + 1 })
counter++
await this.#writeStateThrottled()
},
{ concurrency: this.#mergeBlockConcurrency }
)
await this.#writeState()
this.#state.diskSize = childDisk.getSizeOnDisk()
this.#onProgress({ total: nBlocks, done: nBlocks })
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
*/
async #step_cleanup(parentDisk, childDisk) {
assert.notEqual(this.#state, undefined)
this.#state.step = 'cleanup'
await this.#writeState()
const mergeTargetPath = childDisk.getPath()
// delete intermediate children if needed
if (this.#removeUnused) {
await childDisk.unlink()
}
try {
await parentDisk.rename(mergeTargetPath)
} catch (error) {
// @ts-ignore
if (error.code === 'ENOENT' && this.#isResuming) {
// @ts-ignore
this.#logInfo(`the parent disk was already renamed`, {
parent: parentDisk.getPath(),
mergeTarget: mergeTargetPath,
})
} else {
throw error
}
}
}
/**
* @param {RemoteDisk} parentDisk
* @param {RemoteDisk} childDisk
* @param {boolean} cleanStateFile
*
* @returns {Promise<{mergedDataSize: number, finalDiskSize: number}>} result
*/
async #cleanup(parentDisk, childDisk, cleanStateFile) {
const finalDiskSize = this.#state?.diskSize ?? 0
const mergedDataSize = this.#state?.mergedDataSize ?? 0
await parentDisk.close().catch(warn)
await childDisk.close().catch(warn)
if (cleanStateFile) {
await this.#handler.unlink(this.#statePath).catch(warn)
}
return { mergedDataSize, finalDiskSize }
}
}

View File

@@ -1,204 +0,0 @@
// @ts-check
/**
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
*/
import { RandomAccessDisk } from '@xen-orchestra/disk-transform'
export class RemoteDisk extends RandomAccessDisk {
/**
* Abstract
* @param {Object} options
* @param {boolean} options.force
* @returns {Promise<void>}
*/
async init(options = { force: false }) {
throw new Error(`init must be implemented`)
}
/**
* Abstract
* @returns {Promise<void>}
*/
async close() {
throw new Error(`close must be implemented`)
}
/**
* Abstract
* @returns {number}
*/
getVirtualSize() {
throw new Error(`getVirtualSize must be implemented`)
}
/**
* Abstract
* @returns {number} size
*/
getSizeOnDisk() {
throw new Error(`getSizeOnDisk must be implemented`)
}
/**
* @returns {number}
*/
getBlockSize() {
throw new Error(`getBlockSize must be implemented`)
}
/**
* Abstract
* @returns {string}
*/
getPath() {
throw new Error(`getPath must be implemented`)
}
/**
* Abstract
* @returns {string}
*/
getUuid() {
throw new Error(`getUuid must be implemented`)
}
/**
* Abstract
* @returns {Promise<boolean>} canMergeConcurently
*/
async canMergeConcurently() {
throw new Error(`canMergeConcurently must be implemented`)
}
/**
* Abstract
* @returns {number} getMaxBlockCount
*/
getMaxBlockCount() {
throw new Error(`getMaxBlockCount must be implemented`)
}
/**
* Checks if the VHD contains a specific block.
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
throw new Error(`hasBlock must be implemented`)
}
/**
* Abstract
* Gets the indexes of all blocks in the VHD.
* @returns {Array<number>}
*/
getBlockIndexes() {
throw new Error(`getBlockIndexes must be implemented`)
}
/**
* Abstract
* Returns the parent non inizialized instance
* @returns {RemoteDisk}
*/
instantiateParent() {
throw new Error(`instantiateParent must be implemented`)
}
/**
* Abstract
* Writes a full block.
* @param {DiskBlock} diskBlock
* @return {Promise<number>} blockSize
*/
async writeBlock(diskBlock) {
throw new Error(`writeBlock must be implemented`)
}
/**
* Abstract
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>} diskBlock
*/
async readBlock(index) {
throw new Error(`readBlock must be implemented`)
}
/**
* Abstract
* Reads a specific block from the child disk to copy/move it to this disk.
* @param {RemoteDisk} childDisk
* @param {number} index
* @param {boolean} isResumingMerge
* @returns {Promise<number>} blockSize
*/
async mergeBlock(childDisk, index, isResumingMerge) {
throw new Error(`mergeBlock must be implemented`)
}
/**
* Abstract
* Manually set the disk allocated blocks.
* @param {Array<number>} blockIds
* @returns {Promise<void>}
*/
async setAllocatedBlocks(blockIds) {
throw new Error(`setAllocatedBlocks must be implemented`)
}
/**
* Abstract
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
async flushMetadata(childDisk) {
throw new Error(`flushMetadata must be implemented`)
}
/**
* Abstract
* @param {RemoteDisk} childDisk
*/
mergeMetadata(childDisk) {
throw new Error(`mergeMetadata must be implemented`)
}
/**
* Abstract
* Checks if the VHD is a differencing disk.
* @returns {boolean}
*/
isDifferencing() {
throw new Error(`isDifferencing must be implemented`)
}
/**
* Abstract
* Rename alias/disk
* @param {string} newPath
*/
async rename(newPath) {
throw new Error(`rename must be implemented`)
}
/**
* Abstract
* Deletes alias/disk/disks
*/
async unlink() {
throw new Error(`unlink must be implemented`)
}
/**
* @returns {Promise<RemoteDisk>}
*/
async openParent() {
const parent = await super.openParent()
if (!(parent instanceof RemoteDisk)) {
throw new Error('parent of a RemoteDisk must be also a RemoteDisk')
}
return parent
}
}

View File

@@ -1,894 +0,0 @@
import { test } from 'node:test'
import { strict as assert } from 'node:assert'
import tmp from 'tmp'
import fs from 'fs-extra'
import * as uuid from 'uuid'
import { getHandler } from '@xen-orchestra/fs'
import { pFromCallback } from 'promise-toolbox'
import { VHDFOOTER, VHDHEADER } from '../tests.fixtures.mjs'
import { VhdFile, Constants, VhdDirectory, VhdAbstract } from 'vhd-lib'
import { RemoteVhdDisk } from './RemoteVhdDisk.mjs'
import { RemoteVhdDiskChain } from './RemoteVhdDiskChain.mjs'
import { dirname, basename } from 'node:path'
import { rimraf } from 'rimraf'
import { MergeRemoteDisk } from './MergeRemoteDisk.mjs'
const { beforeEach, afterEach, describe } = test
let tempDir, handler, jobId, vdiId, basePath, relativePath
const rootPath = 'xo-vm-backups/VMUUID'
beforeEach(async () => {
tempDir = await pFromCallback(cb => tmp.dir(cb))
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
jobId = uniqueId()
vdiId = uniqueId()
relativePath = `vdis/${jobId}/${vdiId}`
basePath = `${rootPath}/${relativePath}`
await fs.mkdirp(`${tempDir}/${basePath}`)
})
afterEach(async () => {
await rimraf(tempDir)
await handler.forget()
})
const uniqueId = () => uuid.v1()
const uniqueIdBuffer = () => uuid.v1({}, Buffer.alloc(16))
async function generateVhd(path, opts = {}) {
let vhd
let dataPath = path
if (opts.useAlias) {
await handler.mkdir(dirname(path) + '/data/')
dataPath = dirname(path) + '/data/' + basename(path)
}
if (opts.mode === 'directory') {
await handler.mkdir(dataPath)
vhd = new VhdDirectory(handler, dataPath)
} else {
const fd = await handler.openFile(dataPath, 'wx')
vhd = new VhdFile(handler, fd)
}
vhd.header = { ...VHDHEADER, ...opts.header }
vhd.footer = { ...VHDFOOTER, ...opts.footer, uuid: uniqueIdBuffer() }
if (vhd.header.parentUuid) {
vhd.footer.diskType = Constants.DISK_TYPES.DIFFERENCING
} else {
vhd.footer.diskType = Constants.DISK_TYPES.DYNAMIC
}
if (opts.useAlias === true) {
await VhdAbstract.createAlias(handler, path + '.alias.vhd', dataPath)
}
if (opts.blocks) {
for (const blockId of opts.blocks) {
await vhd.writeEntireBlock({ id: blockId, buffer: Buffer.alloc(2 * 1024 * 1024 + 512, blockId) })
}
}
await vhd.writeBlockAllocationTable()
await vhd.writeHeader()
await vhd.writeFooter()
return vhd
}
/**
* RemoteVhdDisk
*/
describe('tests RemoteVhdDisk', { concurrency: 1 }, () => {
test('RemoteVhdDisk should read block', async () => {
// Create VHD disk
await generateVhd(`${basePath}/disk.vhd`, { blocks: [0, 1] })
const disk = new RemoteVhdDisk({ handler, path: `${basePath}/disk.vhd` })
await disk.init({ force: false })
const readBlock = await disk.readBlock(1)
assert.equal(readBlock.data.length, 2097152, 'block should be readable')
})
test('RemoteVhdDisk should write block', async () => {
// Create VHD disk
await generateVhd(`${basePath}/disk.vhd`, { blocks: [0, 1] })
const disk = new RemoteVhdDisk({ handler, path: `${basePath}/disk.vhd` })
await disk.init({ force: false })
const blockId = 2
await disk.writeBlock({ index: blockId, data: Buffer.alloc(2 * 1024 * 1024, blockId) })
const readBlock = await disk.readBlock(blockId)
assert.equal(readBlock.data.length, 2097152, 'block should be readable')
})
test('RemoteVhdDisk should read block on VHD directory', async () => {
// Create VHD disk
await generateVhd(`${basePath}/directory.vhd`, { blocks: [0, 1], mode: 'directory', useAlias: true })
const directory = new RemoteVhdDisk({ handler, path: `${basePath}/directory.vhd.alias.vhd` })
await directory.init({ force: false })
const readBlock = await directory.readBlock(1)
assert.equal(readBlock.data.length, 2097152, 'block should be readable')
})
test('RemoteVhdDisk should write block on VHD directory', async () => {
// Create VHD disk
await generateVhd(`${basePath}/directory.vhd`, { blocks: [0, 1], mode: 'directory', useAlias: true })
const directory = new RemoteVhdDisk({ handler, path: `${basePath}/directory.vhd.alias.vhd` })
await directory.init({ force: false })
const blockId = 2
await directory.writeBlock({ index: blockId, data: Buffer.alloc(2 * 1024 * 1024, blockId) })
const readBlock = await directory.readBlock(blockId)
assert.equal(readBlock.data.length, 2097152, 'block should be readable')
})
test('RemoteVhdDisk should not support non alias VHD directory', async () => {
// Create VHD directory
await generateVhd(`${basePath}/directory.vhd`, { blocks: [0, 1], mode: 'directory' })
const directory = new RemoteVhdDisk({ handler, path: `${basePath}/directory.vhd` })
try {
await directory.init({ force: true })
} catch (err) {
assert.strictEqual(
err.code,
'NOT_SUPPORTED',
'Initializing a vhd directory without using alias should raise a not supported error'
)
return
}
assert.strictEqual(
true,
false,
'Initializing a vhd directory without using alias should raise a not supported error'
)
})
test('RemoteVhdDisk closes disk on init error', async () => {
// Create VHD disk
await generateVhd(`${basePath}/disk.vhd`, { blocks: [0] })
const disk = new RemoteVhdDisk({ handler, path: `${basePath}/disk.vhd` })
// Override the disk close method to monitor its execution
let diskClosed = false
const originalClose = disk.close.bind(disk)
disk.close = async () => {
diskClosed = true
await originalClose()
}
// Make isDirectory throw an error to simulate init failure
disk.isDirectory = async () => {
throw new Error('simulated write error')
}
// Run init and expect it to throw
let threw = false
try {
await disk.init()
} catch (err) {
threw = true
assert.equal(err.message, 'simulated write error')
}
assert.equal(threw, true, 'merge should throw')
assert.equal(diskClosed, true, 'disk disk should be closed on error')
})
})
/**
* RemoteVhdDiskChain
*/
describe('tests RemoteVhdDiskChain', { concurrency: 1 }, () => {
test('RemoteVhdDiskChain should read block', async () => {
// Create parent and child VHDs
const chainParent = await generateVhd(`${basePath}/parent.vhd`, { blocks: [0] })
const chainChild = await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'parent.vhd',
parentUuid: chainParent.footer.uuid,
},
blocks: [1],
})
await generateVhd(`${basePath}/child_2.vhd`, {
header: {
parentUnicodeName: 'child.vhd',
parentUuid: chainChild.footer.uuid,
},
blocks: [1],
})
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/parent.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
const child_2 = new RemoteVhdDisk({ handler, path: `${basePath}/child_2.vhd` })
const diskChain = new RemoteVhdDiskChain({ disks: [parent, child, child_2] })
await diskChain.init({ force: false })
const readBlock = await diskChain.readBlock(1)
assert.equal(readBlock.data.length, 2097152, 'block should be readable')
})
test('RemoteVhdDiskChain should read block on VHD directory', async () => {
// Create parent and child VHD directories
const chainParent = await generateVhd(`${basePath}/parent.vhd`, { blocks: [0], mode: 'directory', useAlias: true })
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'parent.vhd',
parentUuid: chainParent.footer.uuid,
},
blocks: [1],
mode: 'directory',
useAlias: true,
})
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/parent.vhd.alias.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
const directoryChain = new RemoteVhdDiskChain({ disks: [parent, child] })
await directoryChain.init({ force: false })
const readBlock = await directoryChain.readBlock(1)
assert.equal(readBlock.data.length, 2097152, 'block should be readable')
})
test('RemoteVhdDiskChain should closes disks on init error', async () => {
// Create parent and child VHDs
const chainParent = await generateVhd(`${basePath}/parent.vhd`, { blocks: [0] })
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'parent.vhd',
parentUuid: chainParent.footer.uuid,
},
blocks: [1],
})
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/parent.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
const diskChain = new RemoteVhdDiskChain({ disks: [parent, child] })
// Override the disks close method to monitor their execution
let parentClosed = false
const originalParentClose = parent.close.bind(parent)
parent.close = async () => {
parentClosed = true
await originalParentClose()
}
let childClosed = false
const originalChildClose = child.close.bind(child)
child.close = async () => {
childClosed = true
await originalChildClose()
}
// Make isDifferencing throw an error to simulate init failure of the child disk
child.isDifferencing = () => {
throw new Error('simulated write error')
}
// Run init and expect it to throw
let threw = false
try {
await diskChain.init({ force: false })
} catch (err) {
threw = true
assert.equal(err.message, 'simulated write error')
}
assert.equal(threw, true, 'merge should throw')
assert.equal(parentClosed, true, 'parent disk should be closed on error')
assert.equal(childClosed, true, 'child disk should be closed on error')
})
test('RemoteVhdDiskChain should not support non differencing VHD chain children', async () => {
// Create VHD chain
await generateVhd(`${basePath}/disk_1.vhd`, { blocks: [2] })
await generateVhd(`${basePath}/disk_2.vhd`, { blocks: [3] })
const disk1 = new RemoteVhdDisk({ handler, path: `${basePath}/disk_1.vhd` })
const disk2 = new RemoteVhdDisk({ handler, path: `${basePath}/disk_2.vhd` })
const diskChain = new RemoteVhdDiskChain({ disks: [disk1, disk2] })
try {
await diskChain.init({ force: true })
} catch (err) {
assert.strictEqual(err.code, 'NOT_SUPPORTED', "Can't init vhd directory with non differencing child disks")
return
}
assert.strictEqual(true, false, "Can't init vhd directory with non differencing child disks")
})
})
/**
* MergeVhdChain
*/
describe('tests MergeVhdChain', { concurrency: 1 }, () => {
test('mergeVhdChain merges a simple ancestor VHD + child VHD', async () => {
let progressCalls = 0
const onProgress = () => {
progressCalls++
}
// Create ancestor and child VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1] })
await generateVhd(`${basePath}/child.vhd`, { blocks: [2, 3] })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { onProgress, removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const expectedIndexes = parent.getBlockIndexes().concat(child.getBlockIndexes())
const result = await mergeRemoteDisk.merge(parent, child)
assert.ok(result.finalDiskSize > 0, 'merged disks should have non-zero size')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
assert.ok(progressCalls > 0, 'onProgress should have been called')
// Check that ancestor was renamed to child
const remainingDisks = await handler.list(basePath)
assert.deepEqual(remainingDisks, ['child.vhd'])
const parentIndexes = parent.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
test('mergeVhdChain merges a simple ancestor VHD + child VHD chain', async () => {
let progressCalls = 0
const onProgress = () => {
progressCalls++
}
// Create ancestor and children VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1] })
const chainChild1 = await generateVhd(`${basePath}/child_1.vhd`, { blocks: [2] })
const chainChild2 = await generateVhd(`${basePath}/child_2.vhd`, {
header: {
parentUnicodeName: 'child_1.vhd',
parentUuid: chainChild1.footer.uuid,
},
blocks: [3],
})
await generateVhd(`${basePath}/child_3.vhd`, {
header: {
parentUnicodeName: 'child_2.vhd',
parentUuid: chainChild2.footer.uuid,
},
blocks: [4],
})
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child1 = new RemoteVhdDisk({ handler, path: `${basePath}/child_1.vhd` })
const child2 = new RemoteVhdDisk({ handler, path: `${basePath}/child_2.vhd` })
const child3 = new RemoteVhdDisk({ handler, path: `${basePath}/child_3.vhd` })
const childDiskChain = new RemoteVhdDiskChain({ disks: [child1, child2, child3] })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { onProgress, removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child1.init({ force: isResumingMerge })
await child2.init({ force: isResumingMerge })
await child3.init({ force: isResumingMerge })
await childDiskChain.init({ force: isResumingMerge })
const expectedIndexes = parent
.getBlockIndexes()
.concat(child1.getBlockIndexes())
.concat(child2.getBlockIndexes())
.concat(child3.getBlockIndexes())
const result = await mergeRemoteDisk.merge(parent, childDiskChain)
assert.ok(result.finalDiskSize > 0, 'merged disks should have non-zero size')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
assert.ok(progressCalls > 0, 'onProgress should have been called')
// Check that ancestor was renamed to child
const remainingDisks = await handler.list(basePath)
assert.deepEqual(remainingDisks, ['child_3.vhd']) // TODO: Check that it's the correct child that is remaining.
const parentIndexes = parent.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
test('mergeVhdChain merges an alias ancestor VHD + alias child VHD', async () => {
let progressCalls = 0
const onProgress = () => {
progressCalls++
}
// Create ancestor and child VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1], useAlias: true })
await generateVhd(`${basePath}/child.vhd`, { blocks: [2, 3], useAlias: true })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { onProgress, removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: true })
await child.init({ force: true })
const expectedIndexes = parent.getBlockIndexes().concat(child.getBlockIndexes())
const result = await mergeRemoteDisk.merge(parent, child)
assert.ok(result.finalDiskSize > 0, 'merged disks should have non-zero size')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
assert.ok(progressCalls > 0, 'onProgress should have been called')
// Check that ancestor was renamed to child
const remainingDisks = await handler.list(basePath)
assert.deepEqual(remainingDisks, ['child.vhd.alias.vhd', 'data'])
const remainingDatas = await handler.list(basePath + '/data')
assert.deepEqual(remainingDatas, ['ancestor.vhd'])
const parentIndexes = parent.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
test('mergeVhdChain merges an alias ancestor VHD + alias child VHD chain', async () => {
let progressCalls = 0
const onProgress = () => {
progressCalls++
}
// Create ancestor VHD
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1], useAlias: true })
// Create child VHD chain
const chainParent = await generateVhd(`${basePath}/child_1.vhd`, { blocks: [2], useAlias: true })
await generateVhd(`${basePath}/child_2.vhd`, {
header: {
parentUnicodeName: 'child_1.vhd',
parentUuid: chainParent.footer.uuid,
},
blocks: [3],
useAlias: true,
})
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
const child1 = new RemoteVhdDisk({ handler, path: `${basePath}/child_1.vhd.alias.vhd` })
const child2 = new RemoteVhdDisk({ handler, path: `${basePath}/child_2.vhd.alias.vhd` })
const childDiskChain = new RemoteVhdDiskChain({ disks: [child1, child2] })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { onProgress, removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child1.init({ force: isResumingMerge })
await child2.init({ force: isResumingMerge })
await childDiskChain.init({ force: isResumingMerge })
const expectedIndexes = parent.getBlockIndexes().concat(child1.getBlockIndexes()).concat(child2.getBlockIndexes())
const result = await mergeRemoteDisk.merge(parent, childDiskChain)
assert.ok(result.finalDiskSize > 0, 'merged disks should have non-zero size')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
assert.ok(progressCalls > 0, 'onProgress should have been called')
// Check that ancestor was renamed to child
const remainingDisks = await handler.list(basePath)
assert.deepEqual(remainingDisks, ['child_2.vhd.alias.vhd', 'data'])
const remainingDatas = await handler.list(basePath + '/data')
assert.deepEqual(remainingDatas, ['ancestor.vhd'])
const parentIndexes = parent.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
test('mergeVhdChain merges an alias ancestor VHD + child VHD', async () => {
let progressCalls = 0
const onProgress = () => {
progressCalls++
}
// Create ancestor and child VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1], useAlias: true })
await generateVhd(`${basePath}/child.vhd`, { blocks: [2, 3] })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { onProgress, removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const expectedIndexes = parent.getBlockIndexes().concat(child.getBlockIndexes())
const result = await mergeRemoteDisk.merge(parent, child)
assert.ok(result.finalDiskSize > 0, 'merged disks should have non-zero size')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
assert.ok(progressCalls > 0, 'onProgress should have been called')
// Check that ancestor was renamed to child
const remainingDisks = await handler.list(basePath)
assert.deepEqual(remainingDisks, ['child.vhd', 'data'])
const remainingDatas = await handler.list(basePath + '/data')
assert.deepEqual(remainingDatas, ['ancestor.vhd'])
const parentIndexes = parent.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
test('mergeVhdChain merges an ancestor VHD + alias child VHD', async () => {
let progressCalls = 0
const onProgress = () => {
progressCalls++
}
// Create ancestor and child VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1] })
await generateVhd(`${basePath}/child.vhd`, { blocks: [2, 3], useAlias: true })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { onProgress, removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const expectedIndexes = parent.getBlockIndexes().concat(child.getBlockIndexes())
const result = await mergeRemoteDisk.merge(parent, child)
assert.ok(result.finalDiskSize > 0, 'merged disks should have non-zero size')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
assert.ok(progressCalls > 0, 'onProgress should have been called')
// Check that ancestor was renamed to child
const remainingDisks = await handler.list(basePath)
assert.deepEqual(remainingDisks, ['child.vhd.alias.vhd', 'data'])
const remainingDatas = await handler.list(basePath + '/data')
assert.deepEqual(remainingDatas, [])
const parentIndexes = parent.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
test('mergeVhdChain merges a alias ancestor VHD directory + alias child VHD directory', async () => {
let progressCalls = 0
const onProgress = () => {
progressCalls++
}
// Create ancestor and child VHD directories
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1], mode: 'directory', useAlias: true })
await generateVhd(`${basePath}/child.vhd`, { blocks: [2, 3], mode: 'directory', useAlias: true })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { onProgress, removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const expectedIndexes = parent.getBlockIndexes().concat(child.getBlockIndexes())
const result = await mergeRemoteDisk.merge(parent, child)
assert.ok(result.finalDiskSize > 0, 'merged disks should have non-zero size')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
assert.ok(progressCalls > 0, 'onProgress should have been called')
// Check that ancestor was renamed to child
const remainingDisks = await handler.list(basePath)
assert.deepEqual(remainingDisks, ['child.vhd.alias.vhd', 'data'])
const remainingDatas = await handler.list(basePath + '/data')
assert.deepEqual(remainingDatas, ['ancestor.vhd'])
const parentIndexes = parent.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
const ancestorData = await handler.list(`${basePath}/data/ancestor.vhd`)
assert.ok(ancestorData.length > 0, 'ancestor.vhd directory should contain blocks')
})
test('mergeVhdChain closes disks on error', async () => {
// Create parent and child VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0] })
await generateVhd(`${basePath}/child.vhd`, { blocks: [1] })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
// Override the disks close method to monitor their execution
let parentClosed = false
let childClosed = false
const originalParentClose = parent.close.bind(parent)
parent.close = async () => {
parentClosed = true
await originalParentClose()
}
const originalChildClose = child.close.bind(child)
child.close = async () => {
childClosed = true
await originalChildClose()
}
// Make writeBlock throw an error to simulate failure
parent.writeBlock = async () => {
throw new Error('simulated write error')
}
// Run merge and expect it to throw
let threw = false
try {
await mergeRemoteDisk.merge(parent, child)
} catch (err) {
threw = true
assert.equal(err.message, 'simulated write error')
}
assert.equal(threw, true, 'merge should throw')
assert.equal(parentClosed, true, 'parent disk should be closed on error')
assert.equal(childClosed, true, 'child disk should be closed on error')
})
test('mergeRemoteDisk resumes after interruption', async () => {
// Create parent and child VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1] })
await generateVhd(`${basePath}/child.vhd`, { blocks: [2, 3, 4, 5, 6, 7, 8, 9, 10] })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { removeUnused: true, writeStateDelay: 0 })
let isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const expectedIndexes = parent.getBlockIndexes().concat(child.getBlockIndexes())
// Track write progress
let blocksWritten = 0
const originalWriteBlock = parent.writeBlock.bind(parent)
// Make writeBlock fail on the second block
parent.writeBlock = async diskBlock => {
if (blocksWritten === 5) {
throw new Error('simulated interruption')
}
blocksWritten++
return originalWriteBlock(diskBlock)
}
// First merge attempt should throw
let threw = false
try {
await mergeRemoteDisk.merge(parent, child)
} catch (err) {
threw = true
assert.equal(err.message, 'simulated interruption')
}
assert.equal(threw, true, 'merge should throw on simulated interruption')
// Verify that state file exists
assert.ok(
(await handler.list(dirname(parent.getPath()))).includes(`.${basename(parent.getPath())}.merge.json`),
'merge state file should exist after failure'
)
// Recreate new remoteDisks to simulate a new operation
const parent2 = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child2 = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(isResumingMerge, 'merge is resuming')
await parent2.init({ force: isResumingMerge })
await child2.init({ force: isResumingMerge })
const originalWriteBlock2 = parent2.writeBlock.bind(parent2)
// Make writeBlock fail on the second block
parent2.writeBlock = async diskBlock => {
blocksWritten++
return originalWriteBlock2(diskBlock)
}
// Resume merge
const result = await mergeRemoteDisk.merge(parent2, child2)
// Verify merge completed
assert.ok(result.finalDiskSize > 0, 'final disk size should be > 0')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
// Verify state file removed after successful resume
assert.equal(
(await handler.list(dirname(parent2.getPath()))).includes(`.${basename(parent2.getPath())}.merge.json`),
false,
'merge state file should be removed after successful resume'
)
// Verify all blocks present in parent
const parentIndexes = parent2.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
test('mergeRemoteDisk resumes after interruption with VHD directory', async () => {
// Track write progress
let blocksWritten = 0
// Create parent and child VHDs
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1], useAlias: true, mode: 'directory' })
await generateVhd(`${basePath}/child.vhd`, {
blocks: [2, 3, 4, 5, 6, 7, 8, 9, 10],
useAlias: true,
mode: 'directory',
})
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, {
removeUnused: true,
writeStateDelay: 0,
mergeBlockConcurrency: 4,
})
let isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(!isResumingMerge, 'merge is not resuming')
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const expectedIndexes = parent.getBlockIndexes().concat(child.getBlockIndexes())
const originalMergeBlock = parent.mergeBlock.bind(parent)
// Make writeBlock fail on the second block
parent.mergeBlock = async (disk, index, isResumingMerge) => {
if (blocksWritten === 4) {
throw new Error('simulated interruption')
}
blocksWritten++
return originalMergeBlock(disk, index, isResumingMerge)
}
// First merge attempt should throw
let threw = false
try {
await mergeRemoteDisk.merge(parent, child)
} catch (err) {
threw = true
assert.equal(err.message, 'simulated interruption')
}
assert.equal(threw, true, 'merge should throw on simulated interruption')
// Verify that state file exists
assert.ok(
(await handler.list(dirname(parent.getPath()))).includes(`.${basename(parent.getPath())}.merge.json`),
'merge state file should exist after failure'
)
// Recreate new remoteDisks to simulate a new operation
const parent2 = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
const child2 = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
isResumingMerge = await mergeRemoteDisk.isResuming(parent)
assert.ok(isResumingMerge, 'merge is resuming')
await parent2.init({ force: isResumingMerge })
await child2.init({ force: isResumingMerge })
const originalWriteBlock2 = parent2.writeBlock.bind(parent2)
// Make writeBlock fail on the second block
parent2.writeBlock = async diskBlock => {
blocksWritten++
return originalWriteBlock2(diskBlock)
}
// Resume merge
const result = await mergeRemoteDisk.merge(parent2, child2)
// Verify merge completed
assert.ok(result.finalDiskSize > 0, 'final disk size should be > 0')
assert.ok(result.mergedDataSize > 0, 'merged data size should be > 0')
// Verify state file removed after successful resume
assert.equal(
(await handler.list(dirname(parent2.getPath()))).includes(`.${basename(parent2.getPath())}.merge.json`),
false,
'merge state file should be removed after successful resume'
)
// Verify all blocks present in parent BAT
const parentIndexes = parent2.getBlockIndexes()
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
// Verify all blocks actually present in parent
for (const index of expectedIndexes) {
const parentBlockPath = parent2.getBlockPath(index)
let size
try {
size = await handler.getSize(parentBlockPath)
} catch {
assert.fail(`block ${index} is missing from parent directory`)
}
assert.ok(size >= parent2.getBlockSize(), `block ${index} complete`)
}
})
})

View File

@@ -0,0 +1,171 @@
// @ts-check
/**
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
* @typedef {import('@xen-orchestra/disk-transform').Disk} Disk
* @typedef {import('vhd-lib/Vhd/VhdDirectory.js').VhdDirectory} VhdDirectory
* @typedef {import('vhd-lib/Vhd/VhdFile.js').VhdFile} VhdFile
*/
import { openVhd } from 'vhd-lib'
import { DISK_TYPES } from 'vhd-lib/_constants.js'
import { dirname, join } from 'node:path'
import { RandomAccessDisk } from '@xen-orchestra/disk-transform'
/**
* Represents a remote VHD (Virtual Hard Disk) that extends RandomAccessDisk.
*/
export class RemoteVhd extends RandomAccessDisk {
/**
* @type {string}
*/
#path
/**
* @type {FileAccessor}
*/
#handler
/**
* @type {VhdFile | VhdDirectory | undefined}
*/
#vhd
/**
* @type {boolean | undefined}
*/
#isDifferencing
/**
* @type {() => any}
*/
#dispose = () => {}
/**
* @returns {string}
*/
get path() {
return this.#path
}
/**
* @param {Object} params
* @param {FileAccessor} params.handler
* @param {string} params.path
*/
constructor({ handler, path }) {
super()
// @todo : ensure this is the full path from the root of the remote
this.#path = path
this.#handler = handler
}
/**
* @returns {number}
*/
getVirtualSize() {
if (this.#vhd === undefined) {
throw new Error(`can't call getvirtualsize of a RemoteVhd before init`)
}
return this.#vhd.footer.currentSize
}
/**
* @returns {number}
*/
getBlockSize() {
return 2 * 1024 * 1024
}
/**
* Initializes the VHD.
* @returns {Promise<void>}
*/
async init() {
const { value, dispose } = await openVhd(this.#handler, this.#path)
this.#vhd = value
this.#dispose = dispose
await this.#vhd.readBlockAllocationTable()
this.#isDifferencing = value.footer.diskType === DISK_TYPES.DIFFERENCING
}
/**
* Closes the VHD.
* @returns {Promise<void>}
*/
async close() {
await this.#dispose()
}
/**
* Checks if the VHD contains a specific block.
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call hasblock of a RemoteVhd before init`)
}
return this.#vhd.containsBlock(index)
}
/**
* Gets the indexes of all blocks in the VHD.
* @returns {Array<number>}
*/
getBlockIndexes() {
if (this.#vhd === undefined) {
throw new Error(`can't call getBlockIndexes of a RemoteVhd before init`)
}
const index = []
for (let blockIndex = 0; blockIndex < this.#vhd.header.maxTableEntries; blockIndex++) {
if (this.hasBlock(blockIndex)) {
index.push(blockIndex)
}
}
return index
}
/**
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>}
*/
async readBlock(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhd before init`)
}
const { data } = await this.#vhd.readBlock(index)
return {
index,
data,
}
}
/**
*
* @returns {RandomAccessDisk}
*/
instantiateParent() {
if (this.#vhd === undefined) {
throw new Error(`can't call openParent of a RemoteVhd before init`)
}
const parentPath = this.#vhd.header.parentUnicodeName
const fullParentPath = join(dirname(this.#path), parentPath)
if (!parentPath) {
throw new Error(`Disk ${this.#path} doesn't have parents`)
}
const parent = new RemoteVhd({ handler: this.#handler, path: fullParentPath })
return parent
}
/**
* Checks if the VHD is a differencing disk.
* @returns {boolean}
*/
isDifferencing() {
if (this.#isDifferencing === undefined) {
throw new Error(`can't call isDifferencing of a RemoteVhd before init`)
}
return this.#isDifferencing
}
}

View File

@@ -1,459 +0,0 @@
// @ts-check
/**
* @typedef {import('vhd-lib/Vhd/VhdDirectory.js').VhdDirectory} VhdDirectory
* @typedef {import('vhd-lib/Vhd/VhdFile.js').VhdFile} VhdFile
* @typedef {import('vhd-lib/_createFooterHeader').VhdFooter} VhdFooter
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
*
*/
import { openVhd, VhdAbstract, VhdDirectory } from 'vhd-lib'
import { RemoteDisk } from './RemoteDisk.mjs'
import { DISK_TYPES } from 'vhd-lib/_constants.js'
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
// import { set as setBitmap } from 'vhd-lib/_bitmap.js'
import { stringify } from 'uuid'
import { dirname, join } from 'node:path'
export class RemoteVhdDisk extends RemoteDisk {
/**
* @type {string}
*/
#path
/**
* @type {FileAccessor}
*/
#handler
/**
* @type {VhdFile | VhdDirectory | undefined}
*/
#vhd
/**
* @type {boolean | undefined}
*/
#isDifferencing
/**
* @type {number}
*/
#blockSize = 2 * 1024 * 1024
/**
* @type {number}
*/
#bitmapSize = 512
/**
* @type {() => any}
*/
#dispose = () => {}
/**
* @param {Object} params
* @param {FileAccessor} params.handler
* @param {string} params.path
*/
constructor({ handler, path }) {
super()
// @todo : ensure this is the full path from the root of the remote
this.#path = path
this.#handler = handler
}
/**
* @param {Object} [options]
* @param {boolean} [options.force=false]
* @returns {Promise<void>}
*/
async init(options = {}) {
if (this.#vhd === undefined) {
try {
const { value, dispose } = await openVhd(this.#handler, await resolveVhdAlias(this.#handler, this.#path), {
checkSecondFooter: !options.force,
})
this.#vhd = value
if ((await this.isDirectory()) && !isVhdAlias(this.#path)) {
this.#vhd = undefined
throw Object.assign(new Error("Can't init vhd directory without using alias"), { code: 'NOT_SUPPORTED' })
}
this.#dispose = dispose
await this.#vhd.readBlockAllocationTable()
this.#isDifferencing = value.footer.diskType === DISK_TYPES.DIFFERENCING
} catch (error) {
await this.close()
throw error
}
}
}
/**
* Closes the VHD.
* @returns {Promise<void>}
*/
async close() {
await this.#dispose()
}
/**
* @returns {number}
*/
getVirtualSize() {
if (this.#vhd === undefined) {
throw new Error(`can't call getVirtualSize of a RemoteVhdDisk before init`)
}
return this.#vhd.footer.currentSize
}
/**
* @returns {number} size
*/
getSizeOnDisk() {
if (this.#vhd === undefined) {
throw new Error(`can't call getVirtualSize of a RemoteVhdDisk before init`)
}
return this.#vhd.streamSize()
}
/**
* @returns {number}
*/
getBlockSize() {
return this.#blockSize
}
/**
* @returns {string}
*/
getPath() {
return this.#path
}
/**
* @returns {string}
*/
getUuid() {
if (this.#vhd === undefined) {
throw new Error(`can't call getUid of a RemoteVhdDisk before init`)
}
return stringify(this.#vhd.footer.uuid)
}
/**
* @returns {string}
*/
getParentUuid() {
if (this.#vhd === undefined) {
throw new Error(`can't call getParentUid of a RemoteVhdDisk before init`)
}
return stringify(this.#vhd.header.parentUuid)
}
/**
* @returns {Promise<boolean>} canMergeConcurently
*/
async canMergeConcurently() {
return await this.isDirectory()
}
/**
* @returns {number} getMaxBlockCount
*/
getMaxBlockCount() {
if (this.#vhd === undefined) {
throw new Error(`can't call getMaxBlockCount of a RemoteVhdDisk before init`)
}
return this.#vhd.header.maxTableEntries
}
/**
* Checks if the VHD contains a specific block.
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call hasblock of a RemoteVhdDisk before init`)
}
return this.#vhd.containsBlock(index)
}
/**
* Gets the indexes of all blocks in the VHD.
* @returns {Array<number>}
*/
getBlockIndexes() {
if (this.#vhd === undefined) {
throw new Error(`can't call getBlockIndexes of a RemoteVhdDisk before init`)
}
const indexes = []
for (let blockIndex = 0; blockIndex < this.#vhd.header.maxTableEntries; blockIndex++) {
if (this.hasBlock(blockIndex)) {
indexes.push(blockIndex)
}
}
return indexes
}
/**
* Returns the parent non inizialized instance
* @returns {RemoteDisk}
*/
instantiateParent() {
if (this.#vhd === undefined) {
throw new Error(`can't call instantiateParent of a RemoteVhdDisk before init`)
}
const parentPath = this.#vhd.header.parentUnicodeName
const fullParentPath = join(dirname(this.#path), parentPath)
if (!parentPath) {
throw new Error(`disk ${this.#path} doesn't have parents`)
}
const parent = new RemoteVhdDisk({ handler: this.#handler, path: fullParentPath })
return parent
}
/**
* Writes a full block into this VHD.
* @param {DiskBlock} diskBlock
* @return {Promise<number>} blockSize
*/
async writeBlock(diskBlock) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
await this.#vhd.writeEntireBlock({
id: diskBlock.index,
buffer: Buffer.concat([Buffer.alloc(this.#bitmapSize, 255), diskBlock.data]),
})
return this.getBlockSize()
}
/**
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>} diskBlock
*/
async readBlock(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
const { data } = await this.#vhd.readBlock(index)
return {
index,
data,
}
}
/**
* Reads a specific block from the child disk to copy/move it to this disk.
* @param {RemoteDisk} childDisk
* @param {number} index
* @param {boolean} isResumingMerge
* @returns {Promise<number>} blockSize
*/
async mergeBlock(childDisk, index, isResumingMerge) {
if ((await this.isDirectory()) && childDisk instanceof RemoteVhdDisk && (await childDisk.isDirectory())) {
try {
await this.#handler.rename(childDisk.getBlockPath(index), this.getBlockPath(index))
this.setAllocatedBlocks([index])
} catch (error) {
// @ts-ignore
if (error.code === 'ENOENT' && isResumingMerge === true) {
// when resuming, the blocks moved since the last merge state write are
// not in the child anymore but it should be ok
// it will throw an error if block is missing in parent
// won't detect if the block was already in parent and is broken/missing in child
// since we can't know the initial size, this will create a discrepancy
// on the size
const { data } = await this.readBlock(index)
if (data.length !== this.getBlockSize()) {
throw error
} else {
this.setAllocatedBlocks([index])
}
} else {
throw error
}
}
return this.getBlockSize()
} else {
return this.writeBlock(await childDisk.readBlock(index))
}
}
/**
* Gets a specific block path from the VHD directory.
* @param {number} index
* @returns {string} blockPath
*/
getBlockPath(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
if (this.#vhd instanceof VhdDirectory) {
return this.#vhd.getFullBlockPath(index)
} else {
throw new Error(`can't call getBlockPath of non directory VHD`)
}
}
/**
* Manually set the disk allocated blocks.
* @param {Array<number>} blockIds
* @returns {Promise<void>}
*/
async setAllocatedBlocks(blockIds) {
if (this.#vhd instanceof VhdDirectory) {
this.#vhd.setAllocatedBlocks(blockIds)
}
}
/**
* Writes Block Allocation Table
* @param {RemoteDisk} childDisk
* @returns {Promise<void>}
*/
async flushMetadata(childDisk) {
if (this.#vhd === undefined) {
throw new Error(`can't call flushMetadata of a RemoteVhdDisk before init`)
}
await this.#vhd.writeBlockAllocationTable()
}
/**
* @returns {VhdFooter}
*/
getMetadata() {
if (this.#vhd === undefined) {
throw new Error(`can't call getMetadata of a RemoteVhdDisk before init`)
}
return this.#vhd.footer
}
/**
* @param {RemoteVhdDisk} childDisk
*/
async mergeMetadata(childDisk) {
const childDiskMetadata = childDisk.getMetadata()
if (this.#vhd === undefined) {
throw new Error(`can't call mergeMetadata of a RemoteVhdDisk before init`)
}
// @ts-ignore
this.#vhd.footer.currentSize = childDiskMetadata.currentSize
// @ts-ignore
this.#vhd.footer.diskGeometry = { ...childDiskMetadata.diskGeometry }
// @ts-ignore
this.#vhd.footer.originalSize = childDiskMetadata.originalSize
// @ts-ignore
this.#vhd.footer.timestamp = childDiskMetadata.timestamp
// @ts-ignore
this.#vhd.footer.uuid = childDiskMetadata.uuid
await this.#vhd.writeFooter()
}
/**
* Checks if the VHD is a differencing disk.
* @returns {boolean}
*/
isDifferencing() {
if (this.#isDifferencing === undefined) {
throw new Error(`can't call isDifferencing of a RemoteVhdDisk before init`)
}
return this.#isDifferencing
}
/**
* Rename alias/disk
* @param {string} newPath
*/
async rename(newPath) {
if (isVhdAlias(newPath)) {
const dataPath = await resolveVhdAlias(this.#handler, this.#path)
await this.#handler.unlink(this.#path)
await this.#handler.unlink(newPath)
await VhdAbstract.createAlias(this.#handler, newPath, dataPath)
this.#path = newPath
} else {
try {
await this.#handler.unlink(newPath)
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
await this.#handler.rmtree(newPath).catch(() => {})
}
}
await this.#handler.rename(this.#path, newPath)
this.#path = newPath
}
}
/**
* Deletes disk
*/
async unlink() {
if (this.#vhd === undefined) {
throw new Error(`can't call unlink of a RemoteVhdDisk before init`)
}
await this.close()
if (isVhdAlias(this.#path)) {
try {
await this.#handler.unlink(await resolveVhdAlias(this.#handler, this.#path))
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
await this.#handler.rmtree(await resolveVhdAlias(this.#handler, this.#path)).catch(() => {})
}
}
}
try {
await this.#handler.unlink(this.#path)
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
await this.#handler.rmtree(this.#path).catch(() => {})
}
}
}
/**
* Check if the disk is a VHD directory.
* @returns {Promise<boolean>}
*/
async isDirectory() {
if (this.#vhd === undefined) {
throw new Error(`can't call isDirectory of a RemoteVhdDisk before init`)
}
return this.#vhd instanceof VhdDirectory
}
}

View File

@@ -1,271 +0,0 @@
// @ts-check
/**
* @typedef {import('./RemoteVhdDisk.mjs').VhdFooter} VhdFooter
* @typedef {import('./RemoteVhdDisk.mjs').RemoteVhdDisk} RemoteVhdDisk
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
*/
import { RemoteDisk } from './RemoteDisk.mjs'
export class RemoteVhdDiskChain extends RemoteDisk {
/**
* @type {RemoteVhdDisk[]}
*/
#disks
/**
* @type {number}
*/
#blockSize = 2 * 1024 * 1024
/**
* @type {number}
*/
#headerSize = 1024
/**
* @type {number}
*/
#footerSize = 512
/**
* @type {number}
*/
#bitmapSize = 512
/**
* @param {Object} params
* @param {FileAccessor} params.handler
* @param {RemoteVhdDisk[]} params.disks
*/
constructor({ disks }) {
super()
this.#disks = disks
}
/**
* Initializes the VHD chain
* @param {Object} options
* @param {boolean} options.force
* @returns {Promise<void>}
*/
async init(options) {
try {
await Promise.all(this.#disks.map(disk => disk.init(options)))
let parentUuid = ''
for (const [index, disk] of this.#disks.entries()) {
if (index !== 0) {
if (!disk.isDifferencing()) {
throw Object.assign(new Error("Can't init vhd directory with non differencing child disks"), {
code: 'NOT_SUPPORTED',
})
}
if (disk.getParentUuid() !== parentUuid) {
throw Object.assign(new Error("Can't init vhd directory with incorrect parentage"), {
code: 'NOT_SUPPORTED',
})
}
}
parentUuid = disk.getUuid()
}
} catch (error) {
await this.close()
throw error
}
}
/**
* Closes the VHD.
* @returns {Promise<void>}
*/
async close() {
await Promise.all(this.#disks.map(disk => disk.close()))
}
/**
* @returns {number}
*/
getVirtualSize() {
return this.#disks[this.#disks.length - 1].getVirtualSize()
}
/**
* @returns {number} size
*/
getSizeOnDisk() {
const batEntrySize = 4
const sectorSize = 512
const batSize = Math.ceil((this.getMaxBlockCount() * batEntrySize) / sectorSize) * sectorSize
return (
this.#footerSize +
this.#headerSize +
batSize +
this.getBlockIndexes().length * (this.#blockSize + this.#bitmapSize) +
this.#footerSize
)
}
/**
* @returns {number}
*/
getBlockSize() {
return this.#disks[this.#disks.length - 1].getBlockSize()
}
/**
* @returns {string}
*/
getPath() {
return this.#disks[this.#disks.length - 1].getPath()
}
/**
* @returns {string}
*/
getUuid() {
return this.#disks[this.#disks.length - 1].getUuid()
}
/**
* @returns {Promise<boolean>} canMergeConcurently
*/
async canMergeConcurently() {
for (const disk of this.#disks) {
if (!(await disk.isDirectory())) {
return true
}
}
return false
}
/**
* @returns {number} getMaxBlockCount
*/
getMaxBlockCount() {
return this.#disks[this.#disks.length - 1].getMaxBlockCount()
}
/**
* Checks if the VHD contains a specific block.
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
for (const disk of this.#disks) {
if (disk.hasBlock(index)) {
return true
}
}
return false
}
/**
* Gets the indexes of all blocks in the VHD.
* @returns {Array<number>}
*/
getBlockIndexes() {
const indexes = new Set()
for (const disk of this.#disks) {
for (const index of disk.getBlockIndexes()) {
indexes.add(index)
}
}
return [...indexes]
}
/**
* Returns the parent non inizialized instance
* @returns {RemoteDisk}
*/
instantiateParent() {
return this.#disks[0].instantiateParent()
}
/**
* Writes a full block into this VHD.
* @param {DiskBlock} diskBlock
* @return {Promise<number>} blockSize
*/
async writeBlock(diskBlock) {
throw new Error(`Can't write blocks into a disk chain`)
}
/**
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>} diskBlock
*/
async readBlock(index) {
const reversedDisks = this.#disks.slice().reverse()
for (const disk of reversedDisks) {
if (disk.hasBlock(index)) {
return disk.readBlock(index)
}
}
throw new Error(`Block ${index} not found in chain `)
}
/**
* Reads a specific block from the child disk to copy/move it to this disk.
* @param {RemoteDisk} childDisk
* @param {number} index
* @param {boolean} isResumingMerge
* @returns {Promise<number>} blockSize
*/
async mergeBlock(childDisk, index, isResumingMerge) {
throw new Error(`Can't merge block into a disk chain`)
}
/**
* @returns {VhdFooter}
*/
getMetadata() {
return this.#disks[this.#disks.length - 1].getMetadata()
}
/**
* @param {RemoteVhdDisk} childDisk
* @returns {Promise<void>}
*/
async flushMetadata(childDisk) {
throw new Error(`Can't flush metadata on a disk chain`)
}
/**
* @param {RemoteVhdDisk} childDisk
*/
mergeMetadata(childDisk) {
throw new Error(`Can't merge metadata on a disk chain`)
}
/**
* Checks if the VHD is a differencing disk.
* @returns {boolean}
*/
isDifferencing() {
return this.#disks[0].isDifferencing()
}
/**
* Abstract
* Rename alias/disk
* @param {string} newPath
*/
async rename(newPath) {
throw new Error(`Can't rename a disk chain`)
}
/**
* Deletes all the disks
*/
async unlink() {
for (const disk of this.#disks) {
await disk.unlink()
}
}
}

View File

@@ -2,14 +2,12 @@
/**
*
* @typedef {import('../../disk-transform/src/FileAccessor.mjs').FileAccessor} FileAccessor
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
*/
import { DiskChain } from '@xen-orchestra/disk-transform'
import { RemoteVhdDisk } from './RemoteVhdDisk.mjs'
import { RemoteVhd } from './RemoteVhd.mjs'
import { defer } from 'golike-defer'
/**
* @param {any} $defer
* @param {Object} params
* @param {FileAccessor} params.handler
* @param {string} params.path
@@ -17,18 +15,15 @@ import { defer } from 'golike-defer'
*/
async function _openDiskChain($defer, { handler, path, until }) {
let disk
/**
* @type {Array<RemoteDisk>}
*/
const disks = []
$defer.onFailure(() => Promise.all(disks.map(disk => disk.close())))
disk = new RemoteVhdDisk({ handler, path })
disk = new RemoteVhd({ handler, path })
await disk.init()
disks.push(disk)
while (disk.isDifferencing()) {
disk = await disk.openParent()
if (disk.getPath() === until) {
if (disk.path === until) {
break
}
disks.unshift(disk)

View File

@@ -45,7 +45,7 @@ function formatVmBackup(backup) {
name_label: backup.vm.name_label,
tags: backup.vm.tags,
},
tags: backup.tags ?? [],
differencingVhds,
dynamicVhds,
withMemory,

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.68.2",
"version": "0.67.0",
"engines": {
"node": ">=14.18"
},
@@ -26,13 +26,13 @@
"@vates/disposable": "^0.1.6",
"@vates/fuse-vhd": "^2.1.2",
"@vates/generator-toolbox": "^1.1.0",
"@vates/nbd-client": "^3.2.3",
"@vates/nbd-client": "^3.2.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/disk-transform": "^1.2.1",
"@xen-orchestra/fs": "^4.6.6",
"@xen-orchestra/disk-transform": "^1.2.0",
"@xen-orchestra/fs": "^4.6.4",
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/qcow2": "^1.1.2",
"@xen-orchestra/qcow2": "^1.1.0",
"@xen-orchestra/template": "^0.1.0",
"app-conf": "^3.0.0",
"compare-versions": "^6.0.0",
@@ -48,11 +48,11 @@
"parse-pairs": "^2.0.0",
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"tar": "^7.5.3",
"tar": "^6.1.15",
"uuid": "^9.0.0",
"value-matcher": "^0.2.0",
"vhd-lib": "^4.14.7",
"xen-api": "^4.7.6",
"vhd-lib": "^4.14.4",
"xen-api": "^4.7.5",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -62,7 +62,7 @@
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^8.6.6"
"@xen-orchestra/xapi": "^8.6.1"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -18,7 +18,7 @@
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^4.7.6"
"xen-api": "^4.7.5"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/disk-transform",
"version": "1.2.1",
"version": "1.2.0",
"main": "./dist/index.mjs",
"license": "MIT",
"private": false,
@@ -34,7 +34,7 @@
"node": ">=20.17"
},
"dependencies": {
"vhd-lib": "^4.14.7",
"vhd-lib": "^4.14.4",
"@vates/generator-toolbox": "^1.1.0"
}
}

View File

@@ -6,8 +6,6 @@ export class DiskSmallerBlock extends DiskPassthrough {
#blockSize
#generatedDiskBlocks = 0
#blockCache?: DiskBlock
#blockRatio: number
#maxBlockIndex: number
constructor(source: Disk, blockSize: number) {
super(source)
@@ -19,11 +17,9 @@ export class DiskSmallerBlock extends DiskPassthrough {
assert.strictEqual(
source.getBlockSize() % blockSize,
0,
`source block size ${blockSize} must be a multiple of the target block size ${source.getBlockSize()} `
`target block size ${blockSize} must be a multiple of the source block size ${source.getBlockSize()} `
)
this.#blockSize = blockSize
this.#blockRatio = source.getBlockSize() / blockSize
this.#maxBlockIndex = Math.ceil(this.getVirtualSize() / this.getBlockSize())
}
openSource(): Promise<Disk> {
@@ -39,19 +35,13 @@ export class DiskSmallerBlock extends DiskPassthrough {
try {
const blockRatio = this.source.getBlockSize() / this.#blockSize
const sourceGenerator = this.source.diskBlocks()
// we don't reuse getBlockIndexes here since we want don't want to force
// a use of source.readblock that would limit the use of this class to RandomeAccessDisk
// thus we generate block based on the source block generator
for await (const { data: sourceData, index: sourceIndex } of sourceGenerator) {
for (let i = 0; i < blockRatio; i++) {
this.#generatedDiskBlocks++
const data = sourceData.subarray(i * this.#blockSize, (i + 1) * this.#blockSize)
const index = sourceIndex * blockRatio + i
if (this.#isBlockBeforeEnd(index)) {
yield {
index,
data,
}
yield {
index: sourceIndex * blockRatio + i,
data,
}
}
}
@@ -64,22 +54,15 @@ export class DiskSmallerBlock extends DiskPassthrough {
getBlockSize(): number {
return this.#blockSize
}
// changing the block size can lead
// to the last block being incomplete
// for example going for a block size of 1024 to 512
// and a virtual size of 1200 means 2 blocks in source,
// but 3 here
#isBlockBeforeEnd(blockIndex: number): boolean {
return blockIndex < this.#maxBlockIndex
}
getBlockIndexes(): Array<number> {
const blockRatio = this.source.getBlockSize() / this.getBlockSize()
const sourceIndexes = this.source.getBlockIndexes()
const indexes: Array<number> = []
const maxIndex = Math.ceil(this.getVirtualSize() / this.getBlockSize())
for (const sourceIndex of sourceIndexes) {
for (let i = 0; i < blockRatio; i++) {
const index = sourceIndex * blockRatio + i
if (this.#isBlockBeforeEnd(index)) {
if (index < maxIndex) {
indexes.push(index)
}
}
@@ -88,28 +71,13 @@ export class DiskSmallerBlock extends DiskPassthrough {
}
hasBlock(index: number): boolean {
// asking for a block after the end of the smaller disk => false
if (!this.#isBlockBeforeEnd(index)) {
return false
}
const blockRatio = this.source.getBlockSize() / this.getBlockSize()
const sourceIndex = Math.floor(index / blockRatio)
return this.source.hasBlock(sourceIndex)
}
/**
* handle gracefully readblock if the source is a RandomAccessDisk
* @param index
* @returns Promise<DiskBlock>
*/
async readBlock(index: number): Promise<DiskBlock> {
if (this.source instanceof RandomAccessDisk) {
if (!this.#isBlockBeforeEnd(index)) {
throw new Error(
`Read after the end, asking for ${index}, last block is ${this.#maxBlockIndex - 1}, vitual size is ${this.getVirtualSize()}, blockSize : ${this.getBlockSize()}`
)
}
const blockRatio = this.source.getBlockSize() / this.getBlockSize()
const sourceBlockIndex = Math.floor(index / blockRatio)
const indexInblock = index % blockRatio

View File

@@ -160,8 +160,8 @@ test('hasBlock behavior', async () => {
test('getBlockIndexes', async () => {
const source = new MockDisk(512, 2048, [
[0, Buffer.alloc(2048)],
[2, Buffer.alloc(2048)],
[0, Buffer.alloc(512)],
[2, Buffer.alloc(512)],
])
await source.init()
@@ -182,28 +182,3 @@ test('close propagation', async () => {
await disk.close()
assert(source.closed)
})
test('unaligned END ', async () => {
const source = new MockDisk(1024, 2048 + 256, [
[0, Buffer.alloc(1024)],
[2, Buffer.alloc(1024)],
])
await source.init()
const disk = new DiskSmallerBlock(source, 256)
await disk.init()
assert.strictEqual(source.getVirtualSize(), disk.getVirtualSize())
const indexes = disk.getBlockIndexes()
const expecteBlockIndex = [0, 1, 2, 3, 8]
assert.deepStrictEqual(indexes, expecteBlockIndex)
assert.strictEqual(disk.hasBlock(8), true)
assert.strictEqual(disk.hasBlock(9), false) //After the end of small block disk
assert.strictEqual(disk.hasBlock(12), false) //After the end of source disk
// ensure disk blocks also handle the unaligned part
for await (const { index, data } of disk.diskBlocks()) {
assert.ok(expecteBlockIndex.includes(index))
assert.equal(data.length, 256)
}
})

View File

@@ -18,12 +18,9 @@ export interface FileAccessor {
buffer: Buffer | DataView,
position: number
): Promise<{ bytesRead: number; buffer: Buffer | DataView }>
readFile(path: string): Promise<string>
writeFile(path: string, content: string, opts?: unknown): Promise<void>
getSize(path: string): Promise<number>
mktree(path: string): Promise<void>
rmtree(path: string): Promise<void>
unlink(path: string): Promise<void>
outputStream(stream: Readable): Promise<void>
rename(path: string, newPath: string): Promise<void>
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.6.6",
"version": "4.6.4",
"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

@@ -164,7 +164,7 @@ export default class RemoteHandlerAbstract {
this.#applySafeGuards(options)
}
_conditionRetry(error) {
#conditionRetry(error) {
return !['EEXIST', 'EISDIR', 'ENOTEMPTY', 'ENOENT', 'ENOTDIR', 'SystemInUse', 'ERR_ASSERTION'].includes(error?.code)
}
@@ -193,7 +193,7 @@ export default class RemoteHandlerAbstract {
this[functionName] = pRetry.wrap(this[functionName], {
delays: [100, 200, 500, 1000, 2000],
// these errors should not change on retry
when: err => this._conditionRetry(err),
when: err => this.#conditionRetry(err),
onRetry(error) {
warn('retrying method on fs ', {
method: functionName,
@@ -280,7 +280,6 @@ export default class RemoteHandlerAbstract {
/**
* write a stream to a file using a temporary file
* on immutable remote it won't use a temp file since it won't be able to delete /rename it
*
* @param {string} path
* @param {ReadableStream} input
@@ -683,7 +682,7 @@ export default class RemoteHandlerAbstract {
}
async _outputStream(path, input, { dirMode, validator }) {
const tmpPath = this.isImmutable() ? path : `${dirname(path)}/.${Date.now()}.${basename(path)}`
const tmpPath = `${dirname(path)}/.${Date.now()}.${basename(path)}`
const output = await this._createOutputStream(tmpPath, {
dirMode,
flags: 'wx',
@@ -693,11 +692,8 @@ export default class RemoteHandlerAbstract {
if (validator !== undefined) {
await validator.call(this, tmpPath)
}
if (!this.isImmutable()) {
await this.__rename(tmpPath, path)
}
await this.__rename(tmpPath, path)
} catch (error) {
// this may fail on immutable
await this.__unlink(tmpPath)
throw error
}
@@ -791,10 +787,6 @@ export default class RemoteHandlerAbstract {
get encryptionAlgorithm() {
return this.#encryptor?.algorithm ?? UNENCRYPTED_ALGORITHM
}
isImmutable() {
return this._remote.immutable === true
}
}
// from implementation methods, which names start with `__`, create public

View File

@@ -4,7 +4,6 @@ import {
CopyObjectCommand,
CreateMultipartUploadCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
GetObjectLockConfigurationCommand,
HeadObjectCommand,
@@ -39,7 +38,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
#bucket
#dir
#s3
#immutable = false
constructor(remote, _opts) {
super(remote, _opts)
@@ -141,13 +139,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
}
isImmutable() {
return super.isImmutable() || this.#immutable
}
_conditionRetry(error) {
return ![401, 403, 404, 405].includes(error?.$metadata?.httpStatusCode) && super._conditionRetry(error)
}
async _copy(oldPath, newPath) {
const CopySource = this.#makeCopySource(oldPath)
try {
@@ -393,34 +384,22 @@ export default class S3Handler extends RemoteHandlerAbstract {
)
NextContinuationToken = result.IsTruncated ? result.NextContinuationToken : undefined
try {
await this.#s3.send(
new DeleteObjectsCommand({
Bucket: this.#bucket,
Delete: {
Objects: result.Contents ?? [],
},
})
)
} catch (error) {
warn('Unsupported DeleteObjects, fallback to DeleteObject.', { error, $response: error.$response ?? '' })
await asyncEach(
result.Contents ?? [],
async ({ Key }) => {
// _unlink will add the prefix, but Key contains everything
// also we don't need to check if we delete a directory, since the list only return files
await this.#s3.send(
new DeleteObjectCommand({
Bucket: this.#bucket,
Key,
})
)
},
{
concurrency: 16,
}
)
}
await asyncEach(
result.Contents ?? [],
async ({ Key }) => {
// _unlink will add the prefix, but Key contains everything
// also we don't need to check if we delete a directory, since the list only return files
await this.#s3.send(
new DeleteObjectCommand({
Bucket: this.#bucket,
Key,
})
)
},
{
concurrency: 16,
}
)
} while (NextContinuationToken !== undefined)
}
@@ -441,7 +420,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
// 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))
this.#immutable = true
}
} catch (error) {
// maybe the account doesn't have enough privilege to query the object lock configuration

View File

@@ -1,7 +1,7 @@
# how often does the lift immutability script will run to check if
# some files need to be made mutable
liftEvery = "1h"
liftEvery = 1h
# you can add as many remote as you want, if you change the id ( here : remote1)
#[remotes.remote1]

View File

@@ -44,14 +44,14 @@ List of protected files :
```js
const PATHS = [
// xo-config-backups/scheduleId/date/metadata.json
// xo configuration backupq
'xo-config-backups/*/*/data',
'xo-config-backups/*/*/data.json',
'xo-config-backups/*/*/metadata.json',
// xo-pool-metadata-backups/backupId/scheduleId/date/metadata.json
'xo-pool-metadata-backups/*/*/*/metadata.json',
'xo-pool-metadata-backups/*/*/*/data',
// xo-vm-backups/<vmuuid>/
// pool backupq
'xo-pool-metadata-backups/*/metadata.json',
'xo-pool-metadata-backups/*/data',
// vm backups , xo-vm-backups/<vmuuid>/
'xo-vm-backups/*/*.json',
'xo-vm-backups/*/*.xva',
'xo-vm-backups/*/*.xva.checksum',

View File

@@ -17,19 +17,19 @@
"xo-lift-remote-immutability": "./liftProtection.mjs"
},
"license": "AGPL-3.0-or-later",
"version": "1.0.29",
"version": "1.0.27",
"engines": {
"node": ">=14.0.0"
},
"dependencies": {
"@vates/async-each": "^1.0.1",
"@xen-orchestra/backups": "^0.68.2",
"@xen-orchestra/backups": "^0.67.0",
"@xen-orchestra/log": "^0.7.1",
"app-conf": "^3.0.0",
"chokidar": "^3.6.0",
"execa": "^5.0.0",
"ms": "^2.1.3",
"vhd-lib": "^4.14.7"
"vhd-lib": "^4.14.4"
},
"devDependencies": {
"rimraf": "^6.0.1",

View File

@@ -137,13 +137,11 @@ export async function watchRemote(remoteId, { root, immutabilityDuration, rebuil
// watch the remote for any new VM metadata json file
const PATHS = [
// xo-config-backups/scheduleId/date/metadata.json
'xo-config-backups/*/*/data',
'xo-config-backups/*/*/data.json',
'xo-config-backups/*/*/metadata.json',
// xo-pool-metadata-backups/backupId/scheduleId/date/metadata.json
'xo-pool-metadata-backups/*/*/*/metadata.json',
'xo-pool-metadata-backups/*/*/*/data',
'xo-pool-metadata-backups/*/metadata.json',
'xo-pool-metadata-backups/*/data',
// xo-vm-backups/<vmuuid>/
'xo-vm-backups/*/*.json',
'xo-vm-backups/*/*.xva',

View File

@@ -2,16 +2,6 @@
## **next**
- [Host] Fix host dashboard CPU provisioning calculation [Forum#101359](https://xcp-ng.org/forum/topic/11604/xo-6-dedicated-thread-for-all-your-feedback/84) (PR [#9459](https://github.com/vatesfr/xen-orchestra/pull/9459))
## **0.18.0** (2026-01-29)
- [VM/New] Fix wording in "Memory" section (PR [#9309](https://github.com/vatesfr/xen-orchestra/pull/9309))
- [TreeView] Scroll to current item in list view (PR [#9332](https://github.com/vatesfr/xen-orchestra/pull/9332))
- [VM/New] Added VTPM support (PR [#9389](https://github.com/vatesfr/xen-orchestra/pull/9389))
## **0.17.0** (2025-11-27)
- [Host/HostSystemResourceManagement] Fix display when control domain memory is undefined (PR [#9197](https://github.com/vatesfr/xen-orchestra/pull/9197))
## **0.16.0** (2025-10-30)
@@ -20,7 +10,7 @@
## **0.15.0** (2025-09-30)
- [Settings] fix key-value alignment and refactor page (PR [#8981](https://github.com/vatesfr/xen-orchestra/pull/8981))
- [settigns] fix key-value alignement and refactor page (PR [#8981](https://github.com/vatesfr/xen-orchestra/pull/8981))
- [Settings] Add icon for external links on settings page (PR [#8980](https://github.com/vatesfr/xen-orchestra/pull/8980))
- [VM/dashboard] Update QuickInfo card in dashboard to show more information (PR [#8952](https://github.com/vatesfr/xen-orchestra/pull/8952))
- [StateHero] Update VtsStateHero component and modify usages in every component (PR [#8910](https://github.com/vatesfr/pull/8910))

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.18.0",
"version": "0.16.0",
"type": "module",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
@@ -28,13 +28,13 @@
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18.19.80",
"@vates/types": "^1.19.0",
"@vates/types": "^1.15.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"@vueuse/core": "^13.0.0",
"@vueuse/math": "^13.0.0",
"@vueuse/shared": "^13.0.0",
"@xen-orchestra/web-core": "^0.42.0",
"@xen-orchestra/web-core": "^0.34.0",
"complex-matcher": "^1.0.0",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",

View File

@@ -2,6 +2,7 @@
<header class="app-header">
<div class="left">
<UiButtonIcon
v-if="!uiStore.isDesktopLarge"
ref="navigationTrigger"
:class="{ 'menu-to-right': !uiStore.isMobile }"
accent="brand"
@@ -10,7 +11,7 @@
/>
<RouterLink :to="logoRoute" class="logo-container">
<img v-if="uiStore.isMobile" alt="XO Lite" src="../assets/logo.svg" />
<UiLogoText v-else :text="t('xo-lite')" />
<TextLogo v-else />
</RouterLink>
</div>
<slot />
@@ -25,20 +26,18 @@
<script lang="ts" setup>
import AccountMenu from '@/components/account-menu/AccountMenu.vue'
import PoolOverrideWarning from '@/components/PoolOverrideWarning.vue'
import TextLogo from '@/components/TextLogo.vue'
import XoaButton from '@/components/XoaButton.vue'
import { useNavigationStore } from '@/stores/navigation.store'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiLogoText from '@core/components/ui/logo-text/UiLogoText.vue'
import { useUiStore } from '@core/stores/ui.store'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
const uiStore = useUiStore()
const { pool } = usePoolStore().getContext()
const { t } = useI18n()
const navigationStore = useNavigationStore()
const { trigger: navigationTrigger } = storeToRefs(navigationStore)
@@ -80,7 +79,7 @@ const logoRoute = computed<RouteLocationRaw>(() =>
.left {
display: flex;
align-items: center;
gap: 1.6rem;
gap: 2rem;
@media (max-width: 1023px) {
flex: 1;
@@ -96,8 +95,6 @@ const logoRoute = computed<RouteLocationRaw>(() =>
flex: 1;
text-align: center;
}
height: 2.5rem;
text-decoration: none;
}
.right {

View File

@@ -1,6 +1,11 @@
<template>
<Transition name="slide">
<nav v-if="isOpen" ref="navElement" class="app-navigation">
<nav
v-if="uiStore.isDesktopLarge || isOpen"
ref="navElement"
:class="{ collapsible: !uiStore.isDesktopLarge }"
class="app-navigation"
>
<StoryMenu v-if="route.meta.hasStoryNav" />
<InfraPoolList v-else />
</nav>
@@ -11,84 +16,34 @@
import StoryMenu from '@/components/component-story/StoryMenu.vue'
import InfraPoolList from '@/components/infra/InfraPoolList.vue'
import { useNavigationStore } from '@/stores/navigation.store'
import { onClickOutside, tryOnMounted, useTimeoutFn, watchImmediate } from '@vueuse/core'
import { useUiStore } from '@core/stores/ui.store'
import { onClickOutside, whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const uiStore = useUiStore()
const navigationStore = useNavigationStore()
const { isOpen } = storeToRefs(navigationStore)
const { isOpen, trigger } = storeToRefs(navigationStore)
const navElement = ref()
watch(
() => navigationStore.trigger?.value,
triggerElement => {
if (triggerElement && navElement.value) {
onClickOutside(
navElement,
() => {
if (isOpen.value) {
isOpen.value = false
}
},
{
ignore: [triggerElement],
}
)
whenever(isOpen, () => {
const unregisterEvent = onClickOutside(
navElement,
() => {
isOpen.value = false
unregisterEvent?.()
},
{
ignore: [trigger],
controls: false,
}
},
{ immediate: true }
)
const getCurrentNodeId = () => {
const name = route.name as string | undefined
const params = route.params as Record<string, any>
if (name?.startsWith('/pool/[uuid]')) {
return `pool:${params.uuid}`
}
if (name?.startsWith('/host/[uuid]')) {
return `host:${params.uuid}`
}
if (name?.startsWith('/vm/[uuid]')) {
return `vm:${params.uuid}`
}
return undefined
}
const scrollToCurrent = async () => {
const id = getCurrentNodeId()
if (!id) {
return
}
const container = navElement.value as HTMLElement | undefined
const target = container?.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(id)}"]`)
const hasChildren = !id.startsWith('vm:')
if (target) {
useTimeoutFn(async () => {
if (hasChildren) {
target.style.scrollMarginTop = '0.8rem'
useTimeoutFn(async () => {
target.style.scrollMarginTop = ''
}, 1000)
}
target.scrollIntoView({ block: hasChildren ? 'start' : 'center', behavior: 'smooth' })
}, 200)
} else {
useTimeoutFn(async () => {
await scrollToCurrent()
}, 200)
}
}
tryOnMounted(scrollToCurrent)
watchImmediate(route, scrollToCurrent)
)
})
</script>
<style lang="postcss" scoped>
@@ -100,5 +55,20 @@ watchImmediate(route, scrollToCurrent)
padding: 0.5rem;
border-right: 1px solid var(--color-neutral-border);
background-color: var(--color-neutral-background-primary);
&.collapsible {
position: fixed;
z-index: 1;
}
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(-37rem);
}
</style>

View File

@@ -10,7 +10,7 @@
</UiFilter>
<UiActionButton icon="fa:plus" class="add-filter" @click="openFilterModal()">
{{ t('action:add-filter') }}
{{ t('add-filter') }}
</UiActionButton>
</UiFilterGroup>
</template>

View File

@@ -166,20 +166,20 @@ watch(generatedFilter, value => {
const comparisons = computed<FilterComparisons>(() => {
const comparisonsByType = {
string: {
stringContains: t('filter:comparison:contains'),
stringEquals: t('filter:comparison:equals'),
stringStartsWith: t('filter:comparison:starts-with'),
stringEndsWith: t('filter:comparison:ends-with'),
stringMatchesRegex: t('filter:comparison:matches-regex'),
stringDoesNotContain: t('filter:comparison:not-contain'),
stringDoesNotEqual: t('filter:comparison:not-equal'),
stringDoesNotStartWith: t('filter:comparison:not-start-with'),
stringDoesNotEndWith: t('filter:comparison:not-end-with'),
stringDoesNotMatchRegex: t('filter:comparison:not-match-regex'),
stringContains: t('filter.comparison.contains'),
stringEquals: t('filter.comparison.equals'),
stringStartsWith: t('filter.comparison.starts-with'),
stringEndsWith: t('filter.comparison.ends-with'),
stringMatchesRegex: t('filter.comparison.matches-regex'),
stringDoesNotContain: t('filter.comparison.not-contain'),
stringDoesNotEqual: t('filter.comparison.not-equal'),
stringDoesNotStartWith: t('filter.comparison.not-start-with'),
stringDoesNotEndWith: t('filter.comparison.not-end-with'),
stringDoesNotMatchRegex: t('filter.comparison.not-match-regex'),
},
boolean: {
booleanTrue: t('filter:comparison:is-true'),
booleanFalse: t('filter:comparison:is-false'),
booleanTrue: t('filter.comparison.is-true'),
booleanFalse: t('filter.comparison.is-false'),
},
number: {
numberLessThan: '<',
@@ -189,8 +189,8 @@ const comparisons = computed<FilterComparisons>(() => {
numberGreaterThan: '>',
},
enum: {
enumIs: t('filter:comparison:is'),
enumIsNot: t('filter:comparison:is-not'),
enumIs: t('filter.comparison.is'),
enumIsNot: t('filter.comparison.is-not'),
},
}

View File

@@ -13,12 +13,12 @@
</UiFilter>
<UiActionButton icon="fa:plus" class="add-sort" @click="openFormModal()">
{{ t('action:add-sort') }}
{{ t('add-sort') }}
</UiActionButton>
</UiFilterGroup>
</template>
<script lang="ts" setup generic="TProperty extends PropertyKey">
<script lang="ts" setup>
import UiActionButton from '@/components/ui/UiActionButton.vue'
import UiFilter from '@/components/ui/UiFilter.vue'
import UiFilterGroup from '@/components/ui/UiFilterGroup.vue'
@@ -30,13 +30,13 @@ import { useI18n } from 'vue-i18n'
const { availableSorts } = defineProps<{
availableSorts: Sorts
activeSorts: ActiveSorts<Record<TProperty, any>>
activeSorts: ActiveSorts<Record<string, any>>
}>()
const emit = defineEmits<{
toggleSortDirection: [property: TProperty]
addSort: [property: TProperty, isAscending: boolean]
removeSort: [property: TProperty]
toggleSortDirection: [property: string]
addSort: [property: string, isAscending: boolean]
removeSort: [property: string]
}>()
const { t } = useI18n()
@@ -44,7 +44,7 @@ const { t } = useI18n()
const openFormModal = useModal({
component: import('@/components/modals/CollectionSorterModal.vue'),
props: { availableSorts: computed(() => availableSorts) },
onConfirm: ({ property, isAscending }) => emit('addSort', property as TProperty, isAscending),
onConfirm: ({ property, isAscending }) => emit('addSort', property, isAscending),
})
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="filter-and-sort">
<CollectionFilter
v-if="availableFilters !== undefined"
:active-filters="filters"
:available-filters="availableFilters"
@add-filter="addFilter"
@remove-filter="removeFilter"
/>
<CollectionSorter
v-if="availableSorts !== undefined"
:active-sorts="sorts"
:available-sorts="availableSorts"
@add-sort="addSort"
@remove-sort="removeSort"
@toggle-sort-direction="toggleSortDirection"
/>
</div>
<UiTable vertical-border>
<thead>
<tr>
<td v-if="isSelectable">
<input v-model="areAllSelected" type="checkbox" />
</td>
<slot name="head-row" />
</tr>
</thead>
<tbody>
<tr v-for="item in filteredAndSortedCollection" :key="item.$ref">
<td v-if="isSelectable">
<input v-model="selected" :value="item.$ref" type="checkbox" />
</td>
<slot :item name="body-row" />
</tr>
</tbody>
</UiTable>
</template>
<script generic="T extends XenApiRecord<any>" lang="ts" setup>
import CollectionFilter from '@/components/CollectionFilter.vue'
import CollectionSorter from '@/components/CollectionSorter.vue'
import UiTable from '@/components/ui/UiTable.vue'
import useCollectionFilter from '@/composables/collection-filter.composable'
import useCollectionSorter from '@/composables/collection-sorter.composable'
import useFilteredCollection from '@/composables/filtered-collection.composable'
import useMultiSelect from '@/composables/multi-select.composable'
import useSortedCollection from '@/composables/sorted-collection.composable'
import type { XenApiRecord } from '@/libs/xen-api/xen-api.types'
import type { Filters } from '@/types/filter'
import type { Sorts } from '@/types/sort'
import { computed, toRef, watch } from 'vue'
const props = defineProps<{
modelValue?: T['$ref'][]
availableFilters?: Filters
availableSorts?: Sorts
collection: T[]
}>()
const emit = defineEmits<{
'update:modelValue': [selectedRefs: T['$ref'][]]
}>()
const isSelectable = computed(() => props.modelValue !== undefined)
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter({
queryStringParam: 'filter',
})
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } = useCollectionSorter<Record<string, any>>({
queryStringParam: 'sort',
})
const filteredCollection = useFilteredCollection(toRef(props, 'collection'), predicate)
const filteredAndSortedCollection = useSortedCollection(filteredCollection, compareFn)
const usableRefs = computed(() => props.collection.map(item => item.$ref))
const selectableRefs = computed(() => filteredAndSortedCollection.value.map(item => item.$ref))
const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs)
watch(selected, selected => emit('update:modelValue', selected), {
immediate: true,
})
</script>
<style lang="postcss" scoped>
.filter-and-sort {
display: flex;
}
</style>

View File

@@ -1,38 +1,55 @@
<template>
<VtsTable class="hosts-patches-table" :state :class="{ desktop: uiStore.isDesktop }">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="patch of sortedPatches" :key="patch.$id">
<BodyCells :item="patch" />
</VtsRow>
</tbody>
</VtsTable>
<UiCardSpinner v-if="!areSomeLoaded" />
<UiTable v-else :class="{ desktop: isDesktop }" class="hosts-patches-table">
<tr v-for="patch in sortedPatches" :key="patch.$id">
<th>
<span v-tooltip="{ placement: 'left', content: patch.version }">
{{ patch.name }}
</span>
</th>
<td v-if="hasMultipleHosts">
<UiSpinner v-if="!areAllLoaded" />
<UiCounter
v-else
v-tooltip="{
placement: 'left',
content: t('n-hosts-awaiting-patch', {
n: patch.$hostRefs.size,
}),
}"
:value="patch.$hostRefs.size"
class="counter"
accent="danger"
variant="primary"
size="small"
/>
</td>
</tr>
</UiTable>
</template>
<script lang="ts" setup>
import UiCardSpinner from '@/components/ui/UiCardSpinner.vue'
import UiSpinner from '@/components/ui/UiSpinner.vue'
import UiTable from '@/components/ui/UiTable.vue'
import type { XenApiPatchWithHostRefs } from '@/composables/host-patches.composable'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import { useTableState } from '@core/composables/table-state.composable'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { useUiStore } from '@core/stores/ui.store'
import { usePatchColumns } from '@core/tables/column-sets/patch-columns'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { patches, busy } = defineProps<{
const props = defineProps<{
patches: XenApiPatchWithHostRefs[]
busy?: boolean
hasMultipleHosts: boolean
areAllLoaded: boolean
areSomeLoaded: boolean
}>()
const { t } = useI18n()
const uiStore = useUiStore()
const sortedPatches = computed(() =>
[...patches].sort((patch1, patch2) => {
[...props.patches].sort((patch1, patch2) => {
if (patch1.changelog == null) {
return 1
} else if (patch2.changelog == null) {
@@ -43,21 +60,15 @@ const sortedPatches = computed(() =>
})
)
const state = useTableState({
busy: () => busy,
empty: () => (patches.length === 0 ? { type: 'all-good', message: t('patches-up-to-date') } : false),
})
const { HeadCells, BodyCells } = usePatchColumns({
body: (patch: XenApiPatchWithHostRefs) => ({
name: r => r(patch.name),
version: r => r(patch.version),
}),
})
const { isDesktop } = useUiStore()
</script>
<style lang="postcss" scoped>
.hosts-patches-table.desktop {
max-width: 45rem;
}
.counter {
font-size: 1rem;
}
</style>

View File

@@ -1,12 +1,14 @@
<template>
<UiSpinner v-if="!isReady" />
<template v-else-if="record !== undefined">
<RouterLink v-if="objectRoute" :to="objectRoute">
{{ record.name_label }}
</RouterLink>
<span v-else>{{ record.name_label }}</span>
</template>
<span v-else class="unknown">{{ uuid }}</span>
<div class="object-link">
<UiSpinner v-if="!isReady" />
<template v-else-if="record !== undefined">
<RouterLink v-if="objectRoute" :to="objectRoute">
{{ record.name_label }}
</RouterLink>
<span v-else>{{ record.name_label }}</span>
</template>
<span v-else class="unknown">{{ uuid }}</span>
</div>
</template>
<script generic="T extends ObjectType" lang="ts" setup>

View File

@@ -1,7 +1,7 @@
<template>
<VtsStateHero format="page" type="under-construction" size="large">
<span>{{ t('xo-lite-under-construction') }}</span>
<span class="typo-h3">{{ t('new-features-are-coming!') }}</span>
<span class="typo-h3">{{ t('new-features-are-coming') }}</span>
<p class="contact typo-body-regular">
{{ t('do-you-have-needs') }}
<a

View File

@@ -13,10 +13,10 @@ const props = defineProps<{
}>()
const icons: Record<VM_POWER_STATE, IconName> = {
[VM_POWER_STATE.RUNNING]: 'status:running-circle',
[VM_POWER_STATE.PAUSED]: 'status:paused-circle',
[VM_POWER_STATE.SUSPENDED]: 'status:suspended-circle',
[VM_POWER_STATE.HALTED]: 'status:halted-circle',
[VM_POWER_STATE.RUNNING]: 'legacy:running',
[VM_POWER_STATE.PAUSED]: 'legacy:paused',
[VM_POWER_STATE.SUSPENDED]: 'legacy:suspended',
[VM_POWER_STATE.HALTED]: 'legacy:halted',
}
const icon = computed(() => icons[props.state])

View File

@@ -0,0 +1,18 @@
<template>
<span :title="date.toLocaleString()">{{ relativeTime }}</span>
</template>
<script lang="ts" setup>
import { parseDateTime } from '@/libs/utils'
import useRelativeTime from '@core/composables/relative-time.composable'
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
const props = defineProps<{
date: Date | number | string
}>()
const date = computed(() => new Date(parseDateTime(props.date)))
const now = useNow({ interval: 1000 })
const relativeTime = useRelativeTime(date, now)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<svg
class="text-logo"
viewBox="300.85 622.73 318.32 63.27"
xmlns="http://www.w3.org/2000/svg"
width="100"
height="22"
>
<g>
<polygon
points="355.94 684.92 341.54 684.92 327.84 664.14 315.68 684.92 301.81 684.92 317.59 659.25 338.96 659.25 355.94 684.92"
/>
<path
d="M406.2,627.17c4.62,2.64,8.27,6.33,10.94,11.07,2.67,4.74,4.01,10.1,4.01,16.07s-1.34,11.35-4.01,16.12c-2.67,4.77-6.32,8.48-10.94,11.12-4.63,2.64-9.78,3.97-15.47,3.97s-10.85-1.32-15.47-3.97c-4.63-2.64-8.27-6.35-10.95-11.12-2.67-4.77-4.01-10.14-4.01-16.12s1.34-11.33,4.01-16.07c2.67-4.74,6.32-8.43,10.95-11.07,4.62-2.64,9.78-3.97,15.47-3.97s10.84,1.32,15.47,3.97Zm-24.86,9.65c-2.7,1.61-4.81,3.92-6.33,6.94-1.52,3.02-2.28,6.54-2.28,10.56s.76,7.54,2.28,10.56c1.52,3.02,3.63,5.33,6.33,6.94,2.7,1.61,5.83,2.41,9.39,2.41s6.69-.8,9.39-2.41c2.7-1.61,4.81-3.92,6.33-6.94,1.52-3.02,2.28-6.53,2.28-10.56s-.76-7.54-2.28-10.56-3.63-5.33-6.33-6.94c-2.7-1.61-5.83-2.41-9.39-2.41s-6.69,.8-9.39,2.41Z"
/>
<polygon
points="354.99 624.06 339.53 649.22 317.49 649.22 300.86 624.06 315.26 624.06 328.96 644.84 341.12 624.06 354.99 624.06"
/>
<g>
<path d="M476.32,675.94h20.81v10.04h-33.47v-63.14h12.66v53.1Z" />
<path d="M517.84,622.84v63.14h-12.66v-63.14h12.66Z" />
<path d="M573.29,622.84v10.22h-16.82v52.92h-12.66v-52.92h-16.83v-10.22h46.31Z" />
<path d="M595.18,633.06v15.83h21.26v10.04h-21.26v16.73h23.97v10.31h-36.64v-63.23h36.64v10.31h-23.97Z" />
</g>
</g>
</svg>
</template>
<style lang="postcss" scoped>
.text-logo {
fill: var(--color-logo);
}
</style>

View File

@@ -19,7 +19,7 @@
class="xoa-button"
@click="openXoaDeploy()"
>
{{ t('action:deploy-xoa') }}
{{ t('deploy-xoa') }}
</UiButton>
</template>

View File

@@ -25,7 +25,7 @@
{{ t('access-forum') }}
</MenuItem>
<MenuItem icon="fa:arrow-right-from-bracket" class="menu-item-logout" @click="logout">
{{ t('action:log-out') }}
{{ t('log-out') }}
</MenuItem>
</MenuList>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<VtsSelect v-if="isSelectWidget(widget)" :id="selectId" accent="brand" />
<UiRadioButtonGroup v-else-if="isRadioWidget(widget)" accent="brand" gap="wide">
<UiRadioButtonGroup v-else-if="isRadioWidget(widget)" accent="brand">
<UiRadioButton
v-for="choice in widget.choices"
:key="choice.label"

View File

@@ -1,6 +1,6 @@
<template>
<VtsInputGroup>
<UiInput v-model.number="sizeInput" accent="brand" type="number" :max-decimals="3" />
<UiInput v-model="sizeInput" accent="brand" type="number" :max-decimals="3" />
<VtsSelect :id="prefixSelectId" accent="brand" />
</VtsInputGroup>
</template>
@@ -38,9 +38,9 @@ const scale = format.Scale.create(availablePrefixes, 1024, 1)
const { id: prefixSelectId } = useFormSelect(
[
{ value: 'Ki', label: t('bytes:ki') },
{ value: 'Mi', label: t('bytes:mi') },
{ value: 'Gi', label: t('bytes:gi') },
{ value: 'Ki', label: t('bytes.ki') },
{ value: 'Mi', label: t('bytes.mi') },
{ value: 'Gi', label: t('bytes.gi') },
],
{
model: prefixInput,

View File

@@ -5,7 +5,7 @@
</template>
{{ host.name_label }}
<template v-if="isMaster" #status>
<VtsIcon v-tooltip="t('master')" name="status:primary-circle" size="medium" />
<VtsIcon v-tooltip="t('master')" name="legacy:primary" size="medium" />
</template>
</UiHeadBar>
</template>

View File

@@ -15,8 +15,8 @@
<RouterTab :to="{ name: '/host/[uuid]/tasks', params: { uuid } }" disabled>
{{ t('tasks') }}
</RouterTab>
<RouterTab :to="{ name: '/host/[uuid]/vms', params: { uuid } }">
{{ t('vms') }}
<RouterTab :to="{ name: '/host/[uuid]/vms', params: { uuid } }" disabled>
{{ t('vms', 2) }}
</RouterTab>
</TabList>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<UiCard class="host-dashboard-cpu-provisioning">
<UiCardTitle>{{ t('cpu-provisioning') }}</UiCardTitle>
<VtsStateHero v-if="!isReady" format="card" type="busy" size="medium" />
<VtsStateHero v-if="!isReady" format="card" busy size="medium" />
<template v-else>
<VtsProgressBar
:current="vCpusCount"
@@ -19,6 +19,7 @@
</template>
<script lang="ts" setup>
import { ACTIVE_STATES } from '@/libs/utils'
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { useHostStore } from '@/stores/xen-api/host.store.ts'
import { useVmMetricsStore } from '@/stores/xen-api/vm-metrics.store.ts'
@@ -29,7 +30,6 @@ import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardNumbers from '@core/components/ui/card-numbers/UiCardNumbers.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { cpuProgressThresholds } from '@core/utils/progress.util.ts'
import { VM_POWER_STATE } from '@vates/types'
import { and } from '@vueuse/math'
import { useArrayReduce } from '@vueuse/shared'
import { computed } from 'vue'
@@ -51,11 +51,15 @@ const hostVms = computed(() => recordsByHostRef.value.get(host.$ref) ?? [])
const cpusCount = computed(() => Number(host.cpu_info.cpu_count))
const runningVms = computed(() => hostVms.value.filter(vm => vm.power_state === VM_POWER_STATE.RUNNING))
const vCpusCount = useArrayReduce(
runningVms,
(total, vm) => total + (getVmMetricsByOpaqueRef(vm.metrics)?.VCPUs_number ?? vm.VCPUs_at_startup),
hostVms,
(total, vm) => {
if (ACTIVE_STATES.has(vm.power_state)) {
return total + (getVmMetricsByOpaqueRef(vm.metrics)?.VCPUs_number ?? vm.VCPUs_at_startup)
}
return total + vm.VCPUs_at_startup
},
0
)
</script>

View File

@@ -4,7 +4,7 @@
{{ t('cpu-usage') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsStateHero v-if="loading || data.stats === undefined" format="card" type="busy" size="medium" />
<VtsStateHero v-if="loading || data.stats === undefined" format="card" busy size="medium" />
<VtsStateHero v-else-if="error" format="card" type="error" size="medium">
{{ t('error-no-data') }}
</VtsStateHero>

View File

@@ -4,7 +4,7 @@
{{ t('load-average') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsStateHero v-if="loading || data.stats === undefined" format="card" type="busy" size="medium" />
<VtsStateHero v-if="loading || data.stats === undefined" format="card" busy size="medium" />
<VtsStateHero v-else-if="error" format="card" type="error" size="medium">
{{ t('error-no-data') }}
</VtsStateHero>

View File

@@ -4,7 +4,7 @@
{{ t('network-throughput') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsStateHero v-if="loading || data.stats === undefined" format="card" type="busy" size="medium" />
<VtsStateHero v-if="loading || data.stats === undefined" format="card" busy size="medium" />
<VtsStateHero v-else-if="error" format="card" type="error" size="medium">
{{ t('error-no-data') }}
</VtsStateHero>

View File

@@ -12,15 +12,10 @@
<VtsQuickInfoRow :label="t('master')">
<template #value>
<template v-if="isMaster">
<VtsIcon v-tooltip="t('master')" name="status:primary-circle" size="medium" />
<VtsIcon v-tooltip="t('master')" name="legacy:primary" size="medium" />
{{ t('this-host') }}
</template>
<UiLink
v-else-if="masterHost !== undefined"
:to="`/host/${masterHost.uuid}/`"
size="medium"
icon="object:host"
>
<UiLink v-else-if="masterHost !== undefined" :to="`/host/${masterHost.uuid}/`" size="medium" icon="fa:server">
{{ masterHost.name_label }}
</UiLink>
</template>
@@ -69,6 +64,7 @@ import useRelativeTime from '@core/composables/relative-time.composable'
import { vTooltip } from '@core/directives/tooltip.directive'
import { formatSizeRaw } from '@core/utils/size.util'
import { parseDateTime } from '@core/utils/time.util'
import { useNow } from '@vueuse/core'
import { logicAnd } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -88,13 +84,13 @@ const isReady = logicAnd(isPoolReady, isHostReady, isHostMetricsReady)
const isRunning = computed(() => isHostRunning(host))
const powerState = computed<{ text: string; icon: IconName }>(() => ({
text: isRunning.value ? t(`host:status:running`) : t('host:status:halted'),
icon: isRunning.value ? 'status:running-circle' : 'status:halted-circle',
text: t(`host-status.${isRunning.value ? 'running' : 'halted'}`),
icon: isRunning.value ? 'legacy:running' : 'legacy:halted',
}))
const date = computed(() => new Date(parseDateTime(Number(host.other_config.boot_time) * 1000)))
const relativeStartTime = useRelativeTime(date)
const now = useNow({ interval: 1000 })
const relativeStartTime = useRelativeTime(date, now)
const isMaster = computed(() => isMasterHost(host.$ref))

View File

@@ -1,7 +1,7 @@
<template>
<UiCard class="host-dashboard-ram-provisioning">
<UiCardTitle>{{ t('ram-provisioning') }}</UiCardTitle>
<VtsStateHero v-if="!isReady" format="card" type="busy" size="medium" />
<VtsStateHero v-if="!isReady" format="card" busy size="medium" />
<template v-else>
<VtsProgressBar
:current="memory?.usage ?? 0"

View File

@@ -4,7 +4,7 @@
{{ t('ram-usage') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsStateHero v-if="loading || data.stats === undefined" format="card" type="busy" size="medium" />
<VtsStateHero v-if="loading || data.stats === undefined" format="card" busy size="medium" />
<VtsStateHero v-else-if="error" format="card" type="error" size="medium">
{{ t('error-no-data') }}
</VtsStateHero>

View File

@@ -1,7 +1,7 @@
<template>
<UiCard class="host-dashboard-vms-status">
<UiCardTitle>{{ t('vms-status') }}</UiCardTitle>
<VtsStateHero v-if="!isReady" format="card" type="busy" size="medium" />
<VtsStateHero v-if="!isReady" format="card" busy size="medium" />
<template v-else>
<VtsDonutChartWithLegend :segments />
<UiCardNumbers class="total" :label="t('total')" :value="total" size="small" />
@@ -50,22 +50,22 @@ const total = useSum(() => Object.values(countByPowerState.value))
const segments = computed<DonutChartWithLegendProps['segments']>(() => [
{
label: t('vm:status:running', 2),
label: t('vms-status.running'),
value: countByPowerState.value.running,
accent: 'success',
},
{
label: t('vm:status:paused', 2),
label: t('vms-status.paused'),
value: countByPowerState.value.paused,
accent: 'info',
},
{
label: t('vm:status:suspended', 2),
label: t('vms-status.suspended'),
value: countByPowerState.value.suspended,
accent: 'muted',
},
{
label: t('vm:status:halted', 2),
label: t('vms-status.halted'),
value: countByPowerState.value.halted,
accent: 'danger',
},

View File

@@ -4,8 +4,8 @@
<div :class="{ 'action-buttons-container': uiStore.isMobile }">
<UiButtonIcon
v-if="uiStore.isMobile"
v-tooltip="t('action:close')"
size="small"
v-tooltip="t('close')"
size="medium"
variant="tertiary"
accent="brand"
icon="fa:angle-left"
@@ -13,24 +13,24 @@
/>
<div class="action-buttons">
<UiButton
v-tooltip="t('coming-soon!')"
v-tooltip="t('coming-soon')"
disabled
size="medium"
variant="tertiary"
accent="brand"
left-icon="fa:edit"
>
{{ t('action:edit') }}
{{ t('edit') }}
</UiButton>
<UiButton
v-tooltip="t('coming-soon!')"
v-tooltip="t('coming-soon')"
disabled
size="medium"
variant="tertiary"
accent="danger"
left-icon="fa:trash"
>
{{ t('action:delete') }}
{{ t('delete') }}
</UiButton>
</div>
</div>
@@ -49,7 +49,7 @@
{{ pif.uuid }}
</template>
<template #addons>
<VtsIcon v-if="pif.management" v-tooltip="t('management')" name="status:primary-circle" size="medium" />
<VtsIcon v-if="pif.management" v-tooltip="t('management')" name="legacy:primary" size="medium" />
<VtsCopyButton :value="pif.uuid" />
</template>
</VtsCardRowKeyValue>
@@ -61,7 +61,14 @@
<template #value>
<div class="network">
<!-- TODO Remove the span when the link works and the icon is fixed -->
<span v-tooltip class="value">{{ network?.name_label }}</span>
<!--
<UiComplexIcon size="medium">
<VtsIcon icon="fa:network-wired" accent="current" />
<VtsIcon accent="success" :icon="faCircle" :overlay-icon="faCheck" />
</UiComplexIcon>
<a href="">{{ networkNameLabel }}</a>
-->
<span v-tooltip class="value text-ellipsis">{{ network?.name_label }}</span>
</div>
</template>
<template v-if="network?.name_label" #addons>
@@ -142,10 +149,10 @@
<VtsCopyButton :value="ip" />
<UiButtonIcon
v-if="index === 0 && ipAddresses.length > 1"
v-tooltip="t('coming-soon!')"
v-tooltip="t('coming-soon')"
disabled
icon="fa:ellipsis"
size="small"
size="medium"
accent="brand"
/>
</template>
@@ -233,10 +240,10 @@
<VtsCopyButton :value="device" />
<UiButtonIcon
v-if="index === 0 && bondDevices.length > 1"
v-tooltip="t('coming-soon!')"
v-tooltip="t('coming-soon')"
disabled
icon="fa:ellipsis"
size="small"
size="medium"
accent="brand"
/>
</template>

View File

@@ -2,9 +2,9 @@
<div class="host-pif-table">
<UiTitle>
{{ t('pifs') }}
<template #action>
<template #actions>
<UiButton
v-tooltip="t('coming-soon!')"
v-tooltip="t('coming-soon')"
disabled
left-icon="fa:plus"
variant="secondary"
@@ -20,60 +20,141 @@
<UiQuerySearchBar @search="(value: string) => (searchQuery = value)" />
<UiTableActions :title="t('table-actions')">
<UiButton
v-tooltip="t('coming-soon!')"
v-tooltip="t('coming-soon')"
disabled
left-icon="fa:edit"
variant="tertiary"
accent="brand"
size="medium"
>
{{ t('action:edit') }}
{{ t('edit') }}
</UiButton>
<UiButton
v-tooltip="t('coming-soon!')"
v-tooltip="t('coming-soon')"
disabled
left-icon="fa:trash"
variant="tertiary"
accent="danger"
size="medium"
>
{{ t('action:delete') }}
{{ t('delete') }}
</UiButton>
</UiTableActions>
<UiTopBottomTable :selected-items="0" :total-items="0">
<UiTablePagination v-if="isReady" v-bind="paginationBindings" />
</UiTopBottomTable>
</div>
<VtsTable :state :pagination-bindings sticky="right">
<thead>
<VtsDataTable :is-ready :has-error :no-data-message="pifs.length === 0 ? t('no-pif-detected') : undefined">
<template #thead>
<tr>
<HeadCells />
<template v-for="column of visibleColumns" :key="column.id">
<th v-if="column.id === 'checkbox'" class="checkbox">
<div v-tooltip="t('coming-soon')">
<UiCheckbox disabled :v-model="areAllSelected" accent="brand" />
</div>
</th>
<th v-else-if="column.id === 'more'" class="more">
<UiButtonIcon v-tooltip="t('coming-soon')" icon="fa:ellipsis" accent="brand" disabled size="small" />
</th>
<th v-else>
<div v-tooltip class="text-ellipsis">
<VtsIcon :name="headerIcon[column.id]" size="medium" />
{{ column.label }}
</div>
</th>
</template>
</tr>
</thead>
<tbody>
<VtsRow v-for="pif of paginatedPifs" :key="pif.uuid" :selected="selectedPifId === pif.uuid">
<BodyCells :item="pif" />
</VtsRow>
</tbody>
</VtsTable>
</template>
<template #tbody>
<tr
v-for="row of pifsRecords"
:key="row.id"
:class="{ selected: selectedPifId === row.id }"
@click="selectedPifId = row.id"
>
<td
v-for="column of row.visibleColumns"
:key="column.id"
class="typo-body-regular-small"
:class="{ checkbox: column.id === 'checkbox' }"
>
<div v-if="column.id === 'checkbox'" v-tooltip="t('coming-soon')">
<UiCheckbox v-model="selected" disabled accent="brand" :value="row.id" />
</div>
<UiButtonIcon
v-else-if="column.id === 'more'"
v-tooltip="t('coming-soon')"
icon="fa:ellipsis"
accent="brand"
disabled
size="small"
/>
<div v-else-if="column.id === 'status'">
<VtsStatus :status="column.value" />
</div>
<div v-else-if="column.id === 'network'" class="network">
<!-- TODO Remove the span when the link works and the icon is fixed -->
<!--
<UiComplexIcon size="medium" class="icon">
<VtsIcon icon="fa:network-wired" accent="current" />
<VtsIcon accent="success" :icon="faCircle" :overlay-icon="faCheck" />
</UiComplexIcon>
<a v-tooltip href="" class="text-ellipsis">{{ column.value.name }}</a>
-->
<span v-tooltip class="text-ellipsis">{{ column.value.name }}</span>
<VtsIcon
v-if="column.value.management"
v-tooltip="t('management')"
name="legacy:primary"
size="medium"
/>
</div>
<div v-else-if="column.id === 'IP'" class="ip-addresses">
<span v-tooltip class="text-ellipsis">{{ column.value[0] }}</span>
<span v-if="column.value.length > 1" class="typo-body-regular-small more-ips">
{{ `+${column.value.length - 1}` }}
</span>
</div>
<div v-else v-tooltip="{ placement: 'bottom-end' }" class="text-ellipsis">
{{ column.value }}
</div>
</td>
</tr>
</template>
</VtsDataTable>
<VtsStateHero v-if="searchQuery && filteredPifs.length === 0" format="table" type="no-result" size="small">
<div>{{ t('no-result') }}</div>
</VtsStateHero>
<UiTopBottomTable :selected-items="0" :total-items="0">
<UiTablePagination v-if="isReady" v-bind="paginationBindings" />
</UiTopBottomTable>
</div>
</div>
</template>
<script lang="ts" setup>
import useMultiSelect from '@/composables/multi-select.composable'
import type { XenApiNetwork, XenApiPif } from '@/libs/xen-api/xen-api.types'
import { useNetworkStore } from '@/stores/xen-api/network.store'
import { usePifStore } from '@/stores/xen-api/pif.store'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import type { IconName } from '@core/icons'
import VtsDataTable from '@core/components/data-table/VtsDataTable.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiCheckbox from '@core/components/ui/checkbox/UiCheckbox.vue'
import UiQuerySearchBar from '@core/components/ui/query-search-bar/UiQuerySearchBar.vue'
import UiTableActions from '@core/components/ui/table-actions/UiTableActions.vue'
import UiTablePagination from '@core/components/ui/table-pagination/UiTablePagination.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import UiTopBottomTable from '@core/components/ui/top-bottom-table/UiTopBottomTable.vue'
import { usePagination } from '@core/composables/pagination.composable'
import { useRouteQuery } from '@core/composables/route-query.composable'
import { useTableState } from '@core/composables/table-state.composable'
import { useTable } from '@core/composables/table.composable'
import { vTooltip } from '@core/directives/tooltip.directive'
import { icon } from '@core/icons'
import { usePifColumns } from '@core/tables/column-sets/pif-columns'
import { logicNot } from '@vueuse/math'
import { noop } from '@vueuse/shared'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -89,6 +170,10 @@ const { t } = useI18n()
const selectedPifId = useRouteQuery('id')
const pifsUuids = computed(() => pifs.map(pif => pif.uuid))
const { selected, areAllSelected } = useMultiSelect(pifsUuids)
const getNetworkName = (networkRef: XenApiNetwork['$ref']) => getByOpaqueRef(networkRef)?.name_label ?? ''
const getVlanData = (vlan: number) => (vlan !== -1 ? vlan : t('none'))
@@ -122,47 +207,43 @@ const filteredPifs = computed(() => {
)
})
const state = useTableState({
busy: logicNot(isReady),
error: hasError,
empty: () =>
pifs.length === 0 ? t('no-pif-detected') : filteredPifs.value.length === 0 ? { type: 'no-result' } : false,
const { visibleColumns, rows } = useTable('pifs', filteredPifs, {
rowId: record => record.uuid,
columns: define => [
define('checkbox', noop, { label: '', isHideable: false }),
define(
'network',
record => ({
name: getNetworkName(record.network),
management: record.management,
}),
{ label: t('network') }
),
define('device', { label: t('device') }),
define('status', record => getPifStatus(record), { label: t('status') }),
define('VLAN', record => getVlanData(record.VLAN), { label: t('vlan') }),
define('IP', record => getIpAddresses(record), { label: t('ip-addresses') }),
define('MAC', { label: t('mac-addresses') }),
define('ip_configuration_mode', record => getIpConfigurationMode(record.ip_configuration_mode), {
label: t('ip-mode'),
}),
define('more', noop, { label: '', isHideable: false }),
],
})
const { pageRecords: paginatedPifs, paginationBindings } = usePagination('pifs', filteredPifs)
const { pageRecords: pifsRecords, paginationBindings } = usePagination('pifs', rows)
function getManagementIcon(pif: XenApiPif) {
if (!pif.management) {
return undefined
}
type PifHeader = 'network' | 'device' | 'status' | 'VLAN' | 'IP' | 'MAC' | 'ip_configuration_mode'
return {
icon: icon('status:primary-circle'),
tooltip: t('management'),
}
const headerIcon: Record<PifHeader, IconName> = {
network: 'fa:align-left',
device: 'fa:align-left',
status: 'fa:power-off',
VLAN: 'fa:align-left',
IP: 'fa:at',
MAC: 'fa:at',
ip_configuration_mode: 'fa:caret-down',
}
const { HeadCells, BodyCells } = usePifColumns({
body: (pif: XenApiPif) => {
const name = computed(() => getNetworkName(pif.network))
const status = computed(() => getPifStatus(pif))
const vlan = computed(() => getVlanData(pif.VLAN))
const ipAddresses = computed(() => getIpAddresses(pif))
const ipMode = computed(() => getIpConfigurationMode(pif.ip_configuration_mode))
const rightIcon = computed(() => getManagementIcon(pif))
return {
network: r => r({ label: name.value }),
device: r => r(pif.device, { rightIcon: rightIcon.value }),
status: r => r(status.value),
vlan: r => r(vlan.value),
ip: r => r(ipAddresses.value),
mac: r => r(pif.MAC),
mode: r => r(ipMode.value),
selectItem: r => r(() => (selectedPifId.value = pif.uuid)),
}
},
})
</script>
<style scoped lang="postcss">
@@ -180,5 +261,31 @@ const { HeadCells, BodyCells } = usePifColumns({
.table-actions {
gap: 0.8rem;
}
.network {
display: flex;
align-items: center;
gap: 1.8rem;
}
.ip-addresses {
display: flex;
justify-content: space-between;
align-items: center;
.more-ips {
color: var(--color-neutral-txt-secondary);
}
}
.checkbox,
.more {
width: 4.8rem;
}
.checkbox {
text-align: center;
line-height: 1;
}
}
</style>

View File

@@ -22,7 +22,7 @@
</VtsQuickInfoRow>
<VtsQuickInfoRow :label="t('pool')">
<template v-if="pool !== undefined" #value>
<UiLink size="medium" :to="`/pool/${pool.uuid}/`" icon="object:pool">
<UiLink size="medium" :to="`/pool/${pool.uuid}/`" icon="fa:city">
{{ pool.name_label }}
</UiLink>
</template>
@@ -30,10 +30,10 @@
<VtsQuickInfoRow :label="t('master')">
<template #value>
<template v-if="isMaster">
<VtsIcon v-tooltip="t('master')" name="status:primary-circle" size="medium" />
<VtsIcon v-tooltip="t('master')" name="legacy:primary" size="medium" />
{{ t('this-host') }}
</template>
<UiLink v-else-if="masterHost !== undefined" size="medium" :to="`/host/${masterHost.uuid}/`" icon="object:host">
<UiLink v-else-if="masterHost !== undefined" size="medium" :to="`/host/${masterHost.uuid}/`" icon="fa:server">
{{ masterHost.name_label }}
</UiLink>
</template>
@@ -52,12 +52,12 @@
</template>
<script setup lang="ts">
import VtsRelativeTime from '@/components/RelativeTime.vue'
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsQuickInfoRow from '@core/components/quick-info-row/VtsQuickInfoRow.vue'
import VtsRelativeTime from '@core/components/relative-time/VtsRelativeTime.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'

View File

@@ -14,10 +14,10 @@
</template>
<script setup lang="ts">
import VtsRelativeTime from '@/components/RelativeTime.vue'
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import VtsQuickInfoRow from '@core/components/quick-info-row/VtsQuickInfoRow.vue'
import VtsRelativeTime from '@core/components/relative-time/VtsRelativeTime.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { computed } from 'vue'

View File

@@ -1,13 +1,9 @@
<template>
<VtsTreeItem v-if="host !== undefined" :expanded="isExpanded" class="infra-host-item" :node-id="`host:${host.uuid}`">
<UiTreeItemLabel
:route="{ name: '/host/[uuid]', params: { uuid: host.uuid } }"
icon="object:host"
@toggle="toggle()"
>
<VtsTreeItem v-if="host !== undefined" :expanded="isExpanded" class="infra-host-item">
<UiTreeItemLabel :route="{ name: '/host/[uuid]', params: { uuid: host.uuid } }" icon="fa:server" @toggle="toggle()">
{{ host.name_label || '(Host)' }}
<template #addons>
<VtsIcon v-if="isPoolMaster" v-tooltip="t('master')" name="status:primary-circle" size="medium" />
<VtsIcon v-if="isPoolMaster" v-tooltip="t('master')" name="legacy:primary" size="medium" />
<UiCounter
v-if="isReady"
v-tooltip="t('running-vm', { count: vmCount })"

View File

@@ -2,7 +2,7 @@
<VtsTreeItemError v-if="hasError">
{{ t('error-no-data') }}
</VtsTreeItemError>
<VtsTreeLoadingItem v-else-if="!isReady" icon="object:host">{{ t('loading-hosts') }}</VtsTreeLoadingItem>
<VtsTreeLoadingItem v-else-if="!isReady" icon="fa:server">{{ t('loading-hosts') }}</VtsTreeLoadingItem>
<InfraHostItem v-for="host in hosts" :key="host.$ref" :host-opaque-ref="host.$ref" />
</template>

View File

@@ -1,14 +1,14 @@
<template>
<VtsTreeList class="infra-pool-list">
<VtsTreeItem :expanded="isExpanded" :node-id="`pool:${pool?.uuid}`">
<VtsTreeItem :expanded="isExpanded">
<VtsTreeItemError v-if="hasError">
{{ t('error-no-data') }}
</VtsTreeItemError>
<VtsTreeLoadingItem v-else-if="!isReady || pool === undefined" icon="object:pool" />
<VtsTreeLoadingItem v-else-if="!isReady || pool === undefined" icon="fa:city" />
<UiTreeItemLabel
v-else
:route="{ name: '/pool/[uuid]', params: { uuid: pool.uuid } }"
icon="object:pool"
icon="fa:city"
@toggle="toggle()"
>
{{ pool.name_label || '(Pool)' }}

View File

@@ -1,5 +1,5 @@
<template>
<VtsTreeItem v-if="vm !== undefined" ref="rootElement" expanded class="infra-vm-item" :node-id="`vm:${vm.uuid}`">
<VtsTreeItem v-if="vm !== undefined" ref="rootElement" expanded class="infra-vm-item">
<UiTreeItemLabel v-if="isVisible" :route="{ name: '/vm/[uuid]', params: { uuid: vm.uuid } }" no-indent>
{{ vm.name_label || '(VM)' }}
<template #icon>

View File

@@ -1,7 +1,7 @@
<template>
<VtsTreeItemError v-if="hasError">{{ t('error-no-data') }}</VtsTreeItemError>
<template v-else-if="!isReady">
<VtsTreeLoadingItem v-for="i in 3" :key="i" icon="object:vm" />
<VtsTreeLoadingItem v-for="i in 3" :key="i" icon="fa:display" />
</template>
<InfraVmItem v-for="vm in vms" :key="vm.$ref" :vm-opaque-ref="vm.$ref" />
</template>

View File

@@ -16,7 +16,7 @@
</div>
</div>
<div v-if="newFilters.some(filter => filter.isAdvanced)" class="available-properties">
{{ t('available-properties-for-advanced-filter:') }}
{{ t('available-properties-for-advanced-filter') }}
<div class="properties typo-body-regular">
<UiBadge v-for="(filter, property) in availableFilters" :key="property" :icon="getFilterIcon(filter)">
{{ property }}
@@ -27,7 +27,7 @@
<template #buttons>
<VtsModalCancelButton />
<VtsModalConfirmButton :disabled="!isFilterValid">
{{ editedFilter ? t('action:update') : t('action:add') }}
{{ editedFilter ? t('update') : t('add') }}
</VtsModalConfirmButton>
</template>
</VtsModal>

View File

@@ -9,7 +9,7 @@
<template #buttons>
<VtsModalCancelButton />
<VtsModalConfirmButton :disabled="!newSortProperty">{{ t('action:add') }}</VtsModalConfirmButton>
<VtsModalConfirmButton :disabled="!newSortProperty">{{ t('add') }}</VtsModalConfirmButton>
</template>
</VtsModal>
</template>

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