mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 12:49:14 +00:00
* 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>
1997 lines
65 KiB
JavaScript
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();
|
|
}
|
|
}
|
|
};
|