feat(configs): add support for docker configs (#996)

pull/1360/head
Thomas Kooi 7 years ago committed by Anthony Lapenna
parent ade66414a4
commit 407f0f5807

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

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

@ -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":

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

@ -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',

@ -0,0 +1,81 @@
<rd-header>
<rd-header-title title="Config details">
<a data-toggle="tooltip" title="Refresh" ui-sref="config({id: config.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="configs">Configs</a> &gt; <a ui-sref="config({id: config.Id})">{{ config.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-file-code-o" title="Config details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>{{ config.Name }}</td>
</tr>
<tr>
<td>ID</td>
<td>
{{ config.Id }}
<button class="btn btn-xs btn-danger" ng-click="removeConfig(config.Id)"><i class="fa fa-trash space-right" aria-hidden="true"></i>Delete this config</button>
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ config.CreatedAt | getisodate }}</td>
</tr>
<tr>
<td>Last updated</td>
<td>{{ config.UpdatedAt | getisodate }}</td>
</tr>
<tr ng-if="!(config.Labels | emptyobject)">
<td>Labels</td>
<td>
<table class="table table-bordered table-condensed">
<tr ng-repeat="(k, v) in config.Labels">
<td>{{ k }}</td>
<td>{{ v }}</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="config && applicationState.application.authentication"
resource-id="config.Id"
resource-control="config.ResourceControl"
resource-type="'config'">
</por-access-control-panel>
<!-- !access-control-panel -->
<div class="row" ng-if="config">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-file-code-o" title="Config content"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<textarea id="config-editor" ng-model="config.Data" class="form-control"></textarea>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

@ -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();
}]);

@ -0,0 +1,81 @@
<rd-header>
<rd-header-title title="Configs list">
<a data-toggle="tooltip" title="Refresh" ui-sref="configs" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>Configs</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-file-code-o" title="Configs">
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.config"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add config</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('CreatedAt')">
Created at
<span ng-show="sortType == 'CreatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CreatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ng-click="order('ResourceControl.Ownership')">
Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</thead>
<tbody>
<tr dir-paginate="config in (state.filteredConfigs = ( configs | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: pagination_count))">
<td><input type="checkbox" ng-model="config.Checked" ng-change="selectItem(config)"/></td>
<td><a ui-sref="config({id: config.Id})">{{ config.Name }}</a></td>
<td>{{ config.CreatedAt | getisodate }}</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="config.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ config.ResourceControl.Ownership ? config.ResourceControl.Ownership : config.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!configs">
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="configs.length == 0">
<td colspan="4" class="text-center text-muted">No configs available.</td>
</tr>
</tbody>
</table>
<div ng-if="configs" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

@ -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();
}]);

@ -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();
}]);

@ -0,0 +1,74 @@
<rd-header>
<rd-header-title title="Create config"></rd-header-title>
<rd-header-content>
<a ui-sref="configs">Configs</a> &gt; Add config
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="config_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="formValues.Name" id="config_name" placeholder="e.g. myConfig">
</div>
</div>
<!-- !name-input -->
<!-- config-data -->
<div class="form-group">
<div class="col-sm-12">
<textarea id="config-editor" class="form-control"></textarea>
</div>
</div>
<!-- !config-data -->
<!-- labels -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
</div>
<!-- !labels-->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name" ng-click="create()">Create config</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="configs">Cancel</a>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

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

@ -133,6 +133,7 @@
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#update-config" data-toggle="tab">Update config</a></li>
<li class="interactive" ng-if="applicationState.endpoint.apiVersion >= 1.25"><a data-target="#secrets" data-toggle="tab">Secrets</a></li>
<li class="interactive"><a data-target="#configs" data-toggle="tab" ng-if="applicationState.endpoint.apiVersion >= 1.30">Configs</a></li>
<li class="interactive"><a data-target="#resources-placement" data-toggle="tab" ng-click="refreshSlider()">Resources & Placement</a></li>
</ul>
<!-- tab-content -->
@ -442,6 +443,9 @@
<!-- tab-secrets -->
<div class="tab-pane" id="secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" ng-include="'app/components/createService/includes/secret.html'"></div>
<!-- !tab-secrets -->
<!-- tab-configs -->
<div class="tab-pane" id="configs" ng-if="applicationState.endpoint.apiVersion >= 1.30" ng-include="'app/components/createService/includes/config.html'"></div>
<!-- !tab-configs -->
<!-- tab-resources-placement -->
<div class="tab-pane" id="resources-placement" ng-include="'app/components/createService/includes/resources-placement.html'"></div>
<!-- !tab-resources-placement -->

@ -0,0 +1,27 @@
<form class="form-horizontal" style="margin-top: 15px;">
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Configs</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addConfig()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add a config
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="config in formValues.Configs" style="margin-top: 2px;">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">config</span>
<select class="form-control" ng-model="config.model" ng-options="config.Name for config in availableConfigs">
<option value="" selected="selected">Select a config</option>
</select>
</div>
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">Path in container</span>
<input class="form-control" ng-model="config.FileName" placeholder="e.g. /path/in/container" />
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeConfig($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</form>

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

@ -16,7 +16,7 @@
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.secret">Add secret</a>
<a class="btn btn-primary" type="button" ui-sref="actions.create.secret"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add secret</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
@ -39,8 +39,8 @@
<th>
<a ng-click="order('CreatedAt')">
Created at
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'CreatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CreatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">

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

@ -0,0 +1,62 @@
<form ng-if="applicationState.endpoint.apiVersion >= 1.30" id="service-configs" ng-submit="updateService(service)">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Configs">
</rd-widget-header>
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px;">
Add a config:
<select class="form-control" ng-options="config.Name for config in configs" ng-model="newConfig">
<option selected disabled hidden value="">Select a config</option>
</select>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add config
</a>
</div>
<table class="table" style="margin-top: 5px;">
<thead>
<tr>
<th>Name</th>
<th>Path in container</th>
<th>UID</th>
<th>GID</th>
<th>Mode</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="config in service.ServiceConfigs">
<td><a ui-sref="config({id: config.Id})">{{ config.Name }}</a></td>
<td>
<input class="form-control" ng-model="config.FileName" ng-change="updateConfig(service)" placeholder="e.g. /path/in/container" required />
</td>
<td>{{ config.Uid }}</td>
<td>{{ config.Gid }}</td>
<td>{{ config.Mode }}</td>
<td>
<button class="btn btn-xs btn-danger pull-right" type="button" ng-click="removeConfig(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i> Remove config
</button>
</td>
</tr>
<tr ng-if="service.ServiceConfigs.length === 0">
<td colspan="6" class="text-center text-muted">No configs associated to this service.</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceConfigs'])">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceConfigs'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</form>

@ -117,6 +117,7 @@
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
</ul>
@ -164,6 +165,7 @@
<div id="service-restart-policy" class="padding-top" ng-include="'app/components/service/includes/restart.html'"></div>
<div id="service-update-config" class="padding-top" ng-include="'app/components/service/includes/updateconfig.html'"></div>
<div id="service-labels" class="padding-top" ng-include="'app/components/service/includes/servicelabels.html'"></div>
<div id="service-configs" class="padding-top" ng-include="'app/components/service/includes/configs.html'"></div>
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/components/service/includes/secrets.html'"></div>
<div id="service-tasks" class="padding-top" ng-include="'app/components/service/includes/tasks.html'"></div>
</div>

@ -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() {

@ -44,6 +44,9 @@
<li class="sidebar-list">
<a ui-sref="volumes" ui-sref-active="active">Volumes <span class="menu-icon fa fa-cubes"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.30 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="configs" ui-sref-active="active">Configs <span class="menu-icon fa fa-file-code-o"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="secrets" ui-sref-active="active">Secrets <span class="menu-icon fa fa-user-secret"></span></a>
</li>

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

@ -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 {};
}
};
}]);

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

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

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

@ -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' } }
});
}]);

@ -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: {

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

@ -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;
}]);
Loading…
Cancel
Save