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 + + +
+
+ + + + +
+ + Add config +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + Name + + + + + + Created at + + + + + + Ownership + + + +
{{ config.Name }}{{ config.CreatedAt | getisodate }} + + + {{ config.ResourceControl.Ownership ? config.ResourceControl.Ownership : config.ResourceControl.Ownership = 'public' }} + +
Loading...
No configs available.
+
+ +
+
+
+ +
+
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 + + + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + + add label + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ + + + + +
+ Actions +
+
+
+ + Cancel + +
+
+ +
+
+
+
+
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 @@ +
    +
    +
    + + + add a config + +
    +
    +
    +
    + config + +
    +
    + Path in container + +
    + +
    +
    +
    +
    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 @@
    - Add secret + Add secret
    @@ -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 @@ +
    + + + + +
    + Add a config: + + + add config + +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    NamePath in containerUIDGIDMode
    {{ config.Name }} + + {{ config.Uid }}{{ config.Gid }}{{ config.Mode }} + +
    No configs associated to this service.
    +
    + + + +
    +
    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; +}]);