diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index d5fcc8f2381..bcad79d7538 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -20,12 +20,15 @@ - `rest post` and `rest put` now accept `--input $file` to upload a file and display progress information - [Backup] Detect invalid VDI exports that are incorrectly reported as successful by XAPI - [Backup/HealthCheck] Improve error messages on health check timeout (PR [#8016](https://github.com/vatesfr/xen-orchestra/pull/8016)) +- [Backup] Backup job sequences: configure lists of backup jobs to run in order one after the other (PRs [#7985](https://github.com/vatesfr/xen-orchestra/pull/7985), [#8014](https://github.com/vatesfr/xen-orchestra/pull/8014)) - [Pool/Network] Display the bond mode of a network [#7802](https://github.com/vatesfr/xen-orchestra/issues/7802) (PR [#8010](https://github.com/vatesfr/xen-orchestra/pull/8010)) ### Bug fixes > Users must be able to say: “I had this issue, happy to know it's fixed” +- [REST API] Fix broken _Rolling Pool Update_ pool action [Forum#82867](https://xcp-ng.org/forum/post/82867) + ### Packages to release > When modifying a package, add it here with its release type. diff --git a/packages/xo-server/src/api/schedule.mjs b/packages/xo-server/src/api/schedule.mjs index 2d73c246b9b..9ceaf0dac7e 100644 --- a/packages/xo-server/src/api/schedule.mjs +++ b/packages/xo-server/src/api/schedule.mjs @@ -1,5 +1,7 @@ // FIXME so far, no acls for schedules +import { Task } from '@xen-orchestra/mixins/Tasks.mjs' + export async function getAll() { return /* await */ this.getAllSchedules() } @@ -64,3 +66,25 @@ delete_.params = { } export { delete_ as delete } + +export async function runSequence({ schedules }) { + const t = this.tasks.create({ type: 'xo:schedule:sequence', name: 'Schedule sequence' }) + await t.run(async () => { + const nb = schedules.length + const signal = Task.abortSignal + for (let i = 0; i < nb; i++) { + signal.throwIfAborted() + Task.set('progress', Math.round((i * 100) / nb)) + const idSchedule = schedules[i] + // we can't auto resolve array parameters, we have to resolve them by hand + const schedule = await this.getSchedule(idSchedule) + const job = await this.getJob(schedule.jobId) + await this.runJob(job, schedule) + } + }) +} +runSequence.permission = 'admin' +runSequence.description = 'Run a sequence of schedules, one after the other' +runSequence.params = { + schedules: { type: 'array', items: { type: 'string' } }, +} diff --git a/packages/xo-server/src/xo-mixins/api.mjs b/packages/xo-server/src/xo-mixins/api.mjs index 901c68d6400..b49d55a09df 100644 --- a/packages/xo-server/src/xo-mixins/api.mjs +++ b/packages/xo-server/src/xo-mixins/api.mjs @@ -316,18 +316,30 @@ export default class Api { throw new MethodNotFound(name) } - const apiContext = { __proto__: null, connection } - + let user const userId = connection.get('user_id', undefined) if (userId !== undefined) { - const user = await this._app.getUser(userId) + user = await this._app.getUser(userId) + } + + return this.runWithApiContext(user, () => { + this.apiContext.connection = connection + + return this.#callApiMethod(name, method, params) + }) + } + + async runWithApiContext(user, fn) { + const apiContext = { __proto__: null } + + if (user !== undefined) { apiContext.user = user apiContext.permission = user.permission } else { apiContext.permission = 'none' } - return this.#apiContext.run(apiContext, () => this.#callApiMethod(name, method, params)) + return this.#apiContext.run(apiContext, fn) } async #callApiMethod(name, method, params) { diff --git a/packages/xo-server/src/xo-mixins/jobs/index.mjs b/packages/xo-server/src/xo-mixins/jobs/index.mjs index 1409fcca952..79935800355 100644 --- a/packages/xo-server/src/xo-mixins/jobs/index.mjs +++ b/packages/xo-server/src/xo-mixins/jobs/index.mjs @@ -165,7 +165,7 @@ export default class Jobs { } @decorateWith(defer) - async _runJob($defer, job, schedule, data_) { + async runJob($defer, job, schedule, data_) { const logger = this._logger const { id, type } = job @@ -316,7 +316,7 @@ export default class Jobs { const jobs = await Promise.all(idSequence.map(id => this.getJob(id))) for (const job of jobs) { - await this._runJob(job, schedule, data) + await this.runJob(job, schedule, data) } } } diff --git a/packages/xo-server/src/xo-mixins/rest-api.mjs b/packages/xo-server/src/xo-mixins/rest-api.mjs index 0d51c5b3824..6e43f569378 100644 --- a/packages/xo-server/src/xo-mixins/rest-api.mjs +++ b/packages/xo-server/src/xo-mixins/rest-api.mjs @@ -461,8 +461,7 @@ export default class RestApi { app.authenticateUser({ token: cookies.authenticationToken ?? cookies.token }, { ip }).then( ({ user }) => { if (user.permission === 'admin') { - req.user = user - return next() + return app.runWithApiContext(user, next) } res.sendStatus(401) @@ -658,7 +657,7 @@ export default class RestApi { params.affinityHost = affinity params.installRepository = install?.repository - const vm = await $xapi.createVm(template, params, undefined, req.user.id) + const vm = await $xapi.createVm(template, params, undefined, app.apiContext.user.id) $defer.onFailure.call($xapi, 'VM_destroy', vm.$ref) if (boot) { diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js index 6773db978b1..c785e2b6f37 100644 --- a/packages/xo-web/src/common/intl/messages.js +++ b/packages/xo-web/src/common/intl/messages.js @@ -152,6 +152,8 @@ const messages = { xcpNg: 'XCP-ng', noFileSelected: 'No file selected', nRetriesVmBackupFailures: 'Number of retries if VM backup fails', + sequence: 'Sequence', + sequences: 'Sequences', // ----- Modals ----- alertOk: 'OK', @@ -531,7 +533,9 @@ const messages = { scheduleAdd: 'Add a schedule', scheduleDelete: 'Delete', scheduleRun: 'Run schedule', + scheduleSequence: 'Schedule sequence', unnamedSchedule: 'Unnamed schedule', + unnamedJob: 'Unnamed Job', deleteSelectedSchedules: 'Delete selected schedules', noScheduledJobs: 'No scheduled jobs.', legacySnapshotsLink: 'You can delete all your legacy backup snapshots.', @@ -1671,6 +1675,7 @@ const messages = { missingVm: 'Missing VM', missingVmInJob: 'This VM does not belong to this job', missingSchedule: 'Missing schedule', + unknownSchedule: 'Unknown schedule', noDetachedBackups: 'No backups', noDuplicatedMacAddresses: 'No duplicated MAC addresses', reason: 'Reason', diff --git a/packages/xo-web/src/common/render-xo-item.js b/packages/xo-web/src/common/render-xo-item.js index a059d894704..226dfa02d20 100644 --- a/packages/xo-web/src/common/render-xo-item.js +++ b/packages/xo-web/src/common/render-xo-item.js @@ -4,6 +4,7 @@ import CopyToClipboard from 'react-copy-to-clipboard' import PropTypes from 'prop-types' import React from 'react' import { get } from '@xen-orchestra/defined' +import { injectState, provideState } from 'reaclette' import find from 'lodash/find.js' import isEmpty from 'lodash/isEmpty.js' @@ -13,7 +14,16 @@ import Link from './link' import Tooltip from './tooltip' import { addSubscriptions, connectStore, formatSize, NumericDate, ShortDate } from './utils' import { createGetObject, createSelector } from './selectors' -import { isSrWritable, subscribeBackupNgJobs, subscribeProxies, subscribeRemotes, subscribeUsers } from './xo' +import { + isSrWritable, + subscribeBackupNgJobs, + subscribeMetadataBackupJobs, + subscribeProxies, + subscribeRemotes, + subscribeSchedules, + subscribeUsers, + subscribeMirrorBackupJobs, +} from './xo' // =================================================================== @@ -493,6 +503,62 @@ BackupJob.defaultProps = { // =================================================================== +export const Schedule = decorate([ + addSubscriptions(({ id }) => ({ + schedule: cb => subscribeSchedules(schedules => cb(schedules.find(schedule => schedule.id === id))), + backupJobs: subscribeBackupNgJobs, + metadataBackupJobs: subscribeMetadataBackupJobs, + mirrorBackupJobs: subscribeMirrorBackupJobs, + })), + provideState({ + initialState: () => ({}), + computed: { + job: (_, { backupJobs = [], metadataBackupJobs = [], mirrorBackupJobs = [], schedule }) => + schedule && [...backupJobs, ...metadataBackupJobs, ...mirrorBackupJobs].find(job => job.id === schedule.jobId), + }, + }), + injectState, + ({ id, schedule, showJob, showState, state: { job } }) => { + if (schedule === undefined) { + return unknowItem(id, 'schedule') + } + + const isEnabled = schedule.enabled + const scheduleName = schedule.name.trim() + const jobName = job?.name.trim() + + return ( + + {showState && ( + + {isEnabled ? _('stateEnabled') : _('stateDisabled')} + + )} + {scheduleName === '' ? {_('unnamedSchedule')} : scheduleName} + {showJob && ( + + {' '} + ({job ? jobName === '' ? {_('unnamedJob')} : jobName : unknowItem(schedule.jobId, 'job')}) + + )} + + ) + }, +]) + +Schedule.propTypes = { + id: PropTypes.string.isRequired, + showState: PropTypes.bool, + showJob: PropTypes.bool, +} + +Schedule.defaultProps = { + showState: true, + showJob: true, +} + +// =================================================================== + export const Vgpu = connectStore(() => ({ vgpuType: createGetObject((_, props) => props.vgpu.vgpuType), }))(({ vgpu, vgpuType }) => ( @@ -684,18 +750,8 @@ const xoItemToRender = { PCI: props => , - schedule: schedule => { - const isEnabled = schedule.enabled - const scheduleName = schedule.name.trim() - return ( - - - {isEnabled ? _('stateEnabled') : _('stateDisabled')} - - {scheduleName === '' ? {_('unnamedSchedule')} : scheduleName} - - ) - }, + schedule: props => , + job: job => {job.name}, } diff --git a/packages/xo-web/src/common/select-objects.js b/packages/xo-web/src/common/select-objects.js index d6476f3381c..d102019982d 100644 --- a/packages/xo-web/src/common/select-objects.js +++ b/packages/xo-web/src/common/select-objects.js @@ -257,6 +257,7 @@ class GenericSelect extends React.Component { : undefined, memoryFree: option.xoItem.type === 'host' || undefined, showNetwork: true, + ...(this.props.optionProps ?? {}), })} ) diff --git a/packages/xo-web/src/icons.scss b/packages/xo-web/src/icons.scss index 8fa4ac0cd7b..e7a32f9c6d1 100644 --- a/packages/xo-web/src/icons.scss +++ b/packages/xo-web/src/icons.scss @@ -961,6 +961,10 @@ @extend .fa; @extend .fa-eye; } + &-sequence { + @extend .fa; + @extend .fa-list-ol; + } &-new { @extend .fa; @extend .fa-plus; diff --git a/packages/xo-web/src/xo-app/backup/edit.js b/packages/xo-web/src/xo-app/backup/edit.js index c1aea4de924..0071df8c04d 100644 --- a/packages/xo-web/src/xo-app/backup/edit.js +++ b/packages/xo-web/src/xo-app/backup/edit.js @@ -6,15 +6,23 @@ import Icon from 'icon' import React from 'react' import { injectState, provideState } from 'reaclette' import { find, groupBy, keyBy } from 'lodash' -import { subscribeBackupNgJobs, subscribeMetadataBackupJobs, subscribeMirrorBackupJobs, subscribeSchedules } from 'xo' +import { + subscribeBackupNgJobs, + subscribeJobs, + subscribeMetadataBackupJobs, + subscribeMirrorBackupJobs, + subscribeSchedules, +} from 'xo' import Metadata from './new/metadata' import New from './new' import NewMirrorBackup from './new/mirror' +import NewSequence from './new/sequence' export default decorate([ addSubscriptions({ - jobs: subscribeBackupNgJobs, + jobs: subscribeJobs, + backupJobs: subscribeBackupNgJobs, metadataJobs: subscribeMetadataBackupJobs, mirrorBackupJobs: subscribeMirrorBackupJobs, schedulesByJob: cb => @@ -24,11 +32,20 @@ export default decorate([ }), provideState({ computed: { - job: (_, { jobs, metadataJobs, mirrorBackupJobs, routeParams: { id } }) => - defined(find(jobs, { id }), find(metadataJobs, { id }), find(mirrorBackupJobs, { id })), + job: (_, { backupJobs, jobs, metadataJobs, mirrorBackupJobs, routeParams: { id } }) => + defined( + find(jobs, { id }), + find(backupJobs, { id }), + find(metadataJobs, { id }), + find(mirrorBackupJobs, { id }) + ), schedules: (_, { schedulesByJob, routeParams: { id } }) => schedulesByJob && keyBy(schedulesByJob[id], 'id'), loading: (_, props) => - props.jobs === undefined || props.metadataJobs === undefined || props.schedulesByJob === undefined, + props.jobs === undefined || + props.backupJobs === undefined || + props.metadataJobs === undefined || + props.mirrorBackupJobs === undefined || + props.schedulesByJob === undefined, }, }), injectState, @@ -43,7 +60,11 @@ export default decorate([ ) : job.type === 'mirrorBackup' ? ( - ) : ( + ) : job.type === 'metadataBackup' ? ( + ) : job.type === 'call' && job.method === 'schedule.runSequence' ? ( + + ) : ( + 'Unknown job type' ), ]) diff --git a/packages/xo-web/src/xo-app/backup/index.js b/packages/xo-web/src/xo-app/backup/index.js index 4027d46e8bd..ae18192f965 100644 --- a/packages/xo-web/src/xo-app/backup/index.js +++ b/packages/xo-web/src/xo-app/backup/index.js @@ -15,9 +15,10 @@ import { subscribeBackupNgJobs, subscribeSchedules } from 'xo' import Edit from './edit' import FileRestore from './file-restore' import Health from './health' -import NewVmBackup, { NewMetadataBackup, NewMirrorBackup } from './new' +import NewVmBackup, { NewMetadataBackup, NewMirrorBackup, NewSequence } from './new' import Overview from './overview' import Restore, { RestoreMetadata } from './restore' +import Sequences from './sequences' import Page from '../page' @@ -55,6 +56,9 @@ const HEADER = ( {_('backupOverviewPage')} + + {_('sequences')} + {_('backupNewPage')} @@ -89,6 +93,9 @@ const ChooseBackupType = () => (

{_('backupMetadata')} + {' '} + + {_('sequence')}

@@ -104,7 +111,9 @@ export default routes('overview', { 'new/vms': NewVmBackup, 'new/mirror': NewMirrorBackup, 'new/metadata': NewMetadataBackup, + 'new/sequence': NewSequence, overview: Overview, + sequences: Sequences, restore: Restore, 'restore/metadata': RestoreMetadata, 'file-restore': FileRestore, diff --git a/packages/xo-web/src/xo-app/backup/new/index.js b/packages/xo-web/src/xo-app/backup/new/index.js index 4c52eab4c0d..e34c0c7c7b6 100644 --- a/packages/xo-web/src/xo-app/backup/new/index.js +++ b/packages/xo-web/src/xo-app/backup/new/index.js @@ -48,6 +48,7 @@ import { canDeltaBackup, constructPattern, destructPattern, FormFeedback, FormGr export NewMetadataBackup from './metadata' export NewMirrorBackup from './mirror' +export NewSequence from './sequence' // =================================================================== diff --git a/packages/xo-web/src/xo-app/backup/new/sequence/index.js b/packages/xo-web/src/xo-app/backup/new/sequence/index.js new file mode 100644 index 00000000000..3fe0c6427b7 --- /dev/null +++ b/packages/xo-web/src/xo-app/backup/new/sequence/index.js @@ -0,0 +1,191 @@ +import _ from 'intl' +import ActionButton from 'action-button' +import decorate from 'apply-decorators' +import Icon from 'icon' +import moment from 'moment-timezone' +import React from 'react' +import Scheduler from 'scheduling' +import Upgrade from 'xoa-upgrade' +import { Card, CardBlock, CardHeader } from 'card' +import { Container, Col, Row } from 'grid' +import { createJob, editJob, createSchedule, editSchedule } from 'xo' +import { generateId } from 'reaclette-utils' +import { injectState, provideState } from 'reaclette' +import { SelectSchedule } from 'select-objects' + +import { Input } from '../../utils' + +const NewSequence = decorate([ + provideState({ + initialState: props => ({ + name: props.job?.name ?? '', + sequenceSchedule: + props.schedule !== undefined + ? { cronPattern: props.schedule.cron, timezone: props.schedule.timezone } + : { + cronPattern: '0 0 * * *', + timezone: moment.tz.guess(), + }, + schedules: props.job?.paramsVector?.items?.[0]?.values?.[0]?.schedules?.map(scheduleId => ({ + id: scheduleId, + key: generateId(), + })) ?? [{ key: generateId() }, { key: generateId() }], + }), + effects: { + addSchedule: () => state => ({ schedules: [...state.schedules, { key: generateId() }] }), + removeSchedule: (_, scheduleKey) => state => ({ + schedules: state.schedules.filter(schedule => schedule.key !== scheduleKey), + }), + selectSchedule: (_, scheduleKey, schedule) => state => ({ + schedules: state.schedules.map(s => (s.key !== scheduleKey ? s : { key: s.key, ...schedule })), + }), + onChangeName: (_, event) => ({ name: event.target.value }), + onChangeSequenceSchedule: (_, sequenceSchedule) => ({ sequenceSchedule }), + save: () => async (state, props) => { + const jobFormData = { + name: state.name, + paramsVector: { + type: 'crossProduct', + items: [ + { + type: 'set', + values: [{ schedules: state.schedules.map(schedule => schedule.id) }], + }, + ], + }, + } + + const scheduleFormData = { + cron: state.sequenceSchedule.cronPattern, + timezone: state.sequenceSchedule.timezone, + } + + if (props.job !== undefined) { + await editJob({ + id: props.job.id, + ...jobFormData, + }) + + await editSchedule({ + id: props.schedule.id, + jobId: props.job.id, + enabled: true, + ...scheduleFormData, + }) + } else { + const jobId = await createJob({ + type: 'call', + key: 'genericTask', + method: 'schedule.runSequence', + ...jobFormData, + }) + + await createSchedule(jobId, { + enabled: true, + ...scheduleFormData, + }) + } + }, + }, + }), + injectState, + ({ job, state, effects }) => ( +
+ + + + + + {_('sequence')} + + + + + + + + + +
    + {state.schedules.map(schedule => ( +
  1. +
    + effects.selectSchedule(schedule.key, s)} + value={schedule} + required + optionProps={{ showState: false }} + /> + +
    +
  2. + ))} +
+ + {_('add')} + + +
+
+
+ +
+ + + + + {_('scheduleSequence')} + + + + + + + + + + + + {_('formSave')} + + + + +
+
+ ), +]) + +export default props => ( + + + +) diff --git a/packages/xo-web/src/xo-app/backup/sequences.js b/packages/xo-web/src/xo-app/backup/sequences.js new file mode 100644 index 00000000000..5f00c5d0fa6 --- /dev/null +++ b/packages/xo-web/src/xo-app/backup/sequences.js @@ -0,0 +1,123 @@ +import _ from 'intl' +import ActionButton from 'action-button' +import addSubscriptions from 'add-subscriptions' +import Copiable from 'copiable' +import decorate from 'apply-decorators' +import filter from 'lodash/filter.js' +import groupBy from 'lodash/groupBy.js' +import Icon from 'icon' +import React from 'react' +import SortedTable from 'sorted-table' +import { Card, CardHeader, CardBlock } from 'card' +import { deleteJobs, subscribeJobs, subscribeSchedules, runJob } from 'xo' +import { noop } from 'utils' +import { Schedule } from 'render-xo-item' + +const COLUMNS = [ + { + itemRenderer: ({ id }) => ( + + {id.slice(4, 8)} + + ), + name: _('jobId'), + }, + { + valuePath: 'name', + name: _('jobName'), + default: true, + }, + { + name: _('sequence'), + itemRenderer: sequenceJob => { + const scheduleIds = sequenceJob.paramsVector?.items?.[0]?.values?.[0]?.schedules + if (scheduleIds === undefined) { + return null + } + + return ( +
    + {scheduleIds.map((scheduleId, i) => ( +
  1. + +
  2. + ))} +
+ ) + }, + }, + { + name: _('schedule'), + itemRenderer: (sequenceJob, { schedulesByJob }) => { + const schedules = schedulesByJob?.[sequenceJob.id] + if (schedules === undefined || schedules.length === 0) { + return null + } + + return schedules[0].cron + }, + }, +] + +const ACTIONS = [ + { + handler: jobs => deleteJobs(jobs), + label: _('delete'), + icon: 'delete', + level: 'danger', + }, +] + +const INDIVIDUAL_ACTIONS = [ + { + handler: job => runJob(job), + label: _('scheduleRun'), + icon: 'run-schedule', + level: 'primary', + }, + { + handler: (job, { router }) => router.push(`/backup/${job.id}/edit`), + label: _('formEdit'), + icon: 'edit', + level: 'primary', + }, +] + +const Sequences = decorate([ + addSubscriptions({ + sequenceJobs: cb => subscribeJobs(jobs => cb(filter(jobs, { type: 'call', method: 'schedule.runSequence' }))), + schedulesByJob: cb => subscribeSchedules(schedules => cb(groupBy(schedules, 'jobId'))), + }), + ({ sequenceJobs, schedulesByJob, router }) => ( +
+
+ + + {_('sequences')} + + {_('new')} + + + + + + +
+
+ ), +]) + +export default Sequences diff --git a/packages/xo-web/src/xo-app/backup/utils.js b/packages/xo-web/src/xo-app/backup/utils.js index 8a8c66d1953..559c312a274 100644 --- a/packages/xo-web/src/xo-app/backup/utils.js +++ b/packages/xo-web/src/xo-app/backup/utils.js @@ -4,10 +4,10 @@ import PropTypes from 'prop-types' import React from 'react' import { resolveId, resolveIds } from 'utils' -export const FormGroup = props =>
-export const Input = props => +export const FormGroup = props =>
+export const Input = props => export const Ul = props =>
    -export const Li = props =>
  • +export const Li = props =>
  • export const destructPattern = pattern => pattern && (pattern.id.__or || [pattern.id]) export const constructPattern = values => diff --git a/packages/xo-web/src/xo-app/menu/index.js b/packages/xo-web/src/xo-app/menu/index.js index b56c7a4f8e0..3277b3cfa13 100644 --- a/packages/xo-web/src/xo-app/menu/index.js +++ b/packages/xo-web/src/xo-app/menu/index.js @@ -310,6 +310,11 @@ export default class Menu extends Component { icon: 'menu-backup-overview', label: 'backupOverviewPage', }, + { + to: '/backup/sequences', + icon: 'menu-backup-sequence', + label: 'sequences', + }, { to: '/backup/new', icon: 'menu-backup-new',