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 @@
+
+
+
+
+
+ {{ t('harvester.virtualMachine.sysprep.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ xmlContentValidationError }}
+
+
+
+
+
+
+
+
+
+ {{ t('harvester.virtualMachine.sysprep.createNewTitle') }}
+
+
+
+ {}"
+ />
+
+
+
+
+
+
+
+
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 = [];