diff --git a/src/components/vm/snapshots/vmSnapshotsCard.jsx b/src/components/vm/snapshots/vmSnapshotsCard.jsx index a7e2dcd94..24217f884 100644 --- a/src/components/vm/snapshots/vmSnapshotsCard.jsx +++ b/src/components/vm/snapshots/vmSnapshotsCard.jsx @@ -20,7 +20,7 @@ import React from 'react'; import cockpit from 'cockpit'; import { useDialogs, DialogsContext } from 'dialogs.jsx'; -import { vmId, localize_datetime } from "../../../helpers.js"; +import { vmId, localize_datetime, vmSupportsExternalSnapshots } from "../../../helpers.js"; import { CreateSnapshotModal } from "./vmSnapshotsCreateModal.jsx"; import { ListingTable } from "cockpit-components-table.jsx"; import { Button } from "@patternfly/react-core/dist/esm/components/Button"; @@ -35,12 +35,16 @@ import './vmSnapshotsCard.scss'; const _ = cockpit.gettext; -export const VmSnapshotsActions = ({ vm }) => { +export const VmSnapshotsActions = ({ vm, config, storagePools }) => { const Dialogs = useDialogs(); const id = vmId(vm.name); + const isExternal = vmSupportsExternalSnapshots(config, vm, storagePools); + function open() { Dialogs.show(); } diff --git a/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx b/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx index 31455f56e..664e4f75e 100644 --- a/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx +++ b/src/components/vm/snapshots/vmSnapshotsCreateModal.jsx @@ -28,10 +28,15 @@ import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput" import { FormHelper } from 'cockpit-components-form-helper.jsx'; import { DialogsContext } from 'dialogs.jsx'; import { ModalError } from "cockpit-components-inline-notification.jsx"; +import { FileAutoComplete } from "cockpit-components-file-autocomplete.jsx"; import { snapshotCreate, snapshotGetAll } from "../../../libvirtApi/snapshot.js"; +import { getSortedBootOrderDevices, LIBVIRT_SYSTEM_CONNECTION } from "../../../helpers.js"; const _ = cockpit.gettext; +let current_user = null; +cockpit.user().then(user => { current_user = user }); + const NameRow = ({ onValueChanged, name, validationError }) => { return ( { ); }; +function getDefaultMemoryPath(vm, snapName) { + // Choosing a default path where memory snapshot should be stored might be tricky. Ideally we want + // to store it in the same directory where the primary disk (the disk which is first booted) is stored + // If howver no such disk can be found, we should fallback to libvirt's default /var/lib/libvirt + const devices = getSortedBootOrderDevices(vm).filter(d => d.bootOrder && + d.device.device === "disk" && + d.device.type === "file" && + d.device.source.file); + if (devices.length > 0) { + const primaryDiskPath = devices[0].device.source.file; + const directory = primaryDiskPath.substring(0, primaryDiskPath.lastIndexOf("/") + 1); + return directory + snapName; + } else { + if (vm.connectionName === LIBVIRT_SYSTEM_CONNECTION) + return "/var/lib/libvirt/memory/" + snapName; + else if (current_user) + return current_user.home + "/.local/share/libvirt/memory/" + snapName; + } + + return ""; +} + +const MemoryPathRow = ({ onValueChanged, memoryPath }) => { + return ( + + onValueChanged("memoryPath", value)} + superuser="try" + value={memoryPath} /> + + ); +}; + export class CreateSnapshotModal extends React.Component { static contextType = DialogsContext; @@ -66,9 +104,11 @@ export class CreateSnapshotModal extends React.Component { // cut off seconds, subseconds, and timezone const now = new Date().toISOString() .replace(/:[^:]*$/, ''); + const snapName = props.vm.name + '_' + now; this.state = { - name: props.vm.name + '_' + now, + name: snapName, description: "", + memoryPath: getDefaultMemoryPath(props.vm, snapName), inProgress: false, }; @@ -87,8 +127,8 @@ export class CreateSnapshotModal extends React.Component { } onValidate(submitted = false) { - const { name } = this.state; - const { vm } = this.props; + const { name, memoryPath } = this.state; + const { vm, isExternal } = this.props; const validationError = {}; if (vm.snapshots.findIndex(snap => snap.name === name) > -1) @@ -96,20 +136,33 @@ export class CreateSnapshotModal extends React.Component { else if (!name && submitted) validationError.name = _("Name should not be empty"); + if (isExternal && vm.state === "running" && !memoryPath) + validationError.name = _("Memory file should not be empty"); + return validationError; } onCreate() { const Dialogs = this.context; - const { vm } = this.props; - const { name, description } = this.state; + const { vm, isExternal, storagePools } = this.props; + const { name, description, memoryPath } = this.state; + const disks = Object.values(vm.disks); const validationError = this.onValidate(true); this.setState({ submitted: true }); if (!Object.keys(validationError).length) { this.setState({ inProgress: true }); - snapshotCreate({ connectionName: vm.connectionName, vmId: vm.id, name, description }) + snapshotCreate({ + connectionName: vm.connectionName, + vmId: vm.id, + name, + description, + memoryPath: vm.state === "running" && memoryPath, + disks, + isExternal, + storagePools + }) .then(() => { // VM Snapshots do not trigger any events so we have to refresh them manually snapshotGetAll({ connectionName: vm.connectionName, domainPath: vm.id }); @@ -124,14 +177,15 @@ export class CreateSnapshotModal extends React.Component { render() { const Dialogs = this.context; - const { idPrefix } = this.props; - const { name, description, submitted } = this.state; + const { idPrefix, isExternal, vm } = this.props; + const { name, description, memoryPath, submitted } = this.state; const validationError = this.onValidate(submitted); const body = (
e.preventDefault()} isHorizontal> + {isExternal && vm.state === 'running' && } ); diff --git a/src/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx index b1dea90ca..62ed332fd 100644 --- a/src/components/vm/vmDetailsPage.jsx +++ b/src/components/vm/vmDetailsPage.jsx @@ -173,7 +173,7 @@ export const VmDetailsPage = ({ id: cockpit.format("$0-snapshots", vmId(vm.name)), className: "snapshots-card", title: _("Snapshots"), - actions: , + actions: , body: }); } diff --git a/src/libvirt-xml-create.js b/src/libvirt-xml-create.js index d1747c583..bdcba0293 100644 --- a/src/libvirt-xml-create.js +++ b/src/libvirt-xml-create.js @@ -1,3 +1,5 @@ +import { getStoragePoolPath } from "./helpers.js"; + export function getDiskXML(type, file, device, poolName, volumeName, format, target, cacheMode, shareable, busType, serial) { const doc = document.implementation.createDocument('', '', null); @@ -216,7 +218,8 @@ export function getPoolXML({ name, type, source, target }) { return new XMLSerializer().serializeToString(doc.documentElement); } -export function getSnapshotXML(name, description) { +// see https://libvirt.org/formatsnapshot.html +export function getSnapshotXML(name, description, disks, memoryPath, isExternal, storagePools, connectionName) { const doc = document.implementation.createDocument('', '', null); const snapElem = doc.createElement('domainsnapshot'); @@ -233,6 +236,39 @@ export function getSnapshotXML(name, description) { snapElem.appendChild(descriptionElem); } + if (isExternal) { + if (memoryPath) { + const memoryElem = doc.createElement('memory'); + memoryElem.setAttribute('snapshot', 'external'); + memoryElem.setAttribute('file', memoryPath); + snapElem.appendChild(memoryElem); + } + + const disksElem = doc.createElement('disks'); + disks.forEach(disk => { + // Disk can have attribute "snapshot" set to "no", which means no snapshot should be created of the said disk + // This cannot be configured through cockpit, but we should uphold it nevertheless + // see "snapshot" attribute of element at https://libvirt.org/formatdomain.html#hard-drives-floppy-disks-cdroms + if (disk.snapshot !== "no") { + const diskElem = doc.createElement('disk'); + diskElem.setAttribute('name', disk.target); + diskElem.setAttribute('snapshot', 'external'); + + if (disk.type === "volume") { + const poolPath = getStoragePoolPath(storagePools, disk.source.pool, connectionName); + if (poolPath) { + const sourceElem = doc.createElement('source'); + sourceElem.setAttribute('file', `${poolPath}/${disk.source.volume}.snap`); + diskElem.appendChild(sourceElem); + } + } + + disksElem.appendChild(diskElem); + } + }); + snapElem.appendChild(disksElem); + } + doc.appendChild(snapElem); return new XMLSerializer().serializeToString(doc.documentElement); diff --git a/src/libvirt-xml-parse.js b/src/libvirt-xml-parse.js index 14ab7af77..590722388 100644 --- a/src/libvirt-xml-parse.js +++ b/src/libvirt-xml-parse.js @@ -451,6 +451,7 @@ export function parseDumpxmlForDisks(devicesElem) { }, bootOrder: bootElem?.getAttribute('order'), type: diskElem.getAttribute('type'), // i.e.: file + snapshot: diskElem.getAttribute('snapshot'), // i.e.: internal, external device: diskElem.getAttribute('device'), // i.e. cdrom, disk source: { file: sourceElem?.getAttribute('file'), // optional file name of the disk diff --git a/src/libvirtApi/snapshot.js b/src/libvirtApi/snapshot.js index 51bca60de..9bbc33048 100644 --- a/src/libvirtApi/snapshot.js +++ b/src/libvirtApi/snapshot.js @@ -29,9 +29,8 @@ import { parseDomainSnapshotDumpxml } from '../libvirt-xml-parse.js'; import { call, Enum, timeout } from './helpers.js'; import { logDebug } from '../helpers.js'; -export function snapshotCreate({ connectionName, vmId, name, description }) { - const xmlDesc = getSnapshotXML(name, description); - +export function snapshotCreate({ connectionName, vmId, name, description, memoryPath, disks, isExternal, storagePools }) { + const xmlDesc = getSnapshotXML(name, description, disks, memoryPath, isExternal, storagePools, connectionName); return call(connectionName, vmId, 'org.libvirt.Domain', 'SnapshotCreateXML', [xmlDesc, 0], { timeout, type: 'su' }); }