From f8408469f7ee489f348e48048ff8611ca43aa5ca Mon Sep 17 00:00:00 2001 From: Francesco Torchia Date: Mon, 3 Jun 2024 17:56:18 +0200 Subject: [PATCH] Add Dashboard page Signed-off-by: Francesco Torchia --- .gitignore | 2 + .vscode/settings.json | 1 - pkg/harvester/components/HarvesterUpgrade.vue | 235 +++++ .../components/HarvesterUpgradeHeader.vue | 348 ++++++++ .../HarvesterUpgradeProgressBarList.vue | 109 +++ pkg/harvester/components/UpgradeInfo.vue | 42 + pkg/harvester/index.ts | 10 + .../list/harvesterhci.io.dashboard.vue | 840 ++++++++++++++++++ pkg/harvester/model-loader-require.lib.js | 5 + pkg/harvester/product.ts | 2 +- pkg/harvester/routing/extension-routing.js | 3 +- pkg/harvester/store/harvester-common.js | 183 ++++ .../store/harvester-store/actions.ts | 142 +++ .../store/harvester-store/getters.ts | 126 +++ pkg/harvester/store/harvester-store/index.ts | 39 + .../store/harvester-store/mutations.ts | 1 + .../store/harvester-store/subscribe-shims.ts | 36 + pkg/harvester/{config => }/types.ts | 0 pkg/harvester/validators/hash.js | 7 + pkg/harvester/validators/index.js | 23 + .../validators/monitoringAndLogging.js | 146 +++ pkg/harvester/validators/network.js | 15 + pkg/harvester/validators/setting.js | 61 ++ pkg/harvester/validators/vm-datavolumes.js | 38 + pkg/harvester/validators/vm-image.js | 32 + pkg/harvester/validators/vm.js | 228 +++++ pkg/harvester/validators/volume.js | 9 + 27 files changed, 2680 insertions(+), 3 deletions(-) create mode 100644 pkg/harvester/components/HarvesterUpgrade.vue create mode 100644 pkg/harvester/components/HarvesterUpgradeHeader.vue create mode 100644 pkg/harvester/components/HarvesterUpgradeProgressBarList.vue create mode 100644 pkg/harvester/components/UpgradeInfo.vue create mode 100644 pkg/harvester/list/harvesterhci.io.dashboard.vue create mode 100644 pkg/harvester/model-loader-require.lib.js create mode 100644 pkg/harvester/store/harvester-common.js create mode 100644 pkg/harvester/store/harvester-store/actions.ts create mode 100644 pkg/harvester/store/harvester-store/getters.ts create mode 100644 pkg/harvester/store/harvester-store/index.ts create mode 100644 pkg/harvester/store/harvester-store/mutations.ts create mode 100644 pkg/harvester/store/harvester-store/subscribe-shims.ts rename pkg/harvester/{config => }/types.ts (100%) create mode 100644 pkg/harvester/validators/hash.js create mode 100644 pkg/harvester/validators/index.js create mode 100644 pkg/harvester/validators/monitoringAndLogging.js create mode 100644 pkg/harvester/validators/network.js create mode 100644 pkg/harvester/validators/setting.js create mode 100644 pkg/harvester/validators/vm-datavolumes.js create mode 100644 pkg/harvester/validators/vm-image.js create mode 100644 pkg/harvester/validators/vm.js create mode 100644 pkg/harvester/validators/volume.js diff --git a/.gitignore b/.gitignore index 0b9eca4a..9e993b90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +dev + # compiled output /dist /dist-pkg diff --git a/.vscode/settings.json b/.vscode/settings.json index efb678eb..f642b867 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,6 @@ "babel.config.js": true, "jsconfig.json": true, "LICENSE": true, - "node_modules": true, "pkg/**/.shell": true, "pkg/**/node_modules": true, "yarn-error.log": true diff --git a/pkg/harvester/components/HarvesterUpgrade.vue b/pkg/harvester/components/HarvesterUpgrade.vue new file mode 100644 index 00000000..1eb775c1 --- /dev/null +++ b/pkg/harvester/components/HarvesterUpgrade.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/pkg/harvester/components/HarvesterUpgradeHeader.vue b/pkg/harvester/components/HarvesterUpgradeHeader.vue new file mode 100644 index 00000000..bb854673 --- /dev/null +++ b/pkg/harvester/components/HarvesterUpgradeHeader.vue @@ -0,0 +1,348 @@ + + + + diff --git a/pkg/harvester/components/HarvesterUpgradeProgressBarList.vue b/pkg/harvester/components/HarvesterUpgradeProgressBarList.vue new file mode 100644 index 00000000..b3fd7ae2 --- /dev/null +++ b/pkg/harvester/components/HarvesterUpgradeProgressBarList.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/pkg/harvester/components/UpgradeInfo.vue b/pkg/harvester/components/UpgradeInfo.vue new file mode 100644 index 00000000..192eb406 --- /dev/null +++ b/pkg/harvester/components/UpgradeInfo.vue @@ -0,0 +1,42 @@ + + + diff --git a/pkg/harvester/index.ts b/pkg/harvester/index.ts index e57e949c..0be76a0b 100644 --- a/pkg/harvester/index.ts +++ b/pkg/harvester/index.ts @@ -1,6 +1,9 @@ import { importTypes } from '@rancher/auto-import'; import { IPlugin } from '@shell/core/types'; import extensionRouting from './routing/extension-routing'; +import harvesterCommonStore from './store/harvester-common'; +import harvesterStore from './store/harvester-store'; +import customValidators from './validators'; // Init the package export default function(plugin: IPlugin): void { @@ -15,4 +18,11 @@ export default function(plugin: IPlugin): void { // Add Vue Routes plugin.addRoutes(extensionRouting); + + plugin.addDashboardStore(harvesterCommonStore.config.namespace, harvesterCommonStore.specifics, harvesterCommonStore.config); + plugin.addDashboardStore(harvesterStore.config.namespace, harvesterStore.specifics, harvesterStore.config, harvesterStore.init); + + // plugin.validators = customValidators; + + plugin.register('component', 'NavHeaderRight', () => import(/* webpackChunkName: "pkg/harvester/components" */ `./components/HarvesterUpgradeHeader.vue`)); } diff --git a/pkg/harvester/list/harvesterhci.io.dashboard.vue b/pkg/harvester/list/harvesterhci.io.dashboard.vue new file mode 100644 index 00000000..0b45ceb4 --- /dev/null +++ b/pkg/harvester/list/harvesterhci.io.dashboard.vue @@ -0,0 +1,840 @@ + + + + + diff --git a/pkg/harvester/model-loader-require.lib.js b/pkg/harvester/model-loader-require.lib.js new file mode 100644 index 00000000..06d26d93 --- /dev/null +++ b/pkg/harvester/model-loader-require.lib.js @@ -0,0 +1,5 @@ +// Import all shell models. We could try to be smart here and only pull in the one's that harvester uses... but there's a lot across a +// number of stores +export default function modelLoaderRequire(type) { + return require(`@shell/models/${ type }`); +} diff --git a/pkg/harvester/product.ts b/pkg/harvester/product.ts index 24c00af8..2c94b6f6 100644 --- a/pkg/harvester/product.ts +++ b/pkg/harvester/product.ts @@ -1,6 +1,6 @@ import { IPlugin } from '@shell/core/types'; import { PRODUCT_NAME, BLANK_CLUSTER, LOGO as logo } from './config/harvester'; -import { HCI } from './config/types'; +import { HCI } from './types'; export function init($plugin: IPlugin, store: any) { const { diff --git a/pkg/harvester/routing/extension-routing.js b/pkg/harvester/routing/extension-routing.js index 8bc14168..befe07f2 100644 --- a/pkg/harvester/routing/extension-routing.js +++ b/pkg/harvester/routing/extension-routing.js @@ -1,12 +1,13 @@ // Don't forget to create a VueJS page called index.vue in the /pages folder!!! import Dashboard from '../pages/index.vue'; +import Home from '../list/harvesterhci.io.dashboard.vue'; import { PRODUCT_NAME, BLANK_CLUSTER } from '../config/harvester'; const routes = [ { name: `${ PRODUCT_NAME }-c-cluster`, path: `/${ PRODUCT_NAME }/c/:cluster`, - component: Dashboard, + component: Home, meta: { product: PRODUCT_NAME, cluster: BLANK_CLUSTER, diff --git a/pkg/harvester/store/harvester-common.js b/pkg/harvester/store/harvester-common.js new file mode 100644 index 00000000..d76a86cd --- /dev/null +++ b/pkg/harvester/store/harvester-common.js @@ -0,0 +1,183 @@ +import Vue from 'vue'; +import Parse from 'url-parse'; +import { HCI } from '../types'; +import { PRODUCT_NAME } from '../config/harvester'; + +const state = function() { + return { + latestBundleId: '', + bundlePending: false, + showBundleModal: false, + bundlePercentage: 0, + uploadingImages: [], + uploadingImageError: {}, + }; +}; + +const mutations = { + setLatestBundleId(state, bundleId) { + state.latestBundleId = bundleId; + }, + + setBundlePending(state, value) { + state.bundlePending = value; + }, + + toggleBundleModal(state, value) { + state.showBundleModal = value; + }, + + setBundlePercentage(state, value) { + state.bundlePercentage = value; + }, + + uploadStart(state, value) { + state.uploadingImages.push(value); + }, + + uploadError(state, { name, message }) { + state.uploadingImageError[name] = message; + }, + + uploadEnd(state, value) { + const filtered = state.uploadingImages.filter(l => l !== value); + + Vue.set(state, 'uploadingImages', filtered); + } +}; + +const getters = { + getBundleId(state) { + return state.latestBundleId; + }, + + isBundlePending(state) { + return state.bundlePending; + }, + + isShowBundleModal(state) { + return state.showBundleModal; + }, + + getBundlePercentage(state) { + return state.bundlePercentage; + }, + + uploadingImages(state) { + return state.uploadingImages; + }, + + uploadingImageError(state) { + return name => state.uploadingImageError[name]; + }, + + getHarvesterClusterUrl: (state, getters, rootState, rootGetters) => (url) => { + // returns in multiple clusters: /k8s/clusters/${ clusterId }/${url} + // Directly return the passed url in a single cluster + if (rootGetters['isMultiCluster']) { + const clusterId = rootGetters['clusterId']; + const multipleClusterUrl = `/k8s/clusters/${ clusterId }/${ url }`; + + return `${ multipleClusterUrl }`; + } else { + return url; + } + } +}; + +const actions = { + async bundleProgress({ + state, dispatch, commit, rootGetters + }) { + const parse = Parse(window.history.href); + + const id = state.latestBundleId; + let bundleCrd = await dispatch( + 'harvester/find', + { type: HCI.SUPPORT_BUNDLE, id }, + { root: true } + ); + const t = rootGetters['i18n/t']; + + let count = 0; + + await commit('setBundlePending', true); + const timer = setInterval(async() => { + count = count + 1; + if (count % 3 === 0) { + // ws mayby disconnect + bundleCrd = await dispatch( + 'harvester/find', + { + type: HCI.SUPPORT_BUNDLE, + id, + opt: { force: true } + }, + { root: true } + ); + } + + if (bundleCrd.bundleState !== 'ready') { + bundleCrd = rootGetters['harvester/byId'](HCI.SUPPORT_BUNDLE, id); + const percentage = bundleCrd.precent; + + commit('setBundlePercentage', percentage); + + if (bundleCrd?.bundleMessage) { + const err = bundleCrd?.bundleMessage; + + dispatch( + 'growl/fromError', + { title: t('generic.notification.title.error'), err }, + { root: true } + ); + clearInterval(timer); + commit('setBundlePending', false); + commit('toggleBundleModal', false); + } + } else { + const name = id.split('/')[1]; + + commit('setBundlePercentage', 1); + + setTimeout(() => { + commit('toggleBundleModal', false); + commit('setBundlePending', false); + commit('setBundlePercentage', 0); + }, 600); + + if (rootGetters['isMultiCluster']) { + const clusterId = rootGetters['clusterId']; + const prefix = `/k8s/clusters/${ clusterId }`; + + window.location.href = `${ parse.origin }${ prefix }/v1/harvester/supportbundles/${ name }/download`; + } else { + window.location.href = `${ parse.origin }/v1/harvester/supportbundles/${ name }/download`; + } + + clearInterval(timer); + } + }, 1000); + } +}; + +const harvesterFactory = () => { + return { + state, + + getters: { ...getters }, + + mutations: { ...mutations }, + + actions: { ...actions } + }; +}; +const config = { + namespace: `${ PRODUCT_NAME }-common`, + isClusterStore: false +}; + +export default { + specifics: harvesterFactory(), + config +}; diff --git a/pkg/harvester/store/harvester-store/actions.ts b/pkg/harvester/store/harvester-store/actions.ts new file mode 100644 index 00000000..6b31ef4d --- /dev/null +++ b/pkg/harvester/store/harvester-store/actions.ts @@ -0,0 +1,142 @@ +import { ClusterNotFoundError } from '@shell/utils/error'; +import { SETTING } from '@shell/config/settings'; +import { COUNT, NAMESPACE, MANAGEMENT } from '@shell/config/types'; +import { allHash } from '@shell/utils/promise'; +import { DEV } from '@shell/store/prefs'; +import { HCI } from '../../types'; + +export default { + async loadCluster({ + state, commit, dispatch, getters, rootGetters, rootState + }: any, { id }: any) { + // This is a workaround for a timing issue where the mgmt cluster schema may not be available + // Try and wait until the schema exists before proceeding + await dispatch('management/waitForSchema', { type: MANAGEMENT.CLUSTER }, { root: true }); + + // See if it really exists + const cluster = await dispatch('management/find', { + type: MANAGEMENT.CLUSTER, + id, + opt: { url: `${ MANAGEMENT.CLUSTER }s/${ escape(id) }` } + }, { root: true }); + + let virtualBase = `/k8s/clusters/${ escape(id) }/v1/harvester`; + + if (id === 'local') { + virtualBase = `/v1/harvester`; + } + + if ( !cluster ) { + commit('clusterId', null, { root: true }); + commit('applyConfig', { baseUrl: null }); + throw new ClusterNotFoundError(id); + } + + // Update the Steve client URLs + commit('applyConfig', { baseUrl: virtualBase }); + + await Promise.all([ + dispatch('loadSchemas', true), + ]); + + dispatch('subscribe'); + + const projectArgs = { + type: MANAGEMENT.PROJECT, + opt: { + url: `${ MANAGEMENT.PROJECT }/${ escape(id) }`, + watchNamespace: id + } + }; + + const fetchProjects = async() => { + let limit = 30000; + const sleep = 100; + + while ( limit > 0 && !rootState.managementReady ) { + await setTimeout(() => {}, sleep); + limit -= sleep; + } + + if ( rootGetters['management/schemaFor'](MANAGEMENT.PROJECT) ) { + return dispatch('management/findAll', projectArgs, { root: true }); + } + }; + + if (id !== 'local' && getters['schemaFor'](MANAGEMENT.SETTING)) { // multi-cluster + const settings = await dispatch('findAll', { + type: MANAGEMENT.SETTING, + id: SETTING.SYSTEM_NAMESPACES, + opt: { url: `${ virtualBase }/${ MANAGEMENT.SETTING }s/`, force: true } + }); + + const systemNamespaces = settings?.find((x: any) => x.id === SETTING.SYSTEM_NAMESPACES); + + if (systemNamespaces) { + const namespace = (systemNamespaces.value || systemNamespaces.default)?.split(','); + + commit('setSystemNamespaces', namespace, { root: true }); + } + } + + const hash: { [key: string]: Promise} = { + projects: fetchProjects(), + virtualCount: dispatch('findAll', { type: COUNT }), + virtualNamespaces: dispatch('findAll', { type: NAMESPACE }), + settings: dispatch('findAll', { type: HCI.SETTING }), + clusters: dispatch('management/findAll', { + type: MANAGEMENT.CLUSTER, + opt: { force: true } + }, { root: true }), + }; + + if (getters['schemaFor'](HCI.UPGRADE)) { + hash.upgrades = dispatch('findAll', { type: HCI.UPGRADE }); + } + + const res: any = await allHash(hash); + + await dispatch('cleanNamespaces', null, { root: true }); + + commit('updateNamespaces', { + filters: [], + all: getters.filterNamespace(), + getters + }, { root: true }); + + // Solve compatibility with Rancher v2.6.x, fell remove these codes after not support v2.6.x + const definition = { + def: false, + parseJSON: true, + inheritFrom: DEV, + asUserPreference: true, + }; + + commit('prefs/setDefinition', { + name: 'view-in-api', + definition, + }, { root: true }); + commit('prefs/setDefinition', { + name: 'all-namespaces', + definition, + }, { root: true }); + commit('prefs/setDefinition', { + name: 'theme-shortcut', + definition, + }, { root: true }); + commit('prefs/setDefinition', { + name: 'plugin-developer', + definition, + }, { root: true }); + + const isMultiCluster = !(res.clusters.length === 1 && res.clusters[0].metadata?.name === 'local'); + + if (isMultiCluster) { + commit('managementChanged', { + ready: true, + isMultiCluster: true, + isRancher: true, + }, { root: true }); + } + }, +}; diff --git a/pkg/harvester/store/harvester-store/getters.ts b/pkg/harvester/store/harvester-store/getters.ts new file mode 100644 index 00000000..76d23f0d --- /dev/null +++ b/pkg/harvester/store/harvester-store/getters.ts @@ -0,0 +1,126 @@ +import { + NAMESPACE_FILTER_KINDS, + NAMESPACE_FILTER_ALL as ALL, + NAMESPACE_FILTER_ALL_ORPHANS as ALL_ORPHANS, +} from '@shell/utils/namespace-filter'; +import { MANAGEMENT } from '@shell/config/types'; +import { sortBy } from '@shell/utils/sort'; +import { filterBy } from '@shell/utils/array'; + +export default { + namespaceFilterOptions: (state: any, getters: any, rootState: any, rootGetters: any) => ({ + addNamespace, + divider, + notFilterNamespaces + }: any) => { + const out = [{ + id: ALL, + kind: NAMESPACE_FILTER_KINDS.SPECIAL, + label: rootGetters['i18n/t']('nav.ns.all'), + }]; + + divider(out); + + const namespaces = getters.filterNamespace(notFilterNamespaces); + + if (!rootGetters['isStandaloneHarvester'] && rootGetters['currentCluster'] && rootGetters['currentCluster']?.id !== '_') { + const cluster = rootGetters['currentCluster']; + let projects = rootGetters['management/all']( + MANAGEMENT.PROJECT + ); + + projects = sortBy(filterBy(projects, 'spec.clusterName', cluster.id), [ + 'nameDisplay', + ]).filter((project: any) => project.nameDisplay !== 'System'); + + const projectsById: any = {}; + const namespacesByProject: any = {}; + let firstProject = true; + + namespacesByProject['null'] = []; // For namespaces not in a project + for (const project of projects) { + projectsById[project.metadata.name] = project; + } + + for (const namespace of namespaces) { + let projectId = namespace.projectId; + + if (!projectId || !projectsById[projectId]) { + // If there's a projectId but that project doesn't exist, treat it like no project + projectId = 'null'; + } + + let entry = namespacesByProject[projectId]; + + if (!entry) { + entry = []; + namespacesByProject[namespace.projectId] = entry; + } + entry.push(namespace); + } + + for (const project of projects) { + const id = project.metadata.name; + + if (firstProject) { + firstProject = false; + } else { + divider(out); + } + + out.push({ + id: `project://${ id }`, + kind: 'project', + label: project.nameDisplay, + }); + + const forThisProject = namespacesByProject[id] || []; + + addNamespace(out, forThisProject); + } + + const orphans = namespacesByProject['null']; + + if (orphans.length) { + if (!firstProject) { + divider(out); + } + + out.push({ + id: ALL_ORPHANS, + kind: 'project', + label: rootGetters['i18n/t']('nav.ns.orphan'), + }); + + addNamespace(out, orphans); + } + } else { + addNamespace(out, namespaces); + } + + return out; + }, + + /** + * filter system/fleet/cattle namespace + */ + filterNamespace(state: any, getters: any, rootState: any, rootGetters: any, action: any) { + const allNamespaces = getters.all('namespace'); + + return (notFilterNamespaces: any = []) => { + return allNamespaces.filter((namespace: any) => { + return !namespace.isSystem || notFilterNamespaces.includes(namespace.id); + }); + }; + }, + + filterProject(state: any, getters: any, rootState: any, rootGetters: any) { + const projectsInAllClusters = rootGetters['management/all']( + MANAGEMENT.PROJECT + ); + const currentCluster = rootGetters['currentCluster']; + const clusterId = currentCluster.id; + + return projectsInAllClusters.filter((project: any) => project.spec.clusterName === clusterId && project.nameDisplay !== 'System'); + } +}; diff --git a/pkg/harvester/store/harvester-store/index.ts b/pkg/harvester/store/harvester-store/index.ts new file mode 100644 index 00000000..3d3b5bf3 --- /dev/null +++ b/pkg/harvester/store/harvester-store/index.ts @@ -0,0 +1,39 @@ +import { CoreStoreSpecifics, CoreStoreConfig } from '@shell/core/types'; + +import { SteveFactory, steveStoreInit } from '@shell/plugins/steve/index'; +import { PRODUCT_NAME } from '../../config/harvester'; +import getters from './getters'; +import mutations from './mutations'; +import actions from './actions'; + + +const harvesterFactory = (): CoreStoreSpecifics => { + const steveFactory = SteveFactory(); + + steveFactory.getters = { + ...steveFactory.getters, + ...getters, + }; + + steveFactory.mutations = { + ...steveFactory.mutations, + ...mutations, + }; + + steveFactory.actions = { + ...steveFactory.actions, + ...actions, + }; + + return steveFactory; +}; +const config: CoreStoreConfig = { + namespace: PRODUCT_NAME, + isClusterStore: true +}; + +export default { + specifics: harvesterFactory(), + config, + init: steveStoreInit +}; diff --git a/pkg/harvester/store/harvester-store/mutations.ts b/pkg/harvester/store/harvester-store/mutations.ts new file mode 100644 index 00000000..ff8b4c56 --- /dev/null +++ b/pkg/harvester/store/harvester-store/mutations.ts @@ -0,0 +1 @@ +export default {}; diff --git a/pkg/harvester/store/harvester-store/subscribe-shims.ts b/pkg/harvester/store/harvester-store/subscribe-shims.ts new file mode 100644 index 00000000..23ea5ea2 --- /dev/null +++ b/pkg/harvester/store/harvester-store/subscribe-shims.ts @@ -0,0 +1,36 @@ +import { _MERGE } from '@shell/plugins/dashboard-store/actions'; +import PollerSequential from '@shell/utils/poller-sequential'; + +const polling: any = {}; +const POLL_INTERVAL = 10000; + +export const actions = { + unsubscribe() { + Object.entries(polling).forEach(([type, poll]: [any, any]) => { + console.warn('Epinio: Polling stopped for: ', type); // eslint-disable-line no-console + poll.stop(); + delete polling[type]; + }); + }, + + watch({ dispatch, rootGetters }: any, { type }: any) { + if (rootGetters['type-map/isSpoofed'](type) || polling[type]) { + // Ignore spoofed + return; + } + + console.warn('Epinio: Polling started for: ', type);// eslint-disable-line no-console + + polling[type] = new PollerSequential( + async() => { + console.debug('Epinio: Polling: ', type); // eslint-disable-line no-console + // NOTE - In order for lists to automatically update resources opt to MERGE data in place instead of replace + // (in rancher land these are all handled individually, here we have bulk changes) + await dispatch('findAll', { type, opt: { force: true, load: _MERGE } }); + }, + POLL_INTERVAL, + 5 + ); + polling[type].start(); + } +}; diff --git a/pkg/harvester/config/types.ts b/pkg/harvester/types.ts similarity index 100% rename from pkg/harvester/config/types.ts rename to pkg/harvester/types.ts diff --git a/pkg/harvester/validators/hash.js b/pkg/harvester/validators/hash.js new file mode 100644 index 00000000..7c7af7a7 --- /dev/null +++ b/pkg/harvester/validators/hash.js @@ -0,0 +1,7 @@ +export function hashSHA512(value, getters, errors, validatorArgs, displayKey) { + if (!/^[a-f0-9]{128}$/i.test(value)) { + errors.push(getters['i18n/t']('harvester.validation.hash.sha512')); + } + + return errors; +} diff --git a/pkg/harvester/validators/index.js b/pkg/harvester/validators/index.js new file mode 100644 index 00000000..523e8a29 --- /dev/null +++ b/pkg/harvester/validators/index.js @@ -0,0 +1,23 @@ +import { imageUrl, fileRequired } from './vm-image'; +import { vmNetworks, vmDisks } from './vm'; +import { dataVolumeSize } from './vm-datavolumes'; +import { backupTarget, ntpServers } from './setting'; +import { volumeSize } from './volume'; +import { rancherMonitoring, rancherLogging } from './monitoringAndLogging'; +import { ranges } from './network'; +import { hashSHA512 } from './hash'; + +export default { + imageUrl, + dataVolumeSize, + vmNetworks, + vmDisks, + fileRequired, + backupTarget, + ntpServers, + volumeSize, + rancherMonitoring, + rancherLogging, + ranges, + hashSHA512, +}; diff --git a/pkg/harvester/validators/monitoringAndLogging.js b/pkg/harvester/validators/monitoringAndLogging.js new file mode 100644 index 00000000..8da49af0 --- /dev/null +++ b/pkg/harvester/validators/monitoringAndLogging.js @@ -0,0 +1,146 @@ +import jsyaml from 'js-yaml'; +import { get } from '@shell/utils/object'; + +export function rancherMonitoring(valuesContent, getters, errors, validatorArgs) { + const valueJson = jsyaml.load(valuesContent); + const requiredFields = [ + { + path: 'prometheus.prometheusSpec.scrapeInterval', + translationKey: 'monitoring.prometheus.config.scrape' + }, + { + path: 'prometheus.prometheusSpec.evaluationInterval', + translationKey: 'monitoring.prometheus.config.evaluation' + }, + { + path: 'prometheus.prometheusSpec.retention', + translationKey: 'monitoring.prometheus.config.retention' + }, + { + path: 'prometheus.prometheusSpec.retentionSize', + translationKey: 'monitoring.prometheus.config.retentionSize' + }, + { + path: 'prometheus.prometheusSpec.resources.requests.cpu', + translationKey: 'monitoring.prometheus.config.requests.cpu' + }, + { + path: 'prometheus.prometheusSpec.resources.requests.memory', + translationKey: 'monitoring.prometheus.config.requests.memory' + }, + { + path: 'prometheus.prometheusSpec.resources.limits.cpu', + translationKey: 'monitoring.prometheus.config.limits.cpu' + }, + { + path: 'prometheus.prometheusSpec.resources.limits.memory', + translationKey: 'monitoring.prometheus.config.limits.memory' + }, + { + path: 'prometheus-node-exporter.resources.requests.cpu', + translationKey: 'monitoring.prometheus.config.requests.cpu' + }, + { + path: 'prometheus-node-exporter.resources.requests.memory', + translationKey: 'monitoring.prometheus.config.requests.memory' + }, + { + path: 'prometheus-node-exporter.resources.limits.cpu', + translationKey: 'monitoring.prometheus.config.limits.cpu' + }, + { + path: 'prometheus-node-exporter.resources.limits.memory', + translationKey: 'monitoring.prometheus.config.limits.memory' + }, + { + path: 'grafana.resources.requests.cpu', + translationKey: 'monitoring.prometheus.config.requests.cpu' + }, + { + path: 'grafana.resources.requests.memory', + translationKey: 'monitoring.prometheus.config.requests.memory' + }, + { + path: 'grafana.resources.limits.cpu', + translationKey: 'monitoring.prometheus.config.limits.cpu' + }, + { + path: 'grafana.resources.limits.memory', + translationKey: 'monitoring.prometheus.config.limits.memory' + }, + { + path: 'alertmanager.alertmanagerSpec.retention', + translationKey: 'monitoring.prometheus.config.retention' + }, + { + path: 'alertmanager.alertmanagerSpec.resources.requests.cpu', + translationKey: 'monitoring.prometheus.config.requests.cpu' + }, + { + path: 'alertmanager.alertmanagerSpec.resources.requests.memory', + translationKey: 'monitoring.prometheus.config.requests.memory' + }, + { + path: 'alertmanager.alertmanagerSpec.resources.limits.cpu', + translationKey: 'monitoring.prometheus.config.limits.cpu' + }, + { + path: 'alertmanager.alertmanagerSpec.resources.limits.memory', + translationKey: 'monitoring.prometheus.config.limits.memory' + }, + ]; + + requiredFields.forEach((rule) => { + if (!get(valueJson, rule.path)) { + errors.push(getters['i18n/t']('validation.required', { key: getters['i18n/t'](rule.translationKey) })); + } + }); + + return errors; +} + +export function rancherLogging(valuesContent, getters, errors, validatorArgs) { + const valueJson = jsyaml.load(valuesContent); + const requiredFields = [ + { + path: 'fluentbit.resources.requests.cpu', + translationKey: 'monitoring.prometheus.config.requests.cpu' + }, + { + path: 'fluentbit.resources.requests.memory', + translationKey: 'monitoring.prometheus.config.requests.memory' + }, + { + path: 'fluentbit.resources.limits.cpu', + translationKey: 'monitoring.prometheus.config.limits.cpu' + }, + { + path: 'fluentbit.resources.limits.memory', + translationKey: 'monitoring.prometheus.config.limits.memory' + }, + { + path: 'fluentd.resources.requests.cpu', + translationKey: 'monitoring.prometheus.config.requests.cpu' + }, + { + path: 'fluentd.resources.requests.memory', + translationKey: 'monitoring.prometheus.config.requests.memory' + }, + { + path: 'fluentd.resources.limits.cpu', + translationKey: 'monitoring.prometheus.config.limits.cpu' + }, + { + path: 'fluentd.resources.limits.memory', + translationKey: 'monitoring.prometheus.config.limits.memory' + }, + ]; + + requiredFields.forEach((rule) => { + if (!get(valueJson, rule.path)) { + errors.push(getters['i18n/t']('validation.required', { key: getters['i18n/t'](rule.translationKey) })); + } + }); + + return errors; +} diff --git a/pkg/harvester/validators/network.js b/pkg/harvester/validators/network.js new file mode 100644 index 00000000..66a2db1b --- /dev/null +++ b/pkg/harvester/validators/network.js @@ -0,0 +1,15 @@ +export function ranges(ranges = [], getters, errors, validatorArgs) { + const t = getters['i18n/t']; + + if (ranges.length === 0) { + errors.push(t('validation.required', { key: t('harvester.ipPool.tabs.range') }, true)); + } + + ranges.map((r) => { + if (!r.subnet) { + errors.push(t('validation.required', { key: t('harvester.ipPool.subnet.label') }, true)); + } + }); + + return errors; +} diff --git a/pkg/harvester/validators/setting.js b/pkg/harvester/validators/setting.js new file mode 100644 index 00000000..9799ba1c --- /dev/null +++ b/pkg/harvester/validators/setting.js @@ -0,0 +1,61 @@ +export function backupTarget(value, getters, errors, validatorArgs) { + const t = getters['i18n/t']; + + if (!value) { + return errors; + } + + const parseValue = JSON.parse(value); + const type = parseValue.type; + + if (!type) { + return errors; + } + + if (type === 's3') { + if (!parseValue.accessKeyId) { + errors.push(t('validation.required', { key: 'accessKeyId' })); + } + + if (!parseValue.secretAccessKey) { + errors.push(t('validation.required', { key: 'secretAccessKey' })); + } + + if (!parseValue.bucketRegion) { + errors.push(t('validation.required', { key: 'bucketRegion' })); + } + + if (!parseValue.bucketName) { + errors.push(t('validation.required', { key: 'bucketName' })); + } + } + + if (!parseValue.endpoint && type !== 's3') { + errors.push(t('validation.required', { key: 'endpoint' })); + } + + return errors; +} + +export function ntpServers(value, getters, errors, validatorArgs) { + const { ntpServers } = JSON.parse(value); + const t = getters['i18n/t']; + const ipv4Regex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/; + const hostRegex = /^(?!:\/\/)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$/; + + if (!ntpServers) { + return errors; + } + + const ntpServersSet = new Set(ntpServers); + + if (ntpServers.length !== ntpServersSet.size) { + errors.push(t('harvester.setting.ntpServers.isDuplicate')); + } + + if (ntpServers.find(V => !ipv4Regex.test(V) && !hostRegex.test(V))) { + errors.push(t('harvester.setting.ntpServers.isNotIPV4')); + } + + return errors; +} diff --git a/pkg/harvester/validators/vm-datavolumes.js b/pkg/harvester/validators/vm-datavolumes.js new file mode 100644 index 00000000..96bee4eb --- /dev/null +++ b/pkg/harvester/validators/vm-datavolumes.js @@ -0,0 +1,38 @@ +import { formatSi, parseSi } from '@shell/utils/units'; + +export function dataVolumeSize(storage, getters, errors, validatorArgs) { + const t = getters['i18n/t']; + + if (!storage || storage === '') { + const key = t('harvester.volume.size'); + + errors.push(t('validation.required', { key })); + + return errors; + } + + const size = getSize(storage); + const max = 999999; + const integerRegex = /^[1-9]\d*$/; + + if (!integerRegex.test(size) || size > max) { + errors.push(t('harvester.validation.volume.sizeRange')); + } + + return errors; +} + +function getSize(storage) { + if (!storage) { + return null; + } + + const kibUnitSize = parseSi(storage); + + return formatSi(kibUnitSize, { + addSuffix: false, + increment: 1024, + minExponent: 3, + maxExponent: 3 + }); +} diff --git a/pkg/harvester/validators/vm-image.js b/pkg/harvester/validators/vm-image.js new file mode 100644 index 00000000..214b0fe5 --- /dev/null +++ b/pkg/harvester/validators/vm-image.js @@ -0,0 +1,32 @@ +import { HCI } from '@pkg/harvester/config/labels-annotations'; + +export const VM_IMAGE_FILE_FORMAT = ['qcow', 'qcow2', 'raw', 'img', 'iso']; + +export function imageUrl(url, getters, errors, validatorArgs, type) { + const t = getters['i18n/t']; + + if (!url || url === '') { + return errors; + } + + const suffixName = url.split('/').pop(); + const fileSuffix = suffixName.split('.').pop().toLowerCase(); + + if (!VM_IMAGE_FILE_FORMAT.includes(fileSuffix)) { + const tipString = type === 'file' ? 'harvester.validation.image.ruleFileTip' : 'harvester.validation.image.ruleTip'; + + errors.push(t(tipString)); + } + + return errors; +} + +export function fileRequired(annotations = {}, getters, errors, validatorArgs, type) { + const t = getters['i18n/t']; + + if (!annotations[HCI.IMAGE_NAME]) { + errors.push(t('validation.required', { key: t('harvester.image.fileName') })); + } + + return errors; +} diff --git a/pkg/harvester/validators/vm.js b/pkg/harvester/validators/vm.js new file mode 100644 index 00000000..c1ded608 --- /dev/null +++ b/pkg/harvester/validators/vm.js @@ -0,0 +1,228 @@ +import { PVC } from '@shell/config/types'; +import { isValidMac, isValidDNSLabelName } from '@pkg/utils/regular'; +import { SOURCE_TYPE } from '@pkg/config/harvester-map'; +import { parseVolumeClaimTemplates } from '@pkg/utils/vm'; + +const maxNameLength = 63; + +export function vmNetworks(spec, getters, errors, validatorArgs) { + const { domain: { devices: { interfaces } }, networks } = spec; + + const networkNames = []; + + interfaces.map( (I, index) => { + const N = networks.find( N => I.name === N.name); + const prefix = (I.name || N.name) || `Network ${ index + 1 }`; + + const type = getters['i18n/t']('harvester.fields.network'); + + const lowerType = getters['i18n/t']('harvester.validation.vm.network.lowerType'); + const upperType = getters['i18n/t']('harvester.validation.vm.network.upperType'); + + validName(getters, errors, I.name, networkNames, prefix, type, lowerType, upperType); + + if (N.multus) { + if (!N.multus.networkName) { + const key = getters['i18n/t']('harvester.fields.network'); + const message = getters['i18n/t']('validation.required', { key }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + } + + if (I.macAddress && !isValidMac(I.macAddress) && !N.pod) { + const message = getters['i18n/t']('harvester.validation.vm.network.macFormat'); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + }); + + return errors; +} + +export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value) { + const isVMTemplate = validatorArgs.includes('isVMTemplate'); + const data = isVMTemplate ? this.value.spec.vm : value; + + const _volumeClaimTemplates = parseVolumeClaimTemplates(data); + + const _volumes = spec.template.spec.volumes || []; + const _disks = spec.template.spec.domain.devices.disks || []; + + const diskNames = []; + + _disks.forEach((D, idx) => { + const prefix = D.name || _volumes[idx]?.name || `Volume ${ idx + 1 }`; + + if (!D.disk && !D.cdrom) { + const key = getters['i18n/t']('harvester.fields.type'); + const message = getters['i18n/t']('validation.required', { key }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + + const type = getters['i18n/t']('harvester.fields.volume'); + const lowerType = getters['i18n/t']('harvester.validation.vm.volume.lowerType'); + const upperType = getters['i18n/t']('harvester.validation.vm.volume.upperType'); + + validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType); + }); + + let requiredVolume = false; + + _volumes.forEach((V, idx) => { + const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value); + + const prefix = V.name || idx + 1; + + if ([SOURCE_TYPE.IMAGE, SOURCE_TYPE.ATTACH_VOLUME, SOURCE_TYPE.CONTAINER].includes(type)) { + requiredVolume = true; + } + + if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) { + if (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) { + const key = getters['i18n/t']('harvester.fields.size'); + const message = getters['i18n/t']('validation.required', { key }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + + if (typeValue?.spec?.resources?.requests?.storage && !/^([1-9][0-9]{0,8})[a-zA-Z]+$/.test(typeValue?.spec?.resources?.requests?.storage)) { + const message = getters['i18n/t']('harvester.validation.generic.maximumSize', { max: '999999999 GiB' }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + + if (type === SOURCE_TYPE.IMAGE && !typeValue?.spec?.storageClassName && !isVMTemplate) { // type === SOURCE_TYPE.IMAGE + const key = getters['i18n/t']('harvester.fields.image'); + const message = getters['i18n/t']('validation.required', { key }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + + if (!typeValue?.spec?.storageClassName && V?.persistentVolumeClaim?.claimName && type !== SOURCE_TYPE.IMAGE) { + const key = getters['i18n/t']('harvester.fields.storageClass'); + const message = getters['i18n/t']('validation.required', { key }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + } + + if (type === SOURCE_TYPE.ATTACH_VOLUME) { + const allPVCs = getters['harvester/all'](PVC); + + const selectedVolumeName = V?.persistentVolumeClaim?.claimName; + const hasExistingVolume = allPVCs.find(P => P.id === `${ value.metadata.namespace }/${ selectedVolumeName }`); + + if (!hasExistingVolume && selectedVolumeName) { // selected volume may have been deleted. e.g: use template + const type = getters['i18n/t']('harvester.fields.volume'); + + errors.push(getters['i18n/t']('harvester.validation.generic.hasDelete', { type, name: selectedVolumeName })); + } + + if (!selectedVolumeName) { // volume is not selected. + const key = getters['i18n/t']('harvester.virtualMachine.volume.volume'); + + errors.push(getters['i18n/t']('validation.required', { key })); + } + } + + if (type === SOURCE_TYPE.CONTAINER && !V.containerDisk.image) { + const key = getters['i18n/t']('harvester.fields.dockerImage'); + const message = getters['i18n/t']('validation.required', { key }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + }); + + /** + * At least one volume must be create. (Verify only when create.) + */ + if ((!requiredVolume || _volumes.length === 0) && !value.links) { + errors.push(getters['i18n/t']('harvester.validation.vm.volume.needImageOrExisting')); + } + + return errors; +} + +function getVolumeType(getters, V, DVTS, value) { + let outValue = null; + const allPVCs = getters['harvester/all'](PVC); + + if (V.persistentVolumeClaim) { + const selectedVolumeName = V?.persistentVolumeClaim?.claimName; + const hasExistingVolume = allPVCs.find(P => P.id === `${ value.metadata.namespace }/${ selectedVolumeName }`); + + if (hasExistingVolume) { + // In other cases, claimName will not be empty, so we can judge whether this is an exiting volume based on this attribute + return { + type: SOURCE_TYPE.ATTACH_VOLUME, + typeValue: null + }; + } + + outValue = DVTS.find((DVT) => { + return V.persistentVolumeClaim.claimName === DVT.metadata.name && DVT.metadata?.annotations && Object.prototype.hasOwnProperty.call(DVT.metadata.annotations, 'harvesterhci.io/imageId'); + }); + + if (outValue) { + return { + type: SOURCE_TYPE.IMAGE, + typeValue: outValue + }; + } + + // new type + outValue = DVTS.find(DVT => V.persistentVolumeClaim.claimName === DVT.metadata.name); + + if (outValue) { + return { + type: SOURCE_TYPE.NEW, + typeValue: outValue + }; + } + } + + if (V.containerDisk) { + return { + type: SOURCE_TYPE.CONTAINER, + typeValue: null + }; + } + + return {}; +} + +function validName(getters, errors, name, names = [], prefix, type, lowerType, upperType) { + // Verify that the name is duplicate + if (names.findIndex( N => name === N) !== -1) { + errors.push(getters['i18n/t']('harvester.validation.vm.duplicatedName', { type, name })); + } + + names.push(name); + + // The maximum length of volume name is 63 characters. + if (name && name?.length > maxNameLength) { + const key = getters['i18n/t']('harvester.fields.name'); + const message = getters['i18n/t']('harvester.validation.generic.maxLength', { key, max: maxNameLength }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + + // name required + if (!name) { + const key = getters['i18n/t']('harvester.fields.name'); + const message = getters['i18n/t']('validation.required', { key }); + + errors.push(getters['i18n/t']('harvester.validation.generic.tabError', { prefix, message })); + } + + // valid RFC 1123 + if (!isValidDNSLabelName(name)) { + const regex = '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'; + + errors.push(getters['i18n/t']('harvester.validation.generic.regex', { + lowerType, name, regex, upperType + })); + } +} diff --git a/pkg/harvester/validators/volume.js b/pkg/harvester/validators/volume.js new file mode 100644 index 00000000..24c885bf --- /dev/null +++ b/pkg/harvester/validators/volume.js @@ -0,0 +1,9 @@ +export function volumeSize(size, getters, errors, validatorArgs, displayKey, value) { + if (!/^([1-9][0-9]{0,8})[a-zA-Z]+$/.test(size)) { + const message = getters['i18n/t']('harvester.validation.generic.maximumSize', { max: '999999999 GiB' }); + + errors.push(message); + } + + return errors; +}