mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-11-16 05:46:14 +08:00
* fix(vm): block VM creation with invalid CloudInit YAML content * fix(vm): add isWindows guard to validateCloudInit in template version * fix: validate Windows CloudInit YAML on save Co-authored-by: a110605 <5744158+a110605@users.noreply.github.com> * fix: validate cloud-init by format in the VM mixin Cloud-init picks the user data format from the first line, so scripts, MIME messages and Jinja templates are valid without being YAML. Parsing all user data blocked them, and it stopped any existing VM holding one from being saved, even with nothing changed. Only parse user data that starts with #cloud-config, and require a mapping for it and for network data, because plain words are valid YAML and were still saved. Both forms already use the VM mixin, so the check moves there and shares the header pattern with hasCloudConfigComment. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: save other user data formats as typed and check text without a header The form added #cloud-config to any user data that parses as YAML, so a script was saved with the header and its lines folded together, and Edit Config then failed the check on it. User data that starts with a script, MIME or another cloud-init or Cloudbase-Init format is now saved as typed, and any other text is checked as cloud-config, with or without the header, because the form adds the header when saving it. Editing only checks cloud-init that changed, so a VM saved before this change can still be edited with the cloud-init it already has. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> --------- Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: a110605 <5744158+a110605@users.noreply.github.com> Co-authored-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
622 lines
18 KiB
Vue
622 lines
18 KiB
Vue
<script>
|
|
import { mapGetters } from 'vuex';
|
|
import Tabbed from '@shell/components/Tabbed';
|
|
import Tab from '@shell/components/Tabbed/Tab';
|
|
import { Checkbox } from '@components/Form/Checkbox';
|
|
import CruResource from '@shell/components/CruResource';
|
|
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
|
import { set } from '@shell/utils/object';
|
|
import { Banner } from '@components/Banner';
|
|
import KeyValue from '@shell/components/form/KeyValue';
|
|
import NodeScheduling from '@shell/components/form/NodeScheduling';
|
|
import PodAffinity from '@shell/components/form/PodAffinity';
|
|
import UnitInput from '@shell/components/form/UnitInput';
|
|
import { randomStr } from '@shell/utils/string';
|
|
import { _CONFIG, _EDIT, _VIEW } from '@shell/config/query-params';
|
|
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
|
import CreateEditView from '@shell/mixins/create-edit-view';
|
|
import { AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
|
|
import { HCI } from '../types';
|
|
import VM_MIXIN from '../mixins/harvester-vm';
|
|
import Reserved from './kubevirt.io.virtualmachine/VirtualMachineReserved';
|
|
import Volume from './kubevirt.io.virtualmachine/VirtualMachineVolume';
|
|
import VMPerformanceOptions from './kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions';
|
|
import Network from './kubevirt.io.virtualmachine/VirtualMachineNetwork';
|
|
import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
|
|
import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel';
|
|
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
|
|
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
|
|
import Filesystem from './kubevirt.io.virtualmachine/VirtualMachineFilesystem';
|
|
|
|
export default {
|
|
name: 'HarvesterEditVMTemplate',
|
|
|
|
emits: ['update:templateValue'],
|
|
|
|
components: {
|
|
Tab,
|
|
SSHKey,
|
|
Volume,
|
|
VMPerformanceOptions,
|
|
Tabbed,
|
|
Network,
|
|
Checkbox,
|
|
CpuMemory,
|
|
CpuModel,
|
|
CruResource,
|
|
CloudConfig,
|
|
LabeledSelect,
|
|
NameNsDescription,
|
|
NodeScheduling,
|
|
PodAffinity,
|
|
Reserved,
|
|
UnitInput,
|
|
Banner,
|
|
KeyValue,
|
|
Filesystem,
|
|
},
|
|
|
|
mixins: [CreateEditView, VM_MIXIN],
|
|
|
|
inheritAttrs: false,
|
|
|
|
props: {
|
|
value: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
},
|
|
|
|
data() {
|
|
if (this.mode === _EDIT) {
|
|
this.value.cleanForNew();
|
|
}
|
|
|
|
const templateId = this.value.templateId || this.$route.query.templateId;
|
|
|
|
return {
|
|
templateId,
|
|
templateValue: null,
|
|
templateSpec: null,
|
|
versionName: '',
|
|
description: '',
|
|
defaultVersion: null,
|
|
isDefaultVersion: false,
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
...mapGetters({ t: 'i18n/t' }),
|
|
isConfig() {
|
|
return this.$route.query?.as === _CONFIG || this.isView;
|
|
},
|
|
|
|
realTemplateMode() {
|
|
return this.templateId ? _VIEW : this.mode;
|
|
},
|
|
|
|
secretNamePrefix() {
|
|
return this.templateValue?.metadata?.name;
|
|
},
|
|
|
|
filesystemEnabled() {
|
|
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
templateId: {
|
|
async handler(neu) {
|
|
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
|
|
let templateValue = templates.find( (V) => V.id === neu) || null;
|
|
let templateSpec = templateValue?.spec;
|
|
|
|
if (!templateValue) {
|
|
templateSpec = {
|
|
description: '',
|
|
defaultVersionId: ''
|
|
};
|
|
|
|
templateValue = await this.$store.dispatch('harvester/create', {
|
|
metadata: {
|
|
name: '',
|
|
namespace: ''
|
|
},
|
|
spec: templateSpec,
|
|
type: HCI.VM_TEMPLATE
|
|
});
|
|
}
|
|
|
|
this.templateValue = templateValue;
|
|
this.templateSpec = templateSpec;
|
|
},
|
|
immediate: true
|
|
}
|
|
},
|
|
|
|
created() {
|
|
this.registerAfterHook(async() => {
|
|
if (this.isDefaultVersion) {
|
|
// Set the default version according to annotation:[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME]
|
|
const versions = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_VERSION, opt: { force: true } });
|
|
|
|
const version = versions.find( (V) => V?.metadata?.annotations?.[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME] === this.customName);
|
|
|
|
if (version) {
|
|
try {
|
|
this.templateValue.defaultVersionId = version.id;
|
|
|
|
const data = [{
|
|
op: 'replace', path: '/spec/defaultVersionId', value: version.id
|
|
}];
|
|
|
|
await this.templateValue.patch( data, { url: this.templateValue.linkFor('view') });
|
|
} catch (err) {
|
|
return Promise.reject(new Error(err.message));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
mounted() {
|
|
this.imageId = this.diskRows[0]?.image || '';
|
|
this['filesystemRows'] = this.getFilesystemRows(this.value.spec.vm);
|
|
},
|
|
|
|
methods: {
|
|
updateCpuModel(value) {
|
|
if (!this.spec?.template?.spec?.domain?.cpu) {
|
|
set(this.spec, 'template.spec.domain.cpu', {});
|
|
}
|
|
|
|
if (value && value !== '') {
|
|
set(this.spec.template.spec.domain.cpu, 'model', value);
|
|
} else {
|
|
delete this.spec.template.spec.domain.cpu.model;
|
|
}
|
|
},
|
|
|
|
async saveVMT(buttonCb) {
|
|
this.errors = [];
|
|
this.parseVM();
|
|
|
|
this.validateCloudInit();
|
|
|
|
if (this.errors.length) {
|
|
buttonCb(false);
|
|
|
|
return;
|
|
}
|
|
|
|
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
|
|
const template = templates.find( (O) => O.metadata.name === this.templateValue.metadata.name);
|
|
|
|
try {
|
|
if (!this.templateId) {
|
|
if (this.templateValue?.metadata?.name) {
|
|
await this.templateValue.save();
|
|
} else {
|
|
this.errors = [this.t('validation.required', { key: this.t('harvester.vmTemplate.nameNsDescription.name') })];
|
|
buttonCb(false);
|
|
|
|
return;
|
|
}
|
|
} else {
|
|
template.save();
|
|
}
|
|
|
|
this.value.cleanForNew();
|
|
this.customName = randomStr(10);
|
|
this.value.metadata['annotations'] = {
|
|
...this.value.metadata.annotations,
|
|
[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME]: this.customName
|
|
};
|
|
|
|
const name = this.templateValue.metadata.name || template.metadata.name;
|
|
const namespace = this.templateValue.metadata.namespace || template.metadata.namespace;
|
|
|
|
if (this.isCreate) {
|
|
this.value.metadata.namespace = namespace;
|
|
}
|
|
|
|
this.value.spec['templateId'] = `${ namespace }/${ name }`;
|
|
|
|
// inherit labels and annotations so the VM gets them when created from the template
|
|
this.value.spec.vm.metadata.labels = {
|
|
...this.value.spec.vm.metadata.labels,
|
|
...this.value.metadata.labels
|
|
};
|
|
this.value.spec.vm.metadata.annotations = {
|
|
...this.value.spec.vm.metadata.annotations,
|
|
...this.value.metadata.annotations
|
|
};
|
|
const res = await this.value.save();
|
|
|
|
await this.saveSecret(res);
|
|
await this.applyHooks(AFTER_SAVE_HOOKS);
|
|
this.done();
|
|
} catch (e) {
|
|
this.errors = [e];
|
|
buttonCb(false);
|
|
}
|
|
},
|
|
|
|
onTabChanged({ tab }) {
|
|
if (tab.name === 'advanced') {
|
|
this.$refs.yamlEditor?.refresh();
|
|
}
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<CruResource
|
|
v-if="templateSpec && spec"
|
|
:done-route="doneRoute"
|
|
:resource="value"
|
|
:can-yaml="false"
|
|
:mode="mode"
|
|
:errors="errors"
|
|
:apply-hooks="applyHooks"
|
|
@finish="saveVMT"
|
|
@error="e=>errors=e"
|
|
>
|
|
<NameNsDescription
|
|
:value="templateValue"
|
|
:mode="realTemplateMode"
|
|
name-label="harvester.vmTemplate.nameNsDescription.name"
|
|
:namespaced="true"
|
|
@update:value="$emit('update:templateValue', $event)"
|
|
/>
|
|
|
|
<Checkbox
|
|
v-if="templateId"
|
|
v-model:value="isDefaultVersion"
|
|
class="mb-20"
|
|
:label="t('tableHeaders.defaultVersion')"
|
|
type="checkbox"
|
|
:mode="mode"
|
|
/>
|
|
|
|
<Tabbed
|
|
:side-tabs="true"
|
|
@changed="onTabChanged"
|
|
>
|
|
<Tab
|
|
name="Basics"
|
|
:label="t('harvester.vmTemplate.tabs.basics')"
|
|
>
|
|
<div class="row mb-20">
|
|
<div class="col span-6">
|
|
<LabeledSelect
|
|
v-model:value="osType"
|
|
label-key="harvester.virtualMachine.osType"
|
|
:mode="mode"
|
|
:options="OS"
|
|
:tooltip="isCreate ? t('harvester.virtualMachine.osTypeTooltip') : null"
|
|
/>
|
|
</div>
|
|
<div class="col span-6">
|
|
<CpuModel
|
|
:value="spec.template.spec.domain.cpu?.model || ''"
|
|
:mode="mode"
|
|
@update:value="updateCpuModel"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<CpuMemory
|
|
class="mb-20"
|
|
:cpu="cpu"
|
|
:memory="memory"
|
|
:max-cpu="maxCpu"
|
|
:max-memory="maxMemory"
|
|
:enable-hot-plug="cpuMemoryHotplugEnabled"
|
|
:disabled="isConfig"
|
|
@updateCpuMemory="updateCpuMemory"
|
|
/>
|
|
|
|
<div class="mb-20">
|
|
<Checkbox
|
|
v-if="value.cpuPinningFeatureEnabled"
|
|
v-model:value="cpuPinning"
|
|
class="check"
|
|
type="checkbox"
|
|
tooltip-key="harvester.virtualMachine.cpuPinning.tooltip"
|
|
label-key="harvester.virtualMachine.cpuPinning.label"
|
|
:mode="mode"
|
|
/>
|
|
|
|
<Checkbox
|
|
v-model:value="installUSBTablet"
|
|
class="check"
|
|
type="checkbox"
|
|
:label="t('harvester.virtualMachine.enableUsb')"
|
|
:mode="mode"
|
|
/>
|
|
|
|
<Checkbox
|
|
v-model:value="tpmEnabled"
|
|
class="check"
|
|
type="checkbox"
|
|
label-key="harvester.virtualMachine.systemOptions.tpm"
|
|
:mode="mode"
|
|
/>
|
|
|
|
<Checkbox
|
|
v-if="value.tpmPersistentStateFeatureEnabled && tpmEnabled"
|
|
v-model:value="tpmPersistentStateEnabled"
|
|
class="check"
|
|
type="checkbox"
|
|
:label="t('harvester.virtualMachine.systemOptions.tpmPersistentState')"
|
|
:mode="mode"
|
|
/>
|
|
|
|
<Checkbox
|
|
v-model:value="efiEnabled"
|
|
class="check"
|
|
type="checkbox"
|
|
:label="t('harvester.virtualMachine.systemOptions.efiEnabled')"
|
|
:mode="mode"
|
|
/>
|
|
|
|
<Checkbox
|
|
v-if="value.efiPersistentStateFeatureEnabled && efiEnabled"
|
|
v-model:value="efiPersistentStateEnabled"
|
|
class="check"
|
|
type="checkbox"
|
|
:label="t('harvester.virtualMachine.systemOptions.efiPersistentState')"
|
|
:mode="mode"
|
|
/>
|
|
|
|
<Checkbox
|
|
v-if="efiEnabled"
|
|
v-model:value="secureBoot"
|
|
class="check"
|
|
type="checkbox"
|
|
:label="t('harvester.virtualMachine.systemOptions.secureBoot')"
|
|
:mode="mode"
|
|
/>
|
|
</div>
|
|
|
|
<div class="mb-20">
|
|
<SSHKey
|
|
v-model:value="sshKey"
|
|
:create-namespace="true"
|
|
:namespace="templateValue.metadata.namespace"
|
|
:disable-create="isView"
|
|
:mode="mode"
|
|
@update:sshKey="updateSSHKey"
|
|
/>
|
|
</div>
|
|
</Tab>
|
|
|
|
<Tab
|
|
name="Volume"
|
|
:label="t('harvester.tab.volume')"
|
|
:weight="-1"
|
|
>
|
|
<Volume
|
|
v-model:value="diskRows"
|
|
:mode="mode"
|
|
:namespace="value.metadata.namespace"
|
|
:existing-volume-disabled="true"
|
|
/>
|
|
</Tab>
|
|
|
|
<Tab
|
|
name="Network"
|
|
:label="t('harvester.tab.network')"
|
|
:weight="-2"
|
|
>
|
|
<Network
|
|
v-model:value="networkRows"
|
|
v-model:network-boot-first="networkBootFirst"
|
|
:mode="mode"
|
|
/>
|
|
</Tab>
|
|
|
|
<Tab
|
|
name="nodeScheduling"
|
|
:label="t('workload.container.titles.nodeScheduling')"
|
|
:weight="-3"
|
|
>
|
|
<template #default>
|
|
<NodeScheduling
|
|
:mode="mode"
|
|
:value="spec.template.spec"
|
|
:nodes="nodesIdOptions"
|
|
/>
|
|
</template>
|
|
</Tab>
|
|
|
|
<Tab
|
|
:label="t('harvester.tab.vmScheduling')"
|
|
name="vmScheduling"
|
|
:weight="-4"
|
|
>
|
|
<template #default>
|
|
<PodAffinity
|
|
:mode="mode"
|
|
:value="spec.template.spec"
|
|
:nodes="nodes"
|
|
:all-namespaces-option-available="true"
|
|
:namespaces="filteredNamespaces"
|
|
:overwrite-labels="affinityLabels"
|
|
/>
|
|
</template>
|
|
</Tab>
|
|
|
|
<Tab
|
|
v-if="filesystemEnabled"
|
|
name="filesystem"
|
|
:label="t('harvester.tab.filesystem')"
|
|
:weight="-8"
|
|
>
|
|
<Filesystem
|
|
v-model:value="filesystemRows"
|
|
:mode="mode"
|
|
:namespace="templateValue.metadata.namespace"
|
|
/>
|
|
</Tab>
|
|
|
|
<Tab
|
|
name="labels"
|
|
:label="t('generic.labels')"
|
|
:weight="-9"
|
|
>
|
|
<Banner color="info">
|
|
<t k="harvester.virtualMachine.labels.banner" />
|
|
</Banner>
|
|
<KeyValue
|
|
key="labels"
|
|
:value="value.labels"
|
|
:add-label="t('labels.addLabel')"
|
|
:mode="mode"
|
|
:read-allowed="false"
|
|
:value-can-be-empty="true"
|
|
@update:value="value.setLabels($event)"
|
|
/>
|
|
</Tab>
|
|
|
|
<Tab
|
|
name="instanceLabel"
|
|
:label="t('harvester.tab.instanceLabel')"
|
|
:weight="-10"
|
|
>
|
|
<Banner color="info">
|
|
<t k="harvester.virtualMachine.instanceLabels.banner" />
|
|
</Banner>
|
|
<KeyValue
|
|
key="instance-labels"
|
|
:value="value.instanceLabels"
|
|
:protected-keys="value.systemLabels || []"
|
|
:toggle-filter="toggler"
|
|
:add-label="t('labels.addLabel')"
|
|
:mode="mode"
|
|
:read-allowed="false"
|
|
:value-can-be-empty="true"
|
|
@update:value="value.setInstanceLabels($event)"
|
|
/>
|
|
</Tab>
|
|
<Tab
|
|
name="annotations"
|
|
:label="t('harvester.tab.annotations')"
|
|
:weight="-11"
|
|
>
|
|
<Banner color="info">
|
|
<t k="harvester.virtualMachine.annotations.banner" />
|
|
</Banner>
|
|
<KeyValue
|
|
key="annotations"
|
|
:value="value.annotations"
|
|
:protected-keys="value.systemAnnotations || []"
|
|
:toggle-filter="true"
|
|
:add-label="t('labels.addAnnotation')"
|
|
:mode="mode"
|
|
:read-allowed="false"
|
|
:value-can-be-empty="true"
|
|
@update:value="value.setAnnotations($event)"
|
|
/>
|
|
</Tab>
|
|
<Tab
|
|
name="advanced"
|
|
:label="t('harvester.tab.advanced')"
|
|
:weight="-99"
|
|
>
|
|
<div class="row mb-20">
|
|
<div class="col span-6">
|
|
<LabeledSelect
|
|
v-model:value="runStrategy"
|
|
label-key="harvester.virtualMachine.runStrategy"
|
|
:options="runStrategies"
|
|
:mode="mode"
|
|
/>
|
|
</div>
|
|
<div class="col span-6">
|
|
<LabeledSelect
|
|
v-model:value="maintenanceStrategy"
|
|
label-key="harvester.virtualMachine.maintenanceStrategy.label"
|
|
:options="maintenanceStrategies"
|
|
:get-option-label="getMaintenanceStrategyOptionLabel"
|
|
:mode="mode"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-20">
|
|
<div class="col span-6">
|
|
<Reserved
|
|
:reserved-memory="reservedMemory"
|
|
:mode="mode"
|
|
@updateReserved="updateReserved"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-20">
|
|
<a
|
|
v-if="showAdvanced"
|
|
v-t="'harvester.generic.showMore'"
|
|
role="button"
|
|
@click="toggleAdvanced"
|
|
/>
|
|
<a
|
|
v-else
|
|
v-t="'harvester.generic.showMore'"
|
|
role="button"
|
|
@click="toggleAdvanced"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="showAdvanced">
|
|
<div class="row mb-20">
|
|
<div class="col span-6">
|
|
<UnitInput
|
|
v-model:value="terminationGracePeriodSeconds"
|
|
:suffix="terminationGracePeriodSeconds == 1 ? 'Second' : 'Seconds'"
|
|
:label="t('harvester.virtualMachine.terminationGracePeriodSeconds.label')"
|
|
:mode="mode"
|
|
@change="updateTerminationGracePeriodSeconds"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<VMPerformanceOptions
|
|
v-model:block-multi-queue="blockMultiQueue"
|
|
v-model:io-threads-policy="ioThreadsPolicy"
|
|
v-model:io-thread-count="ioThreadCount"
|
|
:has-virtio-disk="hasVirtioDisk"
|
|
:has-dedicated-iothread="hasDedicatedIOThread"
|
|
:mode="mode"
|
|
class="mb-20"
|
|
/>
|
|
|
|
<CloudConfig
|
|
ref="yamlEditor"
|
|
:mode="mode"
|
|
:user-script="userScript"
|
|
:namespace="templateValue.metadata.namespace"
|
|
:network-script="networkScript"
|
|
@updateUserData="updateUserData"
|
|
@updateNetworkData="updateNetworkData"
|
|
>
|
|
<template #after-user-data>
|
|
<Checkbox
|
|
v-model:value="installAgent"
|
|
class="check mb-20"
|
|
type="checkbox"
|
|
label-key="harvester.virtualMachine.installAgent"
|
|
:mode="mode"
|
|
/>
|
|
</template>
|
|
</CloudConfig>
|
|
</Tab>
|
|
</Tabbed>
|
|
</CruResource>
|
|
</template>
|