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>
366 lines
11 KiB
JavaScript
366 lines
11 KiB
JavaScript
import YAML from 'yaml';
|
|
import jsyaml from 'js-yaml';
|
|
import isEqual from 'lodash/isEqual';
|
|
import { clone } from '@shell/utils/object';
|
|
import { SECRET } from '@shell/config/types';
|
|
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
|
import { HCI } from '../../types';
|
|
import { parseVolumeClaimTemplates } from '../../utils/vm';
|
|
import { OS } from './index';
|
|
|
|
export const QGA_JSON = {
|
|
package_update: true,
|
|
packages: ['qemu-guest-agent'],
|
|
runcmd: [
|
|
[
|
|
'systemctl',
|
|
'enable',
|
|
'--now',
|
|
'qemu-guest-agent.service'
|
|
]
|
|
]
|
|
};
|
|
|
|
export const QGA_MAP = { default: 'qemu-guest-agent.service' };
|
|
|
|
export const USB_TABLET = [{
|
|
bus: 'usb',
|
|
name: 'tablet',
|
|
type: 'tablet'
|
|
}];
|
|
|
|
export const SSH_EXISTING_TYPE = {
|
|
EXISTING_ALL: 'EXISTING_ALL',
|
|
EXISTING_ONLY_ANNOTATION: 'EXISTING_ANNOTATION',
|
|
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', '<script>', '<powershell>', 'content-type:', 'mime-version:'
|
|
];
|
|
|
|
export function isCloudConfig(userScript) {
|
|
const start = (userScript || '').trimStart().toLowerCase();
|
|
|
|
return !OTHER_USER_DATA_FORMATS.some((format) => start.startsWith(format));
|
|
}
|
|
|
|
export default {
|
|
methods: {
|
|
hasCloudConfigComment(userScript) {
|
|
if (typeof userScript === 'string' && /^\s*#cloud-config(\s|$)/.test(userScript)) {
|
|
return true;
|
|
}
|
|
|
|
// Check that userData contains: #cloud-config
|
|
const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({});
|
|
const items = userDataDoc?.contents?.items || [];
|
|
|
|
let exist = false;
|
|
|
|
if (userDataDoc?.comment === 'cloud-config' || userDataDoc?.comment?.includes('cloud-config\n')) {
|
|
exist = true;
|
|
}
|
|
|
|
if (userDataDoc?.commentBefore === 'cloud-config' || userDataDoc?.commentBefore?.includes('cloud-config\n')) {
|
|
exist = true;
|
|
}
|
|
|
|
if (userDataDoc?.contents?.commentBefore === 'cloud-config' || userDataDoc?.contents?.commentBefore?.includes('cloud-config\n')) {
|
|
exist = true;
|
|
}
|
|
|
|
if (userDataDoc?.contents?.comment === 'cloud-config' || userDataDoc?.contents?.comment?.includes('cloud-config\n')) {
|
|
exist = true;
|
|
}
|
|
|
|
items.map((item) => {
|
|
const key = item.key;
|
|
|
|
if (key?.commentBefore === 'cloud-config' || key?.commentBefore?.includes('cloud-config\n')) {
|
|
exist = true;
|
|
}
|
|
});
|
|
|
|
return exist;
|
|
},
|
|
|
|
getSSHValue(id) {
|
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
|
const sshs = this.$store.getters[`${ inStore }/all`](HCI.SSH) || [];
|
|
|
|
return sshs.find( (O) => O.id === id)?.spec?.publicKey || undefined;
|
|
},
|
|
|
|
getOsType(vm) {
|
|
return vm.metadata?.labels?.[HCI_ANNOTATIONS.OS];
|
|
},
|
|
|
|
getMatchQGA(osType) {
|
|
const _QGA_JSON = clone(QGA_JSON);
|
|
let hasCustomQGA = false;
|
|
|
|
OS.forEach((O) => {
|
|
if (O.match) {
|
|
hasCustomQGA = O.match.find((type) => type === osType);
|
|
}
|
|
});
|
|
|
|
if (hasCustomQGA) {
|
|
_QGA_JSON.runcmd[0][3] = QGA_MAP[osType];
|
|
} else {
|
|
_QGA_JSON.runcmd[0][3] = QGA_MAP['default'];
|
|
}
|
|
|
|
return _QGA_JSON;
|
|
},
|
|
|
|
getSimilarRuncmd(osType) {
|
|
const _QGA_JSON = clone(QGA_JSON);
|
|
|
|
if (osType === 'openSUSE') {
|
|
_QGA_JSON.runcmd[0][3] = QGA_MAP['default'];
|
|
} else {
|
|
_QGA_JSON.runcmd[0][3] = QGA_MAP['suse'];
|
|
}
|
|
|
|
return _QGA_JSON.runcmd[0];
|
|
},
|
|
|
|
hasInstallAgent(userScript, osType, oldValue) {
|
|
let dataFormat = {};
|
|
const _QGA_JSON = this.getMatchQGA(osType);
|
|
|
|
try {
|
|
dataFormat = jsyaml.load(userScript) || {};
|
|
} catch (e) {
|
|
new Error('Function(hasInstallAgent) error');
|
|
|
|
return oldValue;
|
|
}
|
|
|
|
return dataFormat?.packages?.includes('qemu-guest-agent') && !!dataFormat?.runcmd?.find( (S) => Array.isArray(S) && S.join('-') === _QGA_JSON.runcmd[0].join('-'));
|
|
},
|
|
|
|
isInstallUSBTablet(spec) {
|
|
const inputs = spec?.template?.spec?.domain?.devices?.inputs;
|
|
|
|
if (Array.isArray(inputs)) {
|
|
return !!inputs.find((O) => {
|
|
return isEqual(O, USB_TABLET[0]);
|
|
});
|
|
} else {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
isEfiEnabled(spec) {
|
|
return !!(spec?.template?.spec?.domain?.firmware?.bootloader?.efi);
|
|
},
|
|
|
|
isTpmEnabled(spec) {
|
|
return !!spec?.template?.spec?.domain?.devices?.tpm;
|
|
},
|
|
|
|
isTPMPersistentStateEnabled(spec) {
|
|
return !!spec?.template?.spec?.domain?.devices?.tpm?.persistent;
|
|
},
|
|
|
|
isEFIPersistentStateEnabled(spec) {
|
|
return !!spec?.template?.spec?.domain?.firmware?.bootloader?.efi?.persistent;
|
|
},
|
|
|
|
isSecureBoot(spec) {
|
|
return !!spec?.template?.spec?.domain?.firmware?.bootloader?.efi?.secureBoot;
|
|
},
|
|
|
|
isCpuPinning(spec) {
|
|
return !!spec?.template?.spec?.domain?.cpu?.dedicatedCpuPlacement;
|
|
},
|
|
|
|
getCloudInitNoCloud(spec) {
|
|
const secret = this.getSecret(spec);
|
|
let userData = secret?.decodedData?.userdata;
|
|
let networkData = secret?.decodedData?.networkdata;
|
|
|
|
const cloudInitNoCloud = spec?.template?.spec?.volumes?.find( (V) => {
|
|
return V.name === 'cloudinitdisk';
|
|
})?.cloudInitNoCloud || {};
|
|
|
|
// If the value is not found inside the secret, the data may be written directly in the yaml
|
|
if (cloudInitNoCloud?.userData) {
|
|
userData = cloudInitNoCloud.userData;
|
|
this.saveUserDataAsClearText = true;
|
|
}
|
|
|
|
if (cloudInitNoCloud?.networkData) {
|
|
networkData = cloudInitNoCloud.networkData;
|
|
this.saveNetworkDataAsClearText = true;
|
|
}
|
|
|
|
return { userData, networkData };
|
|
},
|
|
|
|
getSecret(spec) {
|
|
const cloudInitNoCloud = spec?.template?.spec?.volumes?.find( (V) => {
|
|
return V.name === 'cloudinitdisk';
|
|
})?.cloudInitNoCloud || {};
|
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
|
const secrets = this.$store.getters[`${ inStore }/all`](SECRET) || [];
|
|
|
|
const secretName = cloudInitNoCloud?.secretRef?.name || cloudInitNoCloud?.networkDataSecretRef?.name;
|
|
|
|
const secret = secrets.find((s) => s.metadata.name === secretName);
|
|
|
|
return secret;
|
|
},
|
|
|
|
getAccessCredentials(spec) {
|
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
|
const secrets = this.$store.getters[`${ inStore }/all`](SECRET) || [];
|
|
const credentials = spec?.template?.spec?.accessCredentials || [];
|
|
const annotations = JSON.parse(spec.template.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_NAMES] || '[]');
|
|
|
|
return credentials.map((c) => {
|
|
const source = !!c.userPassword ? 'userPassword' : 'sshPublicKey';
|
|
const secretName = c[source]?.source?.secret?.secretName;
|
|
const secretRef = secrets.find((s) => s.metadata.name === secretName);
|
|
const out = {
|
|
source, username: '', newPassword: '', users: [], sshkeys: [], secretName, secretRef
|
|
};
|
|
|
|
if (!secretRef) {
|
|
out.secretRef = undefined;
|
|
} else if (source === 'userPassword') {
|
|
const username = Object.keys(secretRef?.data)[0];
|
|
const newPassword = secretRef.decodedData[username];
|
|
|
|
out.username = username;
|
|
out.newPassword = newPassword;
|
|
} else {
|
|
const users = c[source].propagationMethod.qemuGuestAgent.users;
|
|
const sshkeys = annotations?.[secretName];
|
|
|
|
out.users = users;
|
|
out.sshkeys = sshkeys;
|
|
}
|
|
|
|
return out;
|
|
});
|
|
},
|
|
|
|
getRootImageId(vm) {
|
|
const volumes = parseVolumeClaimTemplates(vm);
|
|
|
|
return volumes?.[0]?.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID] || '';
|
|
},
|
|
|
|
getSSHFromAnnotation(spec) {
|
|
const ids = spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.SSH_NAMES] || '[]';
|
|
|
|
return JSON.parse(ids);
|
|
},
|
|
|
|
convertToJson(script = '') {
|
|
let out = {};
|
|
|
|
try {
|
|
out = jsyaml.load(script);
|
|
} catch (e) {
|
|
new Error('Function(convertToJson) error');
|
|
}
|
|
|
|
return out;
|
|
},
|
|
|
|
getSSHFromUserData(userData) {
|
|
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;
|
|
|
|
return a.replace(r, '') === b.replace(r, '');
|
|
},
|
|
|
|
mergeAllSSHs(spec) {
|
|
const keys = this.getSSHFromAnnotation(spec);
|
|
const { userScript: userData } = this.getCloudInitNoCloud(spec);
|
|
|
|
if (!keys?.length < 0 && !userData) {
|
|
return [];
|
|
}
|
|
|
|
let out = [];
|
|
|
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
|
const allSSHs = this.$store.getters[`${ inStore }/all`](HCI.SSH) || [];
|
|
|
|
out = (keys || []).map((id) => {
|
|
const hasSSHResource = allSSHs.find((ssh) => ssh.id === id);
|
|
|
|
if (hasSSHResource) {
|
|
return {
|
|
id: hasSSHResource.id,
|
|
data: hasSSHResource,
|
|
type: SSH_EXISTING_TYPE.EXISTING_ALL
|
|
};
|
|
} else {
|
|
return {
|
|
id,
|
|
data: id,
|
|
type: SSH_EXISTING_TYPE.EXISTING_ONLY_ANNOTATION
|
|
};
|
|
}
|
|
});
|
|
|
|
const _userDataSSH = this.getSSHFromUserData(userData);
|
|
|
|
_userDataSSH.map( (sshValue) => {
|
|
const hasSSHResource = allSSHs.find((ssh) => this.compareSSHValue(sshValue, ssh.spec?.publicKey));
|
|
|
|
if (hasSSHResource && !out.find((O) => O.id === hasSSHResource.id)) {
|
|
out.push({
|
|
id: hasSSHResource.id,
|
|
data: hasSSHResource,
|
|
type: SSH_EXISTING_TYPE.EXISTING_ALL
|
|
});
|
|
} else if (!hasSSHResource) {
|
|
out.push({
|
|
id: 'Unknown',
|
|
data: sshValue,
|
|
type: SSH_EXISTING_TYPE.EXISTING_ONLY_CLOUD
|
|
});
|
|
}
|
|
});
|
|
|
|
return out;
|
|
},
|
|
},
|
|
};
|