mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
feat(secrets): allow creating secrets beyond opaque [EE-2625] (#7709)
This commit is contained in:
parent
3b2f0ff9eb
commit
4e20d70a99
23 changed files with 659 additions and 135 deletions
|
@ -1,9 +1,9 @@
|
|||
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
|
||||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
|
||||
export default class {
|
||||
$onInit() {
|
||||
const secrets = (this.configurations || [])
|
||||
.filter((config) => config.Data && config.Type === KubernetesConfigurationTypes.SECRET)
|
||||
.filter((config) => config.Data && config.Type === KubernetesConfigurationKinds.SECRET)
|
||||
.flatMap((config) => Object.entries(config.Data))
|
||||
.map(([key, value]) => ({ key, value }));
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash-es';
|
|||
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
|
||||
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
|
||||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
|
||||
angular.module('portainer.docker').controller('KubernetesApplicationsDatatableController', [
|
||||
'$scope',
|
||||
|
@ -112,7 +112,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
|
|||
};
|
||||
|
||||
this.hasConfigurationSecrets = function (item) {
|
||||
return item.Configurations && item.Configurations.some((config) => config.Data && config.Type === KubernetesConfigurationTypes.SECRET);
|
||||
return item.Configurations && item.Configurations.some((config) => config.Data && config.Type === KubernetesConfigurationKinds.SECRET);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<rd-widget-body classes="no-padding">
|
||||
<!-- table title and action menu -->
|
||||
<div class="toolBar !flex-col gap-1">
|
||||
<div class="toolBar vertical-center !gap-x-5 !gap-y-1 flex-wrap !p-0 w-full">
|
||||
<div class="toolBar vertical-center !gap-x-5 !gap-y-1 flex-wrap !px-0 !py-1 w-full">
|
||||
<div class="toolBarTitle">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'lock'" feather="true"></pr-icon>
|
||||
|
@ -125,11 +125,11 @@
|
|||
</th>
|
||||
<th>
|
||||
<table-column-header
|
||||
col-title="'Type'"
|
||||
col-title="'Kind'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'Type'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Type' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Type')"
|
||||
is-sorted="$ctrl.state.orderBy === 'Kind'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Kind' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Kind')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
<th>
|
||||
|
@ -160,7 +160,7 @@
|
|||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.Namespace })">{{ item.Namespace }}</a>
|
||||
</td>
|
||||
<td>{{ item.Type | kubernetesConfigurationTypeText }}</td>
|
||||
<td>{{ item.Kind | kubernetesConfigurationKindText }}</td>
|
||||
<td>{{ item.CreationDate | getisodate }} {{ item.ConfigurationOwner ? 'by ' + item.ConfigurationOwner : '' }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
|
|
|
@ -21,33 +21,70 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.formValues.IsSimple">
|
||||
<div class="col-sm-12">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<button type="button" class="btn btn-sm btn-default" style="margin-left: 0" ng-click="$ctrl.addEntry()" data-cy="k8sConfigCreate-createEntryButton">
|
||||
<pr-icon class="vertical-center" icon="'plus'" feather="true"></pr-icon> Create entry
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-default" ngf-select="$ctrl.addEntryFromFile($file)" style="margin-left: 0" data-cy="k8sConfigCreate-createConfigsFromFileButton">
|
||||
<pr-icon class="vertical-center" icon="'upload'" feather="true"></pr-icon> Create key/value from file
|
||||
<button
|
||||
ng-if="
|
||||
!(
|
||||
($ctrl.isDockerConfig || $ctrl.formValues.Type.name === $ctrl.KubernetesSecretTypes.TLS.name || $ctrl.formValues.Type === $ctrl.KubernetesSecretTypes.TLS.value) &&
|
||||
$ctrl.formValues.Kind === $ctrl.KubernetesConfigurationKinds.SECRET
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-sm btn-default ml-0"
|
||||
ngf-select="$ctrl.addEntryFromFile($file)"
|
||||
data-cy="k8sConfigCreate-createConfigsFromFileButton"
|
||||
>
|
||||
<pr-icon icon="'upload'" feather="true" class="vertical-center"></pr-icon> Create key/value from file
|
||||
</button>
|
||||
<button
|
||||
ng-if="$ctrl.isDockerConfig && $ctrl.formValues.Kind === $ctrl.KubernetesConfigurationKinds.SECRET"
|
||||
type="button"
|
||||
class="btn btn-sm btn-default ml-0"
|
||||
ngf-select="$ctrl.addEntryFromFile($file)"
|
||||
ngf-accept="'.json'"
|
||||
data-cy="k8sConfigCreate-createConfigsFromFileButton"
|
||||
>
|
||||
<pr-icon icon="'upload'" feather="true" class="vertical-center"></pr-icon> Upload docker config file
|
||||
</button>
|
||||
<button
|
||||
ng-if="
|
||||
($ctrl.formValues.Type.name === $ctrl.KubernetesSecretTypes.TLS.name || $ctrl.formValues.Type === $ctrl.KubernetesSecretTypes.TLS.value) &&
|
||||
$ctrl.formValues.Kind === $ctrl.KubernetesConfigurationKinds.SECRET
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-sm btn-default ml-0"
|
||||
ngf-select="$ctrl.addEntryFromFile($file)"
|
||||
data-cy="k8sConfigCreate-createConfigsFromFileButton"
|
||||
>
|
||||
<pr-icon icon="'upload'" feather="true" class="vertical-center"></pr-icon> Upload TLS key/cert file
|
||||
</button>
|
||||
<portainer-tooltip message="'Maximum upload file size is 1MB'"></portainer-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="(index, entry) in $ctrl.formValues.Data" ng-if="$ctrl.formValues.IsSimple">
|
||||
<div class="form-group">
|
||||
<label for="configuration_data_key_{{ index }}" class="col-sm-3 col-lg-2 control-label text-left required">Key</label>
|
||||
<label for="configuration_data_key_{{ index }}" class="col-sm-3 col-lg-2 control-label text-left required"
|
||||
>Key
|
||||
<portainer-tooltip message="'The key must consist of alphanumeric characters, \'-\', \'_\' or \'.\' and be up to 253 characters in length.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="253"
|
||||
id="configuration_data_key_{{ index }}"
|
||||
name="configuration_data_key_{{ index }}"
|
||||
ng-model="$ctrl.formValues.Data[index].Key"
|
||||
ng-disabled="entry.Used"
|
||||
ng-disabled="entry.Used || $ctrl.isRequiredKey(entry.Key)"
|
||||
required
|
||||
ng-change="$ctrl.onChangeKey(entry)"
|
||||
/>
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px"
|
||||
class="small text-warning mt-1"
|
||||
ng-show="
|
||||
kubernetesConfigurationDataCreationForm['configuration_data_key_' + index].$invalid ||
|
||||
(!entry.Used && $ctrl.state.duplicateKeys[index] !== undefined) ||
|
||||
|
@ -55,18 +92,16 @@
|
|||
"
|
||||
>
|
||||
<ng-messages for="kubernetesConfigurationDataCreationForm['configuration_data_key_' + index].$error">
|
||||
<p ng-message="required" class="vertical-center">
|
||||
<pr-icon class="vertical-center" icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon> This field is required.
|
||||
</p>
|
||||
<p ng-message="required" class="vertical-center"> <pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon> This field is required. </p>
|
||||
</ng-messages>
|
||||
<div>
|
||||
<p ng-if="$ctrl.state.duplicateKeys[index] !== undefined" class="vertical-center">
|
||||
<pr-icon class="vertical-center" icon="'alert-triangle'" feather="true" mode="'warning'" class="vertical-center"></pr-icon>This key is already defined.
|
||||
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'" class="vertical-center"></pr-icon>This key is already defined.
|
||||
</p>
|
||||
</div>
|
||||
<p ng-if="$ctrl.state.invalidKeys[index]" class="vertical-center">
|
||||
<pr-icon class="vertical-center" icon="'alert-triangle'" feather="true" mode="'warning'" class="vertical-center"></pr-icon> This key is invalid. A valid key must
|
||||
consist of alphanumeric characters, '-', '_' or '.'
|
||||
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'" class="vertical-center"></pr-icon> This key is invalid. A valid key must consist of alphanumeric
|
||||
characters, '-', '_' or '.'
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -106,10 +141,11 @@
|
|||
<div class="col-sm-3 col-lg-2"></div>
|
||||
<div class="col-sm-8">
|
||||
<button
|
||||
ng-if="!$ctrl.isRequiredKey(entry.Key) || $ctrl.state.duplicateKeys[index] !== undefined"
|
||||
type="button"
|
||||
class="btn btn-sm btn-dangerlight !ml-0"
|
||||
style="margin-left: 0"
|
||||
ng-disabled="entry.Used"
|
||||
ng-disabled="entry.Used || $ctrl.isEntryRequired()"
|
||||
ng-click="$ctrl.removeEntry(index, entry)"
|
||||
data-cy="k8sConfigDetail-removeEntryButton{{ index }}"
|
||||
>
|
||||
|
|
|
@ -3,6 +3,8 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
|
|||
controller: 'KubernetesConfigurationDataController',
|
||||
bindings: {
|
||||
formValues: '=',
|
||||
isDockerConfig: '=',
|
||||
onChangeValidation: '&',
|
||||
isValid: '=',
|
||||
isCreation: '=',
|
||||
isEditorDirty: '=',
|
||||
|
|
|
@ -6,11 +6,12 @@ import { Base64 } from 'js-base64';
|
|||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
|
||||
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
|
||||
import { KubernetesConfigurationKinds, KubernetesSecretTypes } from 'Kubernetes/models/configuration/models';
|
||||
|
||||
class KubernetesConfigurationDataController {
|
||||
/* @ngInject */
|
||||
constructor($async) {
|
||||
this.$async = $async;
|
||||
constructor($async, Notifications) {
|
||||
Object.assign(this, { $async, Notifications });
|
||||
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.editorUpdateAsync = this.editorUpdateAsync.bind(this);
|
||||
|
@ -18,6 +19,8 @@ class KubernetesConfigurationDataController {
|
|||
this.onFileLoadAsync = this.onFileLoadAsync.bind(this);
|
||||
this.showSimpleMode = this.showSimpleMode.bind(this);
|
||||
this.showAdvancedMode = this.showAdvancedMode.bind(this);
|
||||
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
|
||||
this.KubernetesSecretTypes = KubernetesSecretTypes;
|
||||
}
|
||||
|
||||
onChangeKey(entry) {
|
||||
|
@ -25,6 +28,8 @@ class KubernetesConfigurationDataController {
|
|||
return;
|
||||
}
|
||||
|
||||
this.onChangeValidation();
|
||||
|
||||
this.state.duplicateKeys = KubernetesFormValidationHelper.getDuplicates(_.map(this.formValues.Data, (data) => data.Key));
|
||||
this.state.invalidKeys = KubernetesFormValidationHelper.getInvalidKeys(_.map(this.formValues.Data, (data) => data.Key));
|
||||
this.isValid = Object.keys(this.state.duplicateKeys).length === 0 && Object.keys(this.state.invalidKeys).length === 0;
|
||||
|
@ -32,6 +37,85 @@ class KubernetesConfigurationDataController {
|
|||
|
||||
addEntry() {
|
||||
this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry());
|
||||
|
||||
// logic for setting required keys for new entries, based on the secret type
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
const newDataIndex = this.formValues.Data.length - 1;
|
||||
const typeValue = typeof this.formValues.Type === 'string' ? this.formValues.Type : this.formValues.Type.value;
|
||||
switch (typeValue) {
|
||||
case this.KubernetesSecretTypes.DOCKERCFG.value:
|
||||
this.addMissingKeys(['dockercfg'], newDataIndex);
|
||||
break;
|
||||
case this.KubernetesSecretTypes.DOCKERCONFIGJSON.value:
|
||||
this.addMissingKeys(['.dockerconfigjson'], newDataIndex);
|
||||
break;
|
||||
case this.KubernetesSecretTypes.BASICAUTH.value:
|
||||
// only add a required key if there is no required key out of username and password
|
||||
if (!this.formValues.Data.some((entry) => entry.Key === 'username' || entry.Key === 'password')) {
|
||||
this.addMissingKeys(['username', 'password'], newDataIndex);
|
||||
}
|
||||
break;
|
||||
case this.KubernetesSecretTypes.SSHAUTH.value:
|
||||
this.addMissingKeys(['ssh-privatekey'], newDataIndex);
|
||||
break;
|
||||
case this.KubernetesSecretTypes.TLS.value:
|
||||
this.addMissingKeys(['tls.crt', 'tls.key'], newDataIndex);
|
||||
break;
|
||||
case this.KubernetesSecretTypes.BOOTSTRAPTOKEN.value:
|
||||
this.addMissingKeys(['token-id', 'token-secret'], newDataIndex);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.onChangeValidation();
|
||||
}
|
||||
|
||||
// addMissingKeys adds the keys in the keys array to the entry at the index provided and stops when the first one is added
|
||||
addMissingKeys(keys, newDataIndex) {
|
||||
for (let key of keys) {
|
||||
if (this.formValues.Data.every((entry) => entry.Key !== key)) {
|
||||
this.formValues.Data[newDataIndex].Key = key;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isRequiredKey(key) {
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
const secretTypeValue = typeof this.formValues.Type === 'string' ? this.formValues.Type : this.formValues.Type.value;
|
||||
switch (secretTypeValue) {
|
||||
case this.KubernetesSecretTypes.DOCKERCONFIGJSON.value:
|
||||
if (key === '.dockerconfigjson') {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case this.KubernetesSecretTypes.DOCKERCFG.value:
|
||||
if (key === '.dockercfg') {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case this.KubernetesSecretTypes.SSHAUTH.value:
|
||||
if (key === 'ssh-privatekey') {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case this.KubernetesSecretTypes.TLS.value:
|
||||
if (key === 'tls.crt' || key === 'tls.key') {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case this.KubernetesSecretTypes.BOOTSTRAPTOKEN.value:
|
||||
if (key === 'token-id' || key === 'token-secret') {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
removeEntry(index, entry) {
|
||||
|
@ -55,24 +139,77 @@ class KubernetesConfigurationDataController {
|
|||
}
|
||||
|
||||
async onFileLoadAsync(event) {
|
||||
const entry = new KubernetesConfigurationFormValuesEntry();
|
||||
const encoding = chardet.detect(Buffer.from(event.target.result));
|
||||
const decoder = new TextDecoder(encoding);
|
||||
|
||||
entry.Key = event.target.fileName;
|
||||
entry.IsBinary = KubernetesConfigurationHelper.isBinary(encoding);
|
||||
|
||||
if (!entry.IsBinary) {
|
||||
entry.Value = decoder.decode(event.target.result);
|
||||
} else {
|
||||
const stringValue = decoder.decode(event.target.result);
|
||||
entry.Value = Base64.encode(stringValue);
|
||||
// exit if the file is too big
|
||||
const maximumFileSizeBytes = 1024 * 1024; // 1MB
|
||||
if (event.target.result.byteLength > maximumFileSizeBytes) {
|
||||
this.Notifications.error('File size is too big', 'File size is too big', 'Select a file that is 1MB or smaller.');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = new KubernetesConfigurationFormValuesEntry();
|
||||
try {
|
||||
const encoding = chardet.detect(Buffer.from(event.target.result));
|
||||
const decoder = new TextDecoder(encoding);
|
||||
|
||||
entry.IsBinary = KubernetesConfigurationHelper.isBinary(encoding);
|
||||
|
||||
if (!entry.IsBinary) {
|
||||
entry.Value = decoder.decode(event.target.result);
|
||||
} else {
|
||||
const stringValue = decoder.decode(event.target.result);
|
||||
entry.Value = Base64.encode(stringValue);
|
||||
}
|
||||
} catch (error) {
|
||||
this.Notifications.error('Failed to upload file', error, 'Failed to upload file');
|
||||
return;
|
||||
}
|
||||
|
||||
entry.Key = event.target.fileName;
|
||||
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
if (this.isDockerConfig) {
|
||||
if (this.formValues.Type.name === this.KubernetesSecretTypes.DOCKERCFG.name) {
|
||||
entry.Key = '.dockercfg';
|
||||
} else {
|
||||
entry.Key = '.dockerconfigjson';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.formValues.Type.name === this.KubernetesSecretTypes.TLS.name) {
|
||||
const isCrt = entry.Value.indexOf('BEGIN CERTIFICATE') !== -1;
|
||||
if (isCrt) {
|
||||
entry.Key = 'tls.crt';
|
||||
}
|
||||
const isKey = entry.Value.indexOf('PRIVATE KEY') !== -1;
|
||||
if (isKey) {
|
||||
entry.Key = 'tls.key';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if this.formValues.Data has a key that matches an existing key, then replace it
|
||||
const existingEntryIndex = this.formValues.Data.findIndex((data) => data.Key === entry.Key || (data.Value === '' && data.Key === ''));
|
||||
if (existingEntryIndex !== -1) {
|
||||
this.formValues.Data[existingEntryIndex] = entry;
|
||||
} else {
|
||||
this.formValues.Data.push(entry);
|
||||
}
|
||||
|
||||
this.formValues.Data.push(entry);
|
||||
this.onChangeKey();
|
||||
}
|
||||
|
||||
isEntryRequired() {
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
const typeValue = typeof this.formValues.Type === 'string' ? this.formValues.Type : this.formValues.Type.value;
|
||||
if (this.formValues.Data.length === 1) {
|
||||
if (typeValue !== this.KubernetesSecretTypes.SERVICEACCOUNTTOKEN.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onFileLoad(event) {
|
||||
return this.$async(this.onFileLoadAsync, event);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue