From 7f72fdf2c5df2f75836547a4f130b69b09ba05aa Mon Sep 17 00:00:00 2001 From: "andy.lee" Date: Fri, 13 Sep 2024 13:44:17 +0800 Subject: [PATCH] Add volume and image encryption feature Signed-off-by: andy.lee --- pkg/harvester/config/harvester-map.js | 9 ++ pkg/harvester/config/table-headers.js | 2 - .../index.vue | 43 +++++- .../provisioners/driver.longhorn.io.vue | 130 ++++++++++++++++-- .../harvesterhci.io.virtualmachineimage.vue | 109 +++++++++++++-- pkg/harvester/l10n/en-us.yaml | 11 ++ .../harvesterhci.io.virtualmachineimage.vue | 16 +++ pkg/harvester/list/harvesterhci.io.volume.vue | 16 +++ .../models/harvester/persistentvolumeclaim.js | 10 +- .../harvesterhci.io.virtualmachineimage.js | 24 ++++ 10 files changed, 340 insertions(+), 30 deletions(-) diff --git a/pkg/harvester/config/harvester-map.js b/pkg/harvester/config/harvester-map.js index 02cbfe25..39e01ad4 100644 --- a/pkg/harvester/config/harvester-map.js +++ b/pkg/harvester/config/harvester-map.js @@ -69,3 +69,12 @@ export const ADD_ONS = { RANCHER_MONITORING: 'rancher-monitoring', VM_IMPORT_CONTROLLER: 'vm-import-controller', }; + +export const CSI_SECRETS = { + CSI_PROVISIONER_SECRET_NAME: 'csi.storage.k8s.io/provisioner-secret-name', + CSI_PROVISIONER_SECRET_NAMESPACE: 'csi.storage.k8s.io/provisioner-secret-namespace', + CSI_NODE_PUBLISH_SECRET_NAME: 'csi.storage.k8s.io/node-publish-secret-name', + CSI_NODE_PUBLISH_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-publish-secret-namespace', + CSI_NODE_STAGE_SECRET_NAME: 'csi.storage.k8s.io/node-stage-secret-name', + CSI_NODE_STAGE_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-stage-secret-namespace', +}; diff --git a/pkg/harvester/config/table-headers.js b/pkg/harvester/config/table-headers.js index fa0c42bf..b580f4f0 100644 --- a/pkg/harvester/config/table-headers.js +++ b/pkg/harvester/config/table-headers.js @@ -8,7 +8,6 @@ export const IMAGE_DOWNLOAD_SIZE = { labelKey: 'tableHeaders.size', value: 'downSize', sort: 'status.size', - width: 120 }; export const IMAGE_VIRTUAL_SIZE = { @@ -16,7 +15,6 @@ export const IMAGE_VIRTUAL_SIZE = { labelKey: 'harvester.tableHeaders.virtualSize', value: 'virtualSize', sort: 'status.virtualSize', - width: 120 }; export const IMAGE_PROGRESS = { diff --git a/pkg/harvester/detail/harvesterhci.io.virtualmachineimage/index.vue b/pkg/harvester/detail/harvesterhci.io.virtualmachineimage/index.vue index 72236716..ba5bad35 100644 --- a/pkg/harvester/detail/harvesterhci.io.virtualmachineimage/index.vue +++ b/pkg/harvester/detail/harvesterhci.io.virtualmachineimage/index.vue @@ -7,8 +7,9 @@ import Tabbed from '@shell/components/Tabbed'; import Tab from '@shell/components/Tabbed/Tab'; import { findBy } from '@shell/utils/array'; import { get } from '@shell/utils/object'; - +import { ucFirst } from '@shell/utils/string'; import Storage from './Storage'; +import { SECRET } from '@shell/config/types'; export default { components: { @@ -26,8 +27,14 @@ export default { }, }, + async fetch() { + const inStore = this.$store.getters['currentProduct'].inStore; + + this.secrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }); + }, + data() { - return {}; + return { secrets: [] }; }, computed: { @@ -57,6 +64,21 @@ export default { return this.value?.spec?.sourceType === 'upload'; }, + encryptionSecret() { + if (!this.value.isEncrypted) { + return '-'; + } + + return this.value.encryptionSecret; + }, + secretLink() { + return this.secrets.find(sc => sc.id === this.value.encryptionSecret)?.detailLocation; + }, + + isEncryptedString() { + return ucFirst(String(this.value.isEncrypted)); + }, + imageName() { return this.value?.metadata?.annotations?.[HCI.IMAGE_NAME] || '-'; }, @@ -116,6 +138,23 @@ export default { +
+
+ +
+
+ +
+
+
+ {{ t('harvester.image.encryptionSecret') }} +
+ + {{ encryptionSecret }} + +
+
+
diff --git a/pkg/harvester/edit/harvesterhci.io.storage/provisioners/driver.longhorn.io.vue b/pkg/harvester/edit/harvesterhci.io.storage/provisioners/driver.longhorn.io.vue index fcd37195..b37ff8d3 100644 --- a/pkg/harvester/edit/harvesterhci.io.storage/provisioners/driver.longhorn.io.vue +++ b/pkg/harvester/edit/harvesterhci.io.storage/provisioners/driver.longhorn.io.vue @@ -3,9 +3,9 @@ import KeyValue from '@shell/components/form/KeyValue'; import LabeledSelect from '@shell/components/form/LabeledSelect'; import { LabeledInput } from '@components/Form/LabeledInput'; import RadioGroup from '@components/Form/Radio/RadioGroup'; - +import { SECRET, NAMESPACE, LONGHORN } from '@shell/config/types'; import { _CREATE, _VIEW } from '@shell/config/query-params'; -import { LONGHORN } from '@shell/config/types'; +import { CSI_SECRETS } from '@pkg/harvester/config/harvester-map'; import { clone } from '@shell/utils/object'; import { uniq } from '@shell/utils/array'; @@ -15,8 +15,18 @@ const DEFAULT_PARAMETERS = [ 'diskSelector', 'nodeSelector', 'migratable', + 'encrypted', ]; +const { + CSI_PROVISIONER_SECRET_NAME, + CSI_PROVISIONER_SECRET_NAMESPACE, + CSI_NODE_PUBLISH_SECRET_NAME, + CSI_NODE_PUBLISH_SECRET_NAMESPACE, + CSI_NODE_STAGE_SECRET_NAME, + CSI_NODE_STAGE_SECRET_NAMESPACE +} = CSI_SECRETS; + export default { components: { KeyValue, @@ -40,6 +50,14 @@ export default { }, }, + async fetch() { + const inStore = this.$store.getters['currentProduct'].inStore; + const allNamespaces = await this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }); + + this.secrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }); + this.namespaces = allNamespaces.filter(ns => ns.isSystem === false).map(ns => ns.id); // only show non-system namespaces to user to select + }, + data() { if (this.realMode === _CREATE) { this.value['parameters'] = { @@ -47,11 +65,15 @@ export default { staleReplicaTimeout: '30', diskSelector: null, nodeSelector: null, - migratable: 'true' + encrypted: 'false', + migratable: 'true', }; } - return {}; + return { + secrets: [], + namespaces: [], + }; }, computed: { @@ -97,6 +119,26 @@ export default { }]; }, + secretOptions() { + const selectedNS = this.secretNamespace; + + return this.secrets.filter(secret => secret.namespace === selectedNS).map(secret => secret.name); + }, + + secretNameOptions() { + return this.namespaces; + }, + + volumeEncryptionOptions() { + return [{ + label: this.t('generic.yes'), + value: 'true' + }, { + label: this.t('generic.no'), + value: 'false' + }]; + }, + parameters: { get() { const parameters = clone(this.value?.parameters) || {}; @@ -113,6 +155,49 @@ export default { } }, + volumeEncryption: { + set(neu) { + this.$set(this.value, 'parameters', { + ...this.value.parameters, + encrypted: neu + }); + }, + + get() { + return this.value?.parameters?.encrypted || 'false'; + } + }, + + secretName: { + get() { + return this.value.parameters[CSI_PROVISIONER_SECRET_NAME]; + }, + + set(neu) { + this.$set(this.value, 'parameters', { + ...this.value.parameters, + [CSI_PROVISIONER_SECRET_NAME]: neu, + [CSI_NODE_PUBLISH_SECRET_NAME]: neu, + [CSI_NODE_STAGE_SECRET_NAME]: neu + }); + } + }, + + secretNamespace: { + get() { + return this.value.parameters[CSI_PROVISIONER_SECRET_NAMESPACE]; + }, + + set(neu) { + this.$set(this.value, 'parameters', { + ...this.value.parameters, + [CSI_PROVISIONER_SECRET_NAMESPACE]: neu, + [CSI_NODE_PUBLISH_SECRET_NAMESPACE]: neu, + [CSI_NODE_STAGE_SECRET_NAMESPACE]: neu + }); + } + }, + nodeSelector: { get() { const nodeSelector = this.value?.parameters?.nodeSelector; @@ -221,14 +306,39 @@ export default {
-
+
+ +
+
+ +
+
- +
+
+
diff --git a/pkg/harvester/edit/harvesterhci.io.virtualmachineimage.vue b/pkg/harvester/edit/harvesterhci.io.virtualmachineimage.vue index 795b5fd2..4419f102 100644 --- a/pkg/harvester/edit/harvesterhci.io.virtualmachineimage.vue +++ b/pkg/harvester/edit/harvesterhci.io.virtualmachineimage.vue @@ -8,7 +8,6 @@ import NameNsDescription from '@shell/components/form/NameNsDescription'; import { RadioGroup } from '@components/Form/Radio'; import Select from '@shell/components/form/Select'; import LabeledSelect from '@shell/components/form/LabeledSelect'; - import CreateEditView from '@shell/mixins/create-edit-view'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; import { exceptionToErrorsArray } from '@shell/utils/error'; @@ -18,6 +17,9 @@ import { VM_IMAGE_FILE_FORMAT } from '../validators/vm-image'; import { OS } from '../mixins/harvester-vm'; import { HCI } from '../types'; +const ENCRYPT = 'encrypt'; +const DECRYPT = 'decrypt'; +const CLONE = 'clone'; const DOWNLOAD = 'download'; const UPLOAD = 'upload'; const rawORqcow2 = 'raw_qcow2'; @@ -59,6 +61,8 @@ export default { const defaultStorage = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS).find(s => s.isDefault); this['storageClassName'] = this.storageClassName || defaultStorage?.metadata?.name || 'longhorn'; + this.images = this.$store.getters[`${ inStore }/all`](HCI.IMAGE); + this.selectedImage = this.images.find(i => i.name === this.value.name) || null; }, data() { @@ -71,12 +75,14 @@ export default { } return { - url: this.value.spec.url, - files: [], - resource: '', - headers: {}, - fileUrl: '', - file: '', + selectedImage: null, + images: [], + url: this.value.spec.url, + files: [], + resource: '', + headers: {}, + fileUrl: '', + file: '', }; }, @@ -94,9 +100,16 @@ export default { }, showEditAsYaml() { - return this.value.spec.sourceType === DOWNLOAD; + return this.value.spec.sourceType === DOWNLOAD || this.value.spec.sourceType === CLONE; + }, + radioGroupOptions() { + return [ + DOWNLOAD, + UPLOAD, + ENCRYPT, + DECRYPT + ]; }, - storageClassOptions() { const inStore = this.$store.getters['currentProduct'].inStore; const storages = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS); @@ -122,9 +135,66 @@ export default { this.value.metadata.annotations[HCI_ANNOTATIONS.STORAGE_CLASS] = nue; } }, + sourceImageOptions() { + let options = []; + + if (this.value.spec.sourceType !== CLONE) { + return options; + } + if (this.value.spec.securityParameters.cryptoOperation === ENCRYPT) { + options = this.images.filter(image => !image.isEncrypted); + } else { + options = this.images.filter(image => image.isEncrypted); + } + + return options.map(image => image.spec.displayName); + }, + sourceImageName: { + get() { + return this.selectedImage?.spec.displayName; + }, + set(imageDisplayName) { + this.selectedImage = this.images.find(i => i.spec.displayName === imageDisplayName); + // sourceImageName should bring the name of the image + this.value.spec.securityParameters.sourceImageName = this.selectedImage?.metadata.name || ''; + } + }, + sourceType: { + get() { + if (this.value.spec.sourceType === CLONE) { + return this.value.spec.securityParameters.cryptoOperation; + } else { + return this.value.spec.sourceType; + } + }, + + set(neu) { + if (neu === DECRYPT || neu === ENCRYPT) { + this.value.spec.sourceType = CLONE; + this.$set(this.value.spec, 'securityParameters', { + cryptoOperation: neu, + sourceImageName: '', + sourceImageNamespace: this.value.metadata.namespace + }); + this.selectedImage = null; + } else { + this.$delete(this.value.spec, 'securityParameters'); + this.value.spec.sourceType = neu; + } + } + } }, watch: { + 'value.metadata.namespace'(neu) { + if (this.value.spec.sourceType === CLONE) { + this.$set(this.value.spec, 'securityParameters', { + cryptoOperation: this.value.spec.securityParameters.cryptoOperation, + sourceImageName: '', + sourceImageNamespace: neu + }); + } + }, 'value.spec.url'(neu) { const url = neu.trim(); @@ -300,15 +370,14 @@ export default { > @@ -334,7 +403,7 @@ export default { :tooltip="t('harvester.image.urlTip', {}, true)" /> -
+
+ +
diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml index b964f77a..e1e7c633 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -207,6 +207,7 @@ harvester: =1 {core} other {cores}} tableHeaders: + imageEncryption: Encryption size: Size virtualSize: Virtual Size progress: Progress @@ -767,6 +768,8 @@ harvester: basics: Basics url: URL size: Size + isEncryption: Encryption + encryptionSecret: Encryption Secret virtualSize: Virtual Size urlTip: 'Supports the raw and qcow2 image formats which are supported by qemu. Bootable ISO images can also be used and are treated like raw images.' fileName: File Name @@ -775,6 +778,11 @@ harvester: sourceType: download: URL upload: File + clone: Clone + encrypt: Encrypt + decrypt: Decrypt + sourceImage: Source Image + cryptoOperation: Crypto Operation warning: uploading: |- {count, plural, @@ -1100,6 +1108,9 @@ harvester: storage: label: Storage useDefault: Use the default storage + volumeEncryption: Volume Encryption + secretName: Secret Name + secretNamespace: Secret Namespace migratable: label: Migratable numberOfReplicas: diff --git a/pkg/harvester/list/harvesterhci.io.virtualmachineimage.vue b/pkg/harvester/list/harvesterhci.io.virtualmachineimage.vue index cc21d3b4..ac180274 100644 --- a/pkg/harvester/list/harvesterhci.io.virtualmachineimage.vue +++ b/pkg/harvester/list/harvesterhci.io.virtualmachineimage.vue @@ -75,6 +75,22 @@ export default { +
diff --git a/pkg/harvester/list/harvesterhci.io.volume.vue b/pkg/harvester/list/harvesterhci.io.volume.vue index 26f0df57..45ac6fc2 100644 --- a/pkg/harvester/list/harvesterhci.io.volume.vue +++ b/pkg/harvester/list/harvesterhci.io.volume.vue @@ -170,6 +170,22 @@ v-if="getVMName(scope.row)"
+ diff --git a/pkg/harvester/models/harvester/persistentvolumeclaim.js b/pkg/harvester/models/harvester/persistentvolumeclaim.js index 5c2323ac..816a7b29 100644 --- a/pkg/harvester/models/harvester/persistentvolumeclaim.js +++ b/pkg/harvester/models/harvester/persistentvolumeclaim.js @@ -37,7 +37,7 @@ export default class HciPv extends HarvesterResource { return [ { action: 'exportImage', - enabled: this.hasAction('export'), + enabled: this.hasAction('export') && !this.isEncrypted, icon: 'icon icon-copy', label: this.t('harvester.action.exportImage') }, @@ -213,6 +213,14 @@ export default class HciPv extends HarvesterResource { return false; } + get isEncrypted() { + const inStore = this.$rootGetters['currentProduct'].inStore; + + const longhornVolume = this.$rootGetters[`${ inStore }/all`](LONGHORN.VOLUMES).find(v => v.metadata?.name === this.spec?.volumeName); + + return longhornVolume?.spec.encrypted || false; + } + get longhornVolume() { const inStore = this.$rootGetters['currentProduct'].inStore; diff --git a/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js b/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js index 72e83e8f..7a5ce6a3 100644 --- a/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js +++ b/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js @@ -11,6 +11,12 @@ import { _CLONE } from '@shell/config/query-params'; import { HCI } from '../types'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester'; import HarvesterResource from './harvester'; +import { CSI_SECRETS } from '@pkg/harvester/config/harvester-map'; + +const { + CSI_PROVISIONER_SECRET_NAME, + CSI_PROVISIONER_SECRET_NAMESPACE, +} = CSI_SECRETS; function isReady() { function getStatusConditionOfType(type, defaultValue = []) { @@ -127,6 +133,24 @@ export default class HciVmImage extends HarvesterResource { return stateDisplay(this.metadata.state.name); } + get encryptionSecret() { + const secretNS = this.spec.storageClassParameters[CSI_PROVISIONER_SECRET_NAMESPACE]; + const secretName = this.spec.storageClassParameters[CSI_PROVISIONER_SECRET_NAME]; + + if (secretNS && secretName) { + return `${ secretNS }/${ secretName }`; + } + + return ''; + } + + get isEncrypted() { + return this.spec.sourceType === 'clone' && + this.spec.securityParameters?.cryptoOperation === 'encrypt' && + !!this.spec.securityParameters?.sourceImageName && + !!this.spec.securityParameters?.sourceImageNamespace; + } + get imageMessage() { if (this.uploadError) { return ucFirst(this.uploadError);