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 aa4fd34f..eff46d47 100644 --- a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue +++ b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue @@ -177,8 +177,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 b7d6d44e..a2f01f43 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue @@ -379,6 +379,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 2fce5798..2c922eee 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -890,10 +890,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', '