From 48f508ea698099338fbff0212c0af079369e6f3a Mon Sep 17 00:00:00 2001 From: vichansson Date: Fri, 18 Oct 2024 10:59:25 +0300 Subject: [PATCH] F OpenNebula/one#6742: Implement loading of OS profiles (#3267) + define windows profile OpenNebula/one#6627 * Add windows profile * Adds OS Profiles parsing & loading - new /profiles endpoint - profiles are stored in /etc/fireedge/sunstone/profiles/ - YAML format only * Update profile loading * Load profile only once per step * Add indicator for last applied profile * Fix autocomplete controller equality comparison * Install new 'profiles' directory * Installs windows_optimized profile Signed-off-by: Victor Hansson --- install.sh | 4 + .../sunstone/profiles/windows_optimized.yaml | 53 +++++++++++ .../FormControl/AutocompleteController.js | 6 +- .../CreateForm/Steps/CustomVariables.js | 83 +++++++++++++--- .../booting/cpuModelSchema.js | 2 +- .../Steps/ExtraConfiguration/index.js | 52 +++++++++- .../CreateForm/Steps/General/index.js | 87 +++++++++++++++-- .../Steps/General/informationSchema.js | 48 ++++++++-- .../CreateForm/Steps/General/schema.js | 32 ++++++- .../CreateForm/Steps/General/styles.js | 8 ++ .../VmTemplate/CreateForm/Steps/index.js | 2 + .../src/client/constants/translates.js | 3 + .../src/client/constants/vmTemplate.js | 6 ++ .../client/containers/VmTemplates/Create.js | 35 ++++++- .../src/client/features/General/actions.js | 2 + .../src/client/features/General/hooks.js | 8 ++ .../src/client/features/General/slice.js | 14 +++ .../src/client/features/OneApi/system.js | 34 ++++++- src/fireedge/src/client/utils/merge.js | 27 ++++++ src/fireedge/src/client/utils/parser/index.js | 16 ++-- .../src/server/routes/api/system/functions.js | 55 +++++++++++ .../src/server/routes/api/system/index.js | 7 +- .../src/server/routes/api/system/routes.js | 14 +++ src/fireedge/src/server/utils/profiles.js | 94 +++++++++++++++++++ src/fireedge/src/server/utils/server.js | 3 + 25 files changed, 643 insertions(+), 52 deletions(-) create mode 100644 src/fireedge/etc/sunstone/profiles/windows_optimized.yaml create mode 100644 src/fireedge/src/server/utils/profiles.js diff --git a/install.sh b/install.sh index ce5ebb68ee..8f33c57e96 100755 --- a/install.sh +++ b/install.sh @@ -280,6 +280,7 @@ ETC_DIRS="$ETC_LOCATION/vmm_exec \ $ETC_LOCATION/fireedge/provision/providers.d \ $ETC_LOCATION/fireedge/provision/providers.d-extra \ $ETC_LOCATION/fireedge/sunstone \ + $ETC_LOCATION/fireedge/sunstone/profiles \ $ETC_LOCATION/fireedge/sunstone/admin \ $ETC_LOCATION/fireedge/sunstone/user \ $ETC_LOCATION/fireedge/sunstone/groupadmin \ @@ -888,6 +889,7 @@ INSTALL_FIREEDGE_ETC_FILES=( FIREEDGE_PROVISION_ETC_PROVIDERS:$ETC_LOCATION/fireedge/provision/providers.d FIREEDGE_PROVISION_ETC_PROVIDERS_EXTRA:$ETC_LOCATION/fireedge/provision/providers.d-extra FIREEDGE_SUNSTONE_ETC:$ETC_LOCATION/fireedge/sunstone + FIREEDGE_SUNSTONE_ETC_PROFILES:$ETC_LOCATION/fireedge/sunstone/profiles FIREEDGE_SUNSTONE_ETC_VIEW_ADMIN:$ETC_LOCATION/fireedge/sunstone/admin FIREEDGE_SUNSTONE_ETC_VIEW_USER:$ETC_LOCATION/fireedge/sunstone/user FIREEDGE_SUNSTONE_ETC_VIEW_CLOUD:$ETC_LOCATION/fireedge/sunstone/cloud @@ -2777,6 +2779,8 @@ FIREEDGE_PROVISION_ETC_PROVIDERS_EXTRA="src/fireedge/etc/provision/providers.d-e FIREEDGE_SUNSTONE_ETC="src/fireedge/etc/sunstone/sunstone-server.conf \ src/fireedge/etc/sunstone/sunstone-views.yaml" +FIREEDGE_SUNSTONE_ETC_PROFILES="src/fireedge/etc/sunstone/profiles/windows_optimized.yaml" + FIREEDGE_SUNSTONE_ETC_VIEW_ADMIN="src/fireedge/etc/sunstone/admin/vm-tab.yaml \ src/fireedge/etc/sunstone/admin/vm-template-tab.yaml \ src/fireedge/etc/sunstone/admin/vm-group-tab.yaml \ diff --git a/src/fireedge/etc/sunstone/profiles/windows_optimized.yaml b/src/fireedge/etc/sunstone/profiles/windows_optimized.yaml new file mode 100644 index 0000000000..412f0cb46f --- /dev/null +++ b/src/fireedge/etc/sunstone/profiles/windows_optimized.yaml @@ -0,0 +1,53 @@ +--- +# This profile defines some default windows optimizations +"General": + NAME: "Optimized Windows Profile" + +"Advanced options": + OsCpu: + OS: + ARCH: X86_64 + SD_DISK_BUS: scsi + MACHINE: host-passthrough + FIRMWARE: BIOS + FEATURES: + ACPI: "Yes" + PAE: "Yes" + APIC: "Yes" + HYPERV: "Yes" + LOCALTIME: "Yes" + GUEST_AGENT: "Yes" + VIRTIO_SCSI_QUEUES: "auto" + VIRTIO_BLK_QUEUES: "auto" + # IOTHREADS: + CPU_MODEL: + MODEL: "host-passthrough" + # FEATURES: + # - Tunable depending on host CPU support + # - + RAW: + DATA: |- + + + + + + + + + + + + + + + + + + + + + + + + VALIDATE: "Yes" diff --git a/src/fireedge/src/client/components/FormControl/AutocompleteController.js b/src/fireedge/src/client/components/FormControl/AutocompleteController.js index 8013083707..79437b2bf9 100644 --- a/src/fireedge/src/client/components/FormControl/AutocompleteController.js +++ b/src/fireedge/src/client/components/FormControl/AutocompleteController.js @@ -149,7 +149,11 @@ const AutocompleteController = memo( typeof option === 'object' ? option.text : option } isOptionEqualToValue={(option, value) => - typeof option === 'object' ? option.value === value : option === value + typeof option === 'object' && typeof value === 'object' + ? option?.value === value?.value + : typeof option === 'object' + ? option?.value === value || option?.text === value + : option === value } renderInput={({ inputProps, ...inputParams }) => ( { - const { setValue } = useFormContext() +const Content = ({ isUpdate }) => { + const { setValue, reset, getValues, watch } = useFormContext() + const { useLoadOsProfile } = useGeneralApi() const customVars = useWatch({ name: STEP_ID }) + const [fetchProfile] = useLazyGetOsProfilesQuery() + const osProfile = watch('general.OS_PROFILE') + const profileIsLoaded = + useSelector((state) => state?.general?.loadedOsProfile?.[STEP_ID]) || false + + // Prefill current step based on profile + useEffect(async () => { + if (!profileIsLoaded && osProfile && osProfile !== '-') { + try { + const { data: fetchedProfile } = await fetchProfile({ id: osProfile }) + const currentForm = getValues() + const mappedSteps = Object.fromEntries( + Object.entries(fetchedProfile).map(([key, value]) => [ + STEP_MAP[key] || key, + value, + ]) + ) + + const flattenByKeys = Object.keys(TAB_FORM_MAP) + mappedSteps.extra = flattenObjectByKeys( + mappedSteps.extra, + flattenByKeys + ) + + const mergedSteps = deepmerge(currentForm, mappedSteps) + + reset(mergedSteps, { + shouldDirty: true, + shouldTouch: true, + keepDefaultValues: true, + }) + + useLoadOsProfile({ stepId: STEP_ID }) + } catch (error) {} + } + }, [osProfile]) + const handleChangeAttribute = useCallback( (path, newValue) => { const newCustomVars = cloneObject(customVars) @@ -52,17 +99,27 @@ const Content = () => { ) } +Content.propTypes = { + isUpdate: PropTypes.bool, +} + /** * Custom variables about VM Template. * + * @param {object} params - Props + * @param {object} params.apiTemplateDataExtended - VM Template * @returns {object} Custom configuration step */ -const CustomVariables = () => ({ - id: STEP_ID, - label: T.CustomVariables, - resolver: object(), - optionsValidate: { abortEarly: false }, - content: Content, -}) +const CustomVariables = ({ apiTemplateDataExtended: vmTemplate }) => { + const isUpdate = !!vmTemplate?.NAME + + return { + id: STEP_ID, + label: T.CustomVariables, + resolver: object(), + optionsValidate: { abortEarly: false }, + content: (props) => Content({ ...props, isUpdate }), + } +} export default CustomVariables diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/cpuModelSchema.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/cpuModelSchema.js index 3861109fd7..c1885dccc3 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/cpuModelSchema.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/cpuModelSchema.js @@ -74,7 +74,7 @@ export const FEATURES = { const { data: hosts = [] } = useGetHostsQuery() const kvmFeatures = getKvmCpuFeatures(hosts) - return arrayToOptions(kvmFeatures) + return arrayToOptions(kvmFeatures, { addEmpty: false }) }, validation: array(string().trim()).default(() => []), } diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/index.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/index.js index 5c282bf396..452948a50d 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/index.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/index.js @@ -14,7 +14,8 @@ * limitations under the License. * * ------------------------------------------------------------------------- */ // eslint-disable-next-line no-unused-vars -import { useMemo, ReactElement } from 'react' +import { ReactElement, useMemo, useEffect } from 'react' +import { useSelector } from 'react-redux' import PropTypes from 'prop-types' // eslint-disable-next-line no-unused-vars import { useFormContext, FieldErrors } from 'react-hook-form' @@ -22,6 +23,7 @@ import { useFormContext, FieldErrors } from 'react-hook-form' import { useViews } from 'client/features/Auth' import { Translate } from 'client/components/HOC' +import { deepmerge, flattenObjectByKeys } from 'client/utils' import Tabs from 'client/components/Tabs' import Storage from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage' import Networking from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking' @@ -34,10 +36,18 @@ import InputOutput from 'client/components/Forms/VmTemplate/CreateForm/Steps/Ext import Numa from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa' import Backup from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/backup' +import { + STEP_MAP, + T, + RESOURCE_NAMES, + VmTemplate, + TAB_FORM_MAP, +} from 'client/constants' import { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General' import { SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema' import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper' -import { T, RESOURCE_NAMES, VmTemplate } from 'client/constants' +import { useLazyGetOsProfilesQuery } from 'client/features/OneApi/system' +import { useGeneralApi } from 'client/features/General' const VROUTER_DISABLED_TABS = ['network', 'pci'] @@ -78,8 +88,46 @@ const Content = ({ watch, formState: { errors }, control, + getValues, + reset, } = useFormContext() + const { useLoadOsProfile } = useGeneralApi() + const [fetchProfile] = useLazyGetOsProfilesQuery() const { view, getResourceView } = useViews() + const osProfile = watch('general.OS_PROFILE') + const profileIsLoaded = + useSelector((state) => state?.general?.loadedOsProfile?.[STEP_ID]) || false + + // Prefill current step based on profile + useEffect(async () => { + if (!profileIsLoaded && osProfile && osProfile !== '-') { + try { + const { data: fetchedProfile } = await fetchProfile({ id: osProfile }) + const currentForm = getValues() + const mappedSteps = Object.fromEntries( + Object.entries(fetchedProfile).map(([key, value]) => [ + STEP_MAP[key] || key, + value, + ]) + ) + + const flattenByKeys = Object.keys(TAB_FORM_MAP) + mappedSteps.extra = flattenObjectByKeys( + mappedSteps.extra, + flattenByKeys + ) + + const mergedSteps = deepmerge(currentForm, mappedSteps) + + reset(mergedSteps, { + shouldDirty: true, + shouldTouch: true, + keepDefaultValues: true, + }) + useLoadOsProfile({ stepId: STEP_ID }) + } catch (error) {} + } + }, [osProfile, profileIsLoaded]) const hypervisor = useMemo(() => watch(`${GENERAL_ID}.HYPERVISOR`), []) diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/index.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/index.js index 7ac850a53e..a51603f07b 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/index.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/index.js @@ -13,22 +13,36 @@ * See the License for the specific language governing permissions and * * limitations under the License. * * ------------------------------------------------------------------------- */ -import { useMemo, useEffect } from 'react' +import { useMemo, useEffect, useRef } from 'react' import PropTypes from 'prop-types' import { useWatch, useFormContext } from 'react-hook-form' import { useViews } from 'client/features/Auth' import FormWithSchema from 'client/components/Forms/FormWithSchema' import useStyles from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/styles' +import { useLazyGetOsProfilesQuery } from 'client/features/OneApi/system' import { SCHEMA, SECTIONS, } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/schema' import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper' -import { generateKey } from 'client/utils' -import { T, RESOURCE_NAMES, VmTemplate } from 'client/constants' +import { + generateKey, + deepmerge, + flattenObjectByKeys, + sentenceCase, + isDevelopment, +} from 'client/utils' +import { + T, + RESOURCE_NAMES, + VmTemplate, + STEP_MAP, + TAB_FORM_MAP, +} from 'client/constants' import { useGeneralApi } from 'client/features/General' import { set } from 'lodash' +import { Tr } from 'client/components/HOC' let generalFeatures @@ -40,18 +54,66 @@ const Content = ({ adminGroup, setFormData, isVrouter, + lastOsProfile, }) => { + const [fetchProfile] = useLazyGetOsProfilesQuery() const classes = useStyles() const { view, getResourceView } = useViews() const hypervisor = useWatch({ name: `${STEP_ID}.HYPERVISOR` }) - const { setFieldPath } = useGeneralApi() + const { enqueueSuccess, setFieldPath, useResetLoadOsProfile } = + useGeneralApi() + + const { getValues, setValue, watch, reset } = useFormContext() + const osProfile = watch('general.OS_PROFILE') + const currentProfile = useRef(osProfile) + + // Prefill current step based on profile + const profileSuccessMsg = + Tr(T.Loaded) + ' ' + Tr(T.Profile.toLowerCase()) + ': ' + + useEffect(async () => { + if ( + currentProfile.current !== osProfile && + osProfile && + osProfile !== '-' + ) { + try { + const { data: fetchedProfile } = await fetchProfile({ id: osProfile }) + const currentForm = getValues() + const mappedSteps = Object.fromEntries( + Object.entries(fetchedProfile).map(([key, value]) => [ + STEP_MAP[key] || key, + value, + ]) + ) + const flattenByKeys = Object.keys(TAB_FORM_MAP) + mappedSteps.extra = flattenObjectByKeys( + mappedSteps.extra, + flattenByKeys + ) + + const mergedSteps = deepmerge(currentForm, mappedSteps) + + reset(mergedSteps, { + shouldDirty: true, + shouldTouch: true, + keepDefaultValues: true, + }) + + useResetLoadOsProfile() + currentProfile.current = osProfile + enqueueSuccess(profileSuccessMsg + sentenceCase(osProfile)) + } catch (error) { + isDevelopment() && console.error('Failed to load profile: ', error) + } + } + }, [osProfile]) + useEffect(() => { setFieldPath(`general`) }, []) - const { getValues, setValue } = useFormContext() - // Create watch for vcpu const vcpuWatch = useWatch({ name: 'general.VCPU', @@ -80,7 +142,15 @@ const Content = ({ generalFeatures = features return ( - SECTIONS(hypervisor, isUpdate, features, oneConfig, adminGroup, isVrouter) + SECTIONS( + hypervisor, + isUpdate, + features, + oneConfig, + adminGroup, + isVrouter, + lastOsProfile + ) .filter( ({ id, required }) => required || sectionsAvailable.includes(id) ) @@ -119,6 +189,7 @@ const General = ({ }) => { const isUpdate = !!vmTemplate?.NAME const initialHypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR + const lastOsProfile = vmTemplate?.TEMPLATE?.OS_PROFILE || '' return { id: STEP_ID, @@ -136,6 +207,7 @@ const General = ({ oneConfig, adminGroup, isVrouter, + lastOsProfile, }), } } @@ -146,6 +218,7 @@ Content.propTypes = { adminGroup: PropTypes.bool, setFormData: PropTypes.func, isVrouter: PropTypes.bool, + lastOsProfile: PropTypes.string, } export default General diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/informationSchema.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/informationSchema.js index b542b933bc..d45b748569 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/informationSchema.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/informationSchema.js @@ -17,6 +17,7 @@ import { string, boolean } from 'yup' import Image from 'client/components/Image' import { useGetTemplateLogosQuery } from 'client/features/OneApi/logo' +import { useGetOsProfilesQuery } from 'client/features/OneApi/system' import { Field, arrayToOptions } from 'client/utils' import { T, @@ -80,14 +81,11 @@ export const LOGO = { values: () => { const { data: logos } = useGetTemplateLogosQuery() - return arrayToOptions( - [['-', DEFAULT_TEMPLATE_LOGO], ...Object.entries(logos || {})], - { - addEmpty: false, - getText: ([name]) => name, - getValue: ([, logo]) => logo, - } - ) + return arrayToOptions([...Object.entries(logos || {})], { + addEmpty: true, + getText: ([name]) => name, + getValue: ([, logo]) => logo, + }) }, renderValue: (value) => ( ({ + name: 'OS_PROFILE', + label: T.Profile, + type: INPUT_TYPES.AUTOCOMPLETE, + optionsOnly: true, + values: () => { + const { data: profiles = [] } = useGetOsProfilesQuery() + + return arrayToOptions(profiles, { + addEmpty: true, + getText: (val) => { + try { + const parts = val + ?.split('_') + ?.map((part) => part?.charAt(0).toUpperCase() + part?.slice(1)) + + return `${val === lastOsProfile ? '*' : ''} ${parts?.join(' ')}` + } catch (error) { + return val + } + }, + getValue: (val) => val, + }) + }, + validation: string() + .trim() + .notRequired() + .default(() => '-'), + grid: { md: 6 }, +}) + /** * @param {boolean} isUpdate - If `true`, the form is being updated * @returns {Field[]} List of information fields diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/schema.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/schema.js index 7b5f5ac91e..d4e8fe4965 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/schema.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/schema.js @@ -19,6 +19,7 @@ import { FIELDS as INFORMATION_FIELDS, HYPERVISOR_FIELD, VROUTER_FIELD, + OS_PROFILE, } from './informationSchema' import { MEMORY_FIELDS, @@ -45,6 +46,7 @@ import { T, HYPERVISORS, VmTemplateFeatures } from 'client/constants' * @param {object} oneConfig - Config of oned.conf * @param {boolean} adminGroup - User is admin or not * @param {boolean} isVrouter - VRouter template + * @param {string} lastOsProfile - Last used OS profile * @returns {Section[]} Fields */ const SECTIONS = ( @@ -53,7 +55,8 @@ const SECTIONS = ( features, oneConfig, adminGroup, - isVrouter + isVrouter, + lastOsProfile ) => [ { @@ -61,7 +64,18 @@ const SECTIONS = ( legend: T.Hypervisor, required: true, fields: disableFields( - [HYPERVISOR_FIELD(isUpdate), VROUTER_FIELD], + [HYPERVISOR_FIELD(isUpdate)], + '', + oneConfig, + adminGroup + ), + }, + { + id: 'osprofiles', + legend: T.OsProfile, + required: true, + fields: disableFields( + [OS_PROFILE(isUpdate, lastOsProfile), VROUTER_FIELD], '', oneConfig, adminGroup @@ -143,6 +157,7 @@ const SECTIONS = ( * @param {object} oneConfig - Config of oned.conf * @param {boolean} adminGroup - User is admin or not * @param {boolean} isVrouter - VRouter template + * @param {string} lastOsProfile - Last used OS profile * @returns {BaseSchema} Step schema */ const SCHEMA = ( @@ -151,10 +166,19 @@ const SCHEMA = ( features, oneConfig, adminGroup, - isVrouter + isVrouter, + lastOsProfile ) => getObjectSchemaFromFields( - SECTIONS(hypervisor, isUpdate, features, oneConfig, adminGroup, isVrouter) + SECTIONS( + hypervisor, + isUpdate, + features, + oneConfig, + adminGroup, + isVrouter, + lastOsProfile + ) .map(({ fields }) => fields) .flat() ) diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/styles.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/styles.js index d520b053fb..5632afbf1f 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/styles.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/General/styles.js @@ -26,6 +26,14 @@ const useStyles = makeStyles((theme) => ({ gridTemplateColumns: '1fr', }, }, + osprofiles: { + gridColumn: '1 / span 2', + gridRow: '2', + padding: theme.spacing(1), + [theme.breakpoints.down('sm')]: { + gridColumn: '1 / -1', + }, + }, hypervisor: { gridColumn: '1 / span 2', gridRow: '1', diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js index ce8d508ce8..886f7015c9 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js @@ -124,6 +124,8 @@ const Steps = createSteps([General, ExtraConfiguration, CustomVariables], { const defaultType = T.SelectCluster objectSchema[EXTRA_ID].CLUSTER_HOST_TYPE = defaultType + // Do not load a initial profile + delete objectSchema.general.OS_PROFILE const knownTemplate = schema.cast(objectSchema, { stripUnknown: false, diff --git a/src/fireedge/src/client/constants/translates.js b/src/fireedge/src/client/constants/translates.js index 3e574ebcb8..80d00db9f2 100644 --- a/src/fireedge/src/client/constants/translates.js +++ b/src/fireedge/src/client/constants/translates.js @@ -1220,6 +1220,8 @@ module.exports = { sort the suitable datastores for this VM`, Stripping: 'Stripping', LoadAware: 'Load-aware', + Loaded: 'Loaded', + Loading: 'Loading', Packing: 'Packing', /* VM Template schema - Backup */ BackupVolatileDisksQuestion: 'Backup volatile disks?', @@ -1335,6 +1337,7 @@ module.exports = { Inputs: 'Inputs', PciDevices: 'PCI Devices', Profile: 'Profile', + OsProfile: 'Operating System Profile', DeviceName: 'Device name', Device: 'Device', DeviceTooltip: diff --git a/src/fireedge/src/client/constants/vmTemplate.js b/src/fireedge/src/client/constants/vmTemplate.js index f4146042fb..fc235a01e9 100644 --- a/src/fireedge/src/client/constants/vmTemplate.js +++ b/src/fireedge/src/client/constants/vmTemplate.js @@ -165,6 +165,12 @@ export const TAB_FORM_MAP = { Backup: ['BACKUP_CONFIG'], } +export const STEP_MAP = { + 'Advanced options': 'extra', + General: 'general', + 'Custom Variables': 'custom-variables', +} + /** @enum {string} Methods on IP v4 options type */ export const IPV4_METHODS = { [T.Ipv4Static]: 'static', diff --git a/src/fireedge/src/client/containers/VmTemplates/Create.js b/src/fireedge/src/client/containers/VmTemplates/Create.js index 95d0eb6bc6..fada1c8a2f 100644 --- a/src/fireedge/src/client/containers/VmTemplates/Create.js +++ b/src/fireedge/src/client/containers/VmTemplates/Create.js @@ -14,6 +14,7 @@ * limitations under the License. * * ------------------------------------------------------------------------- */ import { ReactElement, useEffect } from 'react' +import { STEP_MAP, TAB_FORM_MAP, T } from 'client/constants' import { useStore } from 'react-redux' import { useHistory, useLocation } from 'react-router' @@ -28,7 +29,9 @@ import { useGetHostsQuery } from 'client/features/OneApi/host' import { useGetImagesQuery } from 'client/features/OneApi/image' import { useGetUsersQuery } from 'client/features/OneApi/user' import { useGetDatastoresQuery } from 'client/features/OneApi/datastore' +import { useLazyGetOsProfilesQuery } from 'client/features/OneApi/system' +import { deepmerge, isDevelopment } from 'client/utils' import { DefaultFormStepper, SkeletonStepsForm, @@ -38,7 +41,6 @@ import { PATH } from 'client/apps/sunstone/routesOne' import { jsonToXml } from 'client/models/Helper' import { filterTemplateData, transformActionsCreate } from 'client/utils/parser' -import { TAB_FORM_MAP, T } from 'client/constants' import { useSystemData } from 'client/features/Auth' @@ -68,6 +70,7 @@ function CreateVmTemplate() { useGeneralApi() const [update] = useUpdateTemplateMutation() const [allocate] = useAllocateTemplateMutation() + const [fetchProfile] = useLazyGetOsProfilesQuery() const { adminGroup, oneConfig } = useSystemData() const { data: apiTemplateDataExtended } = useGetTemplateQuery( @@ -90,7 +93,35 @@ function CreateVmTemplate() { try { // Get current state and modified fields const currentState = store.getState() - const modifiedFields = currentState.general?.modifiedFields + const osProfile = rawTemplate?.general?.OS_PROFILE + let modifiedFields = currentState.general?.modifiedFields + // This loads the OS profile and marks all fields of it as modified so they wont be filtered out + if (osProfile && osProfile !== '-') { + try { + const convertLeafValuesToBoolean = (obj) => + Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + if (typeof value === 'object' && value !== null) { + return [key, convertLeafValuesToBoolean(value)] + } + + return [key, value != null] + }) + ) + + const { data: fetchedProfile } = await fetchProfile({ id: osProfile }) + const mappedSteps = Object.fromEntries( + Object.entries(fetchedProfile).map(([step, values]) => [ + STEP_MAP[step] || step, + convertLeafValuesToBoolean(values), + ]) + ) + modifiedFields = deepmerge(modifiedFields, mappedSteps) + } catch (error) { + isDevelopment() && + console.error('Failed mapping profile filter: ', error) + } + } // Get the original template const existingTemplate = { diff --git a/src/fireedge/src/client/features/General/actions.js b/src/fireedge/src/client/features/General/actions.js index 266712aa16..9d7037fcdf 100644 --- a/src/fireedge/src/client/features/General/actions.js +++ b/src/fireedge/src/client/features/General/actions.js @@ -23,6 +23,8 @@ export const setSelectedIds = createAction('Set selected IDs') export const setUpdateDialog = createAction('Set update dialog') export const updateDisabledSteps = createAction('Set disabled steps') +export const setLoadOsProfile = createAction('Set load OS profile') +export const resetLoadOsProfile = createAction('Reset load OS profiles') export const dismissSnackbar = createAction('Dismiss snackbar') export const deleteSnackbar = createAction('Delete snackbar') export const setUploadSnackbar = createAction('Change upload snackbar') diff --git a/src/fireedge/src/client/features/General/hooks.js b/src/fireedge/src/client/features/General/hooks.js index c41598f0fc..1dca102f99 100644 --- a/src/fireedge/src/client/features/General/hooks.js +++ b/src/fireedge/src/client/features/General/hooks.js @@ -47,6 +47,14 @@ export const useGeneralApi = () => { }, resetModifiedFields: () => dispatch(actions.resetModifiedFields()), + useLoadOsProfile: (stepId) => { + dispatch(actions.setLoadOsProfile(stepId)) + }, + + useResetLoadOsProfile: () => { + dispatch(actions.resetLoadOsProfile()) + }, + // dismiss all if no key has been defined dismissSnackbar: (key) => dispatch(actions.dismissSnackbar({ key, dismissAll: !key })), diff --git a/src/fireedge/src/client/features/General/slice.js b/src/fireedge/src/client/features/General/slice.js index 1bf57a24f2..c6d1da3bbe 100644 --- a/src/fireedge/src/client/features/General/slice.js +++ b/src/fireedge/src/client/features/General/slice.js @@ -30,6 +30,7 @@ const initial = { isLoading: false, isFixMenu: false, isUpdateDialog: false, + loadedOsProfile: {}, upload: 0, notifications: [], selectedIds: [], @@ -85,6 +86,19 @@ const slice = createSlice({ state.isUpdateDialog = !!payload }) + .addCase(actions.setLoadOsProfile, (state, { payload }) => { + const { stepId } = payload + const exists = get(state.loadedOsProfile, stepId, false) + + if (!exists) { + set(state.loadedOsProfile, stepId, true) + } + }) + + .addCase(actions.resetLoadOsProfile, (state) => { + state.loadedOsProfile = {} + }) + /* FIELD MODIFICATIONS */ .addCase(actions.setFieldPath, (state, { payload }) => { state.fieldPath = payload diff --git a/src/fireedge/src/client/features/OneApi/system.js b/src/fireedge/src/client/features/OneApi/system.js index ca963df3d2..2e20f618ff 100644 --- a/src/fireedge/src/client/features/OneApi/system.js +++ b/src/fireedge/src/client/features/OneApi/system.js @@ -15,8 +15,8 @@ * ------------------------------------------------------------------------- */ import { Actions, Commands } from 'server/utils/constants/commands/system' import { - Actions as VmmActions, - Commands as VmmCommands, + Actions as SystemActions, + Commands as SystemCommands, } from 'server/routes/api/system/routes' import { Actions as SunstoneActions, @@ -122,6 +122,30 @@ const systemApi = oneApi.injectEndpoints({ keepUnusedDataFor: 600, }), + getOsProfiles: builder.query({ + /** + * Returns the OS Profiles or a specific profile's content. + * + * @param {object} params - Request params + * @returns {object} The set config options + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = SystemActions.PROFILES + const command = { name, ...SystemCommands[name] } + + return { params, command } + }, + providesTags: (response) => { + if (Array.isArray(response) && response?.length > 0) { + return response?.map((profile) => ({ type: SYSTEM, id: profile })) + } else { + return [{ type: SYSTEM, id: 'os_profile' }] + } + }, + keepUnusedDataFor: 600, + }), + getVmmConfig: builder.query({ /** * Returns the hypervisor VMM_EXEC config. @@ -131,8 +155,8 @@ const systemApi = oneApi.injectEndpoints({ * @throws Fails when response isn't code 200 */ query: (params) => { - const name = VmmActions.VMM_CONFIG - const command = { name, ...VmmCommands[name] } + const name = SystemActions.VMM_CONFIG + const command = { name, ...SystemCommands[name] } return { params, command } }, @@ -150,6 +174,8 @@ export const { useLazyGetOneConfigQuery, useGetVmmConfigQuery, useLazyGetVmmConfigQuery, + useGetOsProfilesQuery, + useLazyGetOsProfilesQuery, useGetSunstoneConfigQuery, useLazyGetSunstoneConfigQuery, useGetSunstoneViewsQuery, diff --git a/src/fireedge/src/client/utils/merge.js b/src/fireedge/src/client/utils/merge.js index 191363de60..3edf3950e2 100644 --- a/src/fireedge/src/client/utils/merge.js +++ b/src/fireedge/src/client/utils/merge.js @@ -127,3 +127,30 @@ deepmerge.all = function deepmergeAll(array, options) { return deepmerge(prev, next, options) }) } + +/** + * Flattens specified keys in an object by one level without mutating the original object. + * + * @param {object} obj - The object to flatten. + * @param {Array} keysToFlatten - The keys whose nested objects should be flattened. + * @returns {object} The new flattened object. + */ +export const flattenObjectByKeys = (obj, keysToFlatten) => { + let result = { ...obj } + + keysToFlatten.forEach((key) => { + if ( + key in result && + typeof result[key] === 'object' && + result[key] !== null + ) { + result = { + ...result, + ...result[key], + } + delete result[key] + } + }) + + return result +} diff --git a/src/fireedge/src/client/utils/parser/index.js b/src/fireedge/src/client/utils/parser/index.js index 8ade667c20..7b145a0e9a 100644 --- a/src/fireedge/src/client/utils/parser/index.js +++ b/src/fireedge/src/client/utils/parser/index.js @@ -34,19 +34,19 @@ import { import parsePayload from 'client/utils/parser/parseTemplatePayload' export { - templateToObject, + convertKeysToCase, + filterTemplateData, + isDeeplyEmpty, + parseAcl, parseApplicationToForm, + parseCustomInputString, parseFormToApplication, parseFormToDeployApplication, - parseAcl, parseNetworkString, - parseCustomInputString, - convertKeysToCase, - parseVmTemplateContents, - parseTouchedDirty, - isDeeplyEmpty, - filterTemplateData, parsePayload, + parseTouchedDirty, + parseVmTemplateContents, + templateToObject, transformActionsCreate, transformActionsInstantiate, } diff --git a/src/fireedge/src/server/routes/api/system/functions.js b/src/fireedge/src/server/routes/api/system/functions.js index 50d684094b..111cb0084b 100644 --- a/src/fireedge/src/server/routes/api/system/functions.js +++ b/src/fireedge/src/server/routes/api/system/functions.js @@ -23,6 +23,7 @@ const { } = require('server/utils/constants/commands/system') const { createTokenServerAdmin } = require('server/routes/api/auth/utils') const { getVmmConfig } = require('server/utils/vmm') +const { getProfiles } = require('server/utils/profiles') const { defaultEmptyFunction, httpMethod } = defaults const { ok, internalServerError, badRequest, notFound } = httpCodes @@ -163,7 +164,61 @@ const getVmmConfigHandler = async ( next() } +/** + * + * @param {object} res - http response + * @param {Function} next - express stepper + * @param {object} params - params of http request + * @param {object} [params.id="-1"] - fetch [id].yaml profile + * @returns {void} + */ +const getTemplateProfiles = async ( + res, + next = defaultEmptyFunction, + params = {} +) => { + try { + const { id } = params + const fetchAll = id === '-1' + const foundProfiles = await getProfiles(id) + + if (fetchAll) { + if (!Array.isArray(foundProfiles) || foundProfiles.length === 0) { + ;(res.locals ??= {}).httpCode = httpResponse( + notFound, + 'No OS profiles found', + '' + ) + + return next() + } + } else { + if (!foundProfiles || Object.keys(foundProfiles).length === 0) { + ;(res.locals ??= {}).httpCode = httpResponse( + notFound, + 'OS profile not found', + '' + ) + + return next() + } + } + + ;(res.locals ??= {}).httpCode = httpResponse(ok, foundProfiles) + } catch (error) { + const httpError = httpResponse( + internalServerError, + error?.message || 'Error loading OS profiles', + '' + ) + writeInLogger(httpError) + ;(res.locals ??= {}).httpCode = httpError + } + + next() +} module.exports = { getConfig, getVmmConfigHandler, + getTemplateProfiles, } diff --git a/src/fireedge/src/server/routes/api/system/index.js b/src/fireedge/src/server/routes/api/system/index.js index 8f2462ab5f..f6aae32980 100644 --- a/src/fireedge/src/server/routes/api/system/index.js +++ b/src/fireedge/src/server/routes/api/system/index.js @@ -18,9 +18,10 @@ const { Actions, Commands } = require('server/routes/api/system/routes') const { getConfig, getVmmConfigHandler, + getTemplateProfiles, } = require('server/routes/api/system/functions') -const { SYSTEM_CONFIG, VMM_CONFIG } = Actions +const { SYSTEM_CONFIG, VMM_CONFIG, PROFILES } = Actions module.exports = [ { @@ -31,4 +32,8 @@ module.exports = [ ...Commands[VMM_CONFIG], action: getVmmConfigHandler, }, + { + ...Commands[PROFILES], + action: getTemplateProfiles, + }, ] diff --git a/src/fireedge/src/server/routes/api/system/routes.js b/src/fireedge/src/server/routes/api/system/routes.js index e1ba417b92..38a05665c1 100644 --- a/src/fireedge/src/server/routes/api/system/routes.js +++ b/src/fireedge/src/server/routes/api/system/routes.js @@ -24,9 +24,12 @@ const { GET } = httpMethod const SYSTEM_CONFIG = 'system.config' const VMM_CONFIG = 'vmm.config' +const PROFILES = 'system.profiles' + const Actions = { SYSTEM_CONFIG, VMM_CONFIG, + PROFILES, } module.exports = { @@ -48,5 +51,16 @@ module.exports = { }, auth: true, }, + [PROFILES]: { + path: `${basepath}/profiles`, + httpMethod: GET, + params: { + id: { + from: query, + default: '-1', + }, + }, + auth: true, + }, }, } diff --git a/src/fireedge/src/server/utils/profiles.js b/src/fireedge/src/server/utils/profiles.js new file mode 100644 index 0000000000..b5872e5299 --- /dev/null +++ b/src/fireedge/src/server/utils/profiles.js @@ -0,0 +1,94 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2024, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +const fs = require('fs-extra') +const path = require('path') +const { global } = require('window-or-global') +const { parse } = require('yaml') + +/** + * Parse profile configuration. + * + * @param {string} fileContent - Content of the configuration file. + * @returns {object} Parsed os profile object. + */ +const parseProfileFileContent = (fileContent) => { + try { + return parse(fileContent) + } catch (error) { + throw new Error(`Error parsing profile: ${error.message}`) + } +} + +/** + * Get the contents of an OS template profile or list all profiles. + * + * @param {string} [id='-1'] - The name of the profile. If '-1' or undefined, list all profiles. + * @returns {Promise} Parsed os profile object or list of profile names. + */ +const getProfiles = async (id = '-1') => { + const vmTemplateProfilesDir = global?.paths?.OS_PROFILES + + if (!vmTemplateProfilesDir) { + throw new Error('OS_PROFILES path is not defined.') + } + + let profileFilePath + + if (id && id !== '-1') { + profileFilePath = path.join(vmTemplateProfilesDir, `${id}.yaml`) + } else { + profileFilePath = vmTemplateProfilesDir + } + + const exists = await fs.pathExists(profileFilePath) + + if (!exists) { + if (id && id !== '-1') { + throw new Error('OS profile not found') + } else { + throw new Error('No OS profiles found') + } + } + + try { + const stats = await fs.stat(profileFilePath) + + if (stats.isFile()) { + const fileContent = await fs.readFile(profileFilePath, 'utf-8') + + return parseProfileFileContent(fileContent) + } else if (stats.isDirectory()) { + const profiles = await fs.readdir(profileFilePath) + const yamlFiles = profiles + .filter((file) => path.extname(file) === '.yaml') + .map((file) => path.basename(file, '.yaml')) + + if (yamlFiles.length === 0) { + throw new Error('No OS profiles found') + } + + return yamlFiles + } else { + throw new Error(`Unknown file type: ${profileFilePath}`) + } + } catch (error) { + throw new Error(`${error?.message || 'OS Profile error'}`) + } +} + +module.exports = { + getProfiles, +} diff --git a/src/fireedge/src/server/utils/server.js b/src/fireedge/src/server/utils/server.js index a5ae6ab45f..2c70f5fee3 100644 --- a/src/fireedge/src/server/utils/server.js +++ b/src/fireedge/src/server/utils/server.js @@ -584,6 +584,9 @@ const genPathResources = () => { if (!global.paths.VMM_EXEC_CONFIG) { global.paths.VMM_EXEC_CONFIG = `${ETC_LOCATION}/vmm_exec` } + if (!global.paths.OS_PROFILES) { + global.paths.OS_PROFILES = `${ETC_LOCATION}/${defaultSunstonePath}/profiles` + } if (!global.paths.FIREEDGE_KEY_PATH) { global.paths.FIREEDGE_KEY_PATH = `${VAR_LOCATION}/.one/${defaultKeyFilename}` }