diff --git a/pkg/harvester/config/labels-annotations.js b/pkg/harvester/config/labels-annotations.js index 3d24ea15..fd90c2bd 100644 --- a/pkg/harvester/config/labels-annotations.js +++ b/pkg/harvester/config/labels-annotations.js @@ -83,4 +83,5 @@ export const HCI = { NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map', CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind', CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks', + WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep', }; diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineWindowsSysprep/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineWindowsSysprep/index.vue new file mode 100644 index 00000000..6783bba2 --- /dev/null +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineWindowsSysprep/index.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue index 6100be83..b675a824 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue @@ -29,6 +29,7 @@ import RestartVMDialog from '../../dialog/RestartVMDialog'; import PciDevices from './VirtualMachinePciDevices/index'; import AccessCredentials from './VirtualMachineAccessCredentials'; import CloudConfig from './VirtualMachineCloudConfig'; +import WindowsSysprep from './VirtualMachineWindowsSysprep'; import CpuMemory from './VirtualMachineCpuMemory'; import CpuModel from './VirtualMachineCpuModel'; import Network from './VirtualMachineNetwork'; @@ -59,6 +60,7 @@ export default { CpuMemory, CpuModel, CloudConfig, + WindowsSysprep, NodeScheduling, PodAffinity, AccessCredentials, @@ -324,6 +326,10 @@ export default { try { await this.saveSecret(res); await this.saveAccessCredentials(res); + + if (this.isWindows) { + await this.saveSysprepConfig(res); + } } catch (e) { this.errors.push(...exceptionToErrorsArray(e)); } @@ -369,6 +375,7 @@ export default { clear(this.errors); this.validateCPUMemory(); + this.validateWindowsSysprep(); // block create VM flow if has validation errors if (this.errors.length) { @@ -396,6 +403,24 @@ export default { } }, + validateWindowsSysprep() { + if (!this.isWindows) { + return; + } + + if (this.sysprep?.secretName?.trim?.() && !this.sysprep?.xmlContent?.trim?.()) { + this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true)); + + return; + } + + const sysprepValidationError = this.$refs.sysprepConfig?.xmlContentValidationError; + + if (sysprepValidationError) { + this.errors.push(sysprepValidationError); + } + }, + async saveSingle(buttonCb) { this.parseVM(); this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name; @@ -546,6 +571,7 @@ export default { onTabChanged({ tab }) { if (tab.name === 'advanced') { this.$refs.yamlEditor?.refresh(); + this.$refs.sysprepConfig?.refresh(); } }, @@ -1019,11 +1045,11 @@ export default { + + {serverVersion}' @@ -797,6 +798,17 @@ harvester: label: Network Data Template title: "Network Data:" tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. Learn more" + sysprep: + title: Windows Sysprep Configuration + description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. Learn more" + createNew: Create new... + createNewTitle: Create Windows Sysprep Template + xmlContent: autounattend.xml content + secret: + label: Windows Sysprep Template + validation: + invalidXml: Invalid XML syntax + invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend' scheduling: affinity: anyNode: 'Run virtual machine on any available node' diff --git a/pkg/harvester/mixins/harvester-vm/impl.js b/pkg/harvester/mixins/harvester-vm/impl.js index b1d6df83..3c2ae101 100644 --- a/pkg/harvester/mixins/harvester-vm/impl.js +++ b/pkg/harvester/mixins/harvester-vm/impl.js @@ -255,6 +255,29 @@ export default { return this.convertToJson(userData)?.ssh_authorized_keys || []; }, + getSysprepConfig(spec) { + const sysprepVolume = spec?.template?.spec?.volumes?.find( + (v) => v.name === 'sysprep' && v.sysprep?.secret + ); + + if (!sysprepVolume) { + return { secretName: '', xmlContent: '' }; + } + + const inStore = this.$store.getters['currentProduct'].inStore; + const namespace = this.value.metadata.namespace; + const secretName = sysprepVolume.sysprep.secret.name; + const secret = this.$store.getters[`${ inStore }/byId`]( + SECRET, + `${ namespace }/${ secretName }` + ); + + return { + secretName: `${ namespace }/${ secretName }`, + xmlContent: secret?.decodedData?.['autounattend.xml'] || '' + }; + }, + compareSSHValue(a = '', b = '') { const r = /(\r\n\t|\n|\r\t)|(\s*)/gm; diff --git a/pkg/harvester/mixins/harvester-vm/index.js b/pkg/harvester/mixins/harvester-vm/index.js index cfa5ab3e..387ab619 100644 --- a/pkg/harvester/mixins/harvester-vm/index.js +++ b/pkg/harvester/mixins/harvester-vm/index.js @@ -186,6 +186,7 @@ export default { terminationGracePeriodSeconds: '', cpuPinning: false, cpuModel: '', + sysprep: { secretName: '', xmlContent: '' }, }; }, @@ -445,6 +446,17 @@ export default { 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(); }, @@ -609,7 +621,8 @@ export default { out = sortBy(out, 'bootOrder'); - return out.filter( (O) => O.name !== 'cloudinitdisk'); + // Filter out cloudinitdisk and sysprep disk from UI display. + return out.filter( (O) => O.name !== 'cloudinitdisk' && O.name !== 'sysprep'); }, getNetworkRows(vm, config) { @@ -828,6 +841,32 @@ export default { this.secretName = this.generateSecretName(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. + if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) { + const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep'; + + this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`; + } + + // 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({ @@ -836,7 +875,6 @@ export default { }); const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent }); - const cloudinitdisk = { name: 'cloudinitdisk', cloudInitNoCloud: {} @@ -1482,6 +1520,42 @@ export default { } }, + 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 = [];