diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index cc5080228..d267cbc47 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -8,6 +8,7 @@ - [Network] Dedicated page for network creation [#3895](https://github.com/vatesfr/xen-orchestra/issues/3895) (PR [#3906](https://github.com/vatesfr/xen-orchestra/pull/3906)) - [Logs] Add button to download the log [#3957](https://github.com/vatesfr/xen-orchestra/issues/3957) (PR [#3985](https://github.com/vatesfr/xen-orchestra/pull/3985)) - [Continuous Replication] Share full copy between schedules [#3973](https://github.com/vatesfr/xen-orchestra/issues/3973) (PR [#3995](https://github.com/vatesfr/xen-orchestra/pull/3995)) +- [Backup] Ability to backup XO configuration and pool metadata [#808](https://github.com/vatesfr/xen-orchestra/issues/808) [#3501](https://github.com/vatesfr/xen-orchestra/issues/3501) (PR [#3912](https://github.com/vatesfr/xen-orchestra/pull/3912)) ### Bug fixes diff --git a/packages/xo-server-backup-reports/src/index.js b/packages/xo-server-backup-reports/src/index.js index a53df7724..8c9f996a9 100644 --- a/packages/xo-server-backup-reports/src/index.js +++ b/packages/xo-server-backup-reports/src/index.js @@ -154,6 +154,10 @@ class BackupReportsXoPlugin { } _wrapper(status, job, schedule, runJobId) { + if (job.type === 'metadataBackup') { + return + } + return new Promise(resolve => resolve( job.type === 'backup' diff --git a/packages/xo-server/src/api/metadata-backup.js b/packages/xo-server/src/api/metadata-backup.js new file mode 100644 index 000000000..a2fa20064 --- /dev/null +++ b/packages/xo-server/src/api/metadata-backup.js @@ -0,0 +1,103 @@ +export function createJob({ schedules, ...job }) { + job.userId = this.user.id + return this.createMetadataBackupJob(job, schedules) +} + +createJob.permission = 'admin' +createJob.params = { + name: { + type: 'string', + optional: true, + }, + pools: { + type: 'object', + optional: true, + }, + remotes: { + type: 'object', + }, + schedules: { + type: 'object', + }, + settings: { + type: 'object', + }, + xoMetadata: { + type: 'boolean', + optional: true, + }, +} + +export function getAllJobs() { + return this.getAllJobs('metadataBackup') +} + +getAllJobs.permission = 'admin' + +export function getJob({ id }) { + return this.getJob(id, 'metadataBackup') +} + +getJob.permission = 'admin' +getJob.params = { + id: { + type: 'string', + }, +} + +export function deleteJob({ id }) { + return this.deleteMetadataBackupJob(id) +} + +deleteJob.permission = 'admin' +deleteJob.params = { + id: { + type: 'string', + }, +} + +export function editJob(props) { + return this.updateJob(props) +} + +editJob.permission = 'admin' +editJob.params = { + id: { + type: 'string', + }, + name: { + type: 'string', + optional: true, + }, + pools: { + type: ['object', 'null'], + optional: true, + }, + settings: { + type: 'object', + optional: true, + }, + remotes: { + type: 'object', + optional: true, + }, + xoMetadata: { + type: 'boolean', + optional: true, + }, +} + +export async function runJob({ id, schedule }) { + return this.runJobSequence([id], await this.getSchedule(schedule)) +} + +runJob.permission = 'admin' + +runJob.params = { + id: { + type: 'string', + }, + schedule: { + type: 'string', + }, +} diff --git a/packages/xo-server/src/utils.js b/packages/xo-server/src/utils.js index e0503c4ce..4991dcaaf 100644 --- a/packages/xo-server/src/utils.js +++ b/packages/xo-server/src/utils.js @@ -15,6 +15,8 @@ import { dirname, resolve } from 'path' import { utcFormat, utcParse } from 'd3-time-format' import { fromCallback, pAll, pReflect, promisify } from 'promise-toolbox' +import { type SimpleIdPattern } from './utils' + // =================================================================== export function camelToSnakeCase(string) { @@ -417,3 +419,13 @@ export const getFirstPropertyName = object => { } } } + +// ------------------------------------------------------------------- + +export const unboxIdsFromPattern = (pattern?: SimpleIdPattern): string[] => { + if (pattern === undefined) { + return [] + } + const { id } = pattern + return typeof id === 'string' ? [id] : id.__or +} diff --git a/packages/xo-server/src/utils.js.flow b/packages/xo-server/src/utils.js.flow index 53db27422..0349f2d34 100644 --- a/packages/xo-server/src/utils.js.flow +++ b/packages/xo-server/src/utils.js.flow @@ -11,3 +11,5 @@ declare export function safeDateFormat(timestamp: number): string declare export function serializeError(error: Error): Object declare export function streamToBuffer(stream: Readable): Promise + +export type SimpleIdPattern = {| id: string | {| __or: string[] |}, |} diff --git a/packages/xo-server/src/xapi/mixins/pool.js b/packages/xo-server/src/xapi/mixins/pool.js new file mode 100644 index 000000000..2d7834139 --- /dev/null +++ b/packages/xo-server/src/xapi/mixins/pool.js @@ -0,0 +1,14 @@ +import { cancelable } from 'promise-toolbox' + +export default { + @cancelable + exportPoolMetadata($cancelToken) { + const { pool } = this + return this.getResource($cancelToken, '/pool/xmldbdump', { + task: this.createTask( + 'Pool metadata', + pool.name_label ?? pool.$master.name_label + ), + }) + }, +} diff --git a/packages/xo-server/src/xo-mixins/backups-ng/index.js b/packages/xo-server/src/xo-mixins/backups-ng/index.js index cfbfda7be..051b48b8e 100644 --- a/packages/xo-server/src/xo-mixins/backups-ng/index.js +++ b/packages/xo-server/src/xo-mixins/backups-ng/index.js @@ -54,6 +54,8 @@ import { resolveRelativeFromFile, safeDateFormat, serializeError, + type SimpleIdPattern, + unboxIdsFromPattern, } from '../../utils' import { translateLegacyJob } from './migration' @@ -75,10 +77,6 @@ type Settings = {| vmTimeout?: number, |} -type SimpleIdPattern = {| - id: string | {| __or: string[] |}, -|} - export type BackupJob = {| ...$Exact, compression?: 'native' | 'zstd' | '', @@ -310,14 +308,6 @@ const parseVmBackupId = (id: string) => { } } -const unboxIds = (pattern?: SimpleIdPattern): string[] => { - if (pattern === undefined) { - return [] - } - const { id } = pattern - return typeof id === 'string' ? [id] : id.__or -} - // similar to Promise.all() but do not gather results async function waitAll( promises: Promise[], @@ -605,7 +595,7 @@ export default class BackupNg { } } const jobId = job.id - const srs = unboxIds(job.srs).map(id => { + const srs = unboxIdsFromPattern(job.srs).map(id => { const xapi = app.getXapi(id) return { __proto__: xapi.getObject(id), @@ -613,7 +603,7 @@ export default class BackupNg { } }) const remotes = await Promise.all( - unboxIds(job.remotes).map(async id => ({ + unboxIdsFromPattern(job.remotes).map(async id => ({ id, handler: await app.getRemoteHandler(id), })) diff --git a/packages/xo-server/src/xo-mixins/metadata-backups.js b/packages/xo-server/src/xo-mixins/metadata-backups.js new file mode 100644 index 000000000..475d794f2 --- /dev/null +++ b/packages/xo-server/src/xo-mixins/metadata-backups.js @@ -0,0 +1,264 @@ +// @flow +import asyncMap from '@xen-orchestra/async-map' +import defer from 'golike-defer' +import { fromEvent, ignoreErrors } from 'promise-toolbox' + +import { type Xapi } from '../xapi' +import { + safeDateFormat, + type SimpleIdPattern, + unboxIdsFromPattern, +} from '../utils' + +import { type Executor, type Job } from './jobs' +import { type Schedule } from './scheduling' + +const METADATA_BACKUP_JOB_TYPE = 'metadataBackup' + +type Settings = {| + retentionXoMetadata?: number, + retentionPoolMetadata?: number, +|} + +type MetadataBackupJob = { + ...$Exact, + pools?: SimpleIdPattern, + remotes: SimpleIdPattern, + settings: $Dict, + type: METADATA_BACKUP_JOB_TYPE, + xoMetadata?: boolean, +} + +// File structure on remotes: +// +// +// ├─ xo-config-backups +// │ └─ +// │ └─ T +// │ ├─ metadata.json +// │ └─ data.json +// └─ xo-pool-metadata-backups +// └─ +// └─ +// └─ T +// ├─ metadata.json +// └─ data + +export default class metadataBackup { + _app: { + createJob: ( + $Diff + ) => Promise, + createSchedule: ($Diff) => Promise, + deleteSchedule: (id: string) => Promise, + getXapi: (id: string) => Xapi, + getJob: ( + id: string, + ?METADATA_BACKUP_JOB_TYPE + ) => Promise, + updateJob: ( + $Shape, + ?boolean + ) => Promise, + removeJob: (id: string) => Promise, + } + + constructor(app: any) { + this._app = app + app.on('start', () => { + app.registerJobExecutor( + METADATA_BACKUP_JOB_TYPE, + this._executor.bind(this) + ) + }) + } + + async _executor({ cancelToken, job: job_, schedule }): Executor { + if (schedule === undefined) { + throw new Error('backup job cannot run without a schedule') + } + + const job: MetadataBackupJob = (job_: any) + const remoteIds = unboxIdsFromPattern(job.remotes) + if (remoteIds.length === 0) { + throw new Error('metadata backup job cannot run without remotes') + } + + const poolIds = unboxIdsFromPattern(job.pools) + const isEmptyPools = poolIds.length === 0 + if (!job.xoMetadata && isEmptyPools) { + throw new Error('no metadata mode found') + } + + const app = this._app + const { retentionXoMetadata, retentionPoolMetadata } = + job?.settings[schedule.id] || {} + + const timestamp = Date.now() + const formattedTimestamp = safeDateFormat(timestamp) + const commonMetadata = { + jobId: job.id, + jobName: job.name, + scheduleId: schedule.id, + scheduleName: schedule.name, + timestamp, + } + + const files = [] + if (job.xoMetadata && retentionXoMetadata > 0) { + const xoMetadataDir = `xo-config-backups/${schedule.id}` + const dir = `${xoMetadataDir}/${formattedTimestamp}` + + const data = JSON.stringify(await app.exportConfig(), null, 2) + const fileName = `${dir}/data.json` + + const metadata = JSON.stringify(commonMetadata, null, 2) + const metaDataFileName = `${dir}/metadata.json` + + files.push({ + executeBackup: defer(($defer, handler) => { + $defer.onFailure(() => handler.rmtree(dir)) + return Promise.all([ + handler.outputFile(fileName, data), + handler.outputFile(metaDataFileName, metadata), + ]) + }), + dir: xoMetadataDir, + retention: retentionXoMetadata, + }) + } + if (!isEmptyPools && retentionPoolMetadata > 0) { + files.push( + ...(await Promise.all( + poolIds.map(async id => { + const poolMetadataDir = `xo-pool-metadata-backups/${ + schedule.id + }/${id}` + const dir = `${poolMetadataDir}/${formattedTimestamp}` + + // TODO: export the metadata only once then split the stream between remotes + const stream = await app.getXapi(id).exportPoolMetadata(cancelToken) + const fileName = `${dir}/data` + + const xapi = this._app.getXapi(id) + const metadata = JSON.stringify( + { + ...commonMetadata, + pool: xapi.pool, + poolMaster: await xapi.getRecord('host', xapi.pool.master), + }, + null, + 2 + ) + const metaDataFileName = `${dir}/metadata.json` + + return { + executeBackup: defer(($defer, handler) => { + $defer.onFailure(() => handler.rmtree(dir)) + return Promise.all([ + (async () => { + const outputStream = await handler.createOutputStream( + fileName + ) + $defer.onFailure(() => outputStream.destroy()) + + // 'readable-stream/pipeline' not call the callback when an error throws + // from the readable stream + stream.pipe(outputStream) + return fromEvent(stream, 'end').catch(error => { + if (error.message !== 'aborted') { + throw error + } + }) + })(), + handler.outputFile(metaDataFileName, metadata), + ]) + }), + dir: poolMetadataDir, + retention: retentionPoolMetadata, + } + }) + )) + ) + } + + if (files.length === 0) { + throw new Error('no retentions corresponding to the metadata modes found') + } + + cancelToken.throwIfRequested() + + const timestampReg = /^\d{8}T\d{6}Z$/ + return asyncMap( + // TODO: emit a warning task if a remote is broken + asyncMap(remoteIds, id => app.getRemoteHandler(id)::ignoreErrors()), + async handler => { + if (handler === undefined) { + return + } + + await Promise.all( + files.map(async ({ executeBackup, dir, retention }) => { + await executeBackup(handler) + + // deleting old backups + await handler.list(dir).then(list => { + list.sort() + list = list + .filter(timestampDir => timestampReg.test(timestampDir)) + .slice(0, -retention) + return Promise.all( + list.map(timestampDir => + handler.rmtree(`${dir}/${timestampDir}`) + ) + ) + }) + }) + ) + } + ) + } + + async createMetadataBackupJob( + props: $Diff, + schedules: $Dict<$Diff> + ): Promise { + const app = this._app + + const job: MetadataBackupJob = await app.createJob({ + ...props, + type: METADATA_BACKUP_JOB_TYPE, + }) + + const { id: jobId, settings } = job + await asyncMap(schedules, async (schedule, tmpId) => { + const { id: scheduleId } = await app.createSchedule({ + ...schedule, + jobId, + }) + settings[scheduleId] = settings[tmpId] + delete settings[tmpId] + }) + await app.updateJob({ id: jobId, settings }) + + return job + } + + async deleteMetadataBackupJob(id: string): Promise { + const app = this._app + const [schedules] = await Promise.all([ + app.getAllSchedules(), + // it test if the job is of type metadataBackup + app.getJob(id, METADATA_BACKUP_JOB_TYPE), + ]) + + await Promise.all([ + app.removeJob(id), + asyncMap(schedules, schedule => { + if (schedule.id === id) { + return app.deleteSchedule(id) + } + }), + ]) + } +} diff --git a/packages/xo-web/src/common/form/number.js b/packages/xo-web/src/common/form/number.js index d5785430f..9b9128316 100644 --- a/packages/xo-web/src/common/form/number.js +++ b/packages/xo-web/src/common/form/number.js @@ -1,9 +1,11 @@ import PropTypes from 'prop-types' import React from 'react' import { injectState, provideState } from 'reaclette' +import { startsWith } from 'lodash' import decorate from '../apply-decorators' +// it provide `data-*` to add params to the `onChange` const Number_ = decorate([ provideState({ effects: { @@ -18,7 +20,16 @@ const Number_ = decorate([ } } - props.onChange(value) + const params = {} + let empty = true + Object.keys(props).forEach(key => { + if (startsWith(key, 'data-')) { + empty = false + params[key.slice(5)] = props[key] + } + }) + + props.onChange(value, empty ? undefined : params) }, }, }), diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js index ac46bc500..2a59685cf 100644 --- a/packages/xo-web/src/common/intl/messages.js +++ b/packages/xo-web/src/common/intl/messages.js @@ -37,6 +37,8 @@ const messages = { paths: 'Paths', pbdDisconnected: 'PBD disconnected', hasInactivePath: 'Has an inactive path', + pools: 'Pools', + remotes: 'Remotes', // ----- Modals ----- alertOk: 'OK', @@ -125,6 +127,11 @@ const messages = { deltaBackup: 'Delta Backup', disasterRecovery: 'Disaster Recovery', continuousReplication: 'Continuous Replication', + backupType: 'Backup type', + poolMetadata: 'Pool metadata', + xoConfig: 'XO config', + backupVms: 'Backup VMs', + backupMetadata: 'Backup metadata', jobsOverviewPage: 'Overview', jobsNewPage: 'New', jobsSchedulingPage: 'Scheduling', @@ -370,14 +377,17 @@ const messages = { missingBackupMode: 'You need to choose a backup mode!', missingRemotes: 'Missing remotes!', missingSrs: 'Missing SRs!', + missingPools: 'Missing pools!', missingSchedules: 'Missing schedules!', + missingRetentions: + 'The modes need at least a schedule with retention higher than 0', missingExportRetention: 'The Backup mode and The Delta Backup mode require backup retention to be higher than 0!', missingCopyRetention: 'The CR mode and The DR mode require replication retention to be higher than 0!', missingSnapshotRetention: 'The Rolling Snapshot mode requires snapshot retention to be higher than 0!', - retentionNeeded: 'One of the retentions needs to be higher than 0!', + retentionNeeded: 'Requires one retention to be higher than 0!', newScheduleError: 'Invalid schedule', createRemoteMessage: 'No remotes found, please click on the remotes settings button to create one!', @@ -1396,6 +1406,8 @@ const messages = { scheduleExportRetention: 'Backup ret.', scheduleCopyRetention: 'Replication ret.', scheduleSnapshotRetention: 'Snapshot ret.', + poolMetadataRetention: 'Pool ret.', + xoMetadataRetention: 'XO ret.', getRemote: 'Get remote', listRemote: 'List Remote', simpleBackup: 'simple', diff --git a/packages/xo-web/src/common/xo/index.js b/packages/xo-web/src/common/xo/index.js index 5464407da..b3463bd9d 100644 --- a/packages/xo-web/src/common/xo/index.js +++ b/packages/xo-web/src/common/xo/index.js @@ -1922,15 +1922,22 @@ export const subscribeBackupNgLogs = createSubscription(() => _call('backupNg.getAllLogs') ) +export const subscribeMetadataBackupJobs = createSubscription(() => + _call('metadataBackup.getAllJobs') +) + export const createBackupNgJob = props => _call('backupNg.createJob', props)::tap(subscribeBackupNgJobs.forceRefresh) -export const deleteBackupNgJobs = async ids => { - const { length } = ids - if (length === 0) { +export const deleteBackupJobs = async ({ + backupIds = [], + metadataBackupIds = [], +}) => { + const nJobs = backupIds.length + metadataBackupIds.length + if (nJobs === 0) { return } - const vars = { nJobs: length } + const vars = { nJobs } try { await confirm({ title: _('confirmDeleteBackupJobsTitle', vars), @@ -1940,9 +1947,25 @@ export const deleteBackupNgJobs = async ids => { return } - return Promise.all( - ids.map(id => _call('backupNg.deleteJob', { id: resolveId(id) })) - )::tap(subscribeBackupNgJobs.forceRefresh) + const promises = [] + if (backupIds.length !== 0) { + promises.push( + Promise.all( + backupIds.map(id => _call('backupNg.deleteJob', { id: resolveId(id) })) + )::tap(subscribeBackupNgJobs.forceRefresh) + ) + } + if (metadataBackupIds.length !== 0) { + promises.push( + Promise.all( + metadataBackupIds.map(id => + _call('metadataBackup.deleteJob', { id: resolveId(id) }) + ) + )::tap(subscribeMetadataBackupJobs.forceRefresh) + ) + } + + return Promise.all(promises)::tap(subscribeSchedules.forceRefresh) } export const editBackupNgJob = props => @@ -1979,6 +2002,19 @@ export const deleteBackups = async backups => { } } +export const createMetadataBackupJob = props => + _call('metadataBackup.createJob', props) + ::tap(subscribeMetadataBackupJobs.forceRefresh) + ::tap(subscribeSchedules.forceRefresh) + +export const editMetadataBackupJob = props => + _call('metadataBackup.editJob', props) + ::tap(subscribeMetadataBackupJobs.forceRefresh) + ::tap(subscribeSchedules.forceRefresh) + +export const runMetadataBackupJob = params => + _call('metadataBackup.runJob', params) + // Plugins ----------------------------------------------------------- export const loadPlugin = async id => diff --git a/packages/xo-web/src/xo-app/backup-ng/edit.js b/packages/xo-web/src/xo-app/backup-ng/edit.js index 631211881..b75f6b1bd 100644 --- a/packages/xo-web/src/xo-app/backup-ng/edit.js +++ b/packages/xo-web/src/xo-app/backup-ng/edit.js @@ -1,15 +1,22 @@ import addSubscriptions from 'add-subscriptions' import decorate from 'apply-decorators' +import defined from '@xen-orchestra/defined' import React from 'react' import { injectState, provideState } from 'reaclette' -import { subscribeBackupNgJobs, subscribeSchedules } from 'xo' import { find, groupBy, keyBy } from 'lodash' +import { + subscribeBackupNgJobs, + subscribeMetadataBackupJobs, + subscribeSchedules, +} from 'xo' +import Metadata from './new/metadata' import New from './new' export default decorate([ addSubscriptions({ jobs: subscribeBackupNgJobs, + metadataJobs: subscribeMetadataBackupJobs, schedulesByJob: cb => subscribeSchedules(schedules => { cb(groupBy(schedules, 'jobId')) @@ -17,11 +24,17 @@ export default decorate([ }), provideState({ computed: { - job: (_, { jobs, routeParams: { id } }) => find(jobs, { id }), + job: (_, { jobs, metadataJobs, routeParams: { id } }) => + defined(find(jobs, { id }), find(metadataJobs, { id })), schedules: (_, { schedulesByJob, routeParams: { id } }) => schedulesByJob && keyBy(schedulesByJob[id], 'id'), }, }), injectState, - ({ state: { job, schedules } }) => , + ({ state: { job = {}, schedules } }) => + job.type === 'backup' ? ( + + ) : ( + + ), ]) diff --git a/packages/xo-web/src/xo-app/backup-ng/index.js b/packages/xo-web/src/xo-app/backup-ng/index.js index 8c02d6795..fbe1e6ae4 100644 --- a/packages/xo-web/src/xo-app/backup-ng/index.js +++ b/packages/xo-web/src/xo-app/backup-ng/index.js @@ -2,6 +2,7 @@ import _ from 'intl' import ActionButton from 'action-button' import addSubscriptions from 'add-subscriptions' import Button from 'button' +import ButtonLink from 'button-link' import Copiable from 'copiable' import CopyToClipboard from 'react-copy-to-clipboard' import decorate from 'apply-decorators' @@ -16,29 +17,31 @@ import { confirm } from 'modal' import { connectStore, routes } from 'utils' import { constructQueryString } from 'smart-backup' import { Container, Row, Col } from 'grid' -import { createGetLoneSnapshots } from 'selectors' +import { createGetLoneSnapshots, createSelector } from 'selectors' import { get } from '@xen-orchestra/defined' import { isEmpty, map, groupBy, some } from 'lodash' import { NavLink, NavTabs } from 'nav' import { cancelJob, - deleteBackupNgJobs, + deleteBackupJobs, disableSchedule, enableSchedule, runBackupNgJob, + runMetadataBackupJob, subscribeBackupNgJobs, subscribeBackupNgLogs, + subscribeMetadataBackupJobs, subscribeSchedules, } from 'xo' import LogsTable, { LogStatus } from '../logs/backup-ng' import Page from '../page' +import NewVmBackup, { NewMetadataBackup } from './new' import Edit from './edit' -import New from './new' import FileRestore from './file-restore' -import Restore from './restore' import Health from './health' +import Restore from './restore' import { destructPattern } from './utils' const Ul = props =>
    @@ -51,14 +54,26 @@ const Li = props => ( /> ) -const _runBackupNgJob = ({ id, name, schedule }) => +const _runBackupJob = ({ id, name, schedule, type }) => confirm({ title: _('runJob'), body: _('runBackupNgJobConfirm', { id: id.slice(0, 5), name: {name}, }), - }).then(() => runBackupNgJob({ id, schedule })) + }).then(() => + type === 'backup' + ? runBackupNgJob({ id, schedule }) + : runMetadataBackupJob({ id, schedule }) + ) + +const _deleteBackupJobs = items => { + const { backup: backupIds, metadataBackup: metadataBackupIds } = groupBy( + items, + 'type' + ) + return deleteBackupJobs({ backupIds, metadataBackupIds }) +} const SchedulePreviewBody = decorate([ addSubscriptions(({ schedule }) => ({ @@ -119,7 +134,8 @@ const SchedulePreviewBody = decorate([ data-id={job.id} data-name={job.name} data-schedule={schedule.id} - handler={_runBackupNgJob} + data-type={job.type} + handler={_runBackupJob} icon='run-schedule' key='run' size='small' @@ -159,10 +175,19 @@ const MODES = [ test: job => job.mode === 'full' && !isEmpty(get(() => destructPattern(job.srs))), }, + { + label: 'poolMetadata', + test: job => !isEmpty(destructPattern(job.pools)), + }, + { + label: 'xoConfig', + test: job => job.xoMetadata, + }, ] @addSubscriptions({ jobs: subscribeBackupNgJobs, + metadataJobs: subscribeMetadataBackupJobs, schedulesByJob: cb => subscribeSchedules(schedules => { cb(groupBy(schedules, 'jobId')) @@ -176,7 +201,7 @@ class JobsTable extends React.Component { static tableProps = { actions: [ { - handler: deleteBackupNgJobs, + handler: _deleteBackupJobs, label: _('deleteBackupSchedule'), icon: 'delete', level: 'danger', @@ -261,6 +286,7 @@ class JobsTable extends React.Component { pathname: '/home', query: { t: 'VM', s: constructQueryString(job.vms) }, }), + disabled: job => job.type !== 'backup', label: _('redirectToMatchingVms'), icon: 'preview', }, @@ -277,11 +303,17 @@ class JobsTable extends React.Component { this.context.router.push(path) } + _getCollection = createSelector( + () => this.props.jobs, + () => this.props.metadataJobs, + (jobs = [], metadataJobs = []) => [...jobs, ...metadataJobs] + ) + render() { return ( @@ -353,9 +385,31 @@ const HEADER = ( ) +const ChooseBackupType = () => ( + + + + + {_('backupType')} + + + {_('backupVms')} + {' '} + + {_('backupMetadata')} + + + + + + +) + export default routes('overview', { ':id/edit': Edit, - new: New, + new: ChooseBackupType, + 'new/vms': NewVmBackup, + 'new/metadata': NewMetadataBackup, overview: Overview, restore: Restore, 'file-restore': FileRestore, diff --git a/packages/xo-web/src/xo-app/backup-ng/new/_schedules/index.js b/packages/xo-web/src/xo-app/backup-ng/new/_schedules/index.js new file mode 100644 index 000000000..3deca082c --- /dev/null +++ b/packages/xo-web/src/xo-app/backup-ng/new/_schedules/index.js @@ -0,0 +1,224 @@ +import _ from 'intl' +import ActionButton from 'action-button' +import decorate from 'apply-decorators' +import Icon from 'icon' +import moment from 'moment-timezone' +import PropTypes from 'prop-types' +import React from 'react' +import SortedTable from 'sorted-table' +import StateButton from 'state-button' +import UserError from 'user-error' +import { Card, CardBlock, CardHeader } from 'card' +import { form } from 'modal' +import { generateRandomId } from 'utils' +import { get } from '@xen-orchestra/defined' +import { injectState, provideState } from 'reaclette' + +import { FormFeedback } from '../../utils' + +import NewSchedule from './new' + +const DEFAULT_SCHEDULE = { + cron: '0 0 * * *', + timezone: moment.tz.guess(), +} + +const setDefaultRetentions = (schedule, retentions) => { + retentions.forEach(({ defaultValue, valuePath }) => { + if (schedule[valuePath] === undefined) { + schedule[valuePath] = defaultValue + } + }) + return schedule +} + +export const areRetentionsMissing = (value, retentions) => + retentions.length !== 0 && + !retentions.some(({ valuePath }) => value[valuePath] > 0) + +const COLUMNS = [ + { + valuePath: 'name', + name: _('scheduleName'), + default: true, + }, + { + itemRenderer: (schedule, { toggleScheduleState }) => ( + + ), + sortCriteria: 'enabled', + name: _('state'), + }, + { + valuePath: 'cron', + name: _('scheduleCron'), + }, + { + valuePath: 'timezone', + name: _('scheduleTimezone'), + }, +] + +const INDIVIDUAL_ACTIONS = [ + { + handler: (schedule, { showModal }) => showModal(schedule), + icon: 'edit', + label: _('scheduleEdit'), + level: 'primary', + }, + { + handler: ({ id }, { deleteSchedule }) => deleteSchedule(id), + icon: 'delete', + label: _('scheduleDelete'), + level: 'danger', + }, +] + +const Schedules = decorate([ + provideState({ + effects: { + deleteSchedule: (_, id) => (state, props) => { + const schedules = { ...props.schedules } + delete schedules[id] + props.handlerSchedules(schedules) + + const settings = { ...props.settings } + delete settings[id] + props.handlerSettings(settings) + }, + showModal: ( + effects, + { id = generateRandomId(), name, cron, timezone } = DEFAULT_SCHEDULE + ) => async (state, props) => { + const schedule = get(() => props.schedules[id]) + const setting = get(() => props.settings[id]) + + const { + cron: newCron, + name: newName, + timezone: newTimezone, + ...newSetting + } = await form({ + defaultValue: setDefaultRetentions( + { cron, name, timezone, ...setting }, + state.retentions + ), + render: props => ( + + ), + header: ( + + {_('schedule')} + + ), + size: 'large', + handler: value => { + if (areRetentionsMissing(value, state.retentions)) { + throw new UserError(_('newScheduleError'), _('retentionNeeded')) + } + return value + }, + }) + + props.handlerSchedules({ + ...props.schedules, + [id]: { + ...schedule, + cron: newCron, + id, + name: newName, + timezone: newTimezone, + }, + }) + props.handlerSettings({ + ...props.settings, + [id]: { + ...setting, + ...newSetting, + }, + }) + }, + toggleScheduleState: (_, id) => ( + state, + { handlerSchedules, schedules } + ) => { + const schedule = schedules[id] + handlerSchedules({ + ...schedules, + [id]: { + ...schedule, + enabled: !schedule.enabled, + }, + }) + }, + }, + computed: { + columns: (_, { retentions }) => [ + ...COLUMNS, + ...retentions.map(({ defaultValue, ...props }) => props), + ], + rowTransform: (_, { settings = {}, retentions }) => schedule => { + schedule = { ...schedule, ...settings[schedule.id] } + return setDefaultRetentions(schedule, retentions) + }, + }, + }), + injectState, + ({ state, effects, schedules, missingSchedules, missingRetentions }) => ( + + + {_('backupSchedules')}* + + + + + + + ), +]) + +Schedules.propTypes = { + handlerSchedules: PropTypes.func.isRequired, + handlerSettings: PropTypes.func.isRequired, + missingRetentions: PropTypes.bool, + missingSchedules: PropTypes.bool, + retentions: PropTypes.arrayOf( + PropTypes.shape({ + defaultValue: PropTypes.number, + name: PropTypes.node.isRequired, + valuePath: PropTypes.string.isRequired, + }) + ).isRequired, + schedules: PropTypes.object, + settings: PropTypes.object, +} + +export { Schedules as default } diff --git a/packages/xo-web/src/xo-app/backup-ng/new/_schedules/new.js b/packages/xo-web/src/xo-app/backup-ng/new/_schedules/new.js new file mode 100644 index 000000000..6a1fcecf9 --- /dev/null +++ b/packages/xo-web/src/xo-app/backup-ng/new/_schedules/new.js @@ -0,0 +1,94 @@ +import _ from 'intl' +import decorate from 'apply-decorators' +import defined from '@xen-orchestra/defined' +import Icon from 'icon' +import React from 'react' +import Scheduler, { SchedulePreview } from 'scheduling' +import { generateId } from 'reaclette-utils' +import { injectState, provideState } from 'reaclette' +import { Number } from 'form' + +import { FormGroup, Input } from '../../utils' + +import { areRetentionsMissing } from '.' + +export default decorate([ + provideState({ + effects: { + setSchedule: (_, params) => (_, { value, onChange }) => { + onChange({ + ...value, + ...params, + }) + }, + setCronTimezone: ( + { setSchedule }, + { cronPattern: cron, timezone } + ) => () => { + setSchedule({ + cron, + timezone, + }) + }, + setName: ({ setSchedule }, { target: { value } }) => () => { + setSchedule({ + name: value.trim() === '' ? null : value, + }) + }, + setRetention: ({ setSchedule }, value, { name }) => () => { + setSchedule({ + [name]: defined(value, null), + }) + }, + }, + computed: { + idInputName: generateId, + + missingRetentions: (_, { value, retentions }) => + areRetentionsMissing(value, retentions), + }, + }), + injectState, + ({ effects, state, retentions, value: schedule }) => ( +
    + {state.missingRetentions && ( +
    + {_('retentionNeeded')} +
    + )} + + + + + {/* retentions effects are defined on initialize() */} + {retentions.map(({ name, valuePath }) => ( + + + + + ))} + + +
    + ), +]) diff --git a/packages/xo-web/src/xo-app/backup-ng/new/index.js b/packages/xo-web/src/xo-app/backup-ng/new/index.js index ea68ef167..e1742b7e1 100644 --- a/packages/xo-web/src/xo-app/backup-ng/new/index.js +++ b/packages/xo-web/src/xo-app/backup-ng/new/index.js @@ -46,6 +46,7 @@ import Schedules from './schedules' import SmartBackup from './smart-backup' import { canDeltaBackup, + constructPattern, destructPattern, FormFeedback, FormGroup, @@ -54,6 +55,8 @@ import { Ul, } from './../utils' +export NewMetadataBackup from './metadata' + // =================================================================== const DEFAULT_RETENTION = 1 @@ -102,17 +105,6 @@ const normalizeSettings = ({ settings, exportMode, copyMode, snapshotMode }) => : setting ) -const constructPattern = values => - values.length === 1 - ? { - id: resolveId(values[0]), - } - : { - id: { - __or: resolveIds(values), - }, - } - const destructVmsPattern = pattern => pattern.id === undefined ? { diff --git a/packages/xo-web/src/xo-app/backup-ng/new/metadata/index.js b/packages/xo-web/src/xo-app/backup-ng/new/metadata/index.js new file mode 100644 index 000000000..72d163d83 --- /dev/null +++ b/packages/xo-web/src/xo-app/backup-ng/new/metadata/index.js @@ -0,0 +1,410 @@ +import _ from 'intl' +import ActionButton from 'action-button' +import addSubscriptions from 'add-subscriptions' +import decorate from 'apply-decorators' +import defined from '@xen-orchestra/defined' +import Icon from 'icon' +import Link from 'link' +import React from 'react' +import Upgrade from 'xoa-upgrade' +import { Card, CardBlock, CardHeader } from 'card' +import { Container, Col, Row } from 'grid' +import { generateId, linkState } from 'reaclette-utils' +import { injectState, provideState } from 'reaclette' +import { every, isEmpty, mapValues, map } from 'lodash' +import { Remote } from 'render-xo-item' +import { SelectPool, SelectRemote } from 'select-objects' +import { + createMetadataBackupJob, + createSchedule, + deleteSchedule, + editMetadataBackupJob, + editSchedule, + subscribeRemotes, +} from 'xo' + +import { + constructPattern, + destructPattern, + FormFeedback, + FormGroup, + Input, + Li, + Ul, +} from '../../utils' + +import Schedules from '../_schedules' + +// A retention can be: +// - number: set by user +// - undefined: will be replaced by the default value in the display(table + modal) and on submitting the form +// - null: when a user voluntarily deletes its value. +const DEFAULT_RETENTION = 1 + +const RETENTION_POOL_METADATA = { + defaultValue: DEFAULT_RETENTION, + name: _('poolMetadataRetention'), + valuePath: 'retentionPoolMetadata', +} +const RETENTION_XO_METADATA = { + defaultValue: DEFAULT_RETENTION, + name: _('xoMetadataRetention'), + valuePath: 'retentionXoMetadata', +} + +const getInitialState = () => ({ + _modePoolMetadata: undefined, + _modeXoMetadata: undefined, + _name: undefined, + _pools: undefined, + _remotes: undefined, + _schedules: undefined, + _settings: undefined, + showErrors: false, +}) + +export default decorate([ + New => props => ( + + + + ), + addSubscriptions({ + remotes: subscribeRemotes, + }), + provideState({ + initialState: getInitialState, + effects: { + createJob: () => async state => { + if (state.isJobInvalid) { + return { showErrors: true } + } + + await createMetadataBackupJob({ + name: state.name, + pools: state.modePoolMetadata + ? constructPattern(state.pools) + : undefined, + remotes: constructPattern(state.remotes), + xoMetadata: state.modeXoMetadata, + schedules: mapValues( + state.schedules, + ({ id, ...schedule }) => schedule + ), + settings: mapValues( + state.settings, + ({ retentionPoolMetadata, retentionXoMetadata }) => ({ + retentionPoolMetadata: state.modePoolMetadata + ? defined(retentionPoolMetadata, DEFAULT_RETENTION) + : undefined, + retentionXoMetadata: state.modeXoMetadata + ? defined(retentionXoMetadata, DEFAULT_RETENTION) + : undefined, + }) + ), + }) + }, + editJob: () => async (state, props) => { + if (state.isJobInvalid) { + return { showErrors: true } + } + + const settings = { ...state.settings } + await Promise.all([ + ...map(props.schedules, ({ id }) => { + const schedule = state.schedules[id] + if (schedule === undefined) { + return deleteSchedule(id) + } + + return editSchedule({ + id, + cron: schedule.cron, + name: schedule.name, + timezone: schedule.timezone, + enabled: schedule.enabled, + }) + }), + ...map(state.schedules, async schedule => { + if (props.schedules[schedule.id] === undefined) { + const { id } = await createSchedule(props.job.id, { + cron: schedule.cron, + name: schedule.name, + timezone: schedule.timezone, + enabled: schedule.enabled, + }) + settings[id] = settings[schedule.id] + delete settings[schedule.id] + } + }), + ]) + + await editMetadataBackupJob({ + id: props.job.id, + name: state.name, + pools: state.modePoolMetadata ? constructPattern(state.pools) : null, + remotes: constructPattern(state.remotes), + xoMetadata: state.modeXoMetadata, + settings: mapValues( + settings, + ({ retentionPoolMetadata, retentionXoMetadata }) => ({ + retentionPoolMetadata: state.modePoolMetadata + ? defined(retentionPoolMetadata, DEFAULT_RETENTION) + : undefined, + retentionXoMetadata: state.modeXoMetadata + ? defined(retentionXoMetadata, DEFAULT_RETENTION) + : undefined, + }) + ), + }) + }, + + linkState, + reset: () => getInitialState, + setPools: (_, _pools) => () => ({ + _pools, + }), + setSchedules: (_, _schedules) => () => ({ + _schedules, + }), + setSettings: (_, _settings) => () => ({ + _settings, + }), + toggleMode: (_, { mode }) => state => ({ + [`_${mode}`]: !state[mode], + }), + addRemote: (_, { id }) => state => ({ + _remotes: [...state.remotes, id], + }), + deleteRemote: (_, key) => state => { + const _remotes = [...state.remotes] + _remotes.splice(key, 1) + return { + _remotes, + } + }, + }, + computed: { + idForm: generateId, + + modePoolMetadata: ({ _modePoolMetadata }, { job }) => + defined(_modePoolMetadata, () => !isEmpty(destructPattern(job.pools))), + modeXoMetadata: ({ _modeXoMetadata }, { job }) => + defined(_modeXoMetadata, () => job.xoMetadata), + name: (state, { job }) => defined(state._name, () => job.name, ''), + pools: ({ _pools }, { job }) => + defined(_pools, () => destructPattern(job.pools)), + retentions: ({ modePoolMetadata, modeXoMetadata }) => { + const retentions = [] + if (modePoolMetadata) { + retentions.push(RETENTION_POOL_METADATA) + } + if (modeXoMetadata) { + retentions.push(RETENTION_XO_METADATA) + } + return retentions + }, + schedules: ({ _schedules }, { schedules }) => + defined(_schedules, schedules), + settings: ({ _settings }, { job }) => + defined(_settings, () => job.settings), + remotes: ({ _remotes }, { job }) => + defined(_remotes, () => destructPattern(job.remotes), []), + remotesPredicate: ({ remotes }) => ({ id }) => !remotes.includes(id), + + isJobInvalid: state => + state.missingModes || + state.missingPools || + state.missingRemotes || + state.missingRetentionPoolMetadata || + state.missingRetentionXoMetadata || + state.missingSchedules, + + missingModes: state => !state.modeXoMetadata && !state.modePoolMetadata, + missingPools: state => state.modePoolMetadata && isEmpty(state.pools), + missingRemotes: state => isEmpty(state.remotes), + missingRetentionPoolMetadata: state => + state.modePoolMetadata && + every( + state.settings, + ({ retentionPoolMetadata }) => retentionPoolMetadata === null + ), + missingRetentionXoMetadata: state => + state.modeXoMetadata && + every( + state.settings, + ({ retentionXoMetadata }) => retentionXoMetadata === null + ), + missingSchedules: state => isEmpty(state.schedules), + }, + }), + injectState, + ({ state, effects, job, remotes }) => { + const [submitHandler, submitTitle] = + job === undefined + ? [effects.createJob, 'formCreate'] + : [effects.editJob, 'formSave'] + const { + missingModes, + missingPools, + missingRemotes, + missingRetentionPoolMetadata, + missingRetentionXoMetadata, + missingSchedules, + } = state.showErrors ? state : {} + + return ( +
    + + + + + {_('backupName')}* + + + + + + +
    + + {_('poolMetadata')} + {' '} + + {_('xoConfig')} + {' '} +
    +
    +
    + + + {_('remotes')} + + {' '} + {_('remotesSettings')} + + + + {isEmpty(remotes) ? ( + + {_('createRemoteMessage')} + + ) : ( + + + +
    +
      + {state.remotes.map((id, key) => ( +
    • + +
      + +
      +
    • + ))} +
    +
    + )} +
    +
    + + + {state.modePoolMetadata && ( + + {_('pools')}* + + + + + )} + + +
    + + + + + + {_(submitTitle)} + + + {_('formReset')} + + + + + +
    +
    + ) + }, +]) diff --git a/packages/xo-web/src/xo-app/backup-ng/new/schedules.js b/packages/xo-web/src/xo-app/backup-ng/new/schedules.js index e92066e12..2bcf5c833 100644 --- a/packages/xo-web/src/xo-app/backup-ng/new/schedules.js +++ b/packages/xo-web/src/xo-app/backup-ng/new/schedules.js @@ -24,8 +24,6 @@ export default decorate([ provideState({ computed: { disabledDeletion: state => size(state.schedules) <= 1, - disabledEdition: state => - !state.exportMode && !state.copyMode && !state.snapshotMode, error: state => find(FEEDBACK_ERRORS, error => state[error]), individualActions: ( { disabledDeletion, disabledEdition }, @@ -129,7 +127,6 @@ export default decorate([ btnStyle='primary' className='pull-right' handler={effects.showScheduleModal} - disabled={state.disabledEdition} icon='add' tooltip={_('scheduleAdd')} /> diff --git a/packages/xo-web/src/xo-app/backup-ng/utils.js b/packages/xo-web/src/xo-app/backup-ng/utils.js index 5412e5fed..8fe126aed 100644 --- a/packages/xo-web/src/xo-app/backup-ng/utils.js +++ b/packages/xo-web/src/xo-app/backup-ng/utils.js @@ -1,13 +1,25 @@ import Icon from 'icon' import PropTypes from 'prop-types' import React from 'react' +import { resolveId, resolveIds } from 'utils' export const FormGroup = props =>
    export const Input = props => export const Ul = props =>
      export const Li = props =>
    • -export const destructPattern = pattern => pattern.id.__or || [pattern.id] +export const destructPattern = pattern => + pattern && (pattern.id.__or || [pattern.id]) +export const constructPattern = values => + values.length === 1 + ? { + id: resolveId(values[0]), + } + : { + id: { + __or: resolveIds(values), + }, + } export const FormFeedback = ({ component: Component, diff --git a/packages/xo-web/src/xo-app/logs/backup-ng/index.js b/packages/xo-web/src/xo-app/logs/backup-ng/index.js index bc3a8aa4e..e422304a4 100644 --- a/packages/xo-web/src/xo-app/logs/backup-ng/index.js +++ b/packages/xo-web/src/xo-app/logs/backup-ng/index.js @@ -13,7 +13,11 @@ import { createGetObjectsOfType } from 'selectors' import { get } from '@xen-orchestra/defined' import { injectState, provideState } from 'reaclette' import { isEmpty, filter, map, keyBy } from 'lodash' -import { subscribeBackupNgJobs, subscribeBackupNgLogs } from 'xo' +import { + subscribeBackupNgJobs, + subscribeBackupNgLogs, + subscribeMetadataBackupJobs, +} from 'xo' import LogAlertBody from './log-alert-body' import LogAlertHeader from './log-alert-header' @@ -34,6 +38,7 @@ export const LogStatus = ({ log, tooltip = _('logDisplayDetails') }) => { return ( { - if (isEmpty(vmTasks)) { + itemRenderer: ({ tasks: vmTasks, jobId }, { jobs }) => { + if (get(() => jobs[jobId].type) !== 'backup' || isEmpty(vmTasks)) { return null } @@ -151,6 +156,8 @@ export default decorate([ cb(logs && filter(logs, log => log.message !== 'restore')) ), jobs: cb => subscribeBackupNgJobs(jobs => cb(keyBy(jobs, 'id'))), + metadataJobs: cb => + subscribeMetadataBackupJobs(jobs => cb(keyBy(jobs, 'id'))), }), provideState({ computed: { @@ -167,6 +174,7 @@ export default decorate([ } : log ), + jobs: (_, { jobs, metadataJobs }) => ({ ...jobs, ...metadataJobs }), }, }), injectState, @@ -180,7 +188,7 @@ export default decorate([ collection={state.logs} columns={COLUMNS} component={SortedTable} - data-jobs={jobs} + data-jobs={state.jobs} emptyMessage={_('noLogs')} filters={LOG_FILTERS} />