Files
harvester-ui-extension/pkg/harvester/mixins/harvester-vm/index.js
T
Alejandro BonillaandClaude 528255dea4 feat: expose KubeVirt high-performance storage features in VM and Volume UI (#1133)
* feat: expose KubeVirt high-performance storage features in VM and Volume UI

Adds UI to configure KubeVirt high-performance disk/storage settings that
were previously only reachable via VMBuilder/Terraform:

- Per-volume "Storage Performance Options": a performance profile
  (Default / High Performance / Custom) exposing per-disk cache, io and
  dedicatedIOThread on all disk types (VM image/root, new, existing,
  container) and on the standalone Volume form.
- VM-wide "High Performance (I/O Threads and Multi-Queue)": blockMultiQueue
  and ioThreadsPolicy (+ supplemental-pool thread count), shown only in the
  VM section since these are domain-level and cannot be set per-PVC.
- Compatibility guardrails: io=native forces cache=none; cache=none on
  Filesystem volumeMode warns; blockMultiQueue requires a virtio disk.

Ref: harvester/harvester#11550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix: address review feedback on high-performance storage UI

Resolves the Copilot review comments on #1133:

- Gate the whole feature behind a new `highPerformanceStorage` release
  feature flag (v1.9.0) so older supported clusters never render the
  controls or receive unsupported KubeVirt fields.
- Force Cache Mode to "none" whenever Native I/O is selected, including
  from the "Default" ('') cache value, and disable every non-"none" cache
  option (Default included) while Native is active. Previously the empty
  cache value slipped past the guardrail and produced an invalid combo.
- Clear an already-enabled blockMultiQueue when the last virtio disk is
  removed, so a disabled checkbox can no longer persist an invalid setting.
- Strip stale cache/io/dedicatedIOThread fields from the merged disk spec
  when a regenerated disk no longer requests them, so switching a disk back
  to "Default" (or unchecking Dedicated I/O Thread) while editing no longer
  silently preserves the previous values.
- Expose the disclosure toggle's state to assistive tech via aria-expanded
  and an expand/collapse aria-label.
- Add unit tests for the profile transitions and the Native/cache guardrails.

Ref: harvester/harvester#11550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix: address second-round review on high-performance storage UI

Resolves the two Copilot findings on #1133 and votdev's UX feedback on
harvester/harvester#11550:

- Restore the disk's original bus when the performance profile goes back
  to "Default". The "High Performance" preset switches the bus to virtio,
  but leaving the preset only cleared cache/io/dedicatedIOThread, so a
  SATA/SCSI disk was silently left on virtio — a bus change that can make
  an existing guest unbootable. The pre-preset bus is now remembered and
  put back, unless the user picked a different bus themselves in the
  meantime.
- Pass the VM-wide performance values from the VM detail page to the
  shared Volume component. The mixin already parsed them via
  getInitConfig(), but the detail caller never forwarded them, so
  VMPerformanceOptions fell back to its prop defaults and hid itself in
  view mode — configured blockMultiQueue / ioThreadsPolicy settings were
  invisible. They are also refreshed in the value watcher so the panel
  does not go stale.
- Make the VM-wide "High Performance" panel expandable/collapsible,
  matching the per-volume "Storage Performance Options" disclosure. It
  stays collapsed unless the VM already has something configured, so the
  average user is not faced with specialist tuning controls by default.
- Fix the unit tests to use Vue Test Utils v2 mount options. They used
  the v1 top-level `mocks` key, which VTU 2.x ignores, so every case
  failed on mount. The repo has no jest config or test script, so this
  was not caught. Add coverage for the bus save/restore behaviour.

Ref: harvester/harvester#11550

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

---------

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 17:48:18 +08:00

2134 lines
70 KiB
JavaScript

import YAML from 'yaml';
import jsyaml from 'js-yaml';
import isEqual from 'lodash/isEqual';
import isEmpty from 'lodash/isEmpty';
import difference from 'lodash/difference';
import { sortBy } from '@shell/utils/sort';
import { set } from '@shell/utils/object';
import { getVmCPUMemoryValues } from '../../utils/cpuMemory';
import { allHash } from '@shell/utils/promise';
import { randomStr } from '@shell/utils/string';
import { base64Decode } from '@shell/utils/crypto';
import { formatSi, parseSi } from '@shell/utils/units';
import { _CLONE, _CREATE, _VIEW } from '@shell/config/query-params';
import {
PV, PVC, STORAGE_CLASS, NODE, SECRET, CONFIG_MAP, SERVICE_ACCOUNT, NETWORK_ATTACHMENT, NAMESPACE, LONGHORN
} from '@shell/config/types';
import { HOSTNAME } from '@shell/config/labels-annotations';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { uniq } from '@shell/utils/array';
import {
ADD_ONS, SOURCE_TYPE, ACCESS_CREDENTIALS, maintenanceStrategies, runStrategies
} from '../../config/harvester-map';
import { HCI_SETTING } from '../../config/settings';
import { HCI } from '../../types';
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
import impl, { QGA_JSON, USB_TABLET } from './impl';
import { GIBIBYTE } from '../../utils/unit';
import { VOLUME_MODE, ACCESS_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY } = ACCESS_MODE;
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
export const MANAGEMENT_NETWORK = 'management Network';
export const OS = [{
label: 'Windows',
value: 'windows'
}, {
label: 'Linux',
value: 'linux'
}, {
label: 'SUSE Linux Enterprise',
value: 'SLEs'
}, {
label: 'Debian',
value: 'debian'
}, {
label: 'Fedora',
value: 'fedora'
}, {
label: 'Gentoo',
value: 'gentoo'
}, {
label: 'Oracle',
value: 'oracle'
}, {
label: 'Red Hat',
match: ['redhat', 'rhel'],
value: 'redhat'
}, {
label: 'openSUSE',
value: 'openSUSE',
}, {
label: 'Ubuntu',
value: 'ubuntu'
}, {
label: 'Other Linux',
match: ['centos'],
value: 'otherLinux'
}];
export const CD_ROM = 'cd-rom';
export const HARD_DISK = 'disk';
export default {
mixins: [impl],
props: {
value: {
type: Object,
required: true,
},
resourceType: {
type: String,
default: ''
}
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = {
pvs: this.$store.dispatch(`${ inStore }/findAll`, { type: PV }),
pvcs: this.$store.dispatch(`${ inStore }/findAll`, { type: PVC }),
storageClasses: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
sshs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SSH }),
settings: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SETTING }),
images: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IMAGE }),
versions: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM_VERSION }),
templates: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM_TEMPLATE }),
networkAttachment: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
vmis: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMI }),
vmims: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIM }),
vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }),
secrets: this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }),
configMaps: this.$store.dispatch(`${ inStore }/findAll`, { type: CONFIG_MAP }),
serviceAccounts: this.$store.dispatch(`${ inStore }/findAll`, { type: SERVICE_ACCOUNT }),
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
longhornV2Engine: this.$store.dispatch(`${ inStore }/find`, { type: LONGHORN.SETTINGS, id: LONGHORN_V2_DATA_ENGINE }),
};
if (this.$store.getters[`${ inStore }/schemaFor`](NODE)) {
hash.nodes = this.$store.dispatch(`${ inStore }/findAll`, { type: NODE });
}
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.CLUSTER_NETWORK)) {
hash.clusterNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK });
}
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.VLAN_CONFIG)) {
hash.clusterNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN_CONFIG });
}
if (this.$store.getters[`${ inStore }/schemaFor`](LONGHORN.VOLUMES)) {
hash.longhornVolumes = this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.VOLUMES });
}
const res = await allHash(hash);
const hasPCISchema = !!this.$store.getters[`${ inStore }/schemaFor`](HCI.PCI_DEVICE);
const hasSRIOVGPUSchema = !!this.$store.getters[`${ inStore }/schemaFor`](HCI.SR_IOVGPU_DEVICE);
const enabledAddons = res.addons.reduce((acc, addon) => ({ ...acc, [addon.name]: addon.spec?.enabled }), {});
this.enabledPCI = hasPCISchema && enabledAddons[ADD_ONS.PCI_DEVICE_CONTROLLER];
this.enabledSriovgpu = hasSRIOVGPUSchema && enabledAddons[ADD_ONS.PCI_DEVICE_CONTROLLER] && enabledAddons[ADD_ONS.NVIDIA_DRIVER_TOOLKIT_CONTROLLER];
},
data() {
const isClone = this.realMode === _CLONE;
return {
OS,
isClone,
showYaml: false,
spec: null,
osType: 'linux',
useTemplate: false,
sshKey: [],
maintenanceStrategies,
maintenanceStrategy: 'Migrate',
runStrategies,
runStrategy: 'RerunOnFailure',
installAgent: true,
hasCreateVolumes: [],
installUSBTablet: true,
networkScript: '',
userScript: '',
imageId: '',
diskRows: [],
networkRows: [],
filesystemRows: [],
machineType: '',
machineTypes: [],
secretName: '',
// Tracks the name prefix that `secretName` was generated from, so it can be
// regenerated if the VM name changes (e.g. after a failed create attempt is retried
// with a different name). See harvester/harvester#11174.
secretNamePrefixUsed: '',
// Same purpose as secretNamePrefixUsed, but for the Windows sysprep secret name.
sysprepSecretNamePrefixUsed: '',
secretRef: null,
showAdvanced: false,
deleteAgent: true,
memory: null,
cpu: '',
maxMemory: null,
maxCpu: '',
cpuMemoryHotplugEnabled: false,
reservedMemory: null,
accessCredentials: [],
efiEnabled: false,
tpmEnabled: false,
tpmPersistentStateEnabled: false,
efiPersistentStateEnabled: false,
secureBoot: false,
userDataTemplateId: '',
saveUserDataAsClearText: false,
saveNetworkDataAsClearText: false,
enabledPCI: false,
enabledSriovgpu: false,
immutableMode: this.realMode === _CREATE ? _CREATE : _VIEW,
terminationGracePeriodSeconds: '',
cpuPinning: false,
cpuModel: '',
sysprep: { secretName: '', xmlContent: '' },
// KubeVirt high-performance VM-level features
blockMultiQueue: false,
ioThreadsPolicy: '',
ioThreadCount: 2,
};
},
computed: {
inStore() {
return this.$store.getters['currentProduct'].inStore;
},
images() {
return this.$store.getters[`${ this.inStore }/all`](HCI.IMAGE);
},
versions() {
return this.$store.getters[`${ this.inStore }/all`](HCI.VM_VERSION);
},
templates() {
return this.$store.getters[`${ this.inStore }/all`](HCI.VM_TEMPLATE);
},
pvcs() {
return this.$store.getters[`${ this.inStore }/all`](PVC);
},
secrets() {
return this.$store.getters[`${ this.inStore }/all`](SECRET);
},
filteredNamespaces() {
return this.$store.getters['harvester/all'](NAMESPACE).filter((namespace) => !namespace.isSystem);
},
nodes() {
return this.$store.getters['harvester/all'](NODE);
},
nodesIdOptions() {
const nodes = this.$store.getters[`${ this.inStore }/all`](NODE);
const networkNames = this.networkRows.map((n) => n.networkName);
const vmNetworks = this.$store.getters[`${ this.inStore }/all`](NETWORK_ATTACHMENT);
const selectedVMNetworks = networkNames.map((name) => vmNetworks.find((n) => n.id === name)).filter((n) => n?.id);
const clusterNetworks = uniq(selectedVMNetworks.map((n) => n.clusterNetworkResource?.id));
return nodes.filter((N) => !N.isUnSchedulable && N.isEtcd !== 'true').map((node) => {
const requireLabelKeys = [];
let isNetworkSchedule = true;
if (clusterNetworks.length > 0) {
clusterNetworks.map((clusterNetwork) => {
requireLabelKeys.push(`network.harvesterhci.io/${ clusterNetwork }`);
});
}
requireLabelKeys.map((requireLabelKey) => {
if (node.metadata?.labels?.[requireLabelKey] !== 'true') {
isNetworkSchedule = false;
}
});
return {
label: isNetworkSchedule ? node.nameDisplay : `${ node.nameDisplay } (${ this.t('harvester.virtualMachine.scheduling.networkNotSupport') })`,
value: node.id,
disabled: !isNetworkSchedule,
};
});
},
storageClassSetting() {
try {
const storageClassValue = this.$store.getters[`${ this.inStore }/all`](HCI.SETTING).find( (O) => O.id === HCI_SETTING.DEFAULT_STORAGE_CLASS)?.value;
return JSON.parse(storageClassValue);
} catch (e) {
return {};
}
},
customVolumeMode() {
return this.storageClassSetting.volumeMode || VOLUME_MODE.BLOCK;
},
customAccessMode() {
return this.storageClassSetting.accessModes || READ_WRITE_MANY;
},
isWindows() {
return this.osType === 'windows';
},
needNewSecret() {
// When creating a template it is always necessary to create a new secret.
return this.isCreate || this.showYaml ? false : this.resourceType === HCI.VM_VERSION;
},
defaultTerminationSetting() {
const setting = this.$store.getters[`${ this.inStore }/all`](HCI.SETTING).find( (O) => O.id === HCI_SETTING.VM_TERMINATION_PERIOD) || {};
return Number(setting?.value || setting?.default);
},
affinityLabels() {
return {
namespaceInputLabel: this.t('harvester.virtualMachine.affinity.namespaces.label'),
namespaceSelectionLabels: [
this.t('harvester.virtualMachine.affinity.thisPodNamespace'),
this.t('workload.scheduling.affinity.allNamespaces'),
this.t('harvester.virtualMachine.affinity.matchExpressions.inNamespaces')
],
addLabel: this.t('harvester.virtualMachine.affinity.addLabel'),
topologyKeyPlaceholder: this.t('harvester.virtualMachine.affinity.topologyKey.placeholder')
};
},
},
async created() {
await this.$store.dispatch(`${ this.inStore }/findAll`, { type: SECRET });
if (!this.value.vmMachineTypeAutoFeatureEnabled) {
try {
const url = this.$store.getters['harvester-common/getHarvesterClusterUrl'](
'v1/harvester/clusters/local?link=machineTypes'
);
const machineTypes = await this.$store.dispatch('harvester/request', { url });
this.machineTypes = machineTypes;
} catch (err) {
this.machineTypes = [''];
}
} else {
this.machineTypes = [''];
}
this.getInitConfig({ value: this.value, init: this.isCreate });
},
methods: {
getInitConfig(config) {
const {
value, existUserData, fromTemplate = false, init = false
} = config;
const vm = this.resourceType === HCI.VM ? value : this.resourceType === HCI.BACKUP ? this.value.status?.source : value.spec.vm;
const volumeBackups = this.resourceType === HCI.BACKUP ? this.value.status?.volumeBackups : null;
const spec = vm?.spec;
if (!spec) {
return;
}
const resources = spec.template.spec.domain.resources;
// If the user is created via yaml, there may be no "resources.limits": kubectl apply -f https://kubevirt.io/labs/manifests/vm.yaml
if (!resources?.limits || (resources?.limits && !resources?.limits?.memory && resources?.limits?.memory !== null)) {
spec.template.spec.domain.resources = {
...spec.template.spec.domain.resources,
limits: {
...spec.template.spec.domain.resources.limits,
memory: spec.template.spec.domain.resources.requests.memory
}
};
}
if (!vm.metadata.labels) {
vm.metadata.labels = {};
}
const maintenanceStrategy = vm.metadata.labels?.[HCI_ANNOTATIONS.VM_MAINTENANCE_MODE_STRATEGY] || 'Migrate';
const runStrategy = spec.runStrategy || 'RerunOnFailure';
const machineType = spec.template.spec.domain?.machine?.type || this.machineTypes[0];
const {
cpu, memory, maxCpu, maxMemory, isHotplugEnabled
} = getVmCPUMemoryValues(vm);
const cpuMemoryHotplugEnabled = isHotplugEnabled;
const reservedMemory = vm.metadata?.annotations?.[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
const terminationGracePeriodSeconds = spec.template.spec?.terminationGracePeriodSeconds || this.defaultTerminationSetting;
const sshKey = this.getSSHFromAnnotation(spec) || [];
const imageId = this.getRootImageId(vm) || '';
const diskRows = this.getDiskRows(vm, volumeBackups);
const networkRows = this.getNetworkRows(vm, { fromTemplate, init });
const hasCreateVolumes = this.getHasCreatedVolumes(spec) || [];
let { userData = undefined, networkData = undefined } = this.getCloudInitNoCloud(spec);
if (this.resourceType === HCI.BACKUP) {
const secretBackups = this.value.status?.secretBackups;
if (secretBackups) {
const secretNetworkData = secretBackups[0]?.data?.networkdata || '';
const secretUserData = secretBackups[0]?.data?.userdata || '';
userData = base64Decode(secretUserData);
networkData = base64Decode(secretNetworkData);
}
}
const osType = this.getOsType(vm) || 'linux';
userData = this.isCreate && !existUserData && !this.isClone ? this.getInitUserData({ osType }) : userData;
const installUSBTablet = this.isInstallUSBTablet(spec);
const installAgent = this.hasInstallAgent(userData, osType, true);
const efiEnabled = this.isEfiEnabled(spec);
const tpmEnabled = this.isTpmEnabled(spec);
const tpmPersistentStateEnabled = this.isTPMPersistentStateEnabled(spec);
const efiPersistentStateEnabled = this.isEFIPersistentStateEnabled(spec);
const secureBoot = this.isSecureBoot(spec);
const cpuPinning = this.isCpuPinning(spec);
const cpuModel = spec.template.spec.domain.cpu?.model || '';
const blockMultiQueue = spec.template.spec.domain?.devices?.blockMultiQueue || false;
const ioThreadsPolicy = spec.template.spec.domain?.ioThreadsPolicy || '';
const ioThreadCount = spec.template.spec.domain?.ioThreads?.supplementalPoolThreadCount || 2;
const secretRef = this.getSecret(spec);
const accessCredentials = this.getAccessCredentials(spec);
if (Object.prototype.hasOwnProperty.call(spec, 'running')) {
delete spec.running;
spec.runStrategy = 'RerunOnFailure';
}
this['spec'] = spec;
this['maintenanceStrategy'] = maintenanceStrategy;
this['runStrategy'] = runStrategy;
this['secretRef'] = secretRef;
this['accessCredentials'] = accessCredentials;
this['userScript'] = userData;
this['networkScript'] = networkData;
this['sshKey'] = sshKey;
this['osType'] = osType;
this['installAgent'] = installAgent;
this['cpu'] = cpu;
this['memory'] = memory;
this['maxCpu'] = maxCpu;
this['maxMemory'] = maxMemory;
this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled;
this['reservedMemory'] = reservedMemory;
this['machineType'] = machineType;
this['terminationGracePeriodSeconds'] = terminationGracePeriodSeconds;
this['installUSBTablet'] = installUSBTablet;
this['efiEnabled'] = efiEnabled;
this['efiPersistentStateEnabled'] = efiPersistentStateEnabled;
this['tpmEnabled'] = tpmEnabled;
this['tpmPersistentStateEnabled'] = tpmPersistentStateEnabled;
this['secureBoot'] = secureBoot;
this['cpuPinning'] = cpuPinning;
this['cpuModel'] = cpuModel;
this['blockMultiQueue'] = blockMultiQueue;
this['ioThreadsPolicy'] = ioThreadsPolicy;
this['ioThreadCount'] = ioThreadCount;
this['hasCreateVolumes'] = hasCreateVolumes;
this['networkRows'] = networkRows;
this['imageId'] = imageId;
this['diskRows'] = diskRows;
this['filesystemRows'] = this.getFilesystemRows(vm);
let sysprepConfig = { secretName: '', xmlContent: '' };
if (osType === 'windows') {
sysprepConfig = this.getSysprepConfig(spec);
}
this['sysprep'] = {
secretName: sysprepConfig.secretName || this.sysprep?.secretName || '',
xmlContent: sysprepConfig.xmlContent || this.sysprep?.xmlContent || '',
};
this.refreshYamlEditor();
},
getDiskRows(vm, volBackups) {
const namespace = vm.metadata.namespace;
const _volumes = vm.spec.template.spec.volumes || [];
const _disks = vm.spec.template.spec.domain.devices.disks || [];
const _volumeClaimTemplates = parseVolumeClaimTemplates(vm);
let out = [];
if (_disks.length === 0) {
let bus = 'virtio';
let type = HARD_DISK;
let size = '10Gi';
const imageResource = this.images.find( (I) => this.imageId === I.id);
const isIsoImage = /iso$/i.test(imageResource?.imageSuffix);
const imageSize = Math.max(imageResource?.status?.size, imageResource?.status?.virtualSize);
const isEncrypted = imageResource?.isEncrypted || false;
const volumeBackups = volBackups?.find((vBackup) => vBackup.volumeName === 'disk-0') || null ;
if (isIsoImage) {
bus = 'sata';
type = CD_ROM;
}
if (imageSize) {
let imageSizeGiB = Math.ceil(imageSize / 1024 / 1024 / 1024);
if (!isIsoImage) {
imageSizeGiB = Math.max(imageSizeGiB, 10);
}
size = `${ imageSizeGiB }${ GIBIBYTE }`;
}
out.push({
id: randomStr(5),
source: SOURCE_TYPE.IMAGE,
name: 'disk-0',
accessMode: READ_WRITE_MANY, // root disk only support LHv1 volume, should be RWX
bus,
volumeName: '',
size,
type,
storageClassName: '',
image: this.imageId,
volumeMode: VOLUME_MODE.BLOCK,
cache: '',
io: '',
dedicatedIOThread: false,
isEncrypted,
volumeBackups,
});
} else {
out = _disks.map( (DISK, index) => {
const volume = _volumes.find( (V) => V.name === DISK.name );
let size = '';
let image = '';
let source = '';
let realName = '';
let container = '';
let volumeName = '';
let accessMode = '';
let volumeMode = '';
let storageClassName = '';
let hotpluggable = false;
let dataSource = null;
const type = DISK?.cdrom ? CD_ROM : DISK?.disk ? HARD_DISK : '';
if (type === CD_ROM && volume === undefined) {
// Empty CD_ROM
source = SOURCE_TYPE.IMAGE;
image = EMPTY_IMAGE;
size = `0${ GIBIBYTE }`;
} else if (volume.containerDisk) { // SOURCE_TYPE.CONTAINER
source = SOURCE_TYPE.CONTAINER;
container = volume.containerDisk.image;
} else if (volume.persistentVolumeClaim && volume.persistentVolumeClaim?.claimName) {
volumeName = volume.persistentVolumeClaim.claimName;
const DVT = _volumeClaimTemplates.find( (T) => T.metadata.name === volumeName);
realName = volumeName;
// If the DVT can be found, it cannot be an existing volume
if (DVT) {
// has annotation (HCI_ANNOTATIONS.IMAGE_ID) => SOURCE_TYPE.IMAGE
if (DVT.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID] !== undefined) {
image = DVT.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID];
source = SOURCE_TYPE.IMAGE;
} else {
source = SOURCE_TYPE.NEW;
}
const dataVolumeSpecPVC = DVT?.spec || {};
volumeMode = dataVolumeSpecPVC?.volumeMode;
accessMode = dataVolumeSpecPVC?.accessModes?.[0];
size = dataVolumeSpecPVC?.resources?.requests?.storage || '10Gi';
storageClassName = dataVolumeSpecPVC?.storageClassName;
dataSource = dataVolumeSpecPVC?.dataSource;
} else {
// SOURCE_TYPE.ATTACH_VOLUME
// Compatible with VMS that have been created before, Because they're not saved in the annotation
const allPVCs = this.$store.getters['harvester/all'](PVC);
const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`);
source = SOURCE_TYPE.ATTACH_VOLUME;
accessMode = pvcResource?.spec?.accessModes?.[0] || READ_WRITE_MANY;
size = pvcResource?.spec?.resources?.requests?.storage || '10Gi';
storageClassName = pvcResource?.spec?.storageClassName;
volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK;
volumeName = pvcResource?.metadata?.name || '';
}
hotpluggable = volume.persistentVolumeClaim.hotpluggable || false;
}
const bus = DISK?.disk?.bus || DISK?.cdrom?.bus;
const bootOrder = DISK?.bootOrder ? DISK?.bootOrder : index;
const parseValue = parseSi(size);
const formatSize = formatSi(parseValue, {
increment: 1024,
addSuffix: false,
maxExponent: 3,
minExponent: 3,
});
const pvc = this.pvcs.find((P) => P.id === `${ this.value.metadata.namespace }/${ volumeName }`);
const volumeStatus = pvc?.relatedPV?.metadata?.annotations?.[HCI_ANNOTATIONS.VOLUME_ERROR];
const isEncrypted = pvc?.isEncrypted || false;
const volumeBackups = volBackups?.find((vBackup) => vBackup.volumeName === DISK.name) || null;
return {
id: randomStr(5),
bootOrder,
source,
name: DISK.name,
realName,
bus,
volumeName,
container,
accessMode,
size: `${ formatSize }${ GIBIBYTE }`,
volumeMode: volumeMode || this.customVolumeMode,
image,
type,
storageClassName,
hotpluggable,
cache: DISK?.cache || '',
io: DISK?.io || '',
dedicatedIOThread: DISK?.dedicatedIOThread || false,
shareable: DISK.shareable || false,
volumeStatus,
dataSource,
namespace,
isEncrypted,
volumeBackups,
};
});
}
out = sortBy(out, 'bootOrder');
// Filter out cloudinitdisk and sysprep disk from UI display.
return out.filter( (O) => O.name !== 'cloudinitdisk' && O.name !== 'sysprep');
},
getNetworkRows(vm, config) {
const { fromTemplate = false, init = false } = config;
const networks = vm.spec.template.spec.networks || [];
const interfaces = vm.spec.template.spec.domain.devices.interfaces || [];
const annotations = vm.metadata?.annotations || {};
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const out = interfaces.map( (I, index) => {
const network = networks.find( (N) => I.name === N.name);
const type = I.sriov ? 'sriov' : I.bridge ? 'bridge' : 'masquerade';
const isPod = !!network.pod;
return {
...I,
index,
type,
isPod,
newCreateId: (fromTemplate || init) ? randomStr(10) : false,
model: I.model,
networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName,
staticIp: annotations[`${ staticIpPrefix }${ I.name }`] || '',
};
});
return out;
},
parseVM() {
this.userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
this.parseOther();
this.parseAccessCredentials();
this.parseNetworkRows(this.networkRows);
this.parseDiskRows(this.diskRows);
this.parseFilesystemRows();
},
getFilesystemRows(vm) {
const _filesystems = vm.spec.template.spec.domain.devices?.filesystems || [];
const _volumes = vm.spec.template.spec.volumes || [];
return _filesystems.map((fs) => {
const volume = _volumes.find((v) => v.name === fs.name);
let fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP;
let resourceName = '';
if (volume?.configMap) {
fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP;
resourceName = volume.configMap.name;
} else if (volume?.secret) {
fsType = FILESYSTEM_SOURCE_TYPE.SECRET;
resourceName = volume.secret.secretName;
} else if (volume?.serviceAccount) {
fsType = FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT;
resourceName = volume.serviceAccount.serviceAccountName;
}
return {
fsType,
volumeName: fs.name,
resourceName,
};
});
},
parseFilesystemRows() {
const completedRows = this.filesystemRows.filter(
(r) => r.fsType && r.volumeName && r.resourceName
);
const filesystems = completedRows.map((r) => ({
name: r.volumeName,
virtiofs: {},
}));
const fsVolumes = completedRows.map((r) => {
if (r.fsType === FILESYSTEM_SOURCE_TYPE.CONFIGMAP) {
return {
name: r.volumeName,
configMap: { name: r.resourceName },
};
} else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SECRET) {
return {
name: r.volumeName,
secret: { secretName: r.resourceName },
};
} else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT) {
return {
name: r.volumeName,
serviceAccount: { serviceAccountName: r.resourceName },
};
}
return null;
}).filter(Boolean);
if (filesystems.length > 0) {
this.spec.template.spec.domain.devices['filesystems'] = filesystems;
} else {
delete this.spec.template.spec.domain.devices['filesystems'];
}
if (fsVolumes.length > 0) {
if (!this.spec.template.spec.volumes) {
this.spec.template.spec['volumes'] = [];
}
this.spec.template.spec.volumes.push(...fsVolumes);
}
},
parseOther() {
if (!this.spec.template.spec.domain.machine) {
this.spec.template.spec.domain['machine'] = { type: this.machineType };
} else {
this.spec.template.spec.domain.machine['type'] = this.machineType;
}
this.setCPUAndMemory();
// update terminationGracePeriodSeconds
this.spec.template.spec.terminationGracePeriodSeconds = this.terminationGracePeriodSeconds;
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
this.syncStaticIpAnnotations(vm);
// parse reserved memory
if (!this.reservedMemory) {
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
} else {
vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY] = this.reservedMemory;
}
// add or remove cpu memory hotplug annotation
if (this.cpuMemoryHotplugEnabled) {
vm.metadata.annotations[HCI_ANNOTATIONS.VM_CPU_MEMORY_HOTPLUG] = this.cpuMemoryHotplugEnabled.toString();
} else {
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_CPU_MEMORY_HOTPLUG];
}
if (this.maintenanceStrategy === 'Migrate') {
delete vm.metadata.labels[HCI_ANNOTATIONS.VM_MAINTENANCE_MODE_STRATEGY];
} else {
vm.metadata.labels[HCI_ANNOTATIONS.VM_MAINTENANCE_MODE_STRATEGY] = this.maintenanceStrategy;
}
},
syncStaticIpAnnotations(vm) {
if (!vm?.metadata) {
return;
}
if (!vm.metadata.annotations) {
vm.metadata.annotations = {};
}
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const annotations = vm.metadata.annotations;
const desired = {};
this.networkRows.forEach((row) => {
if (row.name && row.staticIp) {
desired[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
}
});
const current = {};
Object.keys(annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
current[key] = annotations[key];
}
});
// Skip mutation when already in sync to avoid triggering a reactive update loop.
const desiredKeys = Object.keys(desired);
const currentKeys = Object.keys(current);
const isSame = desiredKeys.length === currentKeys.length &&
desiredKeys.every((key) => current[key] === desired[key]);
if (isSame) {
return;
}
currentKeys.forEach((key) => {
delete annotations[key];
});
Object.entries(desired).forEach(([key, value]) => {
annotations[key] = value;
});
},
setCPUAndMemory() {
if (this.cpuMemoryHotplugEnabled) {
// set CPU
this.spec.template.spec.domain.cpu.sockets = this.cpu;
this.spec.template.spec.domain.cpu.cores = 1;
// set max CPU
set(this.spec.template.spec, 'domain.cpu.maxSockets', this.maxCpu);
// domain.resources.limits.cpu and memory are defined by k8s which requires string values
// see https://kubernetes.io/docs/tasks/configure-pod-container/assign-cpu-resource/
set(this.spec.template.spec, 'domain.resources.limits.cpu', this.maxCpu?.toString());
// set memory
set(this.spec.template.spec, 'domain.memory.guest', this.memory);
// set max memory
set(this.spec.template.spec, 'domain.memory.maxGuest', this.maxMemory);
set(this.spec.template.spec, 'domain.resources.limits.memory', this.maxMemory);
} else {
this.spec.template.spec.domain.cpu.sockets = 1;
this.spec.template.spec.domain.cpu.cores = this.cpu;
this.spec.template.spec.domain.resources.limits.cpu = this.cpu?.toString();
this.spec.template.spec.domain.resources.limits.memory = this.memory;
// clean below fields as we don't need them if not enable CPU and memory hotplug
if (this.spec?.template?.spec?.domain?.memory?.maxGuest) {
delete this.spec.template.spec.domain.memory.maxGuest;
}
}
},
needVolumeRelatedInfo(R) {
// return [needVolume, needVolumeClaimTemplate]
if (R.source === SOURCE_TYPE.CONTAINER) {
return [true, false];
}
if (R.source === SOURCE_TYPE.IMAGE && R.image === EMPTY_IMAGE) {
return [false, false];
}
return [true, true];
},
parseDiskRows(disk) {
const disks = [];
const volumes = [];
const volumeClaimTemplates = [];
disk.forEach( (R, index) => {
const _disk = this.parseDisk(R, index);
disks.push(_disk);
const prefixName = this.value.metadata?.name || '';
const dataVolumeName = this.parseDataVolumeName(R, prefixName);
const [needVolume, needVolumeClaimTemplate] = this.needVolumeRelatedInfo(R);
if (needVolume) {
const _volume = this.parseVolume(R, dataVolumeName);
volumes.push(_volume);
}
if (needVolumeClaimTemplate) {
const _dataVolumeTemplate = this.parseVolumeClaimTemplate(R, dataVolumeName);
volumeClaimTemplates.push(_dataVolumeTemplate);
}
});
// Regenerate the cloud-init secret name whenever it hasn't been generated yet, or the VM
// name has changed since it was last generated (e.g. a previous create attempt failed with
// an invalid/duplicate name and the user corrected it). Without this, a stale secret name
// derived from a rejected attempt gets reused for the VM that's actually created.
// See harvester/harvester#11174.
if (this.needNewSecret || !this.secretName || (this.isCreate && this.secretNamePrefixUsed !== this.secretNamePrefix)) {
this.secretName = this.generateSecretName(this.secretNamePrefix);
this.secretNamePrefixUsed = this.secretNamePrefix;
}
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
// If we have content but no secret name, it's a new secret that needs a name. Also
// regenerate when creating a VM if the name prefix has changed since it was last
// generated.
const sysprepNeedsNewSecretName = !this.sysprep.secretName?.trim?.() ||
(this.isCreate && this.sysprepSecretNamePrefixUsed !== '' && this.sysprepSecretNamePrefixUsed !== this.secretNamePrefix);
if (hasSysprepContent && sysprepNeedsNewSecretName) {
const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep';
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
this.sysprepSecretNamePrefixUsed = this.secretNamePrefix;
}
// Preserve/attach sysprep whenever a secret is selected/known.
if (this.sysprep.secretName) {
disks.push({
name: 'sysprep',
cdrom: { bus: 'sata' }
});
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
volumes.push({
name: 'sysprep',
sysprep: { secret: { name: secretName } }
});
}
}
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
disks.push({
name: 'cloudinitdisk',
disk: { bus: 'virtio' }
});
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = {
name: 'cloudinitdisk',
cloudInitNoCloud: {}
};
if (this.saveUserDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.userData = userData;
} else {
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
}
if (this.saveNetworkDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
} else {
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
}
volumes.push(cloudinitdisk);
}
const specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
const mergedDisks = this.mergeDeviceList(specDisks, disks);
// `shareable` is an attachment-level opt-in, remove the field
// inherited from the old spec when the disk row no longer requests it
mergedDisks.forEach((disk) => {
const parsed = disks.find((D) => D.name === disk.name);
if (disk.shareable && !parsed?.shareable) {
delete disk.shareable;
}
// High-performance fields are only serialized when requested (see
// parseDisk). When editing, mergeDeviceList would otherwise keep the
// previous spec's cache/io/dedicatedIOThread even after the row is
// switched back to "Default" or the dedicated I/O thread is unchecked,
// silently preserving stale values — strip them so the disk matches
// what the row actually requests.
if (parsed) {
['cache', 'io', 'dedicatedIOThread'].forEach((field) => {
if (!parsed[field] && field in disk) {
delete disk[field];
}
});
}
});
let spec = {
...this.spec,
runStrategy: this.runStrategy,
template: {
...this.spec.template,
metadata: {
...this.spec?.template?.metadata,
annotations: {
...this.spec?.template?.metadata?.annotations,
[HCI_ANNOTATIONS.SSH_NAMES]: JSON.stringify(this.sshKey)
},
labels: {
...this.spec?.template?.metadata?.labels,
[HCI_ANNOTATIONS.VM_NAME]: this.value?.metadata?.name,
}
},
spec: {
...this.spec.template?.spec,
domain: {
...this.spec.template?.spec?.domain,
devices: {
...this.spec.template?.spec?.domain?.devices,
disks: mergedDisks,
},
},
volumes,
}
}
};
if (volumes.length === 0) {
delete spec.template.spec.volumes;
}
// KubeVirt high-performance VM-level features (I/O threads & block multi-queue).
const domainRef = spec.template.spec.domain;
const hasDedicatedIOThread = mergedDisks.some((D) => D.dedicatedIOThread);
// If any disk requests a dedicated I/O thread, KubeVirt requires a policy;
// default it to "shared" so the dedicated thread is honored.
let ioThreadsPolicy = this.ioThreadsPolicy;
if (hasDedicatedIOThread && !ioThreadsPolicy) {
ioThreadsPolicy = 'shared';
}
if (ioThreadsPolicy) {
domainRef.ioThreadsPolicy = ioThreadsPolicy;
} else {
delete domainRef.ioThreadsPolicy;
}
if (ioThreadsPolicy === 'supplementalPool') {
domainRef.ioThreads = { supplementalPoolThreadCount: Number(this.ioThreadCount) || 1 };
} else {
delete domainRef.ioThreads;
}
if (this.blockMultiQueue) {
domainRef.devices.blockMultiQueue = true;
} else {
delete domainRef.devices.blockMultiQueue;
}
if (this.resourceType === HCI.VM) {
if (!this.isSingle) {
spec = this.multiVMScheduler(spec);
}
this.value.metadata['annotations'] = {
...this.value.metadata.annotations,
[HCI_ANNOTATIONS.VOLUME_CLAIM_TEMPLATE]: JSON.stringify(volumeClaimTemplates),
[HCI_ANNOTATIONS.NETWORK_IPS]: JSON.stringify(this.value.networkIps)
};
this.value.metadata['labels'] = {
...this.value.metadata.labels,
[HCI_ANNOTATIONS.CREATOR]: 'harvester',
[HCI_ANNOTATIONS.OS]: this.osType
};
this.value['spec'] = spec;
this['spec'] = spec;
} else if (this.resourceType === HCI.VM_VERSION) {
this.value.spec.vm['spec'] = spec;
this.value.spec.vm.metadata['annotations'] = {
...this.value.spec.vm.metadata.annotations,
[HCI_ANNOTATIONS.VOLUME_CLAIM_TEMPLATE]: JSON.stringify(volumeClaimTemplates),
};
this.value.spec.vm.metadata['labels'] = {
...this.value.spec.vm.metadata.labels,
[HCI_ANNOTATIONS.OS]: this.osType
};
this['spec'] = spec;
}
},
removeTrailingHyphen(str) {
while (str.endsWith('-')) {
str = str.slice(0, -1);
}
return str;
},
multiVMScheduler(spec) {
const namePrefix = this.removeTrailingHyphen(this.namePrefix);
spec.template.metadata.labels[HCI_ANNOTATIONS.VM_NAME_PREFIX] = namePrefix;
const rule = {
weight: 1,
podAffinityTerm: {
topologyKey: HOSTNAME,
labelSelector: { matchLabels: { [HCI_ANNOTATIONS.VM_NAME_PREFIX]: namePrefix } }
}
};
return {
...spec,
template: {
...spec.template,
spec: {
...spec.template.spec,
affinity: {
...spec.template.spec.affinity,
podAntiAffinity: {
...spec.template.spec?.affinity?.podAntiAffinity,
preferredDuringSchedulingIgnoredDuringExecution: [
...(spec.template.spec?.affinity?.podAntiAffinity?.preferredDuringSchedulingIgnoredDuringExecution || []),
rule
]
}
}
}
}
};
},
parseNetworkRows(networkRow) {
const networks = [];
const interfaces = [];
networkRow.forEach( (R) => {
const _network = this.parseNetwork(R);
const _interface = this.parseInterface(R);
networks.push(_network);
interfaces.push(_interface);
});
const specInterfaces = this.spec?.template?.spec?.domain?.devices?.interfaces;
const mergedInterfaces = this.mergeInterfaceList(specInterfaces, interfaces);
const devices = {
...this.spec.template.spec.domain.devices,
interfaces: mergedInterfaces,
};
if (this.isEdit && networkRow.length === 0) {
devices.autoattachPodInterface = false;
} else {
delete devices.autoattachPodInterface;
}
const spec = {
...this.spec.template.spec,
domain: {
...this.spec.template.spec.domain,
devices,
},
networks
};
this.spec.template['spec'] = spec;
},
parseAccessCredentials() {
const out = [];
const annotations = {};
const users = JSON.parse(this.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_USERS] || '[]');
for (const row of this.accessCredentials) {
if (this.needNewSecret) {
row.secretName = this.generateSecretName(this.secretNamePrefix);
}
if (row.source === ACCESS_CREDENTIALS.RESET_PWD) {
users.push(row.username);
out.push({
userPassword: {
source: { secret: { secretName: row.secretName } },
propagationMethod: { qemuGuestAgent: { } }
}
});
}
if (row.source === ACCESS_CREDENTIALS.INJECT_SSH) {
users.push(...row.users);
annotations[row.secretName] = row.sshkeys;
out.push({
sshPublicKey: {
source: { secret: { secretName: row.secretName } },
propagationMethod: { qemuGuestAgent: { users: row.users } }
}
});
}
}
if (out.length === 0 && !!this.spec.template.spec.accessCredentials === false) {
delete this.spec.template.spec.accessCredentials;
} else {
this.spec.template.spec.accessCredentials = out;
}
if (users.length !== 0) {
this.spec.template.metadata.annotations[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_USERS] = JSON.stringify(Array.from(new Set(users)));
this.spec.template.metadata.annotations[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_NAMES] = JSON.stringify(annotations);
}
},
getMaintenanceStrategyOptionLabel(opt) {
return this.t(`harvester.virtualMachine.maintenanceStrategy.options.${ opt.label || opt }`);
},
getInitUserData(config) {
// Windows guests don't use qemu-guest-agent via systemd (VMDP installs
// the QGA as a Windows service), and the Linux `runcmd` recipe would
// fail on Cloudbase-Init. Return an empty string so `this.userData`
// stays falsy on Windows until the user actually enters cloud-config
// content — this prevents emitting a spurious cloudinitdisk volume
// and Secret for every Windows VM (Copilot review comment on #984).
if (config.osType === 'windows') {
return '';
}
const _QGA_JSON = this.getMatchQGA(config.osType);
const out = jsyaml.dump(_QGA_JSON);
return `#cloud-config\n${ out }`;
},
/**
* Generate user data yaml which is decided by the
* "Install guest agent", "OS type", "SSH keys" and user input.
* @param config
*/
getUserData(config) {
try {
// https://github.com/eemeli/yaml/issues/136
let userDataDoc = this.userScript ? YAML.parseDocument(this.userScript) : YAML.parseDocument({});
const allSSHAuthorizedKeys = this.mergeSSHAuthorizedKeys(this.userScript);
if (allSSHAuthorizedKeys.length > 0) {
userDataDoc.setIn(['ssh_authorized_keys'], allSSHAuthorizedKeys);
} else if (YAML.isCollection(userDataDoc.getIn('ssh_authorized_keys'))) {
userDataDoc.deleteIn(['ssh_authorized_keys']);
}
userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config });
const userDataYaml = userDataDoc.toString();
const userDataValue = userDataDoc.toJSON();
if (userDataValue === null || (typeof userDataValue === 'object' && !Array.isArray(userDataValue) && isEmpty(userDataValue))) {
// Empty userData should not create cloud-init content like `null`.
return undefined;
}
const hasCloudComment = this.hasCloudConfigComment(userDataYaml);
return hasCloudComment ? userDataYaml : `#cloud-config\n${ userDataYaml }`;
} catch (e) {
console.error('Error: Unable to parse yaml document', e); // eslint-disable-line no-console
return this.userScript;
}
},
updateSSHKey(neu) {
this['sshKey'] = neu;
},
updateCpuMemory(cpu, memory, maxCpu = '', maxMemory = null, cpuMemoryHotplugEnabled = false) {
this['cpu'] = cpu;
this['memory'] = memory;
this['maxCpu'] = maxCpu;
this['maxMemory'] = maxMemory;
this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled;
},
parseDataVolumeName(R, prefixName) {
let dataVolumeName = '';
if (R.source === SOURCE_TYPE.ATTACH_VOLUME) {
dataVolumeName = R.volumeName;
} else if (this.isClone || !this.hasCreateVolumes.includes(R.realName)) {
dataVolumeName = `${ prefixName }-${ R.name }-${ randomStr(5).toLowerCase() }`;
} else {
dataVolumeName = R.realName;
}
return dataVolumeName;
},
parseDisk(R, index) {
const out = { name: R.name };
if (R.type === HARD_DISK) {
out.disk = { bus: R.bus };
// KubeVirt high-performance per-disk features. `cache`, `io` and
// `dedicatedIOThread` live at the Disk level (siblings of `disk`), not
// inside the DiskTarget. See disks_and_volumes.md#high-performance-features.
if (R.cache) {
out.cache = R.cache;
}
if (R.io) {
out.io = R.io;
}
if (R.dedicatedIOThread) {
out.dedicatedIOThread = true;
}
} else if (R.type === CD_ROM) {
out.cdrom = { bus: R.bus };
}
if (R.shareable) {
out.shareable = true;
}
out.bootOrder = index + 1;
return out;
},
parseVolume(R, dataVolumeName) {
const out = { name: R.name };
if (R.source === SOURCE_TYPE.CONTAINER) {
out.containerDisk = { image: R.container };
} else if (R.source === SOURCE_TYPE.IMAGE || R.source === SOURCE_TYPE.NEW || R.source === SOURCE_TYPE.ATTACH_VOLUME) {
out.persistentVolumeClaim = { claimName: dataVolumeName };
if (R.hotpluggable) {
out.persistentVolumeClaim.hotpluggable = true;
}
}
return out;
},
parseVolumeClaimTemplate(R, dataVolumeName) {
const sizeString = String(R.size);
if (!(sizeString.includes('Gi') || sizeString.includes('Ti')) && R.size) {
R.size = `${ R.size }${ GIBIBYTE }`;
}
const out = {
metadata: { name: dataVolumeName },
spec: {
accessModes: [R.accessMode],
resources: { requests: { storage: R.size } },
volumeMode: R.volumeMode
}
};
if (R.dataSource) {
out.spec.dataSource = R.dataSource;
}
switch (R.source) {
case SOURCE_TYPE.ATTACH_VOLUME:
out.spec.storageClassName = R.storageClassName;
break;
case SOURCE_TYPE.NEW:
out.spec.storageClassName = R.storageClassName;
break;
case SOURCE_TYPE.IMAGE: {
const image = this.images.find( (I) => R.image === I.id);
out.spec.storageClassName = R.realName ? R.storageClassName : (R.storageClassName || image?.storageClassName);
if (image) {
out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: image.id };
} else {
out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: '' };
}
break;
}
}
return out;
},
getSSHListValue(arr) {
return arr.map( (id) => this.getSSHValue(id)).filter( (O) => O !== undefined);
},
parseInterface(R) {
const _interface = {};
const type = R.type;
_interface[type] = {};
if (R.macAddress) {
_interface.macAddress = R.macAddress;
}
_interface.model = R.model;
_interface.name = R.name;
return _interface;
},
parseNetwork(R) {
const out = { name: R.name };
if (R.isPod) {
out.pod = {};
} else {
out.multus = { networkName: R.networkName };
}
return out;
},
updateUserData(value) {
this.userScript = value;
},
updateNetworkData(value) {
this.networkScript = value;
},
mergeSSHAuthorizedKeys(yaml) {
try {
const sshAuthorizedKeys = YAML.parseDocument(yaml)
.get('ssh_authorized_keys')
?.toJSON() || [];
const sshList = this.getSSHListValue(this.sshKey);
return sshAuthorizedKeys.length ? [...new Set([...sshList, ...sshAuthorizedKeys])] : sshList;
} catch (e) {
return [];
}
},
/**
* @param paths A Object path, e.g. 'a.b.c' => ['a', 'b', 'c']. Refer to https://eemeli.org/yaml/#scalar-values
* @returns
*/
deleteYamlDocProp(doc, paths) {
try {
const items = doc.getIn([])?.items;
const firstKey = items?.[0]?.key;
const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config');
const isFirstProp = firstKey?.source === paths[paths.length - 1];
if (firstKey && hasCloudConfigComment && isFirstProp) {
const comment = firstKey.commentBefore;
doc.deleteIn(paths);
// Move the comment to the new first key; if no keys remain the comment is lost.
const newFirstKey = doc.getIn([])?.items?.[0]?.key;
if (newFirstKey) {
newFirstKey.commentBefore = comment;
}
} else {
doc.deleteIn(paths);
}
} catch (e) {}
},
mergeQGA(config) {
const { osType, userDataDoc } = config;
const _QGA_JSON = this.getMatchQGA(osType);
const userDataYAML = userDataDoc.toString();
const userDataJSON = YAML.parse(userDataYAML);
let packages = userDataJSON?.packages || [];
let runcmd = userDataJSON?.runcmd || [];
userDataDoc.setIn(['package_update'], true);
if (Array.isArray(packages)) {
if (!packages.includes('qemu-guest-agent')) {
packages.push('qemu-guest-agent');
}
} else {
packages = QGA_JSON.packages;
}
if (Array.isArray(runcmd)) {
let findIndex = -1;
const hasSameRuncmd = runcmd.find( (S) => Array.isArray(S) && S.join('-') === _QGA_JSON.runcmd[0].join('-'));
const hasSimilarRuncmd = runcmd.find( (S, index) => {
if (Array.isArray(S) && S.join('-') === this.getSimilarRuncmd(osType).join('-')) {
findIndex = index;
return true;
}
return false;
});
if (hasSimilarRuncmd) {
runcmd[findIndex] = _QGA_JSON.runcmd[0];
} else if (!hasSameRuncmd) {
runcmd.push(_QGA_JSON.runcmd[0]);
}
} else {
runcmd = _QGA_JSON.runcmd;
}
if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages);
} else {
this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']);
}
if (runcmd.length > 0) {
userDataDoc.setIn(['runcmd'], runcmd);
} else {
this.deleteYamlDocProp(userDataDoc, ['runcmd']);
}
return userDataDoc;
},
deleteQGA(config) {
const { osType, userDataDoc } = config;
const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || '';
const userDataYAML = userDataDoc.toString();
const userDataJSON = YAML.parse(userDataYAML);
const packages = userDataJSON?.packages || [];
const runcmd = userDataJSON?.runcmd || [];
// Special handling of OS types.
let deletePackage = config.deletePackage ?? false;
switch (osType) {
case 'windows':
deletePackage = true;
break;
}
if (Array.isArray(packages) && deletePackage) {
const templateHasQGAPackage = this.convertToJson(userDataTemplateValue);
for (let i = 0; i < packages.length; i++) {
if (packages[i] === 'qemu-guest-agent') {
if (!(Array.isArray(templateHasQGAPackage?.packages) && templateHasQGAPackage.packages.includes('qemu-guest-agent'))) {
packages.splice(i, 1);
}
}
}
}
if (Array.isArray(runcmd)) {
const _QGA_JSON = this.getMatchQGA(osType);
for (let i = 0; i < runcmd.length; i++) {
if (Array.isArray(runcmd[i]) && runcmd[i].join('-') === _QGA_JSON.runcmd[0].join('-')) {
runcmd.splice(i, 1);
}
}
}
if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages);
} else {
this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']);
}
if (runcmd.length > 0) {
userDataDoc.setIn(['runcmd'], runcmd);
} else {
this.deleteYamlDocProp(userDataDoc, ['runcmd']);
}
return userDataDoc;
},
generateSecretName(name) {
return name ? `${ name }-${ randomStr(5).toLowerCase() }` : undefined;
},
getOwnerReferencesFromVM(resource) {
const name = resource.metadata.name;
const kind = resource.kind;
const apiVersion = this.resourceType === HCI.VM ? 'kubevirt.io/v1' : 'harvesterhci.io/v1beta1';
const uid = resource?.metadata?.uid;
return [{
name,
kind,
uid,
apiVersion,
}];
},
async saveSecret(vm) {
if (!vm?.spec || !this.secretName) {
return true;
}
let secret = this.getSecret(vm.spec);
if (!secret && this.isEdit && this.secretRef) {
// When editing the vm, if the userData and networkData are deleted, we also need to clean up the secret values
secret = this.secretRef;
}
if (!secret || this.needNewSecret) {
secret = await this.$store.dispatch('harvester/create', {
metadata: {
name: this.secretName,
namespace: this.value.metadata.namespace,
labels: { [HCI_ANNOTATIONS.CLOUD_INIT]: 'harvester' },
ownerReferences: this.getOwnerReferencesFromVM(vm)
},
type: SECRET
});
}
try {
if (secret) {
// If none of the data comes from the secret, then no data needs to be saved to the secret
if (!this.saveUserDataAsClearText || !this.saveNetworkDataAsClearText) {
secret.setData('userdata', this.userData || '');
secret.setData('networkdata', this.networkScript || '');
await secret.save();
}
}
} catch (e) {
return Promise.reject(e);
}
},
async saveAccessCredentials(vm) {
if (!vm?.spec) {
return true;
}
// save
const toSave = [];
for (const row of this.accessCredentials) {
let secretRef = row.secretRef;
if (!secretRef || this.needNewSecret) {
secretRef = await this.$store.dispatch('harvester/create', {
metadata: {
name: row.secretName,
namespace: vm.metadata.namespace,
labels: { [HCI_ANNOTATIONS.CLOUD_INIT]: 'harvester' },
ownerReferences: this.getOwnerReferencesFromVM(vm)
},
type: SECRET
});
}
if (row.source === ACCESS_CREDENTIALS.RESET_PWD) {
secretRef.setData(row.username, row.newPassword);
}
if (row.source === ACCESS_CREDENTIALS.INJECT_SSH) {
for (const secretId of row.sshkeys) {
const keypair = (this.$store.getters['harvester/all'](HCI.SSH) || []).find((s) => s.id === secretId);
secretRef.setData(`${ keypair.metadata.namespace }-${ keypair.metadata.name }`, keypair.spec.publicKey);
}
}
toSave.push(secretRef);
}
try {
for (const resource of toSave) {
await resource.save();
}
} catch (e) {
return Promise.reject(e);
}
},
async saveSysprepConfig(vm) {
if (!this.isWindows) {
return;
}
if (!this.sysprep.secretName?.trim?.() && !this.sysprep.xmlContent?.trim?.()) {
return;
}
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
const namespace = vm.metadata.namespace;
const namespacedName = `${ namespace }/${ secretName }`;
let secret;
try {
secret = await this.$store.dispatch('harvester/find', { type: SECRET, id: namespacedName });
} catch (e) {
if (e?.status !== 404) {
throw e;
}
secret = await this.$store.dispatch('harvester/create', {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { [HCI_ANNOTATIONS.WINDOWS_SYSPREP]: 'true' },
},
});
}
secret.setData('autounattend.xml', this.sysprep.xmlContent);
await secret.save();
},
getAccessCredentialsValidation() {
const errors = [];
for (let i = 0; i < this.accessCredentials.length; i++) {
const row = this.accessCredentials[i];
const source = row.source;
if (source === ACCESS_CREDENTIALS.RESET_PWD) {
if (!row.username) {
const fieldName = this.t('harvester.virtualMachine.input.username');
const message = this.t('validation.required', { key: fieldName });
errors.push(message);
}
if (!row.newPassword) {
const fieldName = this.t('harvester.virtualMachine.input.password');
const message = this.t('validation.required', { key: fieldName });
errors.push(message);
}
if (row.newPassword && row.newPassword.length < 6) {
const fieldName = this.t('harvester.virtualMachine.input.password');
const message = this.t('validation.number.min', { key: fieldName, val: '6' });
errors.push(message);
}
} else {
if (!row.users || row.users.length === 0) {
const fieldName = this.t('harvester.virtualMachine.input.username');
const message = this.t('validation.required', { key: fieldName });
errors.push(message);
}
if (!row.sshkeys || row.sshkeys.length === 0) {
const fieldName = this.t('harvester.virtualMachine.input.sshKeyValue');
const message = this.t('validation.required', { key: fieldName });
errors.push(message);
}
}
if (errors.length > 0) {
break;
}
}
return errors;
},
getHasCreatedVolumes(spec) {
const out = [];
if (spec.template.spec.volumes) {
spec.template.spec.volumes.forEach((V) => {
if (V?.persistentVolumeClaim?.claimName) {
out.push(V.persistentVolumeClaim.claimName);
}
});
}
return out;
},
handlerUSBTablet(val) {
const hasExist = this.isInstallUSBTablet(this.spec);
const inputs = this.spec.template.spec.domain.devices?.inputs || [];
if (val && !hasExist) {
if (inputs.length > 0) {
inputs.push(USB_TABLET[0]);
} else {
Object.assign(this.spec.template.spec.domain.devices, {
inputs: [
USB_TABLET[0]
]
});
}
} else if (!val) {
const index = inputs.findIndex((O) => isEqual(O, USB_TABLET[0]));
if (hasExist && inputs.length === 1) {
delete this.spec.template.spec.domain.devices['inputs'];
} else if (hasExist) {
inputs.splice(index, 1);
this.spec.template.spec.domain.devices['inputs'] = inputs;
}
}
},
setBootMethod(boot = {
efi: false, secureBoot: false, efiPersistentStateEnabled: false
}) {
if (boot.efi) {
set(this.spec.template.spec.domain, 'firmware.bootloader.efi.secureBoot', boot.secureBoot);
} else {
delete this.spec.template.spec.domain['firmware'];
delete this.spec.template.spec.domain.features['smm'];
return;
}
if (boot.secureBoot) {
set(this.spec.template.spec.domain, 'features.smm.enabled', true);
} else {
try {
delete this.spec.template.spec.domain.features.smm['enabled'];
const noKeys = Object.keys(this.spec.template.spec.domain.features.smm).length === 0;
if (noKeys) {
delete this.spec.template.spec.domain.features['smm'];
}
} catch (e) {}
}
if (boot.efiPersistentStateEnabled) {
set(this.spec.template.spec.domain, 'firmware.bootloader.efi.persistent', true);
} else {
delete this.spec.template.spec.domain.firmware.bootloader.efi['persistent'];
}
},
setCpuPinning(value) {
if (value) {
set(this.spec.template.spec.domain.cpu, 'dedicatedCpuPlacement', true);
} else {
delete this.spec.template.spec.domain.cpu['dedicatedCpuPlacement'];
}
},
setTPM({ tpmEnabled = false, tpmPersistentStateEnabled = false } = {}) {
if (tpmEnabled) {
set(this.spec.template.spec.domain.devices, 'tpm', tpmPersistentStateEnabled ? { persistent: true } : {});
} else {
delete this.spec.template.spec.domain.devices['tpm'];
}
},
deleteSSHFromUserData(ssh = []) {
const sshAuthorizedKeys = this.getSSHFromUserData(this.userScript);
ssh.map((id) => {
const index = sshAuthorizedKeys.findIndex((value) => value === this.getSSHValue(id));
if (index >= 0) {
sshAuthorizedKeys.splice(index, 1);
}
});
const userDataJson = this.convertToJson(this.userScript);
userDataJson.ssh_authorized_keys = sshAuthorizedKeys;
if (sshAuthorizedKeys.length === 0) {
delete userDataJson.ssh_authorized_keys;
}
if (isEmpty(userDataJson)) {
this['userScript'] = undefined;
} else {
this['userScript'] = jsyaml.dump(userDataJson);
}
this.refreshYamlEditor();
},
mergeInterfaceList(specInterfaceList, interfaceList) {
if (!specInterfaceList || specInterfaceList.length === 0) {
return interfaceList;
}
const specInterfaceMap = new Map(specInterfaceList.map((iface) => [iface.name, iface]));
return interfaceList.map((iface) => {
const specInterface = specInterfaceMap.get(iface.name);
if (specInterface) {
const merged = { ...specInterface, ...iface };
// currently we only have bridge and masquerade network type, they are mutually exclusive
if (iface['bridge']) {
delete merged['masquerade'];
} else {
delete merged['bridge'];
}
return merged;
}
return iface;
});
},
mergeDeviceList(specDeviceList, deviceList) {
if (!specDeviceList || specDeviceList.length === 0) {
return deviceList;
}
const specDeviceMap = new Map(specDeviceList.map((device) => [device.name, device]));
return deviceList.map((device) => {
const specDevice = specDeviceMap.get(device.name);
if (specDevice) {
const merged = { ...specDevice, ...device };
// A disk entry must be either `disk` or `cdrom`, never both.
const type = device?.cdrom ? CD_ROM : device?.disk ? HARD_DISK : '';
if (type === CD_ROM) {
delete merged.disk;
} else if (type === HARD_DISK) {
delete merged.cdrom;
}
return merged;
}
return device;
});
},
refreshYamlEditor() {
this.$nextTick(() => {
this.$refs.yamlEditor?.updateValue();
});
},
toggleAdvanced() {
this.showAdvanced = !this.showAdvanced;
},
updateAgent(value) {
if (!value) {
this.deletePackage = true;
}
},
updateDataTemplateId(type, id) {
if (type === 'user') {
const oldInstallAgent = this.installAgent;
this.userDataTemplateId = id;
this.$nextTick(() => {
if (oldInstallAgent) {
this.installAgent = oldInstallAgent;
}
});
}
},
updateReserved(value = {}) {
const { memory } = value;
this['reservedMemory'] = memory;
},
updateTerminationGracePeriodSeconds(value) {
this['terminationGracePeriodSeconds'] = value;
},
},
watch: {
networkRows: {
handler() {
const vm = this.resourceType === HCI.VM ? this.value : this.value?.spec?.vm;
this.syncStaticIpAnnotations(vm);
},
deep: true
},
diskRows: {
handler(neu, old) {
if (Array.isArray(neu)) {
const imageId = neu[0]?.image;
const image = this.images.find( (I) => imageId === I.id);
const osType = image?.imageOSType;
const oldImageId = old[0]?.image;
if (this.isCreate && oldImageId !== imageId && imageId && osType) {
this.osType = osType;
}
}
}
},
secretRef: {
handler(secret) {
// we should not inherit the secret if it's from VM template.
if (secret && this.resourceType !== HCI.BACKUP && !this.useTemplate) {
this.secretName = secret?.metadata.name;
}
},
immediate: true,
deep: true
},
isWindows(val) {
if (val) {
this['sshKey'] = [];
this['installAgent'] = false;
}
},
installUSBTablet(val) {
this.handlerUSBTablet(val);
},
efiEnabled(val) {
this.setBootMethod({
efi: val, secureBoot: this.secureBoot, efiPersistentStateEnabled: this.efiPersistentStateEnabled
});
},
secureBoot(val) {
this.setBootMethod({
efi: this.efiEnabled, secureBoot: val, efiPersistentStateEnabled: this.efiPersistentStateEnabled
});
},
efiPersistentStateEnabled(val) {
this.setBootMethod({
efi: this.efiEnabled, secureBoot: this.secureBoot, efiPersistentStateEnabled: val
});
},
cpuPinning(value) {
this.setCpuPinning(value);
},
tpmEnabled(val) {
this.setTPM({ tpmEnabled: val, tpmPersistentStateEnabled: this.tpmPersistentStateEnabled });
},
tpmPersistentStateEnabled(val) {
this.setTPM({ tpmEnabled: this.tpmEnabled, tpmPersistentStateEnabled: val });
},
installAgent: {
/**
* rules
* 1. The value in user Data is the first priority
* 2. After selecting the template, if checkbox is checked, only merge operation will be performed on user data,
* if checkbox is unchecked, no value will be deleted in user data
*/
handler(neu) {
if (this.deleteAgent) {
this['userScript'] = this.getUserData({
installAgent: neu, osType: this.osType, deletePackage: this.deletePackage
});
this.refreshYamlEditor();
}
this.deleteAgent = true;
this.deletePackage = false;
}
},
osType(neu, old) {
this.installAgent = old === 'windows' ? true : this.installAgent;
this.userScript = this.getUserData({ installAgent: this.installAgent, osType: neu });
this.refreshYamlEditor();
},
userScript(neu, old) {
const hasInstallAgent = this.hasInstallAgent(neu, this.osType, this.installAgent);
if (hasInstallAgent !== this.installAgent) {
this.deleteAgent = false;
this.installAgent = hasInstallAgent;
}
},
sshKey(neu, old) {
const _diff = difference(old, neu);
// delete removed ssh key in userdata if needed
if (_diff.length > 0 && this.isCreate) {
this.deleteSSHFromUserData(_diff);
}
// refresh yaml editor to get the latest userScript
this.userScript = this.getUserData({ installAgent: this.installAgent, osType: this.osType });
this.refreshYamlEditor();
}
}
};