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 { 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, 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 } from '../../utils/vm'; import impl, { QGA_JSON, USB_TABLET } from './impl'; 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 }), 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, spec: null, osType: 'linux', sshKey: [], maintenanceStrategies, maintenanceStrategy: 'Migrate', runStrategies, runStrategy: 'RerunOnFailure', installAgent: true, hasCreateVolumes: [], installUSBTablet: true, networkScript: '', userScript: '', imageId: '', diskRows: [], networkRows: [], machineType: '', secretName: '', secretRef: null, showAdvanced: false, deleteAgent: true, memory: null, cpu: '', 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, }; }, 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, }; }); }, defaultStorageClass() { const defaultStorage = this.$store.getters[`${ this.inStore }/all`](STORAGE_CLASS).find( (O) => O.isDefault); return defaultStorage; }, 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 || '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.resourceType === HCI.VM_VERSION || this.isCreate; }, 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 }); 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 = value.machineType; const cpu = spec.template.spec.domain?.cpu?.cores; const memory = spec.template.spec.domain.resources.limits.memory; 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 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['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['hasCreateVolumes'] = hasCreateVolumes; this['networkRows'] = networkRows; this['imageId'] = imageId; this['diskRows'] = diskRows; 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 }Gi`; } 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: '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 (volume?.containerDisk) { // SOURCE_TYPE.CONTAINER source = SOURCE_TYPE.CONTAINER; container = volume.containerDisk.image; } 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 || '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 }Gi`, volumeMode: volumeMode || this.customVolumeMode, image, type, storageClassName, hotpluggable, volumeStatus, dataSource, namespace, isEncrypted, volumeBackups, }; }); } out = sortBy(out, 'bootOrder'); return out.filter( (O) => O.name !== 'cloudinitdisk'); }, 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 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, }; }); 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); }, 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.spec.template.spec.domain.cpu.cores = this.cpu; this.spec.template.spec.domain.resources.limits.cpu = this.cpu ? this.cpu.toString() : this.cpu; this.spec.template.spec.domain.resources.limits.memory = this.memory; this.spec.template.spec.terminationGracePeriodSeconds = this.terminationGracePeriodSeconds; // parse reserved memory const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm; if (!this.reservedMemory) { delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY]; } else { vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY] = this.reservedMemory; } 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; } }, parseDiskRows(disk) { const disks = []; const volumes = []; const diskNameLables = []; const volumeClaimTemplates = []; disk.forEach( (R, index) => { const prefixName = this.value.metadata?.name || ''; 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; } const _disk = this.parseDisk(R, index); const _volume = this.parseVolume(R, dataVolumeName); const _dataVolumeTemplate = this.parseVolumeClaimTemplate(R, dataVolumeName); disks.push(_disk); volumes.push(_volume); diskNameLables.push(dataVolumeName); if (R.source !== SOURCE_TYPE.CONTAINER) { volumeClaimTemplates.push(_dataVolumeTemplate); } }); if (!this.secretName || this.needNewSecret) { this.secretName = this.generateSecretName(this.secretNamePrefix); } 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); } } 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, }, }, 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 spec = { ...this.spec.template.spec, domain: { ...this.spec.template.spec.domain, devices: { ...this.spec.template.spec.domain.devices, interfaces, }, }, 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) { 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 decide 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; } return 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) { this['cpu'] = cpu; this['memory'] = memory; }, 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) { if (!String(R.size).includes('Gi') && R.size) { R.size = `${ R.size }Gi`; } 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); // const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent }); 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); } }, 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(); }, 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: { 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) { this.osType = osType; } } } }, secretRef: { handler(secret) { if (secret && this.resourceType !== HCI.BACKUP) { this.secretName = secret?.metadata.name; } }, immediate: true, deep: true }, isWindows(val) { if (val) { this['sshKey'] = []; this['userScript'] = 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) { let out = this.getUserData({ installAgent: neu, osType: this.osType, deletePackage: this.deletePackage }); if (neu) { const hasCloudComment = this.hasCloudConfigComment(out); if (!hasCloudComment) { out = `#cloud-config\n${ out }`; } } this['userScript'] = out; this.refreshYamlEditor(); } this.deleteAgent = true; this.deletePackage = false; } }, osType(neu) { const out = 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); if (_diff.length && this.isEdit) { this.deleteSSHFromUserData(_diff); } } } };