From e0135343734c6adb2ffd9cf5be7bbd0368904e01 Mon Sep 17 00:00:00 2001 From: Alejandro Bonilla Date: Tue, 28 Jul 2026 05:32:30 -0400 Subject: [PATCH] feat: allow Cloud Config for Windows guests (Cloudbase-Init) (#984) * feat(vm): allow Cloud Config for Windows guests Currently the Cloud Config editor is completely hidden when a VM's OS Type is set to Windows, and no `cloudinitdisk` volume or secret is written to the VM spec even if the user has entered content. This blocks a common Windows-on-Harvester workflow: using Cloudbase-Init to run first-boot configuration (install VMDP, enable OpenSSH server, add authorized_keys, join a domain, run `runcmd` steps, etc.). Users on Windows today have to hand-edit the VM YAML after creation to attach a cloudinitdisk, which is fragile and undiscoverable. Changes: - Remove the `v-if="!isWindows"` on the CloudConfig component so the editor is available for every OS type. The Sysprep editor continues to render only for Windows, so Windows now gets both panels; Linux is unchanged. - Drop the `if (!this.isWindows)` guard around the cloudinitdisk volume serialization so the disk is attached whenever the user provided user-data or network-data, regardless of OS. - Drop the `|| this.isWindows` early-return in `saveSecret` so the cloud-init secret is persisted for Windows VMs too. - `getInitUserData` now returns a bare `#cloud-config\n` header for Windows instead of the Linux qemu-guest-agent runcmd template (which would fail on Cloudbase-Init). VMDP installs the QGA on Windows as a native service, so the runcmd path is not needed. The "Install guest agent" checkbox stays disabled for Windows because it specifically drives the Linux QGA-via-runcmd recipe. Fixes point 2 of harvester/harvester#11124. Signed-off-by: Alejandro Bonilla * fix(vm): do not clear cloud-config on isWindows watcher fire The isWindows watcher runs on every change of the isWindows computed property, including on mount when editing an existing Windows VM. Setting this['userScript'] and this['networkScript'] to undefined at that point wipes the cloud-config data that was just loaded from the existing VM's cloudinit secret, so the Advanced -> Cloud Config editor shows empty even though the VM has data on the wire. Remove the two clears - Cloud Config is now supported for Windows guests by this PR, so there is no reason to unset the user/network scripts when the OS type is Windows. sshKey and installAgent remain gated (SSH keys are injected via cloud-init on Linux only, and the qemu-guest-agent package install checkbox does not apply to Windows). Signed-off-by: Alejandro Bonilla Co-authored-by: Volker Theile * refactor(vm): Get CloudInit working for VM templates - Remove unnecessary osType watcher in cloud config. The watcher (see `pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig/DataTemplate.vue`) that cleared the cloud config template for Windows VMs is no longer necessary. - Automatically strip the CloudInit `User Data` properties `package_update` and `packages` for OS type `Windows`. Signed-off-by: Volker Theile --------- Signed-off-by: Alejandro Bonilla Signed-off-by: Volker Theile Co-authored-by: Volker Theile --- .../DataTemplate.vue | 6 -- .../edit/kubevirt.io.virtualmachine/index.vue | 1 - pkg/harvester/mixins/harvester-vm/index.js | 95 +++++++++++-------- 3 files changed, 58 insertions(+), 44 deletions(-) diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig/DataTemplate.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig/DataTemplate.vue index eb7aff86..d8fa57f4 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig/DataTemplate.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig/DataTemplate.vue @@ -68,12 +68,6 @@ export default { }, watch: { - osType(neu) { - if (neu === 'windows') { - this.id = ''; - } - }, - value(neu) { this.yamlScript = neu; }, diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue index 4e69d28a..b712cc5b 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue @@ -1062,7 +1062,6 @@ export default { D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) { - if (!this.isWindows) { - disks.push({ - name: 'cloudinitdisk', - disk: { bus: 'virtio' } - }); + disks.push({ + name: 'cloudinitdisk', + disk: { bus: 'virtio' } + }); - const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent }); - const cloudinitdisk = { - name: 'cloudinitdisk', - cloudInitNoCloud: {} - }; + 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); + 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; @@ -1144,6 +1142,16 @@ export default { }, getInitUserData(config) { + // Windows guests don't use qemu-guest-agent via systemd (VMDP installs + // the QGA as a Windows service), and the Linux `runcmd` recipe would + // fail on Cloudbase-Init. Return an empty string so `this.userData` + // stays falsy on Windows until the user actually enters cloud-config + // content — this prevents emitting a spurious cloudinitdisk volume + // and Secret for every Windows VM (Copilot review comment on #984). + if (config.osType === 'windows') { + return ''; + } + const _QGA_JSON = this.getMatchQGA(config.osType); const out = jsyaml.dump(_QGA_JSON); @@ -1346,13 +1354,22 @@ export default { */ 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]; + const items = doc.getIn([])?.items; + const firstKey = items?.[0]?.key; + const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config'); + const isFirstProp = firstKey?.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 + if (firstKey && hasCloudConfigComment && isFirstProp) { + const comment = firstKey.commentBefore; + + doc.deleteIn(paths); + + // Move the comment to the new first key; if no keys remain the comment is lost. + const newFirstKey = doc.getIn([])?.items?.[0]?.key; + + if (newFirstKey) { + newFirstKey.commentBefore = comment; + } } else { doc.deleteIn(paths); } @@ -1403,7 +1420,6 @@ export default { 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']); } @@ -1418,7 +1434,7 @@ export default { }, deleteQGA(config) { - const { osType, userDataDoc, deletePackage = false } = config; + const { osType, userDataDoc } = config; const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || ''; @@ -1427,6 +1443,15 @@ export default { const packages = userDataJSON?.packages || []; const runcmd = userDataJSON?.runcmd || []; + // Special handling of OS types. + let deletePackage = config.deletePackage ?? false; + + switch (osType) { + case 'windows': + deletePackage = true; + break; + } + if (Array.isArray(packages) && deletePackage) { const templateHasQGAPackage = this.convertToJson(userDataTemplateValue); @@ -1452,7 +1477,6 @@ export default { if (packages.length > 0) { userDataDoc.setIn(['packages'], packages); } else { - userDataDoc.setIn(['packages'], []); this.deleteYamlDocProp(userDataDoc, ['packages']); this.deleteYamlDocProp(userDataDoc, ['package_update']); } @@ -1485,7 +1509,7 @@ export default { }, async saveSecret(vm) { - if (!vm?.spec || !this.secretName || this.isWindows) { + if (!vm?.spec || !this.secretName) { return true; } @@ -1906,8 +1930,6 @@ export default { isWindows(val) { if (val) { this['sshKey'] = []; - this['userScript'] = undefined; - this['networkScript'] = undefined; this['installAgent'] = false; } }, @@ -1967,9 +1989,8 @@ export default { 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 = this.getUserData({ installAgent: this.installAgent, osType: neu }); - this['userScript'] = out; this.refreshYamlEditor(); },