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 markerspull/4484/head
parent
f03cf2a6e4
commit
36fcbb9e18
|
@ -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;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -7,5 +7,6 @@ angular.module('portainer.app').component('stackDuplicationForm', {
|
||||||
endpoints: '<',
|
endpoints: '<',
|
||||||
groups: '<',
|
groups: '<',
|
||||||
currentEndpointId: '<',
|
currentEndpointId: '<',
|
||||||
|
yamlError: '<',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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()"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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');
|
||||||
|
|
Loading…
Reference in New Issue