feat(metadata backup): backup XO config and pool metadata (#3912)

Fixes #3501
This commit is contained in:
badrAZ 2019-02-28 15:31:17 +01:00 committed by Julien Fontanet
parent 468a2c5bf3
commit fea5117ed8
20 changed files with 1308 additions and 55 deletions

View File

@ -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

View File

@ -154,6 +154,10 @@ class BackupReportsXoPlugin {
}
_wrapper(status, job, schedule, runJobId) {
if (job.type === 'metadataBackup') {
return
}
return new Promise(resolve =>
resolve(
job.type === 'backup'

View File

@ -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',
},
}

View File

@ -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
}

View File

@ -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<Buffer>
export type SimpleIdPattern = {| id: string | {| __or: string[] |}, |}

View File

@ -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
),
})
},
}

View File

@ -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<Job>,
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<T>(
promises: Promise<T>[],
@ -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),
}))

View File

@ -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<Job>,
pools?: SimpleIdPattern,
remotes: SimpleIdPattern,
settings: $Dict<Settings>,
type: METADATA_BACKUP_JOB_TYPE,
xoMetadata?: boolean,
}
// File structure on remotes:
//
// <remote>
// ├─ xo-config-backups
// │ └─ <schedule ID>
// │ └─ <YYYYMMDD>T<HHmmss>
// │ ├─ metadata.json
// │ └─ data.json
// └─ xo-pool-metadata-backups
// └─ <schedule ID>
// └─ <pool UUID>
// └─ <YYYYMMDD>T<HHmmss>
// ├─ metadata.json
// └─ data
export default class metadataBackup {
_app: {
createJob: (
$Diff<MetadataBackupJob, {| id: string |}>
) => Promise<MetadataBackupJob>,
createSchedule: ($Diff<Schedule, {| id: string |}>) => Promise<Schedule>,
deleteSchedule: (id: string) => Promise<void>,
getXapi: (id: string) => Xapi,
getJob: (
id: string,
?METADATA_BACKUP_JOB_TYPE
) => Promise<MetadataBackupJob>,
updateJob: (
$Shape<MetadataBackupJob>,
?boolean
) => Promise<MetadataBackupJob>,
removeJob: (id: string) => Promise<void>,
}
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<MetadataBackupJob, {| id: string |}>,
schedules: $Dict<$Diff<Schedule, {| id: string |}>>
): Promise<MetadataBackupJob> {
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<void> {
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)
}
}),
])
}
}

View File

@ -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)
},
},
}),

View File

@ -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',

View File

@ -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) }))
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 =>

View File

@ -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 } }) => <New job={job} schedules={schedules} />,
({ state: { job = {}, schedules } }) =>
job.type === 'backup' ? (
<New job={job} schedules={schedules} />
) : (
<Metadata job={job} schedules={schedules} />
),
])

View File

@ -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 => <ul {...props} style={{ listStyleType: 'none' }} />
@ -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: <strong>{name}</strong>,
}),
}).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 (
<SortedTable
{...JobsTable.tableProps}
collection={this.props.jobs}
collection={this._getCollection()}
data-goTo={this._goTo}
data-schedulesByJob={this.props.schedulesByJob}
/>
@ -353,9 +385,31 @@ const HEADER = (
</Container>
)
const ChooseBackupType = () => (
<Container>
<Row>
<Col>
<Card>
<CardHeader>{_('backupType')}</CardHeader>
<CardBlock className='text-md-center'>
<ButtonLink to='backup-ng/new/vms'>
<Icon icon='backup' /> {_('backupVms')}
</ButtonLink>{' '}
<ButtonLink to='backup-ng/new/metadata'>
<Icon icon='database' /> {_('backupMetadata')}
</ButtonLink>
</CardBlock>
</Card>
</Col>
</Row>
</Container>
)
export default routes('overview', {
':id/edit': Edit,
new: New,
new: ChooseBackupType,
'new/vms': NewVmBackup,
'new/metadata': NewMetadataBackup,
overview: Overview,
restore: Restore,
'file-restore': FileRestore,

View File

@ -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 }) => (
<StateButton
disabledLabel={_('stateDisabled')}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('stateEnabled')}
enabledTooltip={_('logIndicationToDisable')}
handler={toggleScheduleState}
handlerParam={schedule.id}
state={schedule.enabled}
/>
),
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 => (
<NewSchedule retentions={state.retentions} {...props} />
),
header: (
<span>
<Icon icon='schedule' /> {_('schedule')}
</span>
),
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 }) => (
<FormFeedback
component={Card}
error={missingSchedules || missingRetentions}
message={
missingSchedules ? _('missingSchedules') : _('missingRetentions')
}
>
<CardHeader>
{_('backupSchedules')}*
<ActionButton
btnStyle='primary'
className='pull-right'
handler={effects.showModal}
icon='add'
tooltip={_('scheduleAdd')}
/>
</CardHeader>
<CardBlock>
<SortedTable
collection={schedules}
columns={state.columns}
data-deleteSchedule={effects.deleteSchedule}
data-showModal={effects.showModal}
data-toggleScheduleState={effects.toggleScheduleState}
individualActions={INDIVIDUAL_ACTIONS}
rowTransform={state.rowTransform}
/>
</CardBlock>
</FormFeedback>
),
])
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 }

View File

@ -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 }) => (
<div>
{state.missingRetentions && (
<div className='text-danger text-md-center'>
<Icon icon='alarm' /> {_('retentionNeeded')}
</div>
)}
<FormGroup>
<label htmlFor={state.idInputName}>
<strong>{_('formName')}</strong>
</label>
<Input
id={state.idInputName}
onChange={effects.setName}
value={schedule.name}
/>
</FormGroup>
{/* retentions effects are defined on initialize() */}
{retentions.map(({ name, valuePath }) => (
<FormGroup key={valuePath}>
<label>
<strong>{name}</strong>
</label>
<Number
data-name={valuePath}
min='0'
onChange={effects.setRetention}
value={schedule[valuePath]}
/>
</FormGroup>
))}
<Scheduler
onChange={effects.setCronTimezone}
cronPattern={schedule.cron}
timezone={schedule.timezone}
/>
<SchedulePreview
cronPattern={schedule.cron}
timezone={schedule.timezone}
/>
</div>
),
])

View File

@ -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
? {

View File

@ -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 => (
<Upgrade place='newMetadataBackup' required={3}>
<New {...props} />
</Upgrade>
),
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 (
<form id={state.idForm}>
<Container>
<Row>
<Col mediumSize={6}>
<Card>
<CardHeader>{_('backupName')}*</CardHeader>
<CardBlock>
<Input
onChange={effects.linkState}
name='_name'
value={state.name}
/>
</CardBlock>
</Card>
<FormFeedback
component={Card}
error={missingModes}
message={_('missingBackupMode')}
>
<CardBlock>
<div className='text-xs-center'>
<ActionButton
active={state.modePoolMetadata}
data-mode='modePoolMetadata'
handler={effects.toggleMode}
icon='pool'
>
{_('poolMetadata')}
</ActionButton>{' '}
<ActionButton
active={state.modeXoMetadata}
data-mode='modeXoMetadata'
handler={effects.toggleMode}
icon='file'
>
{_('xoConfig')}
</ActionButton>{' '}
</div>
</CardBlock>
</FormFeedback>
<Card>
<CardHeader>
{_('remotes')}
<Link
className='btn btn-primary pull-right'
target='_blank'
to='/settings/remotes'
>
<Icon icon='settings' />{' '}
<strong>{_('remotesSettings')}</strong>
</Link>
</CardHeader>
<CardBlock>
{isEmpty(remotes) ? (
<span className='text-warning'>
<Icon icon='alarm' /> {_('createRemoteMessage')}
</span>
) : (
<FormGroup>
<label>
<strong>{_('backupTargetRemotes')}</strong>
</label>
<FormFeedback
component={SelectRemote}
message={_('missingRemotes')}
onChange={effects.addRemote}
predicate={state.remotesPredicate}
error={missingRemotes}
value={null}
/>
<br />
<Ul>
{state.remotes.map((id, key) => (
<Li key={id}>
<Remote id={id} />
<div className='pull-right'>
<ActionButton
btnStyle='danger'
handler={effects.deleteRemote}
handlerParam={key}
icon='delete'
size='small'
/>
</div>
</Li>
))}
</Ul>
</FormGroup>
)}
</CardBlock>
</Card>
</Col>
<Col mediumSize={6}>
{state.modePoolMetadata && (
<Card>
<CardHeader>{_('pools')}*</CardHeader>
<CardBlock>
<FormFeedback
component={SelectPool}
message={_('missingPools')}
multi
onChange={effects.setPools}
error={missingPools}
value={state.pools}
/>
</CardBlock>
</Card>
)}
<Schedules
handlerSchedules={effects.setSchedules}
handlerSettings={effects.setSettings}
missingRetentions={
missingRetentionPoolMetadata || missingRetentionXoMetadata
}
missingSchedules={missingSchedules}
retentions={state.retentions}
schedules={state.schedules}
settings={state.settings}
/>
</Col>
</Row>
<Row>
<Col>
<Card>
<CardBlock>
<ActionButton
btnStyle='primary'
form={state.idForm}
handler={submitHandler}
icon='save'
redirectOnSuccess={
state.isJobInvalid ? undefined : '/backup-ng'
}
size='large'
>
{_(submitTitle)}
</ActionButton>
<ActionButton
handler={effects.reset}
icon='undo'
className='pull-right'
size='large'
>
{_('formReset')}
</ActionButton>
</CardBlock>
</Card>
</Col>
</Row>
</Container>
</form>
)
},
])

View File

@ -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')}
/>

View File

@ -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 => <div {...props} className='form-group' />
export const Input = props => <input {...props} className='form-control' />
export const Ul = props => <ul {...props} className='list-group' />
export const Li = props => <li {...props} className='list-group-item' />
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,

View File

@ -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 (
<ActionButton
btnStyle={className}
disabled={log.status !== 'failure' && isEmpty(log.tasks)}
handler={showTasks}
handlerParam={log.id}
icon='preview'
@ -84,8 +89,8 @@ const COLUMNS = [
},
{
name: _('labelSize'),
itemRenderer: ({ tasks: vmTasks }) => {
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}
/>