mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
feat: Unattend.xml support for Windows (#954)
Documentation: https://kubevirt.io/user-guide/user_workloads/startup_scripts/#sysprep Related to: https://github.com/harvester/harvester/issues/1836 Signed-off-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
parent
479c298f4a
commit
495e80267d
@ -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',
|
||||
};
|
||||
|
||||
@ -0,0 +1,346 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
import { SECRET } from '@shell/config/types';
|
||||
|
||||
const _AUTOUNATTENDXML = 'autounattend.xml';
|
||||
const _NEW = '_NEW';
|
||||
|
||||
export default {
|
||||
name: 'VirtualMachineWindowsSysprep',
|
||||
|
||||
components: {
|
||||
Banner,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
ModalWithCard,
|
||||
YamlEditor,
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
namespace: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
secretName: '',
|
||||
xmlContent: '',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
secretOptions: [],
|
||||
errors: [],
|
||||
secretName: '',
|
||||
xmlContent: '',
|
||||
newSecretName: '',
|
||||
newXmlContent: '',
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
await this.loadSecretsOptions();
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Force the loading of the XML content.
|
||||
this.secretName = this.value?.secretName || '';
|
||||
},
|
||||
|
||||
watch: {
|
||||
secretName() {
|
||||
if (this.secretName === _NEW) {
|
||||
this.isOpen = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the content from the given secret, or set empty value.
|
||||
this.xmlContent = this.secretName === '' ? '' : this.loadXmlContentFromSecret(this.secretName);
|
||||
|
||||
// Update the editor form field.
|
||||
this.updateValue();
|
||||
|
||||
// Update the property.
|
||||
this.update();
|
||||
},
|
||||
|
||||
xmlContent() {
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
editorMode() {
|
||||
return this.mode === 'view' ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === 'view';
|
||||
},
|
||||
|
||||
xmlContentValidationError() {
|
||||
return this.validateXmlContent(this.xmlContent);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update:value', {
|
||||
secretName: this.secretName,
|
||||
xmlContent: this.xmlContent,
|
||||
});
|
||||
},
|
||||
|
||||
updateValue() {
|
||||
this.$refs?.xmlEditor?.updateValue?.(this.xmlContent);
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.$refs?.xmlEditor?.refresh?.();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
if (this.secretName === _NEW) {
|
||||
this.secretName = '';
|
||||
}
|
||||
this.newSecretName = '';
|
||||
this.newXmlContent = '';
|
||||
this.errors = [];
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
async loadSecretsOptions() {
|
||||
const secrets = await this.$store.dispatch('harvester/findAll', { type: SECRET });
|
||||
|
||||
const templates = secrets
|
||||
.filter(
|
||||
(s) => s.metadata?.namespace === this.namespace &&
|
||||
s.decodedData?.[_AUTOUNATTENDXML]
|
||||
)
|
||||
.map((s) => ({
|
||||
label: s.metadata.name,
|
||||
value: `${ s.metadata.namespace }/${ s.metadata.name }`,
|
||||
}));
|
||||
|
||||
this.secretOptions = [
|
||||
{
|
||||
label: this.t('generic.none'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: this.t('harvester.virtualMachine.sysprep.createNew'),
|
||||
value: _NEW,
|
||||
},
|
||||
...templates,
|
||||
];
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
this.errors = [];
|
||||
|
||||
if (!this.newSecretName?.trim()) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.name') }, true));
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.newXmlContent?.trim()) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const xmlError = this.validateXmlContent(this.newXmlContent);
|
||||
|
||||
if (xmlError) {
|
||||
this.errors.push(xmlError);
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = await this.$store.dispatch('harvester/create', {
|
||||
type: SECRET,
|
||||
metadata: {
|
||||
name: this.newSecretName,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
});
|
||||
|
||||
secret.setData(_AUTOUNATTENDXML, this.newXmlContent);
|
||||
const res = await secret.save();
|
||||
|
||||
if (res?.metadata?.name) {
|
||||
await this.loadSecretsOptions();
|
||||
this.secretName = `${ this.namespace }/${ res.metadata.name }`;
|
||||
}
|
||||
|
||||
buttonCb(true);
|
||||
this.cancel();
|
||||
} catch (err) {
|
||||
this.errors = [err.message];
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
validateXmlContent(content) {
|
||||
if (!content?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(content, 'text/xml');
|
||||
const parseError = doc.querySelector('parsererror');
|
||||
|
||||
if (parseError) {
|
||||
return this.t('harvester.virtualMachine.sysprep.validation.invalidXml');
|
||||
}
|
||||
|
||||
// See https://learn.microsoft.com/en-us/windows-hardware/drivers/gpiobtn/implement-the-unattended-windows-setup-setting
|
||||
if (doc.documentElement.namespaceURI !== 'urn:schemas-microsoft-com:unattend') {
|
||||
return this.t('harvester.virtualMachine.sysprep.validation.invalidNamespace');
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
},
|
||||
|
||||
loadXmlContentFromSecret(id) {
|
||||
const secret = this.$store.getters['harvester/byId'](SECRET, id);
|
||||
|
||||
return secret?.decodedData?.[_AUTOUNATTENDXML] || '';
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 v-if="!isView">
|
||||
{{ t('harvester.virtualMachine.sysprep.title') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="!isView"
|
||||
class="text-muted mb-20"
|
||||
>
|
||||
<t
|
||||
k="harvester.virtualMachine.sysprep.description"
|
||||
:raw="true"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<!-- Secret Selection -->
|
||||
<div class="row mb-20">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="secretName"
|
||||
:options="secretOptions"
|
||||
:label="t('harvester.virtualMachine.sysprep.secret.label')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XML Editor -->
|
||||
<div class="mb-20">
|
||||
<Banner
|
||||
v-if="xmlContentValidationError"
|
||||
color="error"
|
||||
class="mb-10"
|
||||
>
|
||||
<i class="icon icon-error" />
|
||||
{{ xmlContentValidationError }}
|
||||
</Banner>
|
||||
|
||||
<div class="resource-xml">
|
||||
<YamlEditor
|
||||
ref="xmlEditor"
|
||||
v-model:value="xmlContent"
|
||||
:editor-mode="editorMode"
|
||||
class="xml-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="isOpen"
|
||||
name="createSysprepSecret"
|
||||
width="40%"
|
||||
:errors="errors"
|
||||
@finish="save"
|
||||
@close="cancel"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.virtualMachine.sysprep.createNewTitle') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<LabeledInput
|
||||
v-model:value="newSecretName"
|
||||
:label="t('harvester.virtualMachine.input.name')"
|
||||
class="mb-20"
|
||||
required
|
||||
@keydown.native.enter.prevent="()=>{}"
|
||||
/>
|
||||
|
||||
<div class="xml">
|
||||
<div class="resource-xml">
|
||||
<YamlEditor
|
||||
ref="createTemplate"
|
||||
v-model:value="newXmlContent"
|
||||
:editor-mode="editorMode"
|
||||
class="xml-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$xml-height: 350px;
|
||||
|
||||
:deep() .resource-xml {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& .xml-editor {
|
||||
flex: 1;
|
||||
min-height: $xml-height;
|
||||
font-family: monospace;
|
||||
|
||||
& .code-mirror .CodeMirror {
|
||||
min-height: $xml-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.xml {
|
||||
height: $xml-height;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@ -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 {
|
||||
</div>
|
||||
|
||||
<CloudConfig
|
||||
v-if="!isWindows"
|
||||
ref="yamlEditor"
|
||||
:user-script="userScript"
|
||||
:mode="mode"
|
||||
:os-type="osType"
|
||||
:view-code="isWindows"
|
||||
:namespace="value.metadata.namespace"
|
||||
:network-script="networkScript"
|
||||
@updateUserData="updateUserData"
|
||||
@ -1031,6 +1057,14 @@ export default {
|
||||
@updateDataTemplateId="updateDataTemplateId"
|
||||
/>
|
||||
|
||||
<WindowsSysprep
|
||||
v-if="isWindows"
|
||||
ref="sysprepConfig"
|
||||
v-model:value="sysprep"
|
||||
:mode="mode"
|
||||
:namespace="value.metadata.namespace"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="value.cpuPinningFeatureEnabled"
|
||||
v-model:value="cpuPinning"
|
||||
|
||||
@ -7,6 +7,7 @@ generic:
|
||||
inProgress: In Progress
|
||||
basic: Basic
|
||||
loading: Loading...
|
||||
none: None
|
||||
|
||||
unsupported:
|
||||
serverVersion: 'Current version: <code>{serverVersion}</code>'
|
||||
@ -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. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
|
||||
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. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>"
|
||||
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'
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user