From 36fcbb9e18ca6f6c1392fb3bf721199584503369 Mon Sep 17 00:00:00 2001 From: Maxime Bajeux Date: Wed, 3 Mar 2021 14:54:35 +0100 Subject: [PATCH] feat(stack): prevent stack duplication if name already used (#4740) * feat(stack): prevent stack duplication if name already used * refacto(stack): deduplicate functions and rename variables * refacto(stack): add a generic helper for findDeepAll function * fix(templates): remove forgotten conflict markers --- app/docker/helpers/containerHelper.js | 4 +++ .../stack-duplication-form-controller.js | 2 +- .../stack-duplication-form.html | 3 ++ .../stack-duplication-form.js | 1 + app/portainer/helpers/genericHelper.js | 15 ++++++++ app/portainer/helpers/stackHelper.js | 25 ++++++++++++- .../stacks/create/createStackController.js | 35 ++++++++++++++++++- .../views/stacks/create/createstack.html | 19 +++++++--- app/portainer/views/stacks/edit/stack.html | 1 + .../views/stacks/edit/stackController.js | 12 ++++++- 10 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 app/portainer/helpers/genericHelper.js diff --git a/app/docker/helpers/containerHelper.js b/app/docker/helpers/containerHelper.js index 6daf5d4e7..c50a9183e 100644 --- a/app/docker/helpers/containerHelper.js +++ b/app/docker/helpers/containerHelper.js @@ -259,6 +259,10 @@ angular.module('portainer.docker').factory('ContainerHelper', [ return bindings; }; + helper.getContainerNames = function (containers) { + return _.map(_.flatten(_.map(containers, 'Names')), (name) => _.trimStart(name, '/')); + }; + return helper; }, ]); diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js index c2bc89cb6..b0e7b5ff4 100644 --- a/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js @@ -24,7 +24,7 @@ angular.module('portainer.app').controller('StackDuplicationFormController', [ } function isFormValidForDuplication() { - return isFormValidForMigration() && ctrl.formValues.newName; + return isFormValidForMigration() && ctrl.formValues.newName && !ctrl.yamlError; } function duplicateStack() { diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.html b/app/portainer/components/stack-duplication-form/stack-duplication-form.html index 5e7cf743f..cf2e0a293 100644 --- a/app/portainer/components/stack-duplication-form/stack-duplication-form.html +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.html @@ -33,6 +33,9 @@ Duplicate Duplication in progress... +
{{ $ctrl.yamlError }}
diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.js b/app/portainer/components/stack-duplication-form/stack-duplication-form.js index 36e45996f..c9460611d 100644 --- a/app/portainer/components/stack-duplication-form/stack-duplication-form.js +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.js @@ -7,5 +7,6 @@ angular.module('portainer.app').component('stackDuplicationForm', { endpoints: '<', groups: '<', currentEndpointId: '<', + yamlError: '<', }, }); diff --git a/app/portainer/helpers/genericHelper.js b/app/portainer/helpers/genericHelper.js new file mode 100644 index 000000000..47f115890 --- /dev/null +++ b/app/portainer/helpers/genericHelper.js @@ -0,0 +1,15 @@ +import _ from 'lodash-es'; + +class GenericHelper { + static findDeepAll(obj, target, res = []) { + if (typeof obj === 'object') { + _.forEach(obj, (child, key) => { + if (key === target) res.push(child); + if (typeof child === 'object') GenericHelper.findDeepAll(child, target, res); + }); + } + return res; + } +} + +export default GenericHelper; diff --git a/app/portainer/helpers/stackHelper.js b/app/portainer/helpers/stackHelper.js index 77d43949b..bfaf24b9f 100644 --- a/app/portainer/helpers/stackHelper.js +++ b/app/portainer/helpers/stackHelper.js @@ -1,5 +1,6 @@ import _ from 'lodash-es'; - +import YAML from 'yaml'; +import GenericHelper from '@/portainer/helpers/genericHelper'; import { ExternalStackViewModel } from '@/portainer/models/stack'; angular.module('portainer.app').factory('StackHelper', [ @@ -22,6 +23,28 @@ angular.module('portainer.app').factory('StackHelper', [ ); } + helper.validateYAML = function (yaml, containerNames) { + let yamlObject; + + try { + yamlObject = YAML.parse(yaml); + } catch (err) { + return 'There is an error in the yaml syntax: ' + err; + } + + const names = _.uniq(GenericHelper.findDeepAll(yamlObject, 'container_name')); + const duplicateContainers = _.intersection(containerNames, names); + + if (duplicateContainers.length === 0) return; + + return ( + (duplicateContainers.length === 1 ? 'This container name is' : 'These container names are') + + ' already used by another container running in this environment: ' + + _.join(duplicateContainers, ', ') + + '.' + ); + }; + return helper; }, ]); diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index 7d226fccb..119f8d2a7 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -8,14 +8,18 @@ angular .controller('CreateStackController', function ( $scope, $state, + $async, StackService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper, + EndpointProvider, + StackHelper, + ContainerHelper, CustomTemplateService, - EndpointProvider + ContainerService ) { $scope.formValues = { Name: '', @@ -36,6 +40,8 @@ angular formValidationError: '', actionInProgress: false, StackType: null, + editorYamlValidationError: '', + uploadYamlValidationError: '', }; $scope.addEnvironmentVariable = function () { @@ -154,6 +160,26 @@ angular $scope.editorUpdate = function (cm) { $scope.formValues.StackFileContent = cm.getValue(); + $scope.state.editorYamlValidationError = StackHelper.validateYAML($scope.formValues.StackFileContent, $scope.containerNames); + }; + + async function onFileLoadAsync(event) { + $scope.state.uploadYamlValidationError = StackHelper.validateYAML(event.target.result, $scope.containerNames); + } + + function onFileLoad(event) { + return $async(onFileLoadAsync, event); + } + + $scope.uploadFile = function (file) { + $scope.formValues.StackFile = file; + + if (file) { + const temporaryFileReader = new FileReader(); + temporaryFileReader.fileName = file.name; + temporaryFileReader.onload = onFileLoad; + temporaryFileReader.readAsText(file); + } }; $scope.onChangeTemplate = async function onChangeTemplate(template) { @@ -186,6 +212,13 @@ angular } catch (err) { Notifications.error('Failure', err, 'Unable to retrieve the ComposeSyntaxMaxVersion'); } + + try { + $scope.containers = await ContainerService.containers(); + $scope.containerNames = ContainerHelper.getContainerNames($scope.containers); + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve Containers'); + } } initView(); diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html index 10f57d321..6719edfec 100644 --- a/app/portainer/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -90,6 +90,9 @@ You can get more information about Compose file format in the official documentation. +
{{ state.editorYamlValidationError }}
@@ -112,10 +115,13 @@ You can upload a Compose file from your computer. +
{{ state.uploadYamlValidationError }}
- + {{ formValues.StackFile.name }} @@ -235,6 +241,11 @@
Web editor
+
+
{{ state.editorYamlValidationError }}
+
diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index c7e01dc15..fdc20c67d 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -17,6 +17,7 @@ angular.module('portainer.app').controller('StackController', [ 'EndpointService', 'GroupService', 'ModalService', + 'StackHelper', function ( $async, $q, @@ -35,13 +36,16 @@ angular.module('portainer.app').controller('StackController', [ EndpointProvider, EndpointService, GroupService, - ModalService + ModalService, + StackHelper, + ContainerHelper ) { $scope.state = { actionInProgress: false, migrationInProgress: false, externalStack: false, showEditorTab: false, + yamlError: false, }; $scope.formValues = { @@ -187,6 +191,7 @@ angular.module('portainer.app').controller('StackController', [ $scope.editorUpdate = function (cm) { $scope.stackFileContent = cm.getValue(); + $scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames); }; $scope.stopStack = stopStack; @@ -243,11 +248,14 @@ angular.module('portainer.app').controller('StackController', [ $q.all({ stack: StackService.stack(id), groups: GroupService.groups(), + containers: ContainerService.containers(), }) .then(function success(data) { var stack = data.stack; $scope.groups = data.groups; $scope.stack = stack; + $scope.containers = data.containers; + $scope.containerNames = ContainerHelper.getContainerNames($scope.containers); let resourcesPromise = Promise.resolve({}); if (stack.Status === 1) { @@ -268,6 +276,8 @@ angular.module('portainer.app').controller('StackController', [ assignComposeStackResources(data.resources); } } + + $scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve stack details');