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
pull/4484/head
Maxime Bajeux 2021-03-03 14:54:35 +01:00 committed by GitHub
parent f03cf2a6e4
commit 36fcbb9e18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 109 additions and 8 deletions

View File

@ -259,6 +259,10 @@ angular.module('portainer.docker').factory('ContainerHelper', [
return bindings; return bindings;
}; };
helper.getContainerNames = function (containers) {
return _.map(_.flatten(_.map(containers, 'Names')), (name) => _.trimStart(name, '/'));
};
return helper; return helper;
}, },
]); ]);

View File

@ -24,7 +24,7 @@ angular.module('portainer.app').controller('StackDuplicationFormController', [
} }
function isFormValidForDuplication() { function isFormValidForDuplication() {
return isFormValidForMigration() && ctrl.formValues.newName; return isFormValidForMigration() && ctrl.formValues.newName && !ctrl.yamlError;
} }
function duplicateStack() { function duplicateStack() {

View File

@ -33,6 +33,9 @@
<span ng-hide="$ctrl.state.duplicationInProgress"> <i class="fa fa-clone space-right" aria-hidden="true"></i> Duplicate </span> <span ng-hide="$ctrl.state.duplicationInProgress"> <i class="fa fa-clone space-right" aria-hidden="true"></i> Duplicate </span>
<span ng-show="$ctrl.state.duplicationInProgress">Duplication in progress...</span> <span ng-show="$ctrl.state.duplicationInProgress">Duplication in progress...</span>
</button> </button>
<div ng-if="$ctrl.yamlError"
><span class="text-danger small">{{ $ctrl.yamlError }}</span></div
>
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,5 +7,6 @@ angular.module('portainer.app').component('stackDuplicationForm', {
endpoints: '<', endpoints: '<',
groups: '<', groups: '<',
currentEndpointId: '<', currentEndpointId: '<',
yamlError: '<',
}, },
}); });

View File

@ -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;

View File

@ -1,5 +1,6 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import YAML from 'yaml';
import GenericHelper from '@/portainer/helpers/genericHelper';
import { ExternalStackViewModel } from '@/portainer/models/stack'; import { ExternalStackViewModel } from '@/portainer/models/stack';
angular.module('portainer.app').factory('StackHelper', [ 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; return helper;
}, },
]); ]);

View File

@ -8,14 +8,18 @@ angular
.controller('CreateStackController', function ( .controller('CreateStackController', function (
$scope, $scope,
$state, $state,
$async,
StackService, StackService,
Authentication, Authentication,
Notifications, Notifications,
FormValidator, FormValidator,
ResourceControlService, ResourceControlService,
FormHelper, FormHelper,
EndpointProvider,
StackHelper,
ContainerHelper,
CustomTemplateService, CustomTemplateService,
EndpointProvider ContainerService
) { ) {
$scope.formValues = { $scope.formValues = {
Name: '', Name: '',
@ -36,6 +40,8 @@ angular
formValidationError: '', formValidationError: '',
actionInProgress: false, actionInProgress: false,
StackType: null, StackType: null,
editorYamlValidationError: '',
uploadYamlValidationError: '',
}; };
$scope.addEnvironmentVariable = function () { $scope.addEnvironmentVariable = function () {
@ -154,6 +160,26 @@ angular
$scope.editorUpdate = function (cm) { $scope.editorUpdate = function (cm) {
$scope.formValues.StackFileContent = cm.getValue(); $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) { $scope.onChangeTemplate = async function onChangeTemplate(template) {
@ -186,6 +212,13 @@ angular
} catch (err) { } catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve the ComposeSyntaxMaxVersion'); 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(); initView();

View File

@ -90,6 +90,9 @@
<span class="col-sm-12 text-muted small"> <span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>. You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span> </span>
<div class="col-sm-12" ng-if="state.editorYamlValidationError"
><span class="text-danger small">{{ state.editorYamlValidationError }}</span></div
>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
@ -112,10 +115,13 @@
<span class="col-sm-12 text-muted small"> <span class="col-sm-12 text-muted small">
You can upload a Compose file from your computer. You can upload a Compose file from your computer.
</span> </span>
<div class="col-sm-12" ng-if="state.uploadYamlValidationError"
><span class="text-danger small">{{ state.uploadYamlValidationError }}</span></div
>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="button" class="btn btn-sm btn-primary" ngf-select ng-model="formValues.StackFile">Select file</button> <button type="button" class="btn btn-sm btn-primary" ngf-select="uploadFile($file)">Select file</button>
<span style="margin-left: 5px;"> <span style="margin-left: 5px;">
{{ formValues.StackFile.name }} {{ formValues.StackFile.name }}
<i class="fa fa-times red-icon" ng-if="!formValues.StackFile" aria-hidden="true"></i> <i class="fa fa-times red-icon" ng-if="!formValues.StackFile" aria-hidden="true"></i>
@ -235,6 +241,11 @@
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Web editor Web editor
</div> </div>
<div class="form-group">
<div class="col-sm-12" ng-if="state.editorYamlValidationError"
><span class="text-danger small">{{ state.editorYamlValidationError }}</span></div
>
</div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<code-editor <code-editor
@ -292,9 +303,9 @@
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress ng-disabled="state.actionInProgress
|| (state.Method === 'editor' && !formValues.StackFileContent) || (state.Method === 'editor' && (!formValues.StackFileContent || state.editorYamlValidationError))
|| (state.Method === 'upload' && !formValues.StackFile) || (state.Method === 'upload' && (!formValues.StackFile || state.uploadYamlValidationError))
|| (state.Method === 'template' && (!formValues.StackFileContent || !selectedTemplate)) || (state.Method === 'template' && (!formValues.StackFileContent || !selectedTemplate || state.editorYamlValidationError))
|| (state.Method === 'repository' && ((!formValues.RepositoryURL || !formValues.ComposeFilePathInRepository) || (formValues.RepositoryAuthentication && (!formValues.RepositoryUsername || !formValues.RepositoryPassword)))) || (state.Method === 'repository' && ((!formValues.RepositoryURL || !formValues.ComposeFilePathInRepository) || (formValues.RepositoryAuthentication && (!formValues.RepositoryUsername || !formValues.RepositoryPassword))))
|| !formValues.Name" || !formValues.Name"
ng-click="deployStack()" ng-click="deployStack()"

View File

@ -84,6 +84,7 @@
current-endpoint-id="currentEndpointId" current-endpoint-id="currentEndpointId"
on-duplicate="duplicateStack(name, endpointId)" on-duplicate="duplicateStack(name, endpointId)"
on-migrate="migrateStack(name, endpointId)" on-migrate="migrateStack(name, endpointId)"
yaml-error="state.yamlError"
> >
</stack-duplication-form> </stack-duplication-form>
</div> </div>

View File

@ -17,6 +17,7 @@ angular.module('portainer.app').controller('StackController', [
'EndpointService', 'EndpointService',
'GroupService', 'GroupService',
'ModalService', 'ModalService',
'StackHelper',
function ( function (
$async, $async,
$q, $q,
@ -35,13 +36,16 @@ angular.module('portainer.app').controller('StackController', [
EndpointProvider, EndpointProvider,
EndpointService, EndpointService,
GroupService, GroupService,
ModalService ModalService,
StackHelper,
ContainerHelper
) { ) {
$scope.state = { $scope.state = {
actionInProgress: false, actionInProgress: false,
migrationInProgress: false, migrationInProgress: false,
externalStack: false, externalStack: false,
showEditorTab: false, showEditorTab: false,
yamlError: false,
}; };
$scope.formValues = { $scope.formValues = {
@ -187,6 +191,7 @@ angular.module('portainer.app').controller('StackController', [
$scope.editorUpdate = function (cm) { $scope.editorUpdate = function (cm) {
$scope.stackFileContent = cm.getValue(); $scope.stackFileContent = cm.getValue();
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames);
}; };
$scope.stopStack = stopStack; $scope.stopStack = stopStack;
@ -243,11 +248,14 @@ angular.module('portainer.app').controller('StackController', [
$q.all({ $q.all({
stack: StackService.stack(id), stack: StackService.stack(id),
groups: GroupService.groups(), groups: GroupService.groups(),
containers: ContainerService.containers(),
}) })
.then(function success(data) { .then(function success(data) {
var stack = data.stack; var stack = data.stack;
$scope.groups = data.groups; $scope.groups = data.groups;
$scope.stack = stack; $scope.stack = stack;
$scope.containers = data.containers;
$scope.containerNames = ContainerHelper.getContainerNames($scope.containers);
let resourcesPromise = Promise.resolve({}); let resourcesPromise = Promise.resolve({});
if (stack.Status === 1) { if (stack.Status === 1) {
@ -268,6 +276,8 @@ angular.module('portainer.app').controller('StackController', [
assignComposeStackResources(data.resources); assignComposeStackResources(data.resources);
} }
} }
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames);
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve stack details'); Notifications.error('Failure', err, 'Unable to retrieve stack details');