diff --git a/pkg/harvester/edit/__tests__/harvesterhci.io.virtualmachinetemplateversion.test.js b/pkg/harvester/edit/__tests__/harvesterhci.io.virtualmachinetemplateversion.test.js
new file mode 100644
index 00000000..b5f6c6dd
--- /dev/null
+++ b/pkg/harvester/edit/__tests__/harvesterhci.io.virtualmachinetemplateversion.test.js
@@ -0,0 +1,36 @@
+import { HCI } from '@pkg/harvester/types';
+import VM_MIXIN from '@pkg/harvester/mixins/harvester-vm';
+import HarvesterEditTemplateVersion from '../harvesterhci.io.virtualmachinetemplateversion.vue';
+
+async function saveWith(userScript) {
+ const buttonCb = jest.fn();
+ const form = {
+ userScript,
+ errors: [],
+ t: (key) => key,
+ parseVM: jest.fn(),
+ validateCloudInit: VM_MIXIN.methods.validateCloudInit,
+ templateValue: { metadata: {} },
+ $store: { dispatch: jest.fn().mockResolvedValue([]) },
+ };
+
+ await HarvesterEditTemplateVersion.methods.saveVMT.call(form, buttonCb);
+
+ return { form, buttonCb };
+}
+
+describe('page: harvesterhci.io.virtualmachinetemplateversion edit', () => {
+ it('should stop the save when the cloud config is invalid', async() => {
+ const { form, buttonCb } = await saveWith('#cloud-config\nsome words\n');
+
+ expect(form.errors).toStrictEqual(['harvester.virtualMachine.cloudConfig.user.invalidYaml']);
+ expect(buttonCb).toHaveBeenCalledWith(false);
+ expect(form.$store.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('should go on with the save when the user data is a script', async() => {
+ const { form } = await saveWith('#!/bin/bash\napt-get update\n# start it\nsystemctl enable --now nginx\n');
+
+ expect(form.$store.dispatch).toHaveBeenCalledWith('harvester/findAll', { type: HCI.VM_TEMPLATE });
+ });
+});
diff --git a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue
index 2500cb11..19ee3d72 100644
--- a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue
+++ b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue
@@ -179,8 +179,17 @@ export default {
},
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);
diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/__tests__/index.test.js b/pkg/harvester/edit/kubevirt.io.virtualmachine/__tests__/index.test.js
new file mode 100644
index 00000000..1a124fd6
--- /dev/null
+++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/__tests__/index.test.js
@@ -0,0 +1,37 @@
+import VM_MIXIN from '@pkg/harvester/mixins/harvester-vm';
+import HarvesterEditVM from '../index.vue';
+
+function saveWith(userScript) {
+ const buttonCb = jest.fn();
+ const form = {
+ userScript,
+ errors: [],
+ isSingle: true,
+ t: (key) => key,
+ validateCPUMemory: jest.fn(),
+ validateWindowsSysprep: jest.fn(),
+ validateCloudInit: VM_MIXIN.methods.validateCloudInit,
+ saveSingle: jest.fn(),
+ };
+
+ HarvesterEditVM.methods.saveVM.call(form, buttonCb);
+
+ return { form, buttonCb };
+}
+
+describe('page: kubevirt.io.virtualmachine edit', () => {
+ it('should stop the save when the cloud config is invalid', () => {
+ const { form, buttonCb } = saveWith('#cloud-config\nsome words\n');
+
+ expect(form.errors).toStrictEqual(['harvester.virtualMachine.cloudConfig.user.invalidYaml']);
+ expect(buttonCb).toHaveBeenCalledWith(false);
+ expect(form.saveSingle).not.toHaveBeenCalled();
+ });
+
+ it('should save when the user data is a script', () => {
+ const { form, buttonCb } = saveWith('#!/bin/bash\napt-get update\n# start it\nsystemctl enable --now nginx\n');
+
+ expect(form.errors).toStrictEqual([]);
+ expect(form.saveSingle).toHaveBeenCalledWith(buttonCb);
+ });
+});
diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue
index 1a3379aa..c43b3a8d 100644
--- a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue
+++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue
@@ -381,6 +381,7 @@ export default {
this.validateCPUMemory();
this.validateWindowsSysprep();
+ this.validateCloudInit();
// block create VM flow if has validation errors
if (this.errors.length) {
diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml
index 01f7794a..6542a342 100644
--- a/pkg/harvester/l10n/en-us.yaml
+++ b/pkg/harvester/l10n/en-us.yaml
@@ -938,10 +938,12 @@ harvester:
label: User Data Template
title: "User Data:"
tip: "You can specify user data to configure an instance or run a configuration script during launch. If you launch more than one instance at a time, the user data is available to all the instances in that reservation. Learn more"
+ invalidYaml: 'Invalid User Data YAML syntax'
network:
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"
+ invalidYaml: 'Invalid Network Data YAML syntax'
sysprep:
title: Windows Unattended & 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"
diff --git a/pkg/harvester/mixins/harvester-vm/__tests__/index.test.js b/pkg/harvester/mixins/harvester-vm/__tests__/index.test.js
new file mode 100644
index 00000000..ee119285
--- /dev/null
+++ b/pkg/harvester/mixins/harvester-vm/__tests__/index.test.js
@@ -0,0 +1,140 @@
+import VM_MIXIN from '../index';
+import impl from '../impl';
+
+const USER_ERROR = 'harvester.virtualMachine.cloudConfig.user.invalidYaml';
+const NETWORK_ERROR = 'harvester.virtualMachine.cloudConfig.network.invalidYaml';
+const CLOUD_CONFIG = '#cloud-config\npackage_update: true\npackages:\n - qemu-guest-agent\n';
+const YAML_SCRIPT = '#!/bin/bash\napt-get update\napt-get install -y nginx\n';
+
+function validate({
+ userScript, networkScript, isEdit = false, savedCloudInit = {}
+}) {
+ const form = {
+ userScript,
+ networkScript,
+ isEdit,
+ savedCloudInit,
+ errors: [],
+ t: (key) => key,
+ };
+
+ VM_MIXIN.methods.validateCloudInit.call(form);
+
+ return form.errors;
+}
+
+function userDataFor(userScript) {
+ const form = {
+ userScript,
+ sshKey: [],
+ $store: { getters: { 'harvester/byId': () => undefined } },
+ ...impl.methods,
+ ...VM_MIXIN.methods,
+ };
+
+ return form.getUserData({ osType: 'linux', installAgent: false });
+}
+
+describe('mixin: harvester-vm', () => {
+ describe('validateCloudInit', () => {
+ it.each([
+ ['a word after the mapping', `${ CLOUD_CONFIG }some words\n`],
+ ['only words', '#cloud-config\nsome words\n'],
+ ['only words and no header', 'some words\n'],
+ ['a list', '#cloud-config\n- a\n- b\n'],
+ ['bad indentation', '#cloud-config\nusers:\n - name: a\n bad: b\n'],
+ ['an unclosed quote', '#cloud-config\npassword: "abc\n'],
+ ])('should block cloud-config user data with %s', (_, userScript) => {
+ expect(validate({ userScript })).toStrictEqual([USER_ERROR]);
+ });
+
+ it.each([
+ ['undefined', undefined],
+ ['empty', ''],
+ ['only the header', '#cloud-config\n'],
+ ['a valid cloud-config', CLOUD_CONFIG],
+ ['a mapping without the header', 'password: test\n'],
+ ])('should allow user data that is %s', (_, userScript) => {
+ expect(validate({ userScript })).toStrictEqual([]);
+ });
+
+ it.each([
+ ['a script that is not valid YAML', '#!/bin/bash\napt-get update\n# start it\nsystemctl enable --now nginx\n'],
+ ['a script that parses as YAML', YAML_SCRIPT],
+ ['a MIME message with a cloud-config part', 'Content-Type: multipart/mixed; boundary="b"\nMIME-Version: 1.0\n\n--b\nContent-Type: text/cloud-config\n\n#cloud-config\npackages:\n - nginx\n--b--\n'],
+ ['a Jinja template', '## template: jinja\n#cloud-config\n{% if v1.region == "us" %}\npackages:\n - a\n{% endif %}\n'],
+ ['a Cloudbase-Init PowerShell script', '#ps1_sysnative\n# enable RDP\nWrite-Host "done: ok"\n'],
+ ['a Cloudbase-Init batch script', 'rem cmd\nnet user admin Passw0rd /add\n'],
+ ['a cloud-config archive', '#cloud-config-archive\n- type: text/x-shellscript\n content: |\n #!/bin/sh\n'],
+ ])('should not parse user data that is %s', (_, userScript) => {
+ expect(validate({ userScript })).toStrictEqual([]);
+ });
+
+ it.each([
+ ['words', 'some words\n'],
+ ['a list', '- a\n- b\n'],
+ ['bad indentation', 'network:\n version: 1\n bad: x\n'],
+ ])('should block network data with %s', (_, networkScript) => {
+ expect(validate({ networkScript })).toStrictEqual([NETWORK_ERROR]);
+ });
+
+ it.each([
+ ['undefined', undefined],
+ ['empty', ''],
+ ['only whitespace', ' \n'],
+ ['only a comment', '# nothing yet\n'],
+ ['a valid config', 'network:\n version: 1\n config:\n - type: physical\n name: eth0\n'],
+ ])('should allow network data that is %s', (_, networkScript) => {
+ expect(validate({ networkScript })).toStrictEqual([]);
+ });
+
+ it('should report both fields when both are invalid', () => {
+ const errors = validate({ userScript: '#cloud-config\nsome words\n', networkScript: 'some words\n' });
+
+ expect(errors).toStrictEqual([USER_ERROR, NETWORK_ERROR]);
+ });
+
+ it('should save an edited VM with the cloud-init it already has', () => {
+ const savedCloudInit = {
+ userScript: '#cloud-config\n#!/bin/bash\napt-get update apt-get install -y nginx\n',
+ networkScript: 'some words\n',
+ };
+
+ const errors = validate({
+ ...savedCloudInit, isEdit: true, savedCloudInit
+ });
+
+ expect(errors).toStrictEqual([]);
+ });
+
+ it('should check cloud-init changed while editing', () => {
+ const savedCloudInit = { userScript: CLOUD_CONFIG, networkScript: '' };
+ const errors = validate({
+ userScript: '#cloud-config\nsome words\n', networkScript: 'some words\n', isEdit: true, savedCloudInit
+ });
+
+ expect(errors).toStrictEqual([USER_ERROR, NETWORK_ERROR]);
+ });
+
+ it('should check cloud-init a new VM takes from a template', () => {
+ const savedCloudInit = { userScript: '#cloud-config\nsome words\n' };
+
+ expect(validate({ ...savedCloudInit, savedCloudInit })).toStrictEqual([USER_ERROR]);
+ });
+ });
+
+ describe('getUserData', () => {
+ it.each([
+ ['a script that parses as YAML', YAML_SCRIPT],
+ ['a Cloudbase-Init batch script', 'rem cmd\nnet user admin Passw0rd /add\n'],
+ ['a Cloudbase-Init PowerShell script', '#ps1_sysnative\nWrite-Host done\n'],
+ ['a cloud-config archive', '#cloud-config-archive\n- type: text/x-shellscript\n content: echo hi\n'],
+ ])('should save %s as typed', (_, userScript) => {
+ expect(userDataFor(userScript)).toStrictEqual(userScript);
+ });
+
+ it('should add the header to cloud-config typed without one', () => {
+ expect(userDataFor('password: test\n')).toBe('#cloud-config\npassword: test\n');
+ });
+ });
+});
diff --git a/pkg/harvester/mixins/harvester-vm/impl.js b/pkg/harvester/mixins/harvester-vm/impl.js
index 8fd2fa92..3339073b 100644
--- a/pkg/harvester/mixins/harvester-vm/impl.js
+++ b/pkg/harvester/mixins/harvester-vm/impl.js
@@ -35,6 +35,18 @@ export const SSH_EXISTING_TYPE = {
EXISTING_ONLY_CLOUD: 'EXISTING_CLOUD',
};
+// Cloud-init and Cloudbase-Init run user data starting with one of these as a script, MIME or another format, not as cloud-config.
+const OTHER_USER_DATA_FORMATS = [
+ '#!', '#include', '#cloud-boothook', '#part-handler', '#cloud-config-archive', '#cloud-config-jsonp', '## template:',
+ '#ps1', 'rem cmd', '