diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go
index 779431516..fa939bb12 100644
--- a/api/http/handler/resource_control.go
+++ b/api/http/handler/resource_control.go
@@ -84,6 +84,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht
resourceControlType = portainer.SecretResourceControl
case "stack":
resourceControlType = portainer.StackResourceControl
+ case "config":
+ resourceControlType = portainer.ConfigResourceControl
default:
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
return
diff --git a/api/http/proxy/configs.go b/api/http/proxy/configs.go
new file mode 100644
index 000000000..16904c6c2
--- /dev/null
+++ b/api/http/proxy/configs.go
@@ -0,0 +1,107 @@
+package proxy
+
+import (
+ "net/http"
+
+ "github.com/portainer/portainer"
+)
+
+const (
+ // ErrDockerConfigIdentifierNotFound defines an error raised when Portainer is unable to find a config identifier
+ ErrDockerConfigIdentifierNotFound = portainer.Error("Docker config identifier not found")
+ configIdentifier = "ID"
+)
+
+// configListOperation extracts the response as a JSON object, loop through the configs array
+// decorate and/or filter the configs based on resource controls before rewriting the response
+func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
+ var err error
+
+ // ConfigList response is a JSON array
+ // https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
+ responseArray, err := getResponseAsJSONArray(response)
+ if err != nil {
+ return err
+ }
+
+ if executor.operationContext.isAdmin {
+ responseArray, err = decorateConfigList(responseArray, executor.operationContext.resourceControls)
+ } else {
+ responseArray, err = filterConfigList(responseArray, executor.operationContext)
+ }
+ if err != nil {
+ return err
+ }
+
+ return rewriteResponse(response, responseArray, http.StatusOK)
+}
+
+// configInspectOperation extracts the response as a JSON object, verify that the user
+// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID)
+// and either rewrite an access denied response or a decorated config.
+func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
+ // ConfigInspect response is a JSON object
+ // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
+ responseObject, err := getResponseAsJSONOBject(response)
+ if err != nil {
+ return err
+ }
+
+ if responseObject[configIdentifier] == nil {
+ return ErrDockerConfigIdentifierNotFound
+ }
+
+ configID := responseObject[configIdentifier].(string)
+ responseObject, access := applyResourceAccessControl(responseObject, configID, executor.operationContext)
+ if !access {
+ return rewriteAccessDeniedResponse(response)
+ }
+
+ return rewriteResponse(response, responseObject, http.StatusOK)
+}
+
+// decorateConfigList loops through all configs and decorates any config with an existing resource control.
+// Resource controls checks are based on: resource identifier.
+// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
+func decorateConfigList(configData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
+ decoratedConfigData := make([]interface{}, 0)
+
+ for _, config := range configData {
+
+ configObject := config.(map[string]interface{})
+ if configObject[configIdentifier] == nil {
+ return nil, ErrDockerConfigIdentifierNotFound
+ }
+
+ configID := configObject[configIdentifier].(string)
+ configObject = decorateResourceWithAccessControl(configObject, configID, resourceControls)
+
+ decoratedConfigData = append(decoratedConfigData, configObject)
+ }
+
+ return decoratedConfigData, nil
+}
+
+// filterConfigList loops through all configs and filters public configs (no associated resource control)
+// as well as authorized configs (access granted to the user based on existing resource control).
+// Authorized configs are decorated during the process.
+// Resource controls checks are based on: resource identifier.
+// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
+func filterConfigList(configData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
+ filteredConfigData := make([]interface{}, 0)
+
+ for _, config := range configData {
+ configObject := config.(map[string]interface{})
+ if configObject[configIdentifier] == nil {
+ return nil, ErrDockerConfigIdentifierNotFound
+ }
+
+ configID := configObject[configIdentifier].(string)
+ configObject, access := applyResourceAccessControl(configObject, configID, context)
+ if access {
+ filteredConfigData = append(filteredConfigData, configObject)
+ }
+ }
+
+ return filteredConfigData, nil
+}
diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go
index d5febb22a..83edcaf37 100644
--- a/api/http/proxy/transport.go
+++ b/api/http/proxy/transport.go
@@ -41,6 +41,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
path := request.URL.Path
switch {
+ case strings.HasPrefix(path, "/configs"):
+ return p.proxyConfigRequest(request)
case strings.HasPrefix(path, "/containers"):
return p.proxyContainerRequest(request)
case strings.HasPrefix(path, "/services"):
@@ -62,6 +64,24 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
}
}
+func (p *proxyTransport) proxyConfigRequest(request *http.Request) (*http.Response, error) {
+ switch requestPath := request.URL.Path; requestPath {
+ case "/configs/create":
+ return p.executeDockerRequest(request)
+
+ case "/configs":
+ return p.rewriteOperation(request, configListOperation)
+
+ default:
+ // assume /configs/{id}
+ if request.Method == http.MethodGet {
+ return p.rewriteOperation(request, configInspectOperation)
+ }
+ configID := path.Base(requestPath)
+ return p.restrictedOperation(request, configID)
+ }
+}
+
func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/containers/create":
diff --git a/api/portainer.go b/api/portainer.go
index 23de2f91f..63916d08b 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -449,4 +449,6 @@ const (
SecretResourceControl
// StackResourceControl represents a resource control associated to a stack composed of Docker services
StackResourceControl
+ // ConfigResourceControl represents a resource control associated to a Docker config
+ ConfigResourceControl
)
diff --git a/app/__module.js b/app/__module.js
index 45dfe4e31..e4468a0d0 100644
--- a/app/__module.js
+++ b/app/__module.js
@@ -16,6 +16,8 @@ angular.module('portainer', [
'portainer.services',
'auth',
'dashboard',
+ 'config',
+ 'configs',
'container',
'containerConsole',
'containerLogs',
@@ -23,6 +25,7 @@ angular.module('portainer', [
'containerInspect',
'serviceLogs',
'containers',
+ 'createConfig',
'createContainer',
'createNetwork',
'createRegistry',
diff --git a/app/components/config/config.html b/app/components/config/config.html
new file mode 100644
index 000000000..ec7497c79
--- /dev/null
+++ b/app/components/config/config.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+ Configs > {{ config.Name }}
+
+
+
+
+
+
+
+
+
+
+
+ Name |
+ {{ config.Name }} |
+
+
+ ID |
+
+ {{ config.Id }}
+
+ |
+
+
+ Created |
+ {{ config.CreatedAt | getisodate }} |
+
+
+ Last updated |
+ {{ config.UpdatedAt | getisodate }} |
+
+
+ Labels |
+
+
+
+ {{ k }} |
+ {{ v }} |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/config/configController.js b/app/components/config/configController.js
new file mode 100644
index 000000000..6ae37c21f
--- /dev/null
+++ b/app/components/config/configController.js
@@ -0,0 +1,45 @@
+angular.module('config', [])
+.controller('ConfigController', ['$scope', '$transition$', '$state', '$document', 'ConfigService', 'Notifications', 'CodeMirrorService',
+function ($scope, $transition$, $state, $document, ConfigService, Notifications, CodeMirrorService) {
+
+ $scope.removeConfig = function removeConfig(configId) {
+ $('#loadingViewSpinner').show();
+ ConfigService.remove(configId)
+ .then(function success(data) {
+ Notifications.success('Config successfully removed');
+ $state.go('configs', {});
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to remove config');
+ })
+ .finally(function final() {
+ $('#loadingViewSpinner').hide();
+ });
+ };
+
+ function initEditor() {
+ $document.ready(function() {
+ var webEditorElement = $document[0].getElementById('config-editor');
+ if (webEditorElement) {
+ $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, true);
+ }
+ });
+ }
+
+ function initView() {
+ $('#loadingViewSpinner').show();
+ ConfigService.config($transition$.params().id)
+ .then(function success(data) {
+ $scope.config = data;
+ initEditor();
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to retrieve config details');
+ })
+ .finally(function final() {
+ $('#loadingViewSpinner').hide();
+ });
+ }
+
+ initView();
+}]);
diff --git a/app/components/configs/configs.html b/app/components/configs/configs.html
new file mode 100644
index 000000000..3464a8508
--- /dev/null
+++ b/app/components/configs/configs.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+ Configs
+
+
+
diff --git a/app/components/configs/configsController.js b/app/components/configs/configsController.js
new file mode 100644
index 000000000..64f894986
--- /dev/null
+++ b/app/components/configs/configsController.js
@@ -0,0 +1,76 @@
+angular.module('configs', [])
+.controller('ConfigsController', ['$scope', '$stateParams', '$state', 'ConfigService', 'Notifications', 'Pagination',
+function ($scope, $stateParams, $state, ConfigService, Notifications, Pagination) {
+ $scope.state = {};
+ $scope.state.selectedItemCount = 0;
+ $scope.state.pagination_count = Pagination.getPaginationCount('configs');
+ $scope.sortType = 'Name';
+ $scope.sortReverse = false;
+
+ $scope.order = function (sortType) {
+ $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
+ $scope.sortType = sortType;
+ };
+
+ $scope.selectItems = function (allSelected) {
+ angular.forEach($scope.state.filteredConfigs, function (config) {
+ if (config.Checked !== allSelected) {
+ config.Checked = allSelected;
+ $scope.selectItem(config);
+ }
+ });
+ };
+
+ $scope.selectItem = function (item) {
+ if (item.Checked) {
+ $scope.state.selectedItemCount++;
+ } else {
+ $scope.state.selectedItemCount--;
+ }
+ };
+
+ $scope.removeAction = function () {
+ $('#loadingViewSpinner').show();
+ var counter = 0;
+ var complete = function () {
+ counter = counter - 1;
+ if (counter === 0) {
+ $('#loadingViewSpinner').hide();
+ }
+ };
+ angular.forEach($scope.configs, function (config) {
+ if (config.Checked) {
+ counter = counter + 1;
+ ConfigService.remove(config.Id)
+ .then(function success() {
+ Notifications.success('Config deleted', config.Id);
+ var index = $scope.configs.indexOf(config);
+ $scope.configs.splice(index, 1);
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to remove config');
+ })
+ .finally(function final() {
+ complete();
+ });
+ }
+ });
+ };
+
+ function initView() {
+ $('#loadingViewSpinner').show();
+ ConfigService.configs()
+ .then(function success(data) {
+ $scope.configs = data;
+ })
+ .catch(function error(err) {
+ $scope.configs = [];
+ Notifications.error('Failure', err, 'Unable to retrieve configs');
+ })
+ .finally(function final() {
+ $('#loadingViewSpinner').hide();
+ });
+ }
+
+ initView();
+}]);
diff --git a/app/components/createConfig/createConfigController.js b/app/components/createConfig/createConfigController.js
new file mode 100644
index 000000000..f02a334e3
--- /dev/null
+++ b/app/components/createConfig/createConfigController.js
@@ -0,0 +1,102 @@
+angular.module('createConfig', [])
+.controller('CreateConfigController', ['$scope', '$state', '$document', 'Notifications', 'ConfigService', 'Authentication', 'FormValidator', 'ResourceControlService', 'CodeMirrorService',
+function ($scope, $state, $document, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService, CodeMirrorService) {
+
+ $scope.formValues = {
+ Name: '',
+ Labels: [],
+ AccessControlData: new AccessControlFormData()
+ };
+
+ $scope.state = {
+ formValidationError: ''
+ };
+
+ $scope.addLabel = function() {
+ $scope.formValues.Labels.push({ name: '', value: ''});
+ };
+
+ $scope.removeLabel = function(index) {
+ $scope.formValues.Labels.splice(index, 1);
+ };
+
+ function prepareLabelsConfig(config) {
+ var labels = {};
+ $scope.formValues.Labels.forEach(function (label) {
+ if (label.name && label.value) {
+ labels[label.name] = label.value;
+ }
+ });
+ config.Labels = labels;
+ }
+
+ function prepareConfigData(config) {
+ // The codemirror editor does not work with ng-model so we need to retrieve
+ // the value directly from the editor.
+ var configData = $scope.editor.getValue();
+ config.Data = btoa(unescape(encodeURIComponent(configData)));
+ }
+
+ function prepareConfiguration() {
+ var config = {};
+ config.Name = $scope.formValues.Name;
+ prepareConfigData(config);
+ prepareLabelsConfig(config);
+ return config;
+ }
+
+ function validateForm(accessControlData, isAdmin) {
+ $scope.state.formValidationError = '';
+ var error = '';
+ error = FormValidator.validateAccessControl(accessControlData, isAdmin);
+
+ if (error) {
+ $scope.state.formValidationError = error;
+ return false;
+ }
+ return true;
+ }
+
+ $scope.create = function () {
+ $('#createResourceSpinner').show();
+
+ var accessControlData = $scope.formValues.AccessControlData;
+ var userDetails = Authentication.getUserDetails();
+ var isAdmin = userDetails.role === 1 ? true : false;
+
+ if (!validateForm(accessControlData, isAdmin)) {
+ $('#createResourceSpinner').hide();
+ return;
+ }
+
+ var config = prepareConfiguration();
+
+ ConfigService.create(config)
+ .then(function success(data) {
+ var configIdentifier = data.ID;
+ var userId = userDetails.ID;
+ return ResourceControlService.applyResourceControl('config', configIdentifier, userId, accessControlData, []);
+ })
+ .then(function success() {
+ Notifications.success('Config successfully created');
+ $state.go('configs', {}, {reload: true});
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to create config');
+ })
+ .finally(function final() {
+ $('#createResourceSpinner').hide();
+ });
+ };
+
+ function initView() {
+ $document.ready(function() {
+ var webEditorElement = $document[0].getElementById('config-editor', false);
+ if (webEditorElement) {
+ $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, false);
+ }
+ });
+ }
+
+ initView();
+}]);
diff --git a/app/components/createConfig/createconfig.html b/app/components/createConfig/createconfig.html
new file mode 100644
index 000000000..5eee991ca
--- /dev/null
+++ b/app/components/createConfig/createconfig.html
@@ -0,0 +1,74 @@
+
+
+
+ Configs > Add config
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js
index 887fe7cb3..3e3c5ecb1 100644
--- a/app/components/createService/createServiceController.js
+++ b/app/components/createService/createServiceController.js
@@ -1,8 +1,8 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createService', [])
-.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService',
-function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) {
+.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'ConfigService', 'ConfigHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService',
+function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, ConfigHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) {
$scope.formValues = {
Name: '',
@@ -28,6 +28,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
UpdateOrder: 'stop-first',
FailureAction: 'pause',
Secrets: [],
+ Configs: [],
AccessControlData: new AccessControlFormData(),
CpuLimit: 0,
CpuReservation: 0,
@@ -71,6 +72,14 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
$scope.formValues.Volumes.splice(index, 1);
};
+ $scope.addConfig = function() {
+ $scope.formValues.Configs.push({});
+ };
+
+ $scope.removeConfig = function(index) {
+ $scope.formValues.Configs.splice(index, 1);
+ };
+
$scope.addSecret = function() {
$scope.formValues.Secrets.push({});
};
@@ -222,6 +231,20 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences);
}
+ function prepareConfigConfig(config, input) {
+ if (input.Configs) {
+ var configs = [];
+ angular.forEach(input.Configs, function(config) {
+ if (config.model) {
+ var s = ConfigHelper.configConfig(config.model);
+ s.File.Name = config.FileName || s.File.Name;
+ configs.push(s);
+ }
+ });
+ config.TaskTemplate.ContainerSpec.Configs = configs;
+ }
+ }
+
function prepareSecretConfig(config, input) {
if (input.Secrets) {
var secrets = [];
@@ -294,6 +317,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
prepareVolumes(config, input);
prepareNetworks(config, input);
prepareUpdateConfig(config, input);
+ prepareConfigConfig(config, input);
prepareSecretConfig(config, input);
preparePlacementConfig(config, input);
prepareResourcesCpuConfig(config, input);
@@ -382,8 +406,9 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
$q.all({
volumes: VolumeService.volumes(),
- secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
networks: NetworkService.networks(true, true, false, false),
+ secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
+ configs: apiVersion >= 1.30 ? ConfigService.configs() : [],
nodes: NodeService.nodes(),
settings: SettingsService.publicSettings()
})
@@ -391,6 +416,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
$scope.availableVolumes = data.volumes;
$scope.availableNetworks = data.networks;
$scope.availableSecrets = data.secrets;
+ $scope.availableConfigs = data.configs;
var nodes = data.nodes;
initSlidersMaxValuesBasedOnNodeData(nodes);
var settings = data.settings;
diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html
index c026df3de..b59053509 100644
--- a/app/components/createService/createservice.html
+++ b/app/components/createService/createservice.html
@@ -133,6 +133,7 @@
Labels
Update config
Secrets
+ Configs
Resources & Placement
@@ -442,6 +443,9 @@
+
+
+
diff --git a/app/components/createService/includes/config.html b/app/components/createService/includes/config.html
new file mode 100644
index 000000000..8083ae2f2
--- /dev/null
+++ b/app/components/createService/includes/config.html
@@ -0,0 +1,27 @@
+
diff --git a/app/components/createStack/createStackController.js b/app/components/createStack/createStackController.js
index 7b577373d..bb358ee58 100644
--- a/app/components/createStack/createStackController.js
+++ b/app/components/createStack/createStackController.js
@@ -101,7 +101,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
- $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
+ $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false);
if (value) {
$scope.editor.setValue(value);
}
diff --git a/app/components/secrets/secrets.html b/app/components/secrets/secrets.html
index b274ae777..efd4107b3 100644
--- a/app/components/secrets/secrets.html
+++ b/app/components/secrets/secrets.html
@@ -16,7 +16,7 @@
@@ -39,8 +39,8 @@
Created at
-
-
+
+
|
diff --git a/app/components/secrets/secretsController.js b/app/components/secrets/secretsController.js
index 1aa890504..fc71dd58b 100644
--- a/app/components/secrets/secretsController.js
+++ b/app/components/secrets/secretsController.js
@@ -1,6 +1,6 @@
angular.module('secrets', [])
-.controller('SecretsController', ['$scope', '$transition$', '$state', 'SecretService', 'Notifications', 'Pagination',
-function ($scope, $transition$, $state, SecretService, Notifications, Pagination) {
+.controller('SecretsController', ['$scope', '$state', 'SecretService', 'Notifications', 'Pagination',
+function ($scope, $state, SecretService, Notifications, Pagination) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('secrets');
diff --git a/app/components/service/includes/configs.html b/app/components/service/includes/configs.html
new file mode 100644
index 000000000..e1eba62cd
--- /dev/null
+++ b/app/components/service/includes/configs.html
@@ -0,0 +1,62 @@
+
diff --git a/app/components/service/service.html b/app/components/service/service.html
index e11585c62..f54a46b69 100644
--- a/app/components/service/service.html
+++ b/app/components/service/service.html
@@ -117,6 +117,7 @@
Restart policy
Update configuration
Service labels
+ Configs
Secrets
Tasks
@@ -164,6 +165,7 @@
+
diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js
index 9ff2cb6e2..bf3f6d514 100644
--- a/app/components/service/serviceController.js
+++ b/app/components/service/serviceController.js
@@ -1,6 +1,6 @@
angular.module('service', [])
-.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
-function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
+.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
+function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('service_tasks');
@@ -59,6 +59,21 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
}
};
+ $scope.addConfig = function addConfig(service, config) {
+ if (config && service.ServiceConfigs.filter(function(serviceConfig) { return serviceConfig.Id === config.Id;}).length === 0) {
+ service.ServiceConfigs.push({ Id: config.Id, Name: config.Name, FileName: config.Name, Uid: '0', Gid: '0', Mode: 292 });
+ updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs);
+ }
+ };
+ $scope.removeConfig = function removeSecret(service, index) {
+ var removedElement = service.ServiceConfigs.splice(index, 1);
+ if (removedElement !== null) {
+ updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs);
+ }
+ };
+ $scope.updateConfig = function updateConfig(service) {
+ updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs);
+ };
$scope.addSecret = function addSecret(service, secret) {
if (secret && service.ServiceSecrets.filter(function(serviceSecret) { return serviceSecret.Id === secret.Id;}).length === 0) {
service.ServiceSecrets.push({ Id: secret.Id, Name: secret.Name, FileName: secret.Name, Uid: '0', Gid: '0', Mode: 444 });
@@ -193,6 +208,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels);
config.TaskTemplate.ContainerSpec.Image = service.Image;
config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : [];
+ config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : [];
if (service.Mode === 'replicated') {
config.Mode.Replicated.Replicas = service.Replicas;
@@ -289,6 +305,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
function translateServiceArrays(service) {
service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : [];
+ service.ServiceConfigs = service.Configs ? service.Configs.map(ConfigHelper.flattenConfig) : [];
service.EnvironmentVariables = ServiceHelper.translateEnvironmentVariables(service.Env);
service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels);
service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels);
@@ -323,12 +340,14 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
return $q.all({
tasks: TaskService.tasks({ service: [service.Name] }),
nodes: NodeService.nodes(),
- secrets: apiVersion >= 1.25 ? SecretService.secrets() : []
+ secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
+ configs: apiVersion >= 1.30 ? ConfigService.configs() : []
});
})
.then(function success(data) {
$scope.tasks = data.tasks;
$scope.nodes = data.nodes;
+ $scope.configs = data.configs;
$scope.secrets = data.secrets;
// Set max cpu value
@@ -350,6 +369,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
})
.catch(function error(err) {
$scope.secrets = [];
+ $scope.configs = [];
Notifications.error('Failure', err, 'Unable to retrieve service details');
})
.finally(function final() {
diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html
index 828041ddf..db12a7dca 100644
--- a/app/components/sidebar/sidebar.html
+++ b/app/components/sidebar/sidebar.html
@@ -44,6 +44,9 @@
+
diff --git a/app/components/stack/stackController.js b/app/components/stack/stackController.js
index 7d287c0f5..b8147f899 100644
--- a/app/components/stack/stackController.js
+++ b/app/components/stack/stackController.js
@@ -57,7 +57,7 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
- $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
+ $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false);
}
});
diff --git a/app/helpers/configHelper.js b/app/helpers/configHelper.js
new file mode 100644
index 000000000..f47eacc7e
--- /dev/null
+++ b/app/helpers/configHelper.js
@@ -0,0 +1,34 @@
+angular.module('portainer.helpers')
+.factory('ConfigHelper', [function ConfigHelperFactory() {
+ 'use strict';
+ return {
+ flattenConfig: function(config) {
+ if (config) {
+ return {
+ Id: config.ConfigID,
+ Name: config.ConfigName,
+ FileName: config.File.Name,
+ Uid: config.File.UID,
+ Gid: config.File.GID,
+ Mode: config.File.Mode
+ };
+ }
+ return {};
+ },
+ configConfig: function(config) {
+ if (config) {
+ return {
+ ConfigID: config.Id,
+ ConfigName: config.Name,
+ File: {
+ Name: config.FileName || config.Name,
+ UID: config.Uid || '0',
+ GID: config.Gid || '0',
+ Mode: config.Mode || 292
+ }
+ };
+ }
+ return {};
+ }
+ };
+}]);
diff --git a/app/helpers/secretHelper.js b/app/helpers/secretHelper.js
index 9c0f3d65b..afd3b1a56 100644
--- a/app/helpers/secretHelper.js
+++ b/app/helpers/secretHelper.js
@@ -22,9 +22,9 @@ angular.module('portainer.helpers')
SecretName: secret.Name,
File: {
Name: secret.FileName,
- UID: '0',
- GID: '0',
- Mode: 444
+ UID: secret.Uid || '0',
+ GID: secret.Gid || '0',
+ Mode: secret.Mode || 444
}
};
}
diff --git a/app/models/docker/config.js b/app/models/docker/config.js
new file mode 100644
index 000000000..214909b5a
--- /dev/null
+++ b/app/models/docker/config.js
@@ -0,0 +1,15 @@
+function ConfigViewModel(data) {
+ this.Id = data.ID;
+ this.CreatedAt = data.CreatedAt;
+ this.UpdatedAt = data.UpdatedAt;
+ this.Version = data.Version.Index;
+ this.Name = data.Spec.Name;
+ this.Labels = data.Spec.Labels;
+ this.Data = atob(data.Spec.Data);
+
+ if (data.Portainer) {
+ if (data.Portainer.ResourceControl) {
+ this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
+ }
+ }
+}
diff --git a/app/models/docker/service.js b/app/models/docker/service.js
index 28d8609ba..5268bb43e 100644
--- a/app/models/docker/service.js
+++ b/app/models/docker/service.js
@@ -69,6 +69,7 @@ function ServiceViewModel(data, runningTasks, nodes) {
this.Hosts = containerSpec.Hosts;
this.DNSConfig = containerSpec.DNSConfig;
this.Secrets = containerSpec.Secrets;
+ this.Configs = containerSpec.Configs;
}
if (data.Endpoint) {
this.Ports = data.Endpoint.Ports;
diff --git a/app/rest/docker/config.js b/app/rest/docker/config.js
new file mode 100644
index 000000000..330692fbe
--- /dev/null
+++ b/app/rest/docker/config.js
@@ -0,0 +1,12 @@
+angular.module('portainer.rest')
+.factory('Config', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ConfigFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
+ 'use strict';
+ return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/configs/:id/:action', {
+ endpointId: EndpointProvider.endpointID
+ }, {
+ get: { method: 'GET', params: { id: '@id' } },
+ query: { method: 'GET', isArray: true },
+ create: { method: 'POST', params: { action: 'create' } },
+ remove: { method: 'DELETE', params: { id: '@id' } }
+ });
+}]);
diff --git a/app/routes.js b/app/routes.js
index 87adbf0f6..265096184 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -26,6 +26,32 @@ function configureRoutes($stateProvider) {
requiresLogin: false
}
})
+ .state('configs', {
+ url: '^/configs/',
+ views: {
+ 'content@': {
+ templateUrl: 'app/components/configs/configs.html',
+ controller: 'ConfigsController'
+ },
+ 'sidebar@': {
+ templateUrl: 'app/components/sidebar/sidebar.html',
+ controller: 'SidebarController'
+ }
+ }
+ })
+ .state('config', {
+ url: '^/config/:id/',
+ views: {
+ 'content@': {
+ templateUrl: 'app/components/config/config.html',
+ controller: 'ConfigController'
+ },
+ 'sidebar@': {
+ templateUrl: 'app/components/sidebar/sidebar.html',
+ controller: 'SidebarController'
+ }
+ }
+ })
.state('containers', {
parent: 'root',
url: '/containers/',
@@ -156,6 +182,19 @@ function configureRoutes($stateProvider) {
}
}
})
+ .state('actions.create.config', {
+ url: '/config',
+ views: {
+ 'content@': {
+ templateUrl: 'app/components/createConfig/createconfig.html',
+ controller: 'CreateConfigController'
+ },
+ 'sidebar@': {
+ templateUrl: 'app/components/sidebar/sidebar.html',
+ controller: 'SidebarController'
+ }
+ }
+ })
.state('actions.create.container', {
url: '/container/:from',
views: {
diff --git a/app/services/codeMirror.js b/app/services/codeMirror.js
index b5807f87e..e4cf3b821 100644
--- a/app/services/codeMirror.js
+++ b/app/services/codeMirror.js
@@ -2,8 +2,11 @@ angular.module('portainer.services')
.factory('CodeMirrorService', function CodeMirrorService() {
'use strict';
- var codeMirrorOptions = {
- lineNumbers: true,
+ var codeMirrorGenericOptions = {
+ lineNumbers: true
+ };
+
+ var codeMirrorYAMLOptions = {
mode: 'text/x-yaml',
gutters: ['CodeMirror-lint-markers'],
lint: true
@@ -11,8 +14,18 @@ angular.module('portainer.services')
var service = {};
- service.applyCodeMirrorOnElement = function(element) {
- var cm = CodeMirror.fromTextArea(element, codeMirrorOptions);
+ service.applyCodeMirrorOnElement = function(element, yamlLint, readOnly) {
+ var options = codeMirrorGenericOptions;
+
+ if (yamlLint) {
+ options = codeMirrorYAMLOptions;
+ }
+
+ if (readOnly) {
+ options.readOnly = true;
+ }
+
+ var cm = CodeMirror.fromTextArea(element, options);
cm.setSize('100%', 500);
return cm;
};
diff --git a/app/services/docker/configService.js b/app/services/docker/configService.js
new file mode 100644
index 000000000..530c689e7
--- /dev/null
+++ b/app/services/docker/configService.js
@@ -0,0 +1,61 @@
+angular.module('portainer.services')
+.factory('ConfigService', ['$q', 'Config', function ConfigServiceFactory($q, Config) {
+ 'use strict';
+ var service = {};
+
+ service.config = function(configId) {
+ var deferred = $q.defer();
+
+ Config.get({id: configId}).$promise
+ .then(function success(data) {
+ var config = new ConfigViewModel(data);
+ deferred.resolve(config);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve config details', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.configs = function() {
+ var deferred = $q.defer();
+
+ Config.query({}).$promise
+ .then(function success(data) {
+ var configs = data.map(function (item) {
+ return new ConfigViewModel(item);
+ });
+ deferred.resolve(configs);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve configs', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.remove = function(configId) {
+ var deferred = $q.defer();
+
+ Config.remove({ id: configId }).$promise
+ .then(function success(data) {
+ if (data.message) {
+ deferred.reject({ msg: data.message });
+ } else {
+ deferred.resolve();
+ }
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to remove config', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.create = function(config) {
+ return Config.create(config).$promise;
+ };
+
+ return service;
+}]);
|