mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-13 10:41:09 -06:00
Compare commits
3 Commits
debug_back
...
remove_per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e84bf15650 | ||
|
|
60e286a70d | ||
|
|
c7a16a3f7a |
21
.eslintrc.js
21
.eslintrc.js
@@ -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'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']>
|
||||
}
|
||||
|
||||
@@ -1008,7 +1008,6 @@ export interface XenApiMessage {
|
||||
timestamp: string
|
||||
uuid: string
|
||||
}
|
||||
export type XenApiMessageWrapped = WrapperXenApi<XenApiMessage, 'message'>
|
||||
|
||||
export interface XenApiSecret {
|
||||
$ref: Branded<'secret'>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('_getOldEntries() should succeed', () => {
|
||||
expectedIds: [1],
|
||||
testLabel: 'should handle number based retention without timestamp ',
|
||||
},
|
||||
|
||||
{
|
||||
args: [
|
||||
0,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
171
@xen-orchestra/backups/disks/RemoteVhd.mjs
Normal file
171
@xen-orchestra/backups/disks/RemoteVhd.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -45,7 +45,7 @@ function formatVmBackup(backup) {
|
||||
name_label: backup.vm.name_label,
|
||||
tags: backup.vm.tags,
|
||||
},
|
||||
tags: backup.tags ?? [],
|
||||
|
||||
differencingVhds,
|
||||
dynamicVhds,
|
||||
withMemory,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</UiFilter>
|
||||
|
||||
<UiActionButton icon="fa:plus" class="add-filter" @click="openFilterModal()">
|
||||
{{ t('action:add-filter') }}
|
||||
{{ t('add-filter') }}
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
</template>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
94
@xen-orchestra/lite/src/components/CollectionTable.vue
Normal file
94
@xen-orchestra/lite/src/components/CollectionTable.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
18
@xen-orchestra/lite/src/components/RelativeTime.vue
Normal file
18
@xen-orchestra/lite/src/components/RelativeTime.vue
Normal 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>
|
||||
33
@xen-orchestra/lite/src/components/TextLogo.vue
Normal file
33
@xen-orchestra/lite/src/components/TextLogo.vue
Normal 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>
|
||||
@@ -19,7 +19,7 @@
|
||||
class="xoa-button"
|
||||
@click="openXoaDeploy()"
|
||||
>
|
||||
{{ t('action:deploy-xoa') }}
|
||||
{{ t('deploy-xoa') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user