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 }) => (
+
+ ),
+])
+
+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) => (
+ -
+
+
+ ))}
+
+ )
+ },
+ },
+ {
+ 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',