Andy Lee 429d9e18ad
fix: stale cloud-init secret name reused after VM name change (#1012)
* fix: regenerate stale cloud-init secret name after VM name change

When creating a VM, the cloud-init/sysprep secret name was generated
once and cached for the component's lifetime (needNewSecret is always
false during create). If a first create attempt was rejected by the
API (e.g. duplicate name) and the user corrected the name and retried,
the VM was created successfully but reused the secret name derived
from the rejected attempt, and any subsequent secret-creation failure
surfaced as a stale error after the VM already existed.

Now the secret name (and Windows sysprep secret name) is regenerated
whenever the VM name prefix used to generate it no longer matches the
current name, and is proactively reset on a failed create attempt so
retries always start fresh.

Fixes harvester/harvester#11174

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: copilot review

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: remove console

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-17 14:29:01 +08:00

1997 lines
65 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, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
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: '' },
};
},
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 || 'ReadWriteMany';
},
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 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['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: 'ReadWriteMany', // root disk only support LHv1 volume, should be RWX
bus,
volumeName: '',
size,
type,
storageClassName: '',
image: this.imageId,
volumeMode: VOLUME_MODE.BLOCK,
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] || 'ReadWriteMany';
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,
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 }/`;
Object.keys(vm.metadata.annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
delete vm.metadata.annotations[key];
}
});
this.networkRows.forEach((row) => {
if (row.name && row.staticIp) {
vm.metadata.annotations[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
}
});
},
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.networkData)) {
if (!this.isWindows) {
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);
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;
}
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) {
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();
if (userDataYaml === '{}\n') {
// When the YAML parsed value is '{}\n', it means that the userData is empty, then undefined is returned.
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 };
} else if (R.type === CD_ROM) {
out.cdrom = { bus: R.bus };
}
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);
if (image) {
out.spec.storageClassName = image.storageClassName;
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 item = doc.getIn([])?.items[0];
const key = item?.key;
const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config');
const isMatchProp = key.source === paths[paths.length - 1];
if (key && hasCloudConfigComment && isMatchProp) {
// Comments are mounted on the next node and we should not delete the node containing cloud-config
} 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 {
userDataDoc.setIn(['packages'], []); // It needs to be set empty first, as it is possible that cloud-init comments are mounted on this node
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, deletePackage = false } = 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 || [];
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 {
userDataDoc.setIn(['packages'], []);
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 || this.isWindows) {
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['userScript'] = undefined;
this['networkScript'] = undefined;
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;
const out = old === 'windows' ? this.getInitUserData({ osType: neu }) : this.getUserData({ installAgent: this.installAgent, osType: neu });
this['userScript'] = out;
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();
}
}
};