From 44e48423ed641befe8723c75e82e94328b79aead Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 8 Apr 2017 19:38:19 +0100 Subject: [PATCH 01/39] fix(endpoint-init): fix an issue when connecting to a remote TLS endpoint (#783) --- app/components/endpointInit/endpointInit.html | 4 +- .../endpointInit/endpointInitController.js | 62 +++++++++---------- app/services/endpointService.js | 27 +++----- 3 files changed, 43 insertions(+), 50 deletions(-) diff --git a/app/components/endpointInit/endpointInit.html b/app/components/endpointInit/endpointInit.html index faf06866c..8c7abf963 100644 --- a/app/components/endpointInit/endpointInit.html +++ b/app/components/endpointInit/endpointInit.html @@ -21,10 +21,10 @@
- +
- +
diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js index 911f6c994..afaf48321 100644 --- a/app/components/endpointInit/endpointInitController.js +++ b/app/components/endpointInit/endpointInitController.js @@ -5,6 +5,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messa error: '', uploadInProgress: false }; + $scope.formValues = { endpointType: "remote", Name: '', @@ -19,10 +20,29 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messa $state.go('dashboard'); } - $scope.cleanError = function() { + $scope.resetErrorMessage = function() { $scope.state.error = ''; }; + function showErrorMessage(message) { + $scope.state.uploadInProgress = false; + $scope.state.error = message; + } + + function updateEndpointState(endpointID) { + EndpointProvider.setEndpointID(endpointID); + StateManager.updateEndpointState(false) + .then(function success(data) { + $state.go('dashboard'); + }) + .catch(function error(err) { + EndpointService.deleteEndpoint(endpointID) + .then(function success() { + showErrorMessage('Unable to connect to the Docker endpoint'); + }); + }); + } + $scope.createLocalEndpoint = function() { $('#initEndpointSpinner').show(); $scope.state.error = ''; @@ -31,22 +51,10 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messa var TLS = false; EndpointService.createLocalEndpoint(name, URL, TLS, true) - .then( - function success(data) { + .then(function success(data) { var endpointID = data.Id; - EndpointProvider.setEndpointID(endpointID); - StateManager.updateEndpointState(false).then( - function success() { - $state.go('dashboard'); - }, - function error(err) { - EndpointService.deleteEndpoint(endpointID) - .then(function success() { - $scope.state.error = 'Unable to connect to the Docker endpoint'; - }); - }); - }, - function error() { + updateEndpointState(data.Id); + }, function error() { $scope.state.error = 'Unable to create endpoint'; }) .finally(function final() { @@ -63,28 +71,20 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messa var TLSCAFile = $scope.formValues.TLSCACert; var TLSCertFile = $scope.formValues.TLSCert; var TLSKeyFile = $scope.formValues.TLSKey; - EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, TLS ? false : true) + + EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { var endpointID = data.Id; - EndpointProvider.setEndpointID(endpointID); - StateManager.updateEndpointState(false) - .then(function success() { - $state.go('dashboard'); - }, function error(err) { - EndpointService.deleteEndpoint(endpointID) - .then(function success() { - $('#initEndpointSpinner').hide(); - $scope.state.error = 'Unable to connect to the Docker endpoint'; - }); - }); + updateEndpointState(endpointID); }, function error(err) { - $('#initEndpointSpinner').hide(); - $scope.state.uploadInProgress = false; - $scope.state.error = err.msg; + showErrorMessage(err.msg); }, function update(evt) { if (evt.upload) { $scope.state.uploadInProgress = evt.upload; } + }) + .finally(function final() { + $('#initEndpointSpinner').hide(); }); }; }]); diff --git a/app/services/endpointService.js b/app/services/endpointService.js index 9531cceff..9202c695e 100644 --- a/app/services/endpointService.js +++ b/app/services/endpointService.js @@ -53,37 +53,30 @@ angular.module('portainer.services') return Endpoints.create({}, endpoint).$promise; }; - service.createRemoteEndpoint = function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, active) { + service.createRemoteEndpoint = function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) { var endpoint = { Name: name, URL: 'tcp://' + URL, TLS: TLS }; var deferred = $q.defer(); - Endpoints.create({active: active}, endpoint, function success(data) { + Endpoints.create({}, endpoint).$promise + .then(function success(data) { var endpointID = data.Id; if (TLS) { deferred.notify({upload: true}); - FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { + FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) + .then(function success() { deferred.notify({upload: false}); - if (active) { - Endpoints.setActiveEndpoint({}, {id: endpointID}, function success(data) { - deferred.resolve(data); - }, function error(err) { - deferred.reject({msg: 'Unable to create endpoint', err: err}); - }); - } else { - deferred.resolve(data); - } - }, function error(err) { - deferred.notify({upload: false}); - deferred.reject({msg: 'Unable to upload TLS certs', err: err}); + deferred.resolve(data); }); } else { deferred.resolve(data); } - }, function error(err) { - deferred.reject({msg: 'Unable to create endpoint', err: err}); + }) + .catch(function error(err) { + deferred.notify({upload: false}); + deferred.reject({msg: 'Unable to upload TLS certs', err: err}); }); return deferred.promise; }; From abc929824c328a0534b7fb809493479e47e79848 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 9 Apr 2017 19:38:41 +0100 Subject: [PATCH 02/39] fix(endpoints): add the ability to update TLS for an existing endpoint (#784) --- api/http/docker_handler.go | 44 ++---------- api/http/endpoint_handler.go | 9 +++ api/http/proxy.go | 67 +++++++++++++++++++ api/http/server.go | 3 + api/portainer.go | 4 +- app/components/endpoint/endpointController.js | 1 + app/services/endpointService.js | 7 +- 7 files changed, 90 insertions(+), 45 deletions(-) create mode 100644 api/http/proxy.go diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go index cb89d0093..c50d3c23b 100644 --- a/api/http/docker_handler.go +++ b/api/http/docker_handler.go @@ -7,11 +7,9 @@ import ( "log" "net/http" - "net/url" "os" "github.com/gorilla/mux" - "github.com/orcaman/concurrent-map" ) // DockerHandler represents an HTTP API handler for proxying requests to the Docker API. @@ -19,8 +17,7 @@ type DockerHandler struct { *mux.Router Logger *log.Logger EndpointService portainer.EndpointService - ProxyFactory ProxyFactory - proxies cmap.ConcurrentMap + ProxyService *ProxyService } // NewDockerHandler returns a new instance of DockerHandler. @@ -28,10 +25,6 @@ func NewDockerHandler(mw *middleWareService, resourceControlService portainer.Re h := &DockerHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), - ProxyFactory: ProxyFactory{ - ResourceControlService: resourceControlService, - }, - proxies: cmap.New(), } h.PathPrefix("/{id}/").Handler( mw.authenticated(http.HandlerFunc(h.proxyRequestsToDockerAPI))) @@ -74,41 +67,14 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r } var proxy http.Handler - item, ok := handler.proxies.Get(string(endpointID)) - if !ok { - proxy, err = handler.createAndRegisterEndpointProxy(endpoint) + proxy = handler.ProxyService.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyService.CreateAndRegisterProxy(endpoint) if err != nil { Error(w, err, http.StatusBadRequest, handler.Logger) return } - } else { - proxy = item.(http.Handler) } + http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) } - -func (handler *DockerHandler) createAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - var proxy http.Handler - - endpointURL, err := url.Parse(endpoint.URL) - if err != nil { - return nil, err - } - - if endpointURL.Scheme == "tcp" { - if endpoint.TLS { - proxy, err = handler.ProxyFactory.newHTTPSProxy(endpointURL, endpoint) - if err != nil { - return nil, err - } - } else { - proxy = handler.ProxyFactory.newHTTPProxy(endpointURL) - } - } else { - // Assume unix:// scheme - proxy = handler.ProxyFactory.newSocketProxy(endpointURL.Path) - } - - handler.proxies.Set(string(endpoint.ID), proxy) - return proxy, nil -} diff --git a/api/http/endpoint_handler.go b/api/http/endpoint_handler.go index 2178ea475..162ceaa76 100644 --- a/api/http/endpoint_handler.go +++ b/api/http/endpoint_handler.go @@ -20,6 +20,7 @@ type EndpointHandler struct { authorizeEndpointManagement bool EndpointService portainer.EndpointService FileService portainer.FileService + ProxyService *ProxyService } const ( @@ -281,6 +282,12 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http } } + _, err = handler.ProxyService.CreateAndRegisterProxy(endpoint) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) @@ -320,6 +327,8 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h return } + handler.ProxyService.DeleteProxy(string(endpointID)) + err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) diff --git a/api/http/proxy.go b/api/http/proxy.go new file mode 100644 index 000000000..053656be5 --- /dev/null +++ b/api/http/proxy.go @@ -0,0 +1,67 @@ +package http + +import ( + "net/http" + "net/url" + + "github.com/orcaman/concurrent-map" + "github.com/portainer/portainer" +) + +// ProxyService represents a service used to manage Docker proxies. +type ProxyService struct { + proxyFactory *ProxyFactory + proxies cmap.ConcurrentMap +} + +// NewProxyService initializes a new ProxyService +func NewProxyService(resourceControlService portainer.ResourceControlService) *ProxyService { + return &ProxyService{ + proxies: cmap.New(), + proxyFactory: &ProxyFactory{ + ResourceControlService: resourceControlService, + }, + } +} + +// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (service *ProxyService) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + var proxy http.Handler + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + if endpointURL.Scheme == "tcp" { + if endpoint.TLS { + proxy, err = service.proxyFactory.newHTTPSProxy(endpointURL, endpoint) + if err != nil { + return nil, err + } + } else { + proxy = service.proxyFactory.newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = service.proxyFactory.newSocketProxy(endpointURL.Path) + } + + service.proxies.Set(string(endpoint.ID), proxy) + return proxy, nil +} + +// GetProxy returns the proxy associated to a key +func (service *ProxyService) GetProxy(key string) http.Handler { + proxy, ok := service.proxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// DeleteProxy deletes the proxy associated to a key +func (service *ProxyService) DeleteProxy(key string) { + service.proxies.Remove(key) +} diff --git a/api/http/server.go b/api/http/server.go index 974174b5c..916e2370f 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -29,6 +29,7 @@ func (server *Server) Start() error { jwtService: server.JWTService, authDisabled: server.AuthDisabled, } + proxyService := NewProxyService(server.ResourceControlService) var authHandler = NewAuthHandler(middleWareService) authHandler.UserService = server.UserService @@ -45,12 +46,14 @@ func (server *Server) Start() error { templatesHandler.containerTemplatesURL = server.TemplatesURL var dockerHandler = NewDockerHandler(middleWareService, server.ResourceControlService) dockerHandler.EndpointService = server.EndpointService + dockerHandler.ProxyService = proxyService var websocketHandler = NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService var endpointHandler = NewEndpointHandler(middleWareService) endpointHandler.authorizeEndpointManagement = server.EndpointManagement endpointHandler.EndpointService = server.EndpointService endpointHandler.FileService = server.FileService + endpointHandler.ProxyService = proxyService var uploadHandler = NewUploadHandler(middleWareService) uploadHandler.FileService = server.FileService var fileHandler = newFileHandler(server.AssetsPath) diff --git a/api/portainer.go b/api/portainer.go index b4772f771..fe02d6732 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,8 +1,6 @@ package portainer -import ( - "io" -) +import "io" type ( // Pair defines a key/value string pair diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index b221a4f14..c455e38dc 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -10,6 +10,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { error: '', uploadInProgress: false }; + $scope.formValues = { TLSCACert: null, TLSCert: null, diff --git a/app/services/endpointService.js b/app/services/endpointService.js index 9202c695e..e9486cd29 100644 --- a/app/services/endpointService.js +++ b/app/services/endpointService.js @@ -24,13 +24,14 @@ angular.module('portainer.services') if (endpointParams.type && endpointParams.URL) { query.URL = endpointParams.type === 'local' ? ("unix://" + endpointParams.URL) : ("tcp://" + endpointParams.URL); } + var deferred = $q.defer(); - Endpoints.update({id: id}, query).$promise + FileUploadService.uploadTLSFilesForEndpoint(id, endpointParams.TLSCACert, endpointParams.TLSCert, endpointParams.TLSKey) .then(function success() { - return FileUploadService.uploadTLSFilesForEndpoint(id, endpointParams.TLSCAFile, endpointParams.TLSCertFile, endpointParams.TLSKeyFile); + deferred.notify({upload: false}); + return Endpoints.update({id: id}, query).$promise; }) .then(function success(data) { - deferred.notify({upload: false}); deferred.resolve(data); }) .catch(function error(err) { From 8e8b0578b2cdec8b761e3c90b9ad70dd1ec88857 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 10 Apr 2017 19:01:15 +0200 Subject: [PATCH 03/39] docs(README): add docker pulls badge --- README.md | 1 + gruntfile.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 34b4e24ba..74b59b532 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

+[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/) [![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size") [![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/latest/?badge=stable) [![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) diff --git a/gruntfile.js b/gruntfile.js index 4abb9f753..f4891ec0b 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -408,7 +408,7 @@ module.exports = function (grunt) { command: [ 'docker stop portainer', 'docker rm portainer', - 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' + 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics --templates http://192.168.1.47:8080/templates.json' ].join(';') }, runSwarm: { From f15cf3e8be96861770667c10a41a1512cc5f3fb4 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 12 Apr 2017 20:47:22 +0100 Subject: [PATCH 04/39] feat(notifications): replace gritter with toastr (#793) --- app/app.js | 23 +- app/components/auth/authController.js | 12 +- .../container/containerController.js | 52 +-- .../containerConsoleController.js | 18 +- .../containerLogs/containerLogsController.js | 2 +- .../containers/containersController.js | 36 +- .../createContainerController.js | 24 +- .../createNetwork/createNetworkController.js | 10 +- .../createService/createServiceController.js | 16 +- .../createVolume/createVolumeController.js | 14 +- .../dashboard/dashboardController.js | 6 +- app/components/docker/dockerController.js | 8 +- app/components/endpoint/endpointController.js | 8 +- .../endpointAccessController.js | 22 +- .../endpointInit/endpointInitController.js | 4 +- .../endpoints/endpointsController.js | 12 +- app/components/events/eventsController.js | 6 +- app/components/image/imageController.js | 28 +- app/components/images/imagesController.js | 12 +- app/components/network/networkController.js | 22 +- app/components/networks/networksController.js | 18 +- app/components/node/nodeController.js | 12 +- app/components/service/serviceController.js | 20 +- app/components/services/servicesController.js | 24 +- app/components/settings/settingsController.js | 8 +- app/components/sidebar/sidebarController.js | 6 +- app/components/stats/statsController.js | 10 +- app/components/task/taskController.js | 8 +- .../templates/templatesController.js | 10 +- app/components/user/userController.js | 16 +- app/components/users/usersController.js | 12 +- app/components/volumes/volumesController.js | 22 +- app/services/messages.js | 40 -- app/services/notifications.js | 25 ++ assets/css/app.css | 4 + assets/js/jquery.gritter.js | 419 ------------------ bower.json | 4 +- gruntfile.js | 12 +- 38 files changed, 277 insertions(+), 728 deletions(-) delete mode 100644 app/services/messages.js create mode 100644 app/services/notifications.js delete mode 100755 assets/js/jquery.gritter.js diff --git a/app/app.js b/app/app.js index cb55a4f2d..09c01cc21 100644 --- a/app/app.js +++ b/app/app.js @@ -73,6 +73,8 @@ angular.module('portainer', [ $urlRouterProvider.otherwise('/auth'); + toastr.options.timeOut = 3000; + $uibTooltipProvider.setTriggers({ 'mouseenter': 'mouseleave', 'click': 'click', @@ -540,25 +542,8 @@ angular.module('portainer', [ } } }); - - // The Docker API likes to return plaintext errors, this catches them and disp - $httpProvider.interceptors.push(function() { - return { - 'response': function(response) { - if (typeof(response.data) === 'string' && - (_.startsWith(response.data, 'Conflict.') || _.startsWith(response.data, 'conflict:'))) { - $.gritter.add({ - title: 'Error', - text: $('
').text(response.data).html(), - time: 10000 - }); - } - return response; - } - }; - }); }]) - .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Messages', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Messages, Analytics) { + .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics) { EndpointProvider.initialize(); StateManager.initialize().then(function success(state) { if (state.application.authentication) { @@ -579,7 +564,7 @@ angular.module('portainer', [ }); } }, function error(err) { - Messages.error("Failure", err, 'Unable to retrieve application settings'); + Notifications.error("Failure", err, 'Unable to retrieve application settings'); }); $rootScope.$state = $state; diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js index f12ed3c53..39b64d5c4 100644 --- a/app/components/auth/authController.js +++ b/app/components/auth/authController.js @@ -1,6 +1,6 @@ angular.module('auth', []) -.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', -function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, EndpointProvider, Messages) { +.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', +function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, EndpointProvider, Notifications) { $scope.authData = { username: 'admin', @@ -26,14 +26,14 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au .then(function success() { $state.go('dashboard'); }, function error(err) { - Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); + Notifications.error("Failure", err, 'Unable to connect to the Docker endpoint'); }); } else { $state.go('endpointInit'); } }, function error(err) { - Messages.error("Failure", err, 'Unable to retrieve endpoints'); + Notifications.error("Failure", err, 'Unable to retrieve endpoints'); }); } else { Users.checkAdminUser({}, function () {}, @@ -41,7 +41,7 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au if (e.status === 404) { $scope.initPassword = true; } else { - Messages.error("Failure", e, 'Unable to verify administrator account existence'); + Notifications.error("Failure", e, 'Unable to verify administrator account existence'); } }); } @@ -98,7 +98,7 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au .then(function success() { $state.go('dashboard'); }, function error(err) { - Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); + Notifications.error("Failure", err, 'Unable to connect to the Docker endpoint'); }); } else if (data.length === 0 && userDetails.role === 1) { diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 1a1b3fd37..2b6195f9f 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -1,6 +1,6 @@ angular.module('container', []) -.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ImageHelper', 'Network', 'Messages', 'Pagination', -function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ImageHelper, Network, Messages, Pagination) { +.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ImageHelper', 'Network', 'Notifications', 'Pagination', +function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ImageHelper, Network, Notifications, Pagination) { $scope.activityTime = 0; $scope.portBindings = []; $scope.config = { @@ -41,7 +41,7 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').hide(); }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve container info"); + Notifications.error("Failure", e, "Unable to retrieve container info"); }); }; @@ -49,10 +49,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.start({id: $scope.container.Id}, {}, function (d) { update(); - Messages.send("Container started", $stateParams.id); + Notifications.success("Container started", $stateParams.id); }, function (e) { update(); - Messages.error("Failure", e, "Unable to start container"); + Notifications.error("Failure", e, "Unable to start container"); }); }; @@ -60,10 +60,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.stop({id: $stateParams.id}, function (d) { update(); - Messages.send("Container stopped", $stateParams.id); + Notifications.success("Container stopped", $stateParams.id); }, function (e) { update(); - Messages.error("Failure", e, "Unable to stop container"); + Notifications.error("Failure", e, "Unable to stop container"); }); }; @@ -71,10 +71,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.kill({id: $stateParams.id}, function (d) { update(); - Messages.send("Container killed", $stateParams.id); + Notifications.success("Container killed", $stateParams.id); }, function (e) { update(); - Messages.error("Failure", e, "Unable to kill container"); + Notifications.error("Failure", e, "Unable to kill container"); }); }; @@ -86,11 +86,11 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) { $('#createImageSpinner').hide(); update(); - Messages.send("Container commited", $stateParams.id); + Notifications.success("Container commited", $stateParams.id); }, function (e) { $('#createImageSpinner').hide(); update(); - Messages.error("Failure", e, "Unable to commit container"); + Notifications.error("Failure", e, "Unable to commit container"); }); }; @@ -98,10 +98,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.pause({id: $stateParams.id}, function (d) { update(); - Messages.send("Container paused", $stateParams.id); + Notifications.success("Container paused", $stateParams.id); }, function (e) { update(); - Messages.error("Failure", e, "Unable to pause container"); + Notifications.error("Failure", e, "Unable to pause container"); }); }; @@ -109,10 +109,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.unpause({id: $stateParams.id}, function (d) { update(); - Messages.send("Container unpaused", $stateParams.id); + Notifications.success("Container unpaused", $stateParams.id); }, function (e) { update(); - Messages.error("Failure", e, "Unable to unpause container"); + Notifications.error("Failure", e, "Unable to unpause container"); }); }; @@ -121,15 +121,15 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima Container.remove({id: $stateParams.id}, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Messages.send("Error", d.message); + Notifications.error("Failure", d, "Unable to remove container"); } else { $state.go('containers', {}, {reload: true}); - Messages.send("Container removed", $stateParams.id); + Notifications.success("Container removed", $stateParams.id); } }, function (e) { update(); - Messages.error("Failure", e, "Unable to remove container"); + Notifications.error("Failure", e, "Unable to remove container"); }); }; @@ -137,10 +137,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.restart({id: $stateParams.id}, function (d) { update(); - Messages.send("Container restarted", $stateParams.id); + Notifications.success("Container restarted", $stateParams.id); }, function (e) { update(); - Messages.error("Failure", e, "Unable to restart container"); + Notifications.error("Failure", e, "Unable to restart container"); }); }; @@ -148,13 +148,13 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima Container.rename({id: $stateParams.id, 'name': $scope.container.newContainerName}, function (d) { if (d.message) { $scope.container.newContainerName = $scope.container.Name; - Messages.error("Unable to rename container", {}, d.message); + Notifications.error("Unable to rename container", {}, d.message); } else { $scope.container.Name = $scope.container.newContainerName; - Messages.send("Container successfully renamed", d.name); + Notifications.success("Container successfully renamed", d.name); } }, function (e) { - Messages.error("Failure", e, 'Unable to rename container'); + Notifications.error("Failure", e, 'Unable to rename container'); }); $scope.container.edit = false; }; @@ -164,15 +164,15 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima Network.disconnect({id: networkId}, { Container: $stateParams.id, Force: false }, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Messages.send("Error", {}, d.message); + Notifications.error("Error", d, "Unable to disconnect container from network"); } else { $('#loadingViewSpinner').hide(); - Messages.send("Container left network", $stateParams.id); + Notifications.success("Container left network", $stateParams.id); $state.go('container', {id: $stateParams.id}, {reload: true}); } }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to disconnect container from network"); + Notifications.error("Failure", e, "Unable to disconnect container from network"); }); }; diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index 4ef8cc3d6..9b731f9a7 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -1,6 +1,6 @@ angular.module('containerConsole', []) -.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Messages', -function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, EndpointProvider, Messages) { +.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Notifications', +function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, EndpointProvider, Notifications) { $scope.state = {}; $scope.state.loaded = false; $scope.state.connected = false; @@ -17,7 +17,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp Container.get({id: $stateParams.id}, function(d) { $scope.container = d; if (d.message) { - Messages.error("Error", d, 'Unable to retrieve container details'); + Notifications.error("Error", d, 'Unable to retrieve container details'); $('#loadingViewSpinner').hide(); } else { Image.get({id: d.Image}, function(imgData) { @@ -26,12 +26,12 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp $scope.state.loaded = true; $('#loadingViewSpinner').hide(); }, function (e) { - Messages.error("Failure", e, 'Unable to retrieve image details'); + Notifications.error("Failure", e, 'Unable to retrieve image details'); $('#loadingViewSpinner').hide(); }); } }, function (e) { - Messages.error("Failure", e, 'Unable to retrieve container details'); + Notifications.error("Failure", e, 'Unable to retrieve container details'); $('#loadingViewSpinner').hide(); }); @@ -51,7 +51,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp Container.exec(execConfig, function(d) { if (d.message) { $('#loadConsoleSpinner').hide(); - Messages.error("Error", {}, d.message); + Notifications.error("Error", {}, d.message); } else { var execId = d.Id; resizeTTY(execId, termHeight, termWidth); @@ -65,7 +65,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp } }, function (e) { $('#loadConsoleSpinner').hide(); - Messages.error("Failure", e, 'Unable to start an exec instance'); + Notifications.error("Failure", e, 'Unable to start an exec instance'); }); }; @@ -83,10 +83,10 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp $timeout(function() { Exec.resize({id: execId, height: height, width: width}, function (d) { if (d.message) { - Messages.error('Error', {}, 'Unable to resize TTY'); + Notifications.error('Error', {}, 'Unable to resize TTY'); } }, function (e) { - Messages.error("Failure", {}, 'Unable to resize TTY'); + Notifications.error("Failure", {}, 'Unable to resize TTY'); }); }, 2000); diff --git a/app/components/containerLogs/containerLogsController.js b/app/components/containerLogs/containerLogsController.js index fbba46058..cf92fbb72 100644 --- a/app/components/containerLogs/containerLogsController.js +++ b/app/components/containerLogs/containerLogsController.js @@ -14,7 +14,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) { $('#loadingViewSpinner').hide(); }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve container info"); + Notifications.error("Failure", e, "Unable to retrieve container info"); }); function getLogs() { diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index f88821698..ee1ccedf3 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('containers', []) - .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', - function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', + function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); $scope.state.displayAll = Settings.displayAll; @@ -29,10 +29,10 @@ angular.module('containers', []) }) .then(function success() { delete container.Metadata.ResourceControl; - Messages.send('Ownership changed to public', container.Id); + Notifications.success('Ownership changed to public', container.Id); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to change container ownership"); + Notifications.error("Failure", err, "Unable to change container ownership"); }); } @@ -90,7 +90,7 @@ angular.module('containers', []) mapUsersToContainers(data); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to retrieve users"); + Notifications.error("Failure", err, "Unable to retrieve users"); }) .finally(function final() { $('#loadContainersSpinner').hide(); @@ -100,7 +100,7 @@ angular.module('containers', []) } }, function (e) { $('#loadContainersSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve containers"); + Notifications.error("Failure", e, "Unable to retrieve containers"); $scope.containers = []; }); }; @@ -120,56 +120,56 @@ angular.module('containers', []) counter = counter + 1; if (action === Container.start) { action({id: c.Id}, {}, function (d) { - Messages.send("Container " + msg, c.Id); + Notifications.success("Container " + msg, c.Id); complete(); }, function (e) { - Messages.error("Failure", e, "Unable to start container"); + Notifications.error("Failure", e, "Unable to start container"); complete(); }); } else if (action === Container.remove) { action({id: c.Id}, function (d) { if (d.message) { - Messages.send("Error", d.message); + Notifications.error("Error", d, "Unable to remove container"); } else { if (c.Metadata && c.Metadata.ResourceControl) { ResourceControlService.removeContainerResourceControl(c.Metadata.ResourceControl.OwnerId, c.Id) .then(function success() { - Messages.send("Container " + msg, c.Id); + Notifications.success("Container " + msg, c.Id); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to remove container ownership"); + Notifications.error("Failure", err, "Unable to remove container ownership"); }); } else { - Messages.send("Container " + msg, c.Id); + Notifications.success("Container " + msg, c.Id); } } complete(); }, function (e) { - Messages.error("Failure", e, 'Unable to remove container'); + Notifications.error("Failure", e, 'Unable to remove container'); complete(); }); } else if (action === Container.pause) { action({id: c.Id}, function (d) { if (d.message) { - Messages.send("Container is already paused", c.Id); + Notifications.success("Container is already paused", c.Id); } else { - Messages.send("Container " + msg, c.Id); + Notifications.success("Container " + msg, c.Id); } complete(); }, function (e) { - Messages.error("Failure", e, 'Unable to pause container'); + Notifications.error("Failure", e, 'Unable to pause container'); complete(); }); } else { action({id: c.Id}, function (d) { - Messages.send("Container " + msg, c.Id); + Notifications.success("Container " + msg, c.Id); complete(); }, function (e) { - Messages.error("Failure", e, 'An error occured'); + Notifications.error("Failure", e, 'An error occured'); complete(); }); diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 4b12fef07..f7b58efc6 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.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('createContainer', []) -.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Messages', -function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Messages) { +.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', +function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications) { $scope.formValues = { Ownership: $scope.applicationState.application.authentication ? 'private' : '', @@ -83,7 +83,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Volume.query({}, function (d) { $scope.availableVolumes = d.Volumes; }, function (e) { - Messages.error("Failure", e, "Unable to retrieve volumes"); + Notifications.error("Failure", e, "Unable to retrieve volumes"); }); Network.query({}, function (d) { @@ -105,7 +105,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.config.HostConfig.NetworkMode = 'nat'; } }, function (e) { - Messages.error("Failure", e, "Unable to retrieve networks"); + Notifications.error("Failure", e, "Unable to retrieve networks"); }); Container.query({}, function (d) { @@ -115,7 +115,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai } $scope.runningContainers = containers; }, function(e) { - Messages.error("Failure", e, "Unable to retrieve running containers"); + Notifications.error("Failure", e, "Unable to retrieve running containers"); }); }); @@ -123,15 +123,15 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Container.start({id: containerID}, {}, function (cd) { if (cd.message) { $('#createContainerSpinner').hide(); - Messages.error('Error', {}, cd.message); + Notifications.error('Error', {}, cd.message); } else { $('#createContainerSpinner').hide(); - Messages.send('Container Started', containerID); + Notifications.success('Container Started', containerID); $state.go('containers', {}, {reload: true}); } }, function (e) { $('#createContainerSpinner').hide(); - Messages.error("Failure", e, 'Unable to start container'); + Notifications.error("Failure", e, 'Unable to start container'); }); } @@ -139,7 +139,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Container.create(config, function (d) { if (d.message) { $('#createContainerSpinner').hide(); - Messages.error('Error', {}, d.message); + Notifications.error('Error', {}, d.message); } else { if ($scope.formValues.Ownership === 'private') { ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, d.Id) @@ -148,7 +148,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }) .catch(function error(err) { $('#createContainerSpinner').hide(); - Messages.error("Failure", err, 'Unable to apply resource control on container'); + Notifications.error("Failure", err, 'Unable to apply resource control on container'); }); } else { startContainer(d.Id); @@ -156,7 +156,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai } }, function (e) { $('#createContainerSpinner').hide(); - Messages.error("Failure", e, 'Unable to create container'); + Notifications.error("Failure", e, 'Unable to create container'); }); } @@ -165,7 +165,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai createContainer(config); }, function (e) { $('#createContainerSpinner').hide(); - Messages.error('Failure', e, 'Unable to pull image'); + Notifications.error('Failure', e, 'Unable to pull image'); }); } diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js index 79970cf76..14a8b2143 100644 --- a/app/components/createNetwork/createNetworkController.js +++ b/app/components/createNetwork/createNetworkController.js @@ -1,6 +1,6 @@ angular.module('createNetwork', []) -.controller('CreateNetworkController', ['$scope', '$state', 'Messages', 'Network', -function ($scope, $state, Messages, Network) { +.controller('CreateNetworkController', ['$scope', '$state', 'Notifications', 'Network', +function ($scope, $state, Notifications, Network) { $scope.formValues = { DriverOptions: [], Subnet: '', @@ -42,15 +42,15 @@ function ($scope, $state, Messages, Network) { Network.create(config, function (d) { if (d.message) { $('#createNetworkSpinner').hide(); - Messages.error('Unable to create network', {}, d.message); + Notifications.error('Unable to create network', {}, d.message); } else { - Messages.send("Network created", d.Id); + Notifications.success("Network created", d.Id); $('#createNetworkSpinner').hide(); $state.go('networks', {}, {reload: true}); } }, function (e) { $('#createNetworkSpinner').hide(); - Messages.error("Failure", e, 'Unable to create network'); + Notifications.error("Failure", e, 'Unable to create network'); }); } diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index f7b1d100a..ce304ca4f 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', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Messages', -function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ResourceControlService, Messages) { +.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', +function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ResourceControlService, Notifications) { $scope.formValues = { Ownership: $scope.applicationState.application.authentication ? 'private' : '', @@ -219,21 +219,21 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ResourceControlService.setServiceResourceControl(Authentication.getUserDetails().ID, d.ID) .then(function success() { $('#createServiceSpinner').hide(); - Messages.send('Service created', d.ID); + Notifications.success('Service created', d.ID); $state.go('services', {}, {reload: true}); }) .catch(function error(err) { $('#createContainerSpinner').hide(); - Messages.error("Failure", err, 'Unable to apply resource control on service'); + Notifications.error("Failure", err, 'Unable to apply resource control on service'); }); } else { $('#createServiceSpinner').hide(); - Messages.send('Service created', d.ID); + Notifications.success('Service created', d.ID); $state.go('services', {}, {reload: true}); } }, function (e) { $('#createServiceSpinner').hide(); - Messages.error("Failure", e, 'Unable to create service'); + Notifications.error("Failure", e, 'Unable to create service'); }); } @@ -246,7 +246,7 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, Volume.query({}, function (d) { $scope.availableVolumes = d.Volumes; }, function (e) { - Messages.error("Failure", e, "Unable to retrieve volumes"); + Notifications.error("Failure", e, "Unable to retrieve volumes"); }); Network.query({}, function (d) { @@ -256,6 +256,6 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, } }); }, function (e) { - Messages.error("Failure", e, "Unable to retrieve networks"); + Notifications.error("Failure", e, "Unable to retrieve networks"); }); }]); diff --git a/app/components/createVolume/createVolumeController.js b/app/components/createVolume/createVolumeController.js index fca49bcc1..b18a64f5e 100644 --- a/app/components/createVolume/createVolumeController.js +++ b/app/components/createVolume/createVolumeController.js @@ -1,6 +1,6 @@ angular.module('createVolume', []) -.controller('CreateVolumeController', ['$scope', '$state', 'VolumeService', 'InfoService', 'ResourceControlService', 'Authentication', 'Messages', -function ($scope, $state, VolumeService, InfoService, ResourceControlService, Authentication, Messages) { +.controller('CreateVolumeController', ['$scope', '$state', 'VolumeService', 'InfoService', 'ResourceControlService', 'Authentication', 'Notifications', +function ($scope, $state, VolumeService, InfoService, ResourceControlService, Authentication, Notifications) { $scope.formValues = { Ownership: $scope.applicationState.application.authentication ? 'private' : '', @@ -30,19 +30,19 @@ function ($scope, $state, VolumeService, InfoService, ResourceControlService, Au if ($scope.formValues.Ownership === 'private') { ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, data.Name) .then(function success() { - Messages.send("Volume created", data.Name); + Notifications.success("Volume created", data.Name); $state.go('volumes', {}, {reload: true}); }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to apply resource control on volume'); + Notifications.error("Failure", err, 'Unable to apply resource control on volume'); }); } else { - Messages.send("Volume created", data.Name); + Notifications.success("Volume created", data.Name); $state.go('volumes', {}, {reload: true}); } }) .catch(function error(err) { - Messages.error('Failure', err, 'Unable to create volume'); + Notifications.error('Failure', err, 'Unable to create volume'); }) .finally(function final() { $('#createVolumeSpinner').hide(); @@ -56,7 +56,7 @@ function ($scope, $state, VolumeService, InfoService, ResourceControlService, Au $scope.availableVolumeDrivers = data; }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to retrieve volume plugin information'); + Notifications.error("Failure", err, 'Unable to retrieve volume plugin information'); }) .finally(function final() { $('#loadingViewSpinner').hide(); diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index e82cec9da..3ece5956d 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -1,6 +1,6 @@ angular.module('dashboard', []) -.controller('DashboardController', ['$scope', '$q', 'Config', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'Info', 'Messages', -function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume, Info, Messages) { +.controller('DashboardController', ['$scope', '$q', 'Config', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'Info', 'Notifications', +function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume, Info, Notifications) { $scope.containerData = { total: 0 @@ -82,7 +82,7 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume $('#loadingViewSpinner').hide(); }, function(e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to load dashboard data"); + Notifications.error("Failure", e, "Unable to load dashboard data"); }); } diff --git a/app/components/docker/dockerController.js b/app/components/docker/dockerController.js index 42e8c7792..727724d7f 100644 --- a/app/components/docker/dockerController.js +++ b/app/components/docker/dockerController.js @@ -1,6 +1,6 @@ angular.module('docker', []) -.controller('DockerController', ['$scope', 'Info', 'Version', 'Messages', -function ($scope, Info, Version, Messages) { +.controller('DockerController', ['$scope', 'Info', 'Version', 'Notifications', +function ($scope, Info, Version, Notifications) { $scope.state = { loaded: false }; @@ -14,11 +14,11 @@ function ($scope, Info, Version, Messages) { $scope.state.loaded = true; $('#loadingViewSpinner').hide(); }, function (e) { - Messages.error("Failure", e, 'Unable to retrieve engine details'); + Notifications.error("Failure", e, 'Unable to retrieve engine details'); $('#loadingViewSpinner').hide(); }); }, function (e) { - Messages.error("Failure", e, 'Unable to retrieve engine information'); + Notifications.error("Failure", e, 'Unable to retrieve engine information'); $('#loadingViewSpinner').hide(); }); }]); diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index c455e38dc..43a013834 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -1,6 +1,6 @@ angular.module('endpoint', []) -.controller('EndpointController', ['$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'Messages', -function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { +.controller('EndpointController', ['$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'Notifications', +function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) { if (!$scope.applicationState.application.endpointManagement) { $state.go('endpoints'); @@ -31,7 +31,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { EndpointService.updateEndpoint(ID, endpointParams) .then(function success(data) { - Messages.send("Endpoint updated", $scope.endpoint.Name); + Notifications.success("Endpoint updated", $scope.endpoint.Name); $state.go('endpoints'); }, function error(err) { $scope.state.error = err.msg; @@ -58,7 +58,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { $scope.formValues.TLSKey = data.TLSKey; }, function error(err) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", err, "Unable to retrieve endpoint details"); + Notifications.error("Failure", err, "Unable to retrieve endpoint details"); }); } diff --git a/app/components/endpointAccess/endpointAccessController.js b/app/components/endpointAccess/endpointAccessController.js index 44ebb9c2c..8ec92e926 100644 --- a/app/components/endpointAccess/endpointAccessController.js +++ b/app/components/endpointAccess/endpointAccessController.js @@ -1,6 +1,6 @@ angular.module('endpointAccess', []) -.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'Pagination', 'Messages', -function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, Pagination, Messages) { +.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'Pagination', 'Notifications', +function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, Pagination, Notifications) { $scope.state = { pagination_count_users: Pagination.getPaginationCount('endpoint_access_users'), @@ -43,10 +43,10 @@ function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserServic .then(function success(data) { $scope.authorizedUsers = $scope.authorizedUsers.concat($scope.users); $scope.users = []; - Messages.send('Access granted for all users'); + Notifications.success('Access granted for all users'); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to update endpoint permissions"); + Notifications.error("Failure", err, "Unable to update endpoint permissions"); }); }; @@ -55,10 +55,10 @@ function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserServic .then(function success(data) { $scope.users = $scope.users.concat($scope.authorizedUsers); $scope.authorizedUsers = []; - Messages.send('Access removed for all users'); + Notifications.success('Access removed for all users'); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to update endpoint permissions"); + Notifications.error("Failure", err, "Unable to update endpoint permissions"); }); }; @@ -72,10 +72,10 @@ function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserServic .then(function success(data) { removeUserFromArray(user.Id, $scope.users); $scope.authorizedUsers.push(user); - Messages.send('Access granted for user', user.Username); + Notifications.success('Access granted for user', user.Username); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to update endpoint permissions"); + Notifications.error("Failure", err, "Unable to update endpoint permissions"); }); }; @@ -91,10 +91,10 @@ function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserServic .then(function success(data) { removeUserFromArray(user.Id, $scope.authorizedUsers); $scope.users.push(user); - Messages.send('Access removed for user', user.Username); + Notifications.success('Access removed for user', user.Username); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to update endpoint permissions"); + Notifications.error("Failure", err, "Unable to update endpoint permissions"); }); }; @@ -128,7 +128,7 @@ function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserServic $scope.templates = []; $scope.users = []; $scope.authorizedUsers = []; - Messages.error("Failure", err, "Unable to retrieve endpoint details"); + Notifications.error("Failure", err, "Unable to retrieve endpoint details"); }) .finally(function final(){ $('#loadingViewSpinner').hide(); diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js index afaf48321..e2fc30725 100644 --- a/app/components/endpointInit/endpointInitController.js +++ b/app/components/endpointInit/endpointInitController.js @@ -1,6 +1,6 @@ angular.module('endpointInit', []) -.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', -function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messages) { +.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', +function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notifications) { $scope.state = { error: '', uploadInProgress: false diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index ba50144d4..7bf2a854a 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -1,6 +1,6 @@ angular.module('endpoints', []) -.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Messages', 'Pagination', -function ($scope, $state, EndpointService, EndpointProvider, Messages, Pagination) { +.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination', +function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagination) { $scope.state = { error: '', uploadInProgress: false, @@ -54,7 +54,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Messages, Paginatio var TLSCertFile = $scope.formValues.TLSCert; var TLSKeyFile = $scope.formValues.TLSKey; EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) { - Messages.send("Endpoint created", name); + Notifications.success("Endpoint created", name); $state.reload(); }, function error(err) { $scope.state.uploadInProgress = false; @@ -79,12 +79,12 @@ function ($scope, $state, EndpointService, EndpointProvider, Messages, Paginatio if (endpoint.Checked) { counter = counter + 1; EndpointService.deleteEndpoint(endpoint.Id).then(function success(data) { - Messages.send("Endpoint deleted", endpoint.Name); + Notifications.success("Endpoint deleted", endpoint.Name); var index = $scope.endpoints.indexOf(endpoint); $scope.endpoints.splice(index, 1); complete(); }, function error(err) { - Messages.error("Failure", err, 'Unable to remove endpoint'); + Notifications.error("Failure", err, 'Unable to remove endpoint'); complete(); }); } @@ -99,7 +99,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Messages, Paginatio $scope.activeEndpointID = EndpointProvider.endpointID(); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to retrieve endpoints"); + Notifications.error("Failure", err, "Unable to retrieve endpoints"); $scope.endpoints = []; }) .finally(function final() { diff --git a/app/components/events/eventsController.js b/app/components/events/eventsController.js index b4f2ac9d8..41c2a8340 100644 --- a/app/components/events/eventsController.js +++ b/app/components/events/eventsController.js @@ -1,6 +1,6 @@ angular.module('events', []) -.controller('EventsController', ['$scope', 'Messages', 'Events', 'Pagination', -function ($scope, Messages, Events, Pagination) { +.controller('EventsController', ['$scope', 'Notifications', 'Events', 'Pagination', +function ($scope, Notifications, Events, Pagination) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('events'); $scope.sortType = 'Time'; @@ -27,6 +27,6 @@ function ($scope, Messages, Events, Pagination) { }, function (e) { $('#loadEventsSpinner').hide(); - Messages.error("Failure", e, "Unable to load events"); + Notifications.error("Failure", e, "Unable to load events"); }); }]); diff --git a/app/components/image/imageController.js b/app/components/image/imageController.js index 8514ff1bb..6f0c17eae 100644 --- a/app/components/image/imageController.js +++ b/app/components/image/imageController.js @@ -1,6 +1,6 @@ angular.module('image', []) -.controller('ImageController', ['$scope', '$stateParams', '$state', 'ImageService', 'Messages', -function ($scope, $stateParams, $state, ImageService, Messages) { +.controller('ImageController', ['$scope', '$stateParams', '$state', 'ImageService', 'Notifications', +function ($scope, $stateParams, $state, ImageService, Notifications) { $scope.config = { Image: '', Registry: '' @@ -13,11 +13,11 @@ function ($scope, $stateParams, $state, ImageService, Messages) { ImageService.tagImage($stateParams.id, image, registry) .then(function success(data) { - Messages.send('Image successfully tagged'); + Notifications.success('Image successfully tagged'); $state.go('image', {id: $stateParams.id}, {reload: true}); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to tag image"); + Notifications.error("Failure", err, "Unable to tag image"); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -28,10 +28,10 @@ function ($scope, $stateParams, $state, ImageService, Messages) { $('#loadingViewSpinner').show(); ImageService.pushImage(tag) .then(function success() { - Messages.send('Image successfully pushed'); + Notifications.success('Image successfully pushed'); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to push image tag"); + Notifications.error("Failure", err, "Unable to push image tag"); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -45,10 +45,10 @@ function ($scope, $stateParams, $state, ImageService, Messages) { ImageService.pullImage(image, registry) .then(function success(data) { - Messages.send('Image successfully pulled', image); + Notifications.success('Image successfully pulled', image); }) .catch(function error(err){ - Messages.error("Failure", err, "Unable to pull image"); + Notifications.error("Failure", err, "Unable to pull image"); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -60,15 +60,15 @@ function ($scope, $stateParams, $state, ImageService, Messages) { ImageService.deleteImage(id, false) .then(function success() { if ($scope.image.RepoTags.length === 1) { - Messages.send('Image successfully deleted', id); + Notifications.success('Image successfully deleted', id); $state.go('images', {}, {reload: true}); } else { - Messages.send('Tag successfully deleted', id); + Notifications.success('Tag successfully deleted', id); $state.go('image', {id: $stateParams.id}, {reload: true}); } }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to remove image'); + Notifications.error("Failure", err, 'Unable to remove image'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -79,11 +79,11 @@ function ($scope, $stateParams, $state, ImageService, Messages) { $('#loadingViewSpinner').show(); ImageService.deleteImage(id, false) .then(function success() { - Messages.send('Image successfully deleted', id); + Notifications.success('Image successfully deleted', id); $state.go('images', {}, {reload: true}); }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to remove image'); + Notifications.error("Failure", err, 'Unable to remove image'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -97,7 +97,7 @@ function ($scope, $stateParams, $state, ImageService, Messages) { $scope.image = data; }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to retrieve image details"); + Notifications.error("Failure", err, "Unable to retrieve image details"); $state.go('images'); }) .finally(function final() { diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index 350b77c2a..6a12eb7f2 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -1,6 +1,6 @@ angular.module('images', []) -.controller('ImagesController', ['$scope', '$state', 'Config', 'ImageService', 'Messages', 'Pagination', 'ModalService', -function ($scope, $state, Config, ImageService, Messages, Pagination, ModalService) { +.controller('ImagesController', ['$scope', '$state', 'Config', 'ImageService', 'Notifications', 'Pagination', 'ModalService', +function ($scope, $state, Config, ImageService, Notifications, Pagination, ModalService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('images'); $scope.sortType = 'RepoTags'; @@ -47,7 +47,7 @@ function ($scope, $state, Config, ImageService, Messages, Pagination, ModalServi $state.reload(); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to pull image"); + Notifications.error("Failure", err, "Unable to pull image"); }) .finally(function final() { $('#pullImageSpinner').hide(); @@ -76,12 +76,12 @@ function ($scope, $state, Config, ImageService, Messages, Pagination, ModalServi counter = counter + 1; ImageService.deleteImage(i.Id, force) .then(function success(data) { - Messages.send("Image deleted", i.Id); + Notifications.success("Image deleted", i.Id); var index = $scope.images.indexOf(i); $scope.images.splice(index, 1); }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to remove image'); + Notifications.error("Failure", err, 'Unable to remove image'); }) .finally(function final() { complete(); @@ -97,7 +97,7 @@ function ($scope, $state, Config, ImageService, Messages, Pagination, ModalServi $scope.images = data; }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to retrieve images"); + Notifications.error("Failure", err, "Unable to retrieve images"); $scope.images = []; }) .finally(function final() { diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index 77287618d..4c89f343a 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -1,21 +1,21 @@ angular.module('network', []) -.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Network', 'Container', 'ContainerHelper', 'Messages', -function ($scope, $state, $stateParams, $filter, Config, Network, Container, ContainerHelper, Messages) { +.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Network', 'Container', 'ContainerHelper', 'Notifications', +function ($scope, $state, $stateParams, $filter, Config, Network, Container, ContainerHelper, Notifications) { $scope.removeNetwork = function removeNetwork(networkId) { $('#loadingViewSpinner').show(); Network.remove({id: $stateParams.id}, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Messages.send("Error", {}, d.message); + Notifications.error("Error", d, "Unable to remove network"); } else { $('#loadingViewSpinner').hide(); - Messages.send("Network removed", $stateParams.id); + Notifications.success("Network removed", $stateParams.id); $state.go('networks', {}); } }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to remove network"); + Notifications.error("Failure", e, "Unable to remove network"); }); }; @@ -24,15 +24,15 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con Network.disconnect({id: $stateParams.id}, { Container: containerId, Force: false }, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Messages.send("Error", {}, d.message); + Notifications.error("Error", d, "Unable to disconnect container from network"); } else { $('#loadingViewSpinner').hide(); - Messages.send("Container left network", $stateParams.id); + Notifications.success("Container left network", $stateParams.id); $state.go('network', {id: network.Id}, {reload: true}); } }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to disconnect container from network"); + Notifications.error("Failure", e, "Unable to disconnect container from network"); }); }; @@ -43,7 +43,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con getContainersInNetwork(data); }, function error(err) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", err, "Unable to retrieve network info"); + Notifications.error("Failure", err, "Unable to retrieve network info"); }); } @@ -77,7 +77,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con $('#loadingViewSpinner').hide(); }, function error(err) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", err, "Unable to retrieve containers in network"); + Notifications.error("Failure", err, "Unable to retrieve containers in network"); }); } else { Container.query({ @@ -87,7 +87,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con $('#loadingViewSpinner').hide(); }, function error(err) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", err, "Unable to retrieve containers in network"); + Notifications.error("Failure", err, "Unable to retrieve containers in network"); }); } } diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 120ab0c86..472081ef3 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -1,6 +1,6 @@ angular.module('networks', []) -.controller('NetworksController', ['$scope', '$state', 'Network', 'Config', 'Messages', 'Pagination', -function ($scope, $state, Network, Config, Messages, Pagination) { +.controller('NetworksController', ['$scope', '$state', 'Network', 'Config', 'Notifications', 'Pagination', +function ($scope, $state, Network, Config, Notifications, Pagination) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('networks'); $scope.state.selectedItemCount = 0; @@ -34,15 +34,15 @@ function ($scope, $state, Network, Config, Messages, Pagination) { Network.create(config, function (d) { if (d.message) { $('#createNetworkSpinner').hide(); - Messages.error('Unable to create network', {}, d.message); + Notifications.error('Unable to create network', {}, d.message); } else { - Messages.send("Network created", d.Id); + Notifications.success("Network created", d.Id); $('#createNetworkSpinner').hide(); $state.reload(); } }, function (e) { $('#createNetworkSpinner').hide(); - Messages.error("Failure", e, 'Unable to create network'); + Notifications.error("Failure", e, 'Unable to create network'); }); }; @@ -82,15 +82,15 @@ function ($scope, $state, Network, Config, Messages, Pagination) { counter = counter + 1; Network.remove({id: network.Id}, function (d) { if (d.message) { - Messages.send("Error", d.message); + Notifications.error("Error", d, "Unable to remove network"); } else { - Messages.send("Network removed", network.Id); + Notifications.success("Network removed", network.Id); var index = $scope.networks.indexOf(network); $scope.networks.splice(index, 1); } complete(); }, function (e) { - Messages.error("Failure", e, 'Unable to remove network'); + Notifications.error("Failure", e, 'Unable to remove network'); complete(); }); } @@ -104,7 +104,7 @@ function ($scope, $state, Network, Config, Messages, Pagination) { $('#loadNetworksSpinner').hide(); }, function (e) { $('#loadNetworksSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve networks"); + Notifications.error("Failure", e, "Unable to retrieve networks"); $scope.networks = []; }); } diff --git a/app/components/node/nodeController.js b/app/components/node/nodeController.js index 517424296..c09b7c604 100644 --- a/app/components/node/nodeController.js +++ b/app/components/node/nodeController.js @@ -1,6 +1,6 @@ angular.module('node', []) -.controller('NodeController', ['$scope', '$state', '$stateParams', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Pagination', 'Messages', -function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pagination, Messages) { +.controller('NodeController', ['$scope', '$state', '$stateParams', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Pagination', 'Notifications', +function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pagination, Notifications) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('node_tasks'); @@ -68,11 +68,11 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag Node.update({ id: node.Id, version: node.Version }, config, function (data) { $('#loadServicesSpinner').hide(); - Messages.send("Node successfully updated", "Node updated"); + Notifications.success("Node successfully updated", "Node updated"); $state.go('node', {id: node.Id}, {reload: true}); }, function (e) { $('#loadServicesSpinner').hide(); - Messages.error("Failure", e, "Failed to update node"); + Notifications.error("Failure", e, "Failed to update node"); }); }; @@ -81,7 +81,7 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { Node.get({ id: $stateParams.id}, function(d) { if (d.message) { - Messages.error("Failure", e, "Unable to inspect the node"); + Notifications.error("Failure", e, "Unable to inspect the node"); } else { var node = new NodeViewModel(d); originalNode = angular.copy(node); @@ -102,7 +102,7 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag return new TaskViewModel(task, [node]); }); }, function (e) { - Messages.error("Failure", e, "Unable to retrieve tasks associated to the node"); + Notifications.error("Failure", e, "Unable to retrieve tasks associated to the node"); }); } } diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 961aa17b3..9f7661e2e 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -1,6 +1,6 @@ angular.module('service', []) -.controller('ServiceController', ['$scope', '$stateParams', '$state', '$location', '$anchorScroll', 'Service', 'ServiceHelper', 'Task', 'Node', 'Messages', 'Pagination', 'ModalService', -function ($scope, $stateParams, $state, $location, $anchorScroll, Service, ServiceHelper, Task, Node, Messages, Pagination, ModalService) { +.controller('ServiceController', ['$scope', '$stateParams', '$state', '$location', '$anchorScroll', 'Service', 'ServiceHelper', 'Task', 'Node', 'Notifications', 'Pagination', 'ModalService', +function ($scope, $stateParams, $state, $location, $anchorScroll, Service, ServiceHelper, Task, Node, Notifications, Pagination, ModalService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('service_tasks'); @@ -213,12 +213,12 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadingViewSpinner').hide(); - Messages.send("Service successfully updated", "Service updated"); + Notifications.success("Service successfully updated", "Service updated"); $scope.cancelChanges({}); fetchServiceDetails(); }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to update service"); + Notifications.error("Failure", e, "Unable to update service"); }); }; @@ -237,15 +237,15 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi Service.remove({id: $stateParams.id}, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Messages.send("Error", {}, d.message); + Notifications.error("Error", d, "Unable to remove service"); } else { $('#loadingViewSpinner').hide(); - Messages.send("Service removed", $stateParams.id); + Notifications.success("Service removed", $stateParams.id); $state.go('services', {}); } }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to remove service"); + Notifications.error("Failure", e, "Unable to remove service"); }); } @@ -283,15 +283,15 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi $scope.tasks = tasks.map(function (task) { return new TaskViewModel(task, null); }); - Messages.error("Failure", e, "Unable to retrieve node information"); + Notifications.error("Failure", e, "Unable to retrieve node information"); }); }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve tasks associated to the service"); + Notifications.error("Failure", e, "Unable to retrieve tasks associated to the service"); }); }, function (e) { $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve service details"); + Notifications.error("Failure", e, "Unable to retrieve service details"); }); } diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 2a0522db6..72c5ada89 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -1,6 +1,6 @@ angular.module('services', []) -.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', 'Pagination', 'Task', 'Node', 'NodeHelper', 'Authentication', 'UserService', 'ModalService', 'ResourceControlService', -function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagination, Task, Node, NodeHelper, Authentication, UserService, ModalService, ResourceControlService) { +.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'Authentication', 'UserService', 'ModalService', 'ResourceControlService', +function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, Authentication, UserService, ModalService, ResourceControlService) { $scope.state = {}; $scope.state.selectedItemCount = 0; $scope.state.pagination_count = Pagination.getPaginationCount('services'); @@ -21,10 +21,10 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pa }) .then(function success() { delete service.Metadata.ResourceControl; - Messages.send('Ownership changed to public', service.Id); + Notifications.success('Ownership changed to public', service.Id); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to change service ownership"); + Notifications.error("Failure", err, "Unable to change service ownership"); }); } @@ -58,13 +58,13 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pa config.Mode.Replicated.Replicas = service.Replicas; Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadServicesSpinner').hide(); - Messages.send("Service successfully scaled", "New replica count: " + service.Replicas); + Notifications.success("Service successfully scaled", "New replica count: " + service.Replicas); $state.reload(); }, function (e) { $('#loadServicesSpinner').hide(); service.Scale = false; service.Replicas = service.ReplicaCount; - Messages.error("Failure", e, "Unable to scale service"); + Notifications.error("Failure", e, "Unable to scale service"); }); }; @@ -93,27 +93,27 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pa Service.remove({id: service.Id}, function (d) { if (d.message) { $('#loadServicesSpinner').hide(); - Messages.error("Unable to remove service", {}, d[0].message); + Notifications.error("Unable to remove service", {}, d[0].message); } else { if (service.Metadata && service.Metadata.ResourceControl) { ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id) .then(function success() { - Messages.send("Service deleted", service.Id); + Notifications.success("Service deleted", service.Id); var index = $scope.services.indexOf(service); $scope.services.splice(index, 1); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to remove service ownership"); + Notifications.error("Failure", err, "Unable to remove service ownership"); }); } else { - Messages.send("Service deleted", service.Id); + Notifications.success("Service deleted", service.Id); var index = $scope.services.indexOf(service); $scope.services.splice(index, 1); } } complete(); }, function (e) { - Messages.error("Failure", e, 'Unable to remove service'); + Notifications.error("Failure", e, 'Unable to remove service'); complete(); }); } @@ -173,7 +173,7 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pa }) .catch(function error(err) { $scope.services = []; - Messages.error("Failure", err, "Unable to retrieve services"); + Notifications.error("Failure", err, "Unable to retrieve services"); }) .finally(function final() { $('#loadServicesSpinner').hide(); diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js index eb20ec9bb..010634800 100644 --- a/app/components/settings/settingsController.js +++ b/app/components/settings/settingsController.js @@ -1,6 +1,6 @@ angular.module('settings', []) -.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Messages', -function ($scope, $state, $sanitize, Authentication, UserService, Messages) { +.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications', +function ($scope, $state, $sanitize, Authentication, UserService, Notifications) { $scope.formValues = { currentPassword: '', newPassword: '', @@ -15,14 +15,14 @@ function ($scope, $state, $sanitize, Authentication, UserService, Messages) { UserService.updateUserPassword(userID, currentPassword, newPassword) .then(function success() { - Messages.send("Success", "Password successfully updated"); + Notifications.success("Success", "Password successfully updated"); $state.reload(); }) .catch(function error(err) { if (err.invalidPassword) { $scope.invalidPassword = true; } else { - Messages.error("Failure", err, err.msg); + Notifications.error("Failure", err, err.msg); } }); }; diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index 9b040af7f..75b91dd14 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -1,6 +1,6 @@ angular.module('sidebar', []) -.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', 'Authentication', -function ($scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Messages, Authentication) { +.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', +function ($scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Notifications, Authentication) { Config.$promise.then(function (c) { $scope.logo = c.logo; @@ -17,7 +17,7 @@ function ($scope, $state, Settings, Config, EndpointService, StateManager, Endpo $state.go('dashboard'); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to connect to the Docker endpoint"); + Notifications.error("Failure", err, "Unable to connect to the Docker endpoint"); EndpointProvider.setEndpointID(activeEndpointID); StateManager.updateEndpointState(true) .then(function success() {}); diff --git a/app/components/stats/statsController.js b/app/components/stats/statsController.js index 4329d0a05..a9977bf19 100644 --- a/app/components/stats/statsController.js +++ b/app/components/stats/statsController.js @@ -1,6 +1,6 @@ angular.module('stats', []) -.controller('StatsController', ['Pagination', '$scope', 'Messages', '$timeout', 'Container', 'ContainerTop', '$stateParams', 'humansizeFilter', '$sce', '$document', -function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stateParams, humansizeFilter, $sce, $document) { +.controller('StatsController', ['Pagination', '$scope', 'Notifications', '$timeout', 'Container', 'ContainerTop', '$stateParams', 'humansizeFilter', '$sce', '$document', +function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, $stateParams, humansizeFilter, $sce, $document) { // TODO: Force scale to 0-100 for cpu, fix charts on dashboard, // TODO: Force memory scale to 0 - max memory $scope.ps_args = ''; @@ -126,7 +126,7 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat return d[key]; }); if (arr.join('').indexOf('no such id') !== -1) { - Messages.error('Unable to retrieve stats', {}, 'Is this container running?'); + Notifications.error('Unable to retrieve stats', {}, 'Is this container running?'); return; } @@ -137,7 +137,7 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat updateNetworkChart(d); setUpdateStatsTimeout(); }, function () { - Messages.error('Unable to retrieve stats', {}, 'Is this container running?'); + Notifications.error('Unable to retrieve stats', {}, 'Is this container running?'); setUpdateStatsTimeout(); }); } @@ -211,7 +211,7 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat Container.get({id: $stateParams.id}, function (d) { $scope.container = d; }, function (e) { - Messages.error("Failure", e, "Unable to retrieve container info"); + Notifications.error("Failure", e, "Unable to retrieve container info"); }); $scope.getTop(); }]); diff --git a/app/components/task/taskController.js b/app/components/task/taskController.js index e6a1dc33e..c705449d6 100644 --- a/app/components/task/taskController.js +++ b/app/components/task/taskController.js @@ -1,6 +1,6 @@ angular.module('task', []) -.controller('TaskController', ['$scope', '$stateParams', '$state', 'Task', 'Service', 'Messages', -function ($scope, $stateParams, $state, Task, Service, Messages) { +.controller('TaskController', ['$scope', '$stateParams', '$state', 'Task', 'Service', 'Notifications', +function ($scope, $stateParams, $state, Task, Service, Notifications) { $scope.task = {}; $scope.serviceName = 'service'; @@ -13,7 +13,7 @@ function ($scope, $stateParams, $state, Task, Service, Messages) { fetchAssociatedServiceDetails(d.ServiceID); $('#loadingViewSpinner').hide(); }, function (e) { - Messages.error("Failure", e, "Unable to retrieve task details"); + Notifications.error("Failure", e, "Unable to retrieve task details"); }); } @@ -21,7 +21,7 @@ function ($scope, $stateParams, $state, Task, Service, Messages) { Service.get({id: serviceId}, function (d) { $scope.serviceName = d.Spec.Name; }, function (e) { - Messages.error("Failure", e, "Unable to retrieve associated service details"); + Notifications.error("Failure", e, "Unable to retrieve associated service details"); }); } diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 003ddba7d..42bbe6de5 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,6 +1,6 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Messages', 'Pagination', 'ResourceControlService', 'Authentication', -function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Messages, Pagination, ResourceControlService, Authentication) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', +function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, @@ -56,7 +56,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ return ContainerService.createAndStartContainer(templateConfiguration); }) .then(function success(data) { - Messages.send('Container Started', data.Id); + Notifications.success('Container started', data.Id); if ($scope.formValues.Ownership === 'private') { ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, data.Id) .then(function success(data) { @@ -67,7 +67,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ } }) .catch(function error(err) { - Messages.error('Failure', err, err.msg); + Notifications.error('Failure', err, err.msg); }) .finally(function final() { $('#createContainerSpinner').hide(); @@ -155,7 +155,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ }) .catch(function error(err) { $scope.templates = []; - Messages.error("Failure", err, "An error occured during apps initialization."); + Notifications.error("Failure", err, "An error occured during apps initialization."); }) .finally(function final(){ $('#loadTemplatesSpinner').hide(); diff --git a/app/components/user/userController.js b/app/components/user/userController.js index f62e5b243..abeb71fe7 100644 --- a/app/components/user/userController.js +++ b/app/components/user/userController.js @@ -1,6 +1,6 @@ angular.module('user', []) -.controller('UserController', ['$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Messages', -function ($scope, $state, $stateParams, UserService, ModalService, Messages) { +.controller('UserController', ['$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', +function ($scope, $state, $stateParams, UserService, ModalService, Notifications) { $scope.state = { updatePasswordError: '', @@ -28,11 +28,11 @@ function ($scope, $state, $stateParams, UserService, ModalService, Messages) { UserService.updateUser($scope.user.Id, undefined, role) .then(function success(data) { var newRole = role === 1 ? 'administrator' : 'user'; - Messages.send('Permissions successfully updated', $scope.user.Username + ' is now ' + newRole); + Notifications.success('Permissions successfully updated', $scope.user.Username + ' is now ' + newRole); $state.reload(); }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to update user permissions'); + Notifications.error("Failure", err, 'Unable to update user permissions'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -43,7 +43,7 @@ function ($scope, $state, $stateParams, UserService, ModalService, Messages) { $('#loadingViewSpinner').show(); UserService.updateUser($scope.user.Id, $scope.formValues.newPassword, undefined) .then(function success(data) { - Messages.send('Password successfully updated'); + Notifications.success('Password successfully updated'); $state.reload(); }) .catch(function error(err) { @@ -58,11 +58,11 @@ function ($scope, $state, $stateParams, UserService, ModalService, Messages) { $('#loadingViewSpinner').show(); UserService.deleteUser($scope.user.Id) .then(function success(data) { - Messages.send('User successfully deleted', $scope.user.Username); + Notifications.success('User successfully deleted', $scope.user.Username); $state.go('users'); }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to remove user'); + Notifications.error("Failure", err, 'Unable to remove user'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -78,7 +78,7 @@ function ($scope, $state, $stateParams, UserService, ModalService, Messages) { $scope.formValues.Administrator = user.RoleId === 1 ? true : false; }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to retrieve user information'); + Notifications.error("Failure", err, 'Unable to retrieve user information'); }) .finally(function final() { $('#loadingViewSpinner').hide(); diff --git a/app/components/users/usersController.js b/app/components/users/usersController.js index 7fb8e89b2..218b97cd7 100644 --- a/app/components/users/usersController.js +++ b/app/components/users/usersController.js @@ -1,6 +1,6 @@ angular.module('users', []) -.controller('UsersController', ['$scope', '$state', 'UserService', 'ModalService', 'Messages', 'Pagination', -function ($scope, $state, UserService, ModalService, Messages, Pagination) { +.controller('UsersController', ['$scope', '$state', 'UserService', 'ModalService', 'Notifications', 'Pagination', +function ($scope, $state, UserService, ModalService, Notifications, Pagination) { $scope.state = { userCreationError: '', selectedItemCount: 0, @@ -62,7 +62,7 @@ function ($scope, $state, UserService, ModalService, Messages, Pagination) { var role = $scope.formValues.Administrator ? 1 : 2; UserService.createUser(username, password, role) .then(function success(data) { - Messages.send("User created", username); + Notifications.success("User created", username); $state.reload(); }) .catch(function error(err) { @@ -89,10 +89,10 @@ function ($scope, $state, UserService, ModalService, Messages, Pagination) { .then(function success(data) { var index = $scope.users.indexOf(user); $scope.users.splice(index, 1); - Messages.send('User successfully deleted', user.Username); + Notifications.success('User successfully deleted', user.Username); }) .catch(function error(err) { - Messages.error("Failure", err, 'Unable to remove user'); + Notifications.error("Failure", err, 'Unable to remove user'); }) .finally(function final() { complete(); @@ -120,7 +120,7 @@ function ($scope, $state, UserService, ModalService, Messages, Pagination) { }); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to retrieve users"); + Notifications.error("Failure", err, "Unable to retrieve users"); $scope.users = []; }) .finally(function final() { diff --git a/app/components/volumes/volumesController.js b/app/components/volumes/volumesController.js index 1df40343a..7ec5774ad 100644 --- a/app/components/volumes/volumesController.js +++ b/app/components/volumes/volumesController.js @@ -1,6 +1,6 @@ angular.module('volumes', []) -.controller('VolumesController', ['$scope', '$state', 'Volume', 'Messages', 'Pagination', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', -function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentication, ResourceControlService, UserService) { +.controller('VolumesController', ['$scope', '$state', 'Volume', 'Notifications', 'Pagination', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', +function ($scope, $state, Volume, Notifications, Pagination, ModalService, Authentication, ResourceControlService, UserService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('volumes'); $scope.state.selectedItemCount = 0; @@ -14,10 +14,10 @@ function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentica ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) .then(function success() { delete volume.Metadata.ResourceControl; - Messages.send('Ownership changed to public', volume.Name); + Notifications.success('Ownership changed to public', volume.Name); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to change volume ownership"); + Notifications.error("Failure", err, "Unable to change volume ownership"); }); } @@ -68,27 +68,27 @@ function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentica counter = counter + 1; Volume.remove({name: volume.Name}, function (d) { if (d.message) { - Messages.error("Unable to remove volume", {}, d.message); + Notifications.error("Unable to remove volume", {}, d.message); } else { if (volume.Metadata && volume.Metadata.ResourceControl) { ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) .then(function success() { - Messages.send("Volume deleted", volume.Name); + Notifications.success("Volume deleted", volume.Name); var index = $scope.volumes.indexOf(volume); $scope.volumes.splice(index, 1); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to remove volume ownership"); + Notifications.error("Failure", err, "Unable to remove volume ownership"); }); } else { - Messages.send("Volume deleted", volume.Name); + Notifications.success("Volume deleted", volume.Name); var index = $scope.volumes.indexOf(volume); $scope.volumes.splice(index, 1); } } complete(); }, function (e) { - Messages.error("Failure", e, "Unable to remove volume"); + Notifications.error("Failure", e, "Unable to remove volume"); complete(); }); } @@ -126,7 +126,7 @@ function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentica mapUsersToVolumes(data); }) .catch(function error(err) { - Messages.error("Failure", err, "Unable to retrieve users"); + Notifications.error("Failure", err, "Unable to retrieve users"); }) .finally(function final() { $('#loadVolumesSpinner').hide(); @@ -136,7 +136,7 @@ function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentica } }, function (e) { $('#loadVolumesSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve volumes"); + Notifications.error("Failure", e, "Unable to retrieve volumes"); $scope.volumes = []; }); } diff --git a/app/services/messages.js b/app/services/messages.js deleted file mode 100644 index 7ed10b87d..000000000 --- a/app/services/messages.js +++ /dev/null @@ -1,40 +0,0 @@ -angular.module('portainer.services') -.factory('Messages', ['$sanitize', function MessagesFactory($sanitize) { - 'use strict'; - return { - send: function (title, text) { - $.gritter.add({ - title: $sanitize(title), - text: $sanitize(text), - time: 2000, - before_open: function () { - if ($('.gritter-item-wrapper').length === 3) { - return false; - } - } - }); - }, - error: function (title, e, fallbackText) { - var msg = fallbackText; - if (e.data && e.data.message) { - msg = e.data.message; - } else if (e.message) { - msg = e.message; - } else if (e.data && e.data.length > 0 && e.data[0].message) { - msg = e.data[0].message; - } else if (e.msg) { - msg = e.msg; - } - $.gritter.add({ - title: $sanitize(title), - text: $sanitize(msg), - time: 10000, - before_open: function () { - if ($('.gritter-item-wrapper').length === 4) { - return false; - } - } - }); - } - }; -}]); diff --git a/app/services/notifications.js b/app/services/notifications.js new file mode 100644 index 000000000..20b943a4b --- /dev/null +++ b/app/services/notifications.js @@ -0,0 +1,25 @@ +angular.module('portainer.services') +.factory('Notifications', ['$sanitize', function NotificationsFactory($sanitize) { + 'use strict'; + var service = {}; + + service.success = function(title, text) { + toastr.success($sanitize(text), $sanitize(title)); + }; + + service.error = function(title, e, fallbackText) { + var msg = fallbackText; + if (e.data && e.data.message) { + msg = e.data.message; + } else if (e.message) { + msg = e.message; + } else if (e.data && e.data.length > 0 && e.data[0].message) { + msg = e.data[0].message; + } else if (e.msg) { + msg = e.msg; + } + toastr.error($sanitize(msg), $sanitize(title), {timeOut: 6000}); + }; + + return service; +}]); diff --git a/assets/css/app.css b/assets/css/app.css index ed58a6d81..966284973 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -398,3 +398,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { -webkit-box-shadow: inset 0 0 1px rgba(0,0,0,.5), inset 0 0 40px #337ab7; box-shadow: inset 0 0 1px rgba(0,0,0,.5), inset 0 0 40px #337ab7; } + +#toast-container > div { + opacity: 0.9; +} diff --git a/assets/js/jquery.gritter.js b/assets/js/jquery.gritter.js deleted file mode 100755 index 7230a87f0..000000000 --- a/assets/js/jquery.gritter.js +++ /dev/null @@ -1,419 +0,0 @@ -/* - * Gritter for jQuery - * http://www.boedesign.com/ - * - * Copyright (c) 2012 Jordan Boesch - * Dual licensed under the MIT and GPL licenses. - * - * Date: February 24, 2012 - * Version: 1.7.4 - */ - -(function($){ - - /** - * Set it up as an object under the jQuery namespace - */ - $.gritter = {}; - - /** - * Set up global options that the user can over-ride - */ - $.gritter.options = { - position: '', - class_name: '', // could be set to 'gritter-light' to use white notifications - fade_in_speed: 'medium', // how fast notifications fade in - fade_out_speed: 1000, // how fast the notices fade out - time: 6000 // hang on the screen for... - } - - /** - * Add a gritter notification to the screen - * @see Gritter#add(); - */ - $.gritter.add = function(params){ - - try { - return Gritter.add(params || {}); - } catch(e) { - - var err = 'Gritter Error: ' + e; - (typeof(console) != 'undefined' && console.error) ? - console.error(err, params) : - alert(err); - - } - - } - - /** - * Remove a gritter notification from the screen - * @see Gritter#removeSpecific(); - */ - $.gritter.remove = function(id, params){ - Gritter.removeSpecific(id, params || {}); - } - - /** - * Remove all notifications - * @see Gritter#stop(); - */ - $.gritter.removeAll = function(params){ - Gritter.stop(params || {}); - } - - /** - * Big fat Gritter object - * @constructor (not really since its object literal) - */ - var Gritter = { - - // Public - options to over-ride with $.gritter.options in "add" - position: '', - fade_in_speed: '', - fade_out_speed: '', - time: '', - - // Private - no touchy the private parts - _custom_timer: 0, - _item_count: 0, - _is_setup: 0, - _tpl_close: 'Close Notification', - _tpl_title: '[[title]]', - _tpl_item: '', - _tpl_wrap: '
', - - /** - * Add a gritter notification to the screen - * @param {Object} params The object that contains all the options for drawing the notification - * @return {Integer} The specific numeric id to that gritter notification - */ - add: function(params){ - // Handle straight text - if(typeof(params) == 'string'){ - params = {text:params}; - } - - // We might have some issues if we don't have a title or text! - if(params.text === null){ - throw 'You must supply "text" parameter.'; - } - - // Check the options and set them once - if(!this._is_setup){ - this._runSetup(); - } - - // Basics - var title = params.title, - text = params.text, - image = params.image || '', - sticky = params.sticky || false, - item_class = params.class_name || $.gritter.options.class_name, - position = $.gritter.options.position, - time_alive = params.time || ''; - - this._verifyWrapper(); - - this._item_count++; - var number = this._item_count, - tmp = this._tpl_item; - - // Assign callbacks - $(['before_open', 'after_open', 'before_close', 'after_close']).each(function(i, val){ - Gritter['_' + val + '_' + number] = ($.isFunction(params[val])) ? params[val] : function(){} - }); - - // Reset - this._custom_timer = 0; - - // A custom fade time set - if(time_alive){ - this._custom_timer = time_alive; - } - - var image_str = (image != '') ? '' : '', - class_name = (image != '') ? 'gritter-with-image' : 'gritter-without-image'; - - // String replacements on the template - if(title){ - title = this._str_replace('[[title]]',title,this._tpl_title); - }else{ - title = ''; - } - - tmp = this._str_replace( - ['[[title]]', '[[text]]', '[[close]]', '[[image]]', '[[number]]', '[[class_name]]', '[[item_class]]'], - [title, text, this._tpl_close, image_str, this._item_count, class_name, item_class], tmp - ); - - // If it's false, don't show another gritter message - if(this['_before_open_' + number]() === false){ - return false; - } - - $('#gritter-notice-wrapper').addClass(position).append(tmp); - - var item = $('#gritter-item-' + this._item_count); - - item.fadeIn(this.fade_in_speed, function(){ - Gritter['_after_open_' + number]($(this)); - }); - - if(!sticky){ - this._setFadeTimer(item, number); - } - - // Bind the hover/unhover states - $(item).bind('mouseenter mouseleave', function(event){ - if(event.type == 'mouseenter'){ - if(!sticky){ - Gritter._restoreItemIfFading($(this), number); - } - } - else { - if(!sticky){ - Gritter._setFadeTimer($(this), number); - } - } - Gritter._hoverState($(this), event.type); - }); - - // Clicking (X) makes the perdy thing close - $(item).find('.gritter-close').click(function(){ - Gritter.removeSpecific(number, {}, null, true); - return false; - }); - - return number; - - }, - - /** - * If we don't have any more gritter notifications, get rid of the wrapper using this check - * @private - * @param {Integer} unique_id The ID of the element that was just deleted, use it for a callback - * @param {Object} e The jQuery element that we're going to perform the remove() action on - * @param {Boolean} manual_close Did we close the gritter dialog with the (X) button - */ - _countRemoveWrapper: function(unique_id, e, manual_close){ - - // Remove it then run the callback function - e.remove(); - this['_after_close_' + unique_id](e, manual_close); - - // Check if the wrapper is empty, if it is.. remove the wrapper - if($('.gritter-item-wrapper').length == 0){ - $('#gritter-notice-wrapper').remove(); - } - - }, - - /** - * Fade out an element after it's been on the screen for x amount of time - * @private - * @param {Object} e The jQuery element to get rid of - * @param {Integer} unique_id The id of the element to remove - * @param {Object} params An optional list of params to set fade speeds etc. - * @param {Boolean} unbind_events Unbind the mouseenter/mouseleave events if they click (X) - */ - _fade: function(e, unique_id, params, unbind_events){ - - var params = params || {}, - fade = (typeof(params.fade) != 'undefined') ? params.fade : true, - fade_out_speed = params.speed || this.fade_out_speed, - manual_close = unbind_events; - - this['_before_close_' + unique_id](e, manual_close); - - // If this is true, then we are coming from clicking the (X) - if(unbind_events){ - e.unbind('mouseenter mouseleave'); - } - - // Fade it out or remove it - if(fade){ - - e.animate({ - opacity: 0 - }, fade_out_speed, function(){ - e.animate({ height: 0 }, 300, function(){ - Gritter._countRemoveWrapper(unique_id, e, manual_close); - }) - }) - - } - else { - - this._countRemoveWrapper(unique_id, e); - - } - - }, - - /** - * Perform actions based on the type of bind (mouseenter, mouseleave) - * @private - * @param {Object} e The jQuery element - * @param {String} type The type of action we're performing: mouseenter or mouseleave - */ - _hoverState: function(e, type){ - - // Change the border styles and add the (X) close button when you hover - if(type == 'mouseenter'){ - - e.addClass('hover'); - - // Show close button - e.find('.gritter-close').show(); - - } - // Remove the border styles and hide (X) close button when you mouse out - else { - - e.removeClass('hover'); - - // Hide close button - e.find('.gritter-close').hide(); - - } - - }, - - /** - * Remove a specific notification based on an ID - * @param {Integer} unique_id The ID used to delete a specific notification - * @param {Object} params A set of options passed in to determine how to get rid of it - * @param {Object} e The jQuery element that we're "fading" then removing - * @param {Boolean} unbind_events If we clicked on the (X) we set this to true to unbind mouseenter/mouseleave - */ - removeSpecific: function(unique_id, params, e, unbind_events){ - - if(!e){ - var e = $('#gritter-item-' + unique_id); - } - - // We set the fourth param to let the _fade function know to - // unbind the "mouseleave" event. Once you click (X) there's no going back! - this._fade(e, unique_id, params || {}, unbind_events); - - }, - - /** - * If the item is fading out and we hover over it, restore it! - * @private - * @param {Object} e The HTML element to remove - * @param {Integer} unique_id The ID of the element - */ - _restoreItemIfFading: function(e, unique_id){ - - clearTimeout(this['_int_id_' + unique_id]); - e.stop().css({ opacity: '', height: '' }); - - }, - - /** - * Setup the global options - only once - * @private - */ - _runSetup: function(){ - - for(var opt in $.gritter.options){ - this[opt] = $.gritter.options[opt]; - } - this._is_setup = 1; - - }, - - /** - * Set the notification to fade out after a certain amount of time - * @private - * @param {Object} item The HTML element we're dealing with - * @param {Integer} unique_id The ID of the element - */ - _setFadeTimer: function(e, unique_id){ - - var timer_str = (this._custom_timer) ? this._custom_timer : this.time; - this['_int_id_' + unique_id] = setTimeout(function(){ - Gritter._fade(e, unique_id); - }, timer_str); - - }, - - /** - * Bring everything to a halt - * @param {Object} params A list of callback functions to pass when all notifications are removed - */ - stop: function(params){ - - // callbacks (if passed) - var before_close = ($.isFunction(params.before_close)) ? params.before_close : function(){}; - var after_close = ($.isFunction(params.after_close)) ? params.after_close : function(){}; - - var wrap = $('#gritter-notice-wrapper'); - before_close(wrap); - wrap.fadeOut(function(){ - $(this).remove(); - after_close(); - }); - - }, - - /** - * An extremely handy PHP function ported to JS, works well for templating - * @private - * @param {String/Array} search A list of things to search for - * @param {String/Array} replace A list of things to replace the searches with - * @return {String} sa The output - */ - _str_replace: function(search, replace, subject, count){ - - var i = 0, j = 0, temp = '', repl = '', sl = 0, fl = 0, - f = [].concat(search), - r = [].concat(replace), - s = subject, - ra = r instanceof Array, sa = s instanceof Array; - s = [].concat(s); - - if(count){ - this.window[count] = 0; - } - - for(i = 0, sl = s.length; i < sl; i++){ - - if(s[i] === ''){ - continue; - } - - for (j = 0, fl = f.length; j < fl; j++){ - - temp = s[i] + ''; - repl = ra ? (r[j] !== undefined ? r[j] : '') : r[0]; - s[i] = (temp).split(f[j]).join(repl); - - if(count && s[i] !== temp){ - this.window[count] += (temp.length-s[i].length) / f[j].length; - } - - } - } - - return sa ? s : s[0]; - - }, - - /** - * A check to make sure we have something to wrap our notices with - * @private - */ - _verifyWrapper: function(){ - - if($('#gritter-notice-wrapper').length == 0){ - $('body').append(this._tpl_wrap); - } - - } - - } - -})(jQuery); diff --git a/bower.json b/bower.json index 7bb9b5a25..ced5b086c 100644 --- a/bower.json +++ b/bower.json @@ -40,7 +40,6 @@ "bootstrap": "~3.3.6", "filesize": "~3.3.0", "jquery": "1.11.1", - "jquery.gritter": "1.7.4", "lodash": "4.12.0", "rdash-ui": "1.0.*", "moment": "~2.14.1", @@ -48,7 +47,8 @@ "font-awesome": "~4.7.0", "ng-file-upload": "~12.2.13", "splitargs": "~0.2.0", - "bootbox.js": "bootbox#^4.4.0" + "bootbox.js": "bootbox#^4.4.0", + "toastr": "~2.1.3" }, "resolutions": { "angular": "1.5.5" diff --git a/gruntfile.js b/gruntfile.js index f4891ec0b..0435e2675 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -140,7 +140,7 @@ module.exports = function (grunt) { 'bower_components/moment/min/moment.min.js', 'bower_components/xterm.js/dist/xterm.js', 'bower_components/bootbox.js/bootbox.js', - 'assets/js/jquery.gritter.js', // Using custom version to fix error in minified build due to "use strict" + 'bower_components/toastr/toastr.min.js', 'assets/js/legend.js' // Not a bower package ], html: ['index.html'], @@ -148,11 +148,11 @@ module.exports = function (grunt) { css: ['assets/css/app.css'], cssVendor: [ 'bower_components/bootstrap/dist/css/bootstrap.css', - 'bower_components/jquery.gritter/css/jquery.gritter.css', 'bower_components/font-awesome/css/font-awesome.min.css', 'bower_components/rdash-ui/dist/css/rdash.min.css', 'bower_components/angular-ui-select/dist/select.min.css', - 'bower_components/xterm.js/dist/xterm.css' + 'bower_components/xterm.js/dist/xterm.css', + 'bower_components/toastr/toastr.min.css' ] }, clean: { @@ -211,12 +211,6 @@ module.exports = function (grunt) { {dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,svg}', expand: true, cwd: 'bower_components/bootstrap/fonts/'}, {dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,svg}', expand: true, cwd: 'bower_components/font-awesome/fonts/'}, {dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,svg}', expand: true, cwd: 'bower_components/rdash-ui/dist/fonts/'}, - { - dest: '<%= distdir %>/images/', - src: ['**', '!trees.jpg'], - expand: true, - cwd: 'bower_components/jquery.gritter/images/' - }, { dest: '<%= distdir %>/images/', src: ['**'], From 5745606fe736d44e5dd72f3243488f4d62e84ce3 Mon Sep 17 00:00:00 2001 From: dedalusj Date: Sun, 16 Apr 2017 17:54:51 +1000 Subject: [PATCH 05/39] feat(cli): Allow setting admin password from CLI (#752) --- api/cli/cli.go | 18 ++++++++++++------ api/cmd/portainer/main.go | 13 +++++++++++++ api/portainer.go | 1 + 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/api/cli/cli.go b/api/cli/cli.go index 6148fdc95..afb1814b4 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -15,11 +15,12 @@ import ( type Service struct{} const ( - errInvalidEnpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") - errSocketNotFound = portainer.Error("Unable to locate Unix socket") - errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") - errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") - errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") + errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") + errSocketNotFound = portainer.Error("Unable to locate Unix socket") + errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") + errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") + errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") + errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password") ) // ParseFlags parse the CLI flags and return a portainer.Flags struct @@ -42,6 +43,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), + AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), } kingpin.Parse() @@ -70,13 +72,17 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { return err } + if *flags.NoAuth && (*flags.AdminPassword != "") { + return errNoAuthExcludeAdminPassword + } + return nil } func validateEndpoint(endpoint string) error { if endpoint != "" { if !strings.HasPrefix(endpoint, "unix://") && !strings.HasPrefix(endpoint, "tcp://") { - return errInvalidEnpointProtocol + return errInvalidEndpointProtocol } if strings.HasPrefix(endpoint, "unix://") { diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 6ebdf1ee1..775f76718 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -140,6 +140,19 @@ func main() { } } + if *flags.AdminPassword != "" { + log.Printf("Creating admin user with password hash %s", *flags.AdminPassword) + user := &portainer.User{ + Username: "admin", + Role: portainer.AdministratorRole, + Password: *flags.AdminPassword, + } + err := store.UserService.CreateUser(user) + if err != nil { + log.Fatal(err) + } + } + var server portainer.Server = &http.Server{ BindAddress: *flags.Addr, AssetsPath: *flags.Assets, diff --git a/api/portainer.go b/api/portainer.go index fe02d6732..9d7fb3096 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -26,6 +26,7 @@ type ( TLSCacert *string TLSCert *string TLSKey *string + AdminPassword *string } // Settings represents Portainer settings. From 8215cf78571d9d2103b9b6d5f7f9b80904c87536 Mon Sep 17 00:00:00 2001 From: Hilscher Date: Sun, 16 Apr 2017 09:57:47 +0200 Subject: [PATCH 06/39] feat(container-creation): add support for devices (#729) --- .../createContainerController.js | 24 +++++++++++++- .../createContainer/createcontainer.html | 32 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index f7b58efc6..0cb2a76dd 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -31,7 +31,8 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Binds: [], NetworkMode: 'bridge', Privileged: false, - ExtraHosts: [] + ExtraHosts: [], + Devices:[] }, Labels: {} }; @@ -75,7 +76,14 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.removeExtraHost = function(index) { $scope.formValues.ExtraHosts.splice(index, 1); }; + + $scope.addDevice = function() { + $scope.config.HostConfig.Devices.push({ pathOnHost: '', pathInContainer: '' }); + }; + $scope.removeDevice = function(index) { + $scope.config.HostConfig.Devices.splice(index, 1); + }; Config.$promise.then(function (c) { var containersToHideLabels = c.hiddenLabels; @@ -275,6 +283,19 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); config.Labels = labels; } + + function prepareDevices(config) { + var path = []; + config.HostConfig.Devices.forEach(function (p) { + if (p.pathOnHost) { + if(p.pathInContainer === '') { + p.pathInContainer = p.pathOnHost; + } + path.push({PathOnHost:p.pathOnHost,PathInContainer:p.pathInContainer,CgroupPermissions:'rwm'}); + } + }); + config.HostConfig.Devices = path; + } function prepareConfiguration() { var config = angular.copy($scope.config); @@ -286,6 +307,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai prepareEnvironmentVariables(config); prepareVolumes(config); prepareLabels(config); + prepareDevices(config); return config; } diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 3b5b13f30..cef979453 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -485,8 +485,38 @@
+
+ +
+
+ + + add device + +
+ +
+
+
+ host + +
+
+ container + +
+ +
+
+ +
+ +
+ - + From c526209925e6b094d0bd8ce6048ea654d177d725 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 16 Apr 2017 11:15:56 +0200 Subject: [PATCH 07/39] chore(gruntfile): remove --templates flag in run-dev task --- gruntfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gruntfile.js b/gruntfile.js index 0435e2675..d9f9254b1 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -402,7 +402,7 @@ module.exports = function (grunt) { command: [ 'docker stop portainer', 'docker rm portainer', - 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics --templates http://192.168.1.47:8080/templates.json' + 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' ].join(';') }, runSwarm: { From 2761959f93c6bf8bd4fa1bfbb4a2683bf31a6339 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 18 Apr 2017 17:16:00 +0100 Subject: [PATCH 08/39] feat(templates): add support for the note field (#805) --- app/components/templates/templates.html | 4 ++-- app/models/template.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 3c15a0a50..cc33b4cc2 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -15,9 +15,9 @@
-
+
- {{ state.selectedTemplate.Description }} +
diff --git a/app/models/template.js b/app/models/template.js index 3456e8c9d..6f20027f0 100644 --- a/app/models/template.js +++ b/app/models/template.js @@ -1,6 +1,7 @@ function TemplateViewModel(data) { this.Title = data.title; this.Description = data.description; + this.Note = data.note ? data.note : data.description; this.Category = data.category; this.Logo = data.logo; this.Image = data.image; From ac872b577a1a5f0cd22c7e83a2c071428a3ca247 Mon Sep 17 00:00:00 2001 From: Thomas Krzero Date: Tue, 25 Apr 2017 10:20:57 +0200 Subject: [PATCH 09/39] feat(containers) - Add the ability to force remove a container with confirmation (#814) --- app/components/container/container.html | 2 +- .../container/containerController.js | 22 ++++++++++++++---- app/components/containers/containers.html | 2 +- .../containers/containersController.js | 23 ++++++++++++++++++- app/components/service/serviceController.js | 2 +- app/components/services/servicesController.js | 2 +- app/components/user/userController.js | 2 +- app/components/users/usersController.js | 2 +- app/rest/container.js | 2 +- app/services/modalService.js | 2 +- 10 files changed, 48 insertions(+), 13 deletions(-) diff --git a/app/components/container/container.html b/app/components/container/container.html index af9c4a2e2..1e3406fb2 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -19,7 +19,7 @@ - +
diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 2b6195f9f..7931481db 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -1,6 +1,6 @@ angular.module('container', []) -.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ImageHelper', 'Network', 'Notifications', 'Pagination', -function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ImageHelper, Network, Notifications, Pagination) { +.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService', +function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ImageHelper, Network, Notifications, Pagination, ModalService) { $scope.activityTime = 0; $scope.portBindings = []; $scope.config = { @@ -116,9 +116,23 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima }); }; - $scope.remove = function () { + $scope.confirmRemove = function () { + if ($scope.container.State.Running) { + ModalService.confirmDeletion( + 'You are about to remove a running container.', + function (confirmed) { + if(!confirmed) { return; } + $scope.remove(); + } + ); + } else { + $scope.remove(); + } + }; + + $scope.remove = function() { $('#loadingViewSpinner').show(); - Container.remove({id: $stateParams.id}, function (d) { + Container.remove({id: $stateParams.id, force: true}, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); Notifications.error("Failure", d, "Unable to remove container"); diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 41e322b5d..2ba31535e 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -31,7 +31,7 @@ - + Add container diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index ee1ccedf3..656d0c111 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -128,7 +128,7 @@ angular.module('containers', []) }); } else if (action === Container.remove) { - action({id: c.Id}, function (d) { + action({id: c.Id, force: true}, function (d) { if (d.message) { Notifications.error("Error", d, "Unable to remove container"); } @@ -231,6 +231,27 @@ angular.module('containers', []) batch($scope.containers, Container.remove, "Removed"); }; + $scope.confirmRemoveAction = function () { + var isOneContainerRunning = false; + angular.forEach($scope.containers, function (c) { + if (c.Checked && c.State === 'running') { + isOneContainerRunning = true; + return; + } + }); + if (isOneContainerRunning) { + ModalService.confirmDeletion( + 'You are about to remove one or more running containers.', + function (confirmed) { + if(!confirmed) { return; } + $scope.removeAction(); + } + ); + } else { + $scope.removeAction(); + } + }; + function retrieveSwarmHostsInfo(data) { var swarm_hosts = {}; var systemStatus = data.SystemStatus; diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 9f7661e2e..e39ed08fa 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -224,7 +224,7 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi $scope.removeService = function() { ModalService.confirmDeletion( - 'Do you want to delete this service? All the containers associated to this service will be removed too.', + 'Do you want to remove this service? All the containers associated to this service will be removed too.', function onConfirm(confirmed) { if(!confirmed) { return; } removeService(); diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 72c5ada89..b4e5d54ba 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -70,7 +70,7 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification $scope.removeAction = function() { ModalService.confirmDeletion( - 'Do you want to delete the selected service(s)? All the containers associated to the selected service(s) will be removed too.', + 'Do you want to remove the selected service(s)? All the containers associated to the selected service(s) will be removed too.', function onConfirm(confirmed) { if(!confirmed) { return; } removeServices(); diff --git a/app/components/user/userController.js b/app/components/user/userController.js index abeb71fe7..6c0040432 100644 --- a/app/components/user/userController.js +++ b/app/components/user/userController.js @@ -14,7 +14,7 @@ function ($scope, $state, $stateParams, UserService, ModalService, Notifications $scope.deleteUser = function() { ModalService.confirmDeletion( - 'Do you want to delete this user? This user will not be able to login into Portainer anymore.', + 'Do you want to remove this user? This user will not be able to login into Portainer anymore.', function onConfirm(confirmed) { if(!confirmed) { return; } deleteUser(); diff --git a/app/components/users/usersController.js b/app/components/users/usersController.js index 218b97cd7..6e35233bf 100644 --- a/app/components/users/usersController.js +++ b/app/components/users/usersController.js @@ -103,7 +103,7 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) $scope.removeAction = function () { ModalService.confirmDeletion( - 'Do you want to delete the selected users? They will not be able to login into Portainer anymore.', + 'Do you want to remove the selected users? They will not be able to login into Portainer anymore.', function onConfirm(confirmed) { if(!confirmed) { return; } deleteSelectedUsers(); diff --git a/app/rest/container.js b/app/rest/container.js index 4130a5592..fa16d9d3b 100644 --- a/app/rest/container.js +++ b/app/rest/container.js @@ -23,7 +23,7 @@ angular.module('portainer.rest') transformResponse: genericHandler }, remove: { - method: 'DELETE', params: {id: '@id', v: 0}, + method: 'DELETE', params: {id: '@id', v: 0, force: '@force'}, transformResponse: genericHandler }, rename: { diff --git a/app/services/modalService.js b/app/services/modalService.js index b9d9453bb..f6667fe5e 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -75,7 +75,7 @@ angular.module('portainer.services') message: message, buttons: { confirm: { - label: 'Delete', + label: 'Remove', className: 'btn-danger' } }, From ca5c606dfcb567f66d9d95fcfecf079d39ba1627 Mon Sep 17 00:00:00 2001 From: Thomas Krzero Date: Tue, 25 Apr 2017 10:37:38 +0200 Subject: [PATCH 10/39] fix(services): replicas count misunderstanding (#806) --- app/components/services/servicesController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index b4e5d54ba..1cdf61da9 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -154,7 +154,7 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification $scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes); $scope.services = data.services.map(function (service) { var serviceTasks = data.tasks.filter(function (task) { - return task.ServiceID === service.ID; + return task.ServiceID === service.ID && task.Status.State === "running"; }); var taskNodes = data.nodes.filter(function (node) { return node.Spec.Availability === 'active' && node.Status.State === 'ready'; From e70817f7762f7fcdb1e8fefe2b2bc4b87d4c898f Mon Sep 17 00:00:00 2001 From: Thomas Krzero Date: Tue, 25 Apr 2017 11:09:06 +0200 Subject: [PATCH 11/39] feat(containers): show health status of containers (#622) --- app/components/container/container.html | 29 +++++++++++++++++++++++ app/components/containers/containers.html | 5 +++- app/filters/filters.js | 10 ++++++-- assets/css/app.css | 4 ++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/app/components/container/container.html b/app/components/container/container.html index 1e3406fb2..4a09e38eb 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -82,6 +82,35 @@ +
+
+ + + + + + + + + + + + + + + + + + +
Status + + {{ container.State.Health.Status }} +
Failure count{{ container.State.Health.FailingStreak }}
Last output{{ container.State.Health.Log[container.State.Health.Log.length - 1].Output }}
+
+ +
+
+
diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 2ba31535e..5153577ec 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -102,7 +102,10 @@ - {{ container.Status }} + + {{ container.Status }} + {{ container.Status }} + {{ container|swarmcontainername|truncate: 40}} {{ container|containername|truncate: 40}} {{ container.Image | hideshasum }} diff --git a/app/filters/filters.js b/app/filters/filters.js index 075fa5902..c220ac20a 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -40,11 +40,11 @@ angular.module('portainer.filters', []) 'use strict'; return function (text) { var status = _.toLower(text); - if (status.indexOf('paused') !== -1) { + if (status.indexOf('paused') !== -1 || status.indexOf('starting') !== -1) { return 'warning'; } else if (status.indexOf('created') !== -1) { return 'info'; - } else if (status.indexOf('stopped') !== -1) { + } else if (status.indexOf('stopped') !== -1 || status.indexOf('unhealthy') !== -1) { return 'danger'; } return 'success'; @@ -60,6 +60,12 @@ angular.module('portainer.filters', []) return 'created'; } else if (status.indexOf('exited') !== -1) { return 'stopped'; + } else if (status.indexOf('(healthy)') !== -1) { + return 'healthy'; + } else if (status.indexOf('(unhealthy)') !== -1) { + return 'unhealthy'; + } else if (status.indexOf('(health: starting)') !== -1) { + return 'starting'; } return 'running'; }; diff --git a/assets/css/app.css b/assets/css/app.css index 966284973..5d05de383 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -115,6 +115,10 @@ a[ng-click]{ color: #ae2323; } +.fa.orange-icon { + color: #f0ad4e; +} + .fa.white-icon { color: white; } From 6fa6dde63703cbef19efac233a187b4deb83288b Mon Sep 17 00:00:00 2001 From: 030 <030@users.noreply.github.com> Date: Tue, 25 Apr 2017 11:51:22 +0200 Subject: [PATCH 12/39] feat(backend): native SSL support --- api/cli/cli.go | 3 +++ api/cli/defaults.go | 3 +++ api/cli/defaults_windows.go | 3 +++ api/cmd/portainer/main.go | 3 +++ api/http/server.go | 6 ++++++ api/portainer.go | 3 +++ 6 files changed, 21 insertions(+) diff --git a/api/cli/cli.go b/api/cli/cli.go index afb1814b4..72a4560f8 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -43,6 +43,9 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), + SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(), + SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(), + SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(), AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), } diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 160b74808..5de413911 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -13,5 +13,8 @@ const ( defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCertPath = "/certs/cert.pem" defaultTLSKeyPath = "/certs/key.pem" + defaultSSL = "false" + defaultSSLCertPath = "/certs/portainer.crt" + defaultSSLKeyPath = "/certs/portainer.key" defaultSyncInterval = "60s" ) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index cbd0555a8..da5f0ce45 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -11,5 +11,8 @@ const ( defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCertPath = "C:\\certs\\cert.pem" defaultTLSKeyPath = "C:\\certs\\key.pem" + defaultSSL = "false" + defaultSSLCertPath = "C:\\certs\\portainer.crt" + defaultSSLKeyPath = "C:\\certs\\portainer.key" defaultSyncInterval = "60s" ) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 775f76718..0743fc140 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -166,6 +166,9 @@ func main() { CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, + SSL: *flags.SSL, + SSLCert: *flags.SSLCert, + SSLKey: *flags.SSLKey, } log.Printf("Starting Portainer on %s", *flags.Addr) diff --git a/api/http/server.go b/api/http/server.go index 916e2370f..18e37a99a 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -21,6 +21,9 @@ type Server struct { Settings *portainer.Settings TemplatesURL string Handler *Handler + SSL bool + SSLCert string + SSLKey string } // Start starts the HTTP server @@ -70,5 +73,8 @@ func (server *Server) Start() error { UploadHandler: uploadHandler, } + if server.SSL { + return http.ListenAndServeTLS(server.BindAddress, server.SSLCert, server.SSLKey, server.Handler) + } return http.ListenAndServe(server.BindAddress, server.Handler) } diff --git a/api/portainer.go b/api/portainer.go index 9d7fb3096..6b742246f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -26,6 +26,9 @@ type ( TLSCacert *string TLSCert *string TLSKey *string + SSL *bool + SSLCert *string + SSLKey *string AdminPassword *string } From 25206e71cf99d63ff0e79e0f8542fdaffff40d25 Mon Sep 17 00:00:00 2001 From: GP8x Date: Tue, 25 Apr 2017 21:32:27 +0100 Subject: [PATCH 13/39] feat(container-creation): add support for ip assignments (#812) --- .../createContainer/createContainerController.js | 14 +++++++++++++- .../createContainer/createcontainer.html | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 0cb2a76dd..09808ad01 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -12,7 +12,9 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Registry: '', NetworkContainer: '', Labels: [], - ExtraHosts: [] + ExtraHosts: [], + IPv4: '', + IPv6: '' }; $scope.imageConfig = {}; @@ -34,6 +36,9 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai ExtraHosts: [], Devices:[] }, + NetworkingConfig: { + EndpointsConfig: {} + }, Labels: {} }; @@ -267,6 +272,13 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai } config.HostConfig.NetworkMode = networkMode; + config.NetworkingConfig.EndpointsConfig[networkMode] = { + IPAMConfig: { + IPv4Address: $scope.formValues.IPv4, + IPv6Address: $scope.formValues.IPv6 + } + }; + $scope.formValues.ExtraHosts.forEach(function (v) { if (v.value) { config.HostConfig.ExtraHosts.push(v.value); diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index cef979453..530ad0f02 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -350,6 +350,22 @@
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+
From 90d13684e5067a7eff7bbdd68075b8d7a9267b95 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 27 Apr 2017 18:09:40 +0200 Subject: [PATCH 14/39] chore(project): add eslint and codeclimate configuration files --- .codeclimate.yml | 31 ++++++ .eslintrc.yml | 284 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 316 insertions(+) create mode 100644 .codeclimate.yml create mode 100644 .eslintrc.yml diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 000000000..179db3a65 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,31 @@ +--- +engines: + gofmt: + enabled: true + golint: + enabled: true + govet: + enabled: true + csslint: + enabled: true + duplication: + enabled: true + config: + languages: + - ruby + - javascript + - python + - php + eslint: + enabled: true + config: + config: .eslintrc.yml + fixme: + enabled: true +ratings: + paths: + - "**.css" + - "**.js" + - "**.go" +exclude_paths: +- test/ diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 000000000..47be470b1 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,284 @@ +env: + browser: true + jquery: true + +# globals: +# angular: true +# $: true +# _: true +# moment: true +# filesize: true +# splitargs: true +extends: + - 'eslint:recommended' + +# http://eslint.org/docs/rules/ +rules: + # Possible Errors + no-await-in-loop: off + no-cond-assign: error + no-console: off + no-constant-condition: error + no-control-regex: error + no-debugger: error + no-dupe-args: error + no-dupe-keys: error + no-duplicate-case: error + no-empty-character-class: error + no-empty: error + no-ex-assign: error + no-extra-boolean-cast: error + no-extra-parens: off + no-extra-semi: error + no-func-assign: error + no-inner-declarations: + - error + - functions + no-invalid-regexp: error + no-irregular-whitespace: error + no-negated-in-lhs: error + no-obj-calls: error + no-prototype-builtins: off + no-regex-spaces: error + no-sparse-arrays: error + no-template-curly-in-string: off + no-unexpected-multiline: error + no-unreachable: error + no-unsafe-finally: off + no-unsafe-negation: off + use-isnan: error + valid-jsdoc: off + valid-typeof: error + + # Best Practices + accessor-pairs: error + array-callback-return: off + block-scoped-var: off + class-methods-use-this: off + complexity: + - error + - 6 + consistent-return: off + curly: off + default-case: off + dot-location: off + dot-notation: off + eqeqeq: error + guard-for-in: error + no-alert: error + no-caller: error + no-case-declarations: error + no-div-regex: error + no-else-return: off + no-empty-function: off + no-empty-pattern: error + no-eq-null: error + no-eval: error + no-extend-native: error + no-extra-bind: error + no-extra-label: off + no-fallthrough: error + no-floating-decimal: off + no-global-assign: off + no-implicit-coercion: off + no-implied-eval: error + no-invalid-this: off + no-iterator: error + no-labels: + - error + - allowLoop: true + allowSwitch: true + no-lone-blocks: error + no-loop-func: error + no-magic-number: off + no-multi-spaces: off + no-multi-str: off + no-native-reassign: error + no-new-func: error + no-new-wrappers: error + no-new: error + no-octal-escape: error + no-octal: error + no-param-reassign: off + no-proto: error + no-redeclare: error + no-restricted-properties: off + no-return-assign: error + no-return-await: off + no-script-url: error + no-self-assign: off + no-self-compare: error + no-sequences: off + no-throw-literal: off + no-unmodified-loop-condition: off + no-unused-expressions: error + no-unused-labels: off + no-useless-call: error + no-useless-concat: error + no-useless-escape: off + no-useless-return: off + no-void: error + no-warning-comments: off + no-with: error + prefer-promise-reject-errors: off + radix: error + require-await: off + vars-on-top: off + wrap-iife: error + yoda: off + + # Strict + strict: off + + # Variables + init-declarations: off + no-catch-shadow: error + no-delete-var: error + no-label-var: error + no-restricted-globals: off + no-shadow-restricted-names: error + no-shadow: off + no-undef-init: error + no-undef: off + no-undefined: off + no-unused-vars: off + no-use-before-define: off + + # Node.js and CommonJS + callback-return: error + global-require: error + handle-callback-err: error + no-mixed-requires: off + no-new-require: off + no-path-concat: error + no-process-env: off + no-process-exit: error + no-restricted-modules: off + no-sync: off + + # Stylistic Issues + array-bracket-spacing: off + block-spacing: off + brace-style: off + camelcase: off + capitalized-comments: off + comma-dangle: + - error + - never + comma-spacing: off + comma-style: off + computed-property-spacing: off + consistent-this: off + eol-last: off + func-call-spacing: off + func-name-matching: off + func-names: off + func-style: off + id-length: off + id-match: off + indent: off + jsx-quotes: off + key-spacing: off + keyword-spacing: off + line-comment-position: off + linebreak-style: + - error + - unix + lines-around-comment: off + lines-around-directive: off + max-depth: off + max-len: off + max-nested-callbacks: off + max-params: off + max-statements-per-line: off + max-statements: + - error + - 30 + multiline-ternary: off + new-cap: off + new-parens: off + newline-after-var: off + newline-before-return: off + newline-per-chained-call: off + no-array-constructor: off + no-bitwise: off + no-continue: off + no-inline-comments: off + no-lonely-if: off + no-mixed-operators: off + no-mixed-spaces-and-tabs: off + no-multi-assign: off + no-multiple-empty-lines: off + no-negated-condition: off + no-nested-ternary: off + no-new-object: off + no-plusplus: off + no-restricted-syntax: off + no-spaced-func: off + no-tabs: off + no-ternary: off + no-trailing-spaces: off + no-underscore-dangle: off + no-unneeded-ternary: off + object-curly-newline: off + object-curly-spacing: off + object-property-newline: off + one-var-declaration-per-line: off + one-var: off + operator-assignment: off + operator-linebreak: off + padded-blocks: off + quote-props: off + quotes: + - error + - single + require-jsdoc: off + semi-spacing: off + semi: + - error + - always + sort-keys: off + sort-vars: off + space-before-blocks: off + space-before-function-paren: off + space-in-parens: off + space-infix-ops: off + space-unary-ops: off + spaced-comment: off + template-tag-spacing: off + unicode-bom: off + wrap-regex: off + + # ECMAScript 6 + arrow-body-style: off + arrow-parens: off + arrow-spacing: off + constructor-super: off + generator-star-spacing: off + no-class-assign: off + no-confusing-arrow: off + no-const-assign: off + no-dupe-class-members: off + no-duplicate-imports: off + no-new-symbol: off + no-restricted-imports: off + no-this-before-super: off + no-useless-computed-key: off + no-useless-constructor: off + no-useless-rename: off + no-var: off + object-shorthand: off + prefer-arrow-callback: off + prefer-const: off + prefer-destructuring: off + prefer-numeric-literals: off + prefer-rest-params: off + prefer-reflect: off + prefer-spread: off + prefer-template: off + require-yield: off + rest-spread-spacing: off + sort-imports: off + symbol-description: off + template-curly-spacing: off + yield-star-spacing: off diff --git a/package.json b/package.json index 1432aacd8..206b6ce7d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": {}, "devDependencies": { "bower": "^1.5.2", + "eslint": "^3.19.0", "grunt": "~0.4.0", "grunt-contrib-clean": "~0.4.0", "grunt-contrib-concat": "~0.1.3", From 87250d13d707c077d8d0be9a6a69087b46d50f8f Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 27 Apr 2017 18:11:48 +0200 Subject: [PATCH 15/39] chore(project): update codeclimate configuration --- .codeclimate.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.codeclimate.yml b/.codeclimate.yml index 179db3a65..b6deabfde 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -12,10 +12,7 @@ engines: enabled: true config: languages: - - ruby - javascript - - python - - php eslint: enabled: true config: From 5a07638f4d299793bd8c384c8355d64cf52f1a93 Mon Sep 17 00:00:00 2001 From: Thomas Krzero Date: Thu, 27 Apr 2017 20:40:37 +0200 Subject: [PATCH 16/39] fix(container) - correct since date for created containers (#822) --- app/components/container/container.html | 11 ++++++++--- app/components/container/containerController.js | 2 ++ app/filters/filters.js | 3 +++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/components/container/container.html b/app/components/container/container.html index 4a09e38eb..1693368a2 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -54,15 +54,20 @@ Status - - {{ container.State|getstatetext }} since {{ activityTime }} with exit code {{ container.State.ExitCode }} + + + {{ container.State|getstatetext }} since {{ activityTime }} with exit code {{ container.State.ExitCode }} + + Created + {{ container.Created|getisodate }} + Start time {{ container.State.StartedAt|getisodate }} - + Finished {{ container.State.FinishedAt|getisodate }} diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 7931481db..a79419cfe 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -23,6 +23,8 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima if (d.State.Running) { $scope.activityTime = moment.duration(moment(d.State.StartedAt).utc().diff(moment().utc())).humanize(); + } else if (d.State.Status === "created") { + $scope.activityTime = moment.duration(moment(d.Created).utc().diff(moment().utc())).humanize(); } else { $scope.activityTime = moment.duration(moment().utc().diff(moment(d.State.FinishedAt).utc())).humanize(); } diff --git a/app/filters/filters.js b/app/filters/filters.js index c220ac20a..396e35d45 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -109,6 +109,9 @@ angular.module('portainer.filters', []) if (state.Running) { return 'Running'; } + if (state.Status === 'created') { + return 'Created'; + } return 'Stopped'; }; }) From 3d8eec25574c43a5059fd5e5a77f7bfee1586f15 Mon Sep 17 00:00:00 2001 From: Thomas Krzero Date: Mon, 1 May 2017 12:18:06 +0200 Subject: [PATCH 17/39] feat(containers) - clean non-persistent volumes when removing a container (#824) --- .../container/containerController.js | 26 ++++--- .../containers/containersController.js | 26 ++++--- app/rest/container.js | 2 +- app/services/modalService.js | 71 +++++++++++++++---- assets/css/app.css | 18 ++++- 5 files changed, 103 insertions(+), 40 deletions(-) diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index a79419cfe..66b3b25cc 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -119,22 +119,26 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima }; $scope.confirmRemove = function () { + var title = 'You are about to remove a container.'; if ($scope.container.State.Running) { - ModalService.confirmDeletion( - 'You are about to remove a running container.', - function (confirmed) { - if(!confirmed) { return; } - $scope.remove(); - } - ); - } else { - $scope.remove(); + title = 'You are about to remove a running container.'; } + ModalService.confirmContainerDeletion( + title, + function (result) { + if(!result) { return; } + var cleanAssociatedVolumes = false; + if (result[0]) { + cleanAssociatedVolumes = true; + } + $scope.remove(cleanAssociatedVolumes); + } + ); }; - $scope.remove = function() { + $scope.remove = function(cleanAssociatedVolumes) { $('#loadingViewSpinner').show(); - Container.remove({id: $stateParams.id, force: true}, function (d) { + Container.remove({id: $stateParams.id, v: (cleanAssociatedVolumes) ? 1 : 0, force: true}, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); Notifications.error("Failure", d, "Unable to remove container"); diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 656d0c111..25eb90d25 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -17,6 +17,8 @@ angular.module('containers', []) Pagination.setPaginationCount('containers', $scope.state.pagination_count); }; + $scope.cleanAssociatedVolumes = false; + function removeContainerResourceControl(container) { volumeResourceControlQueries = []; angular.forEach(container.Mounts, function (volume) { @@ -128,7 +130,7 @@ angular.module('containers', []) }); } else if (action === Container.remove) { - action({id: c.Id, force: true}, function (d) { + action({id: c.Id, v: ($scope.cleanAssociatedVolumes) ? 1 : 0, force: true}, function (d) { if (d.message) { Notifications.error("Error", d, "Unable to remove container"); } @@ -239,17 +241,21 @@ angular.module('containers', []) return; } }); + var title = 'You are about to remove one or more container.'; if (isOneContainerRunning) { - ModalService.confirmDeletion( - 'You are about to remove one or more running containers.', - function (confirmed) { - if(!confirmed) { return; } - $scope.removeAction(); - } - ); - } else { - $scope.removeAction(); + title = 'You are about to remove one or more running containers.'; } + ModalService.confirmContainerDeletion( + title, + function (result) { + if(!result) { return; } + $scope.cleanAssociatedVolumes = false; + if (result[0]) { + $scope.cleanAssociatedVolumes = true; + } + $scope.removeAction(); + } + ); }; function retrieveSwarmHostsInfo(data) { diff --git a/app/rest/container.js b/app/rest/container.js index fa16d9d3b..b53b49264 100644 --- a/app/rest/container.js +++ b/app/rest/container.js @@ -23,7 +23,7 @@ angular.module('portainer.rest') transformResponse: genericHandler }, remove: { - method: 'DELETE', params: {id: '@id', v: 0, force: '@force'}, + method: 'DELETE', params: {id: '@id', v: '@v', force: '@force'}, transformResponse: genericHandler }, rename: { diff --git a/app/services/modalService.js b/app/services/modalService.js index f6667fe5e..c70704935 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -3,21 +3,7 @@ angular.module('portainer.services') 'use strict'; var service = {}; - service.confirm = function(options){ - var box = bootbox.confirm({ - title: options.title, - message: options.message, - buttons: { - confirm: { - label: options.buttons.confirm.label, - className: options.buttons.confirm.className - }, - cancel: { - label: options.buttons.cancel && options.buttons.cancel.label ? options.buttons.cancel.label : 'Cancel' - } - }, - callback: options.callback - }); + var applyBoxCSS = function(box) { box.css({ 'top': '50%', 'margin-top': function () { @@ -26,6 +12,40 @@ angular.module('portainer.services') }); }; + var confirmButtons = function(options) { + var buttons = { + confirm: { + label: options.buttons.confirm.label, + className: options.buttons.confirm.className + }, + cancel: { + label: options.buttons.cancel && options.buttons.cancel.label ? options.buttons.cancel.label : 'Cancel' + } + }; + return buttons; + }; + + service.confirm = function(options){ + var box = bootbox.confirm({ + title: options.title, + message: options.message, + buttons: confirmButtons(options), + callback: options.callback + }); + applyBoxCSS(box); + }; + + service.prompt = function(options){ + var box = bootbox.prompt({ + title: options.title, + inputType: options.inputType, + inputOptions: options.inputOptions, + buttons: confirmButtons(options), + callback: options.callback + }); + applyBoxCSS(box); + }; + service.confirmOwnershipChange = function(callback, msg) { service.confirm({ title: 'Are you sure ?', @@ -82,5 +102,26 @@ angular.module('portainer.services') callback: callback, }); }; + + service.confirmContainerDeletion = function(title, callback) { + service.prompt({ + title: title, + inputType: 'checkbox', + inputOptions: [ + { + text: 'Automatically remove non-persistent volumes', + value: '1' + } + ], + buttons: { + confirm: { + label: 'Remove', + className: 'btn-danger' + } + }, + callback: callback + }); + }; + return service; }]); diff --git a/assets/css/app.css b/assets/css/app.css index 5d05de383..d0d1cd0f5 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -371,11 +371,23 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { float: none !important; } +.bootbox-form .bootbox-input-checkbox { + display: none +} + +.bootbox-form label { + padding-left: 0; +} + .switch input { display: none; } -.switch i { +.bootbox-form .checkbox i { + margin-left: 21px; +} + +.switch i, .bootbox-form .checkbox i { display: inline-block; vertical-align: middle; cursor: pointer; @@ -386,7 +398,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { box-shadow: inset 0 0 1px 1px rgba(0,0,0,.5); } -.switch i:before { +.switch i:before, .bootbox-form .checkbox i:before { display: block; content: ''; width: 24px; @@ -396,7 +408,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { box-shadow: 0 0 1px 1px rgba(0,0,0,.5); } -.switch :checked + i { +.switch :checked + i, .bootbox-form .checkbox :checked ~ i { padding-right: 0; padding-left: 24px; -webkit-box-shadow: inset 0 0 1px rgba(0,0,0,.5), inset 0 0 40px #337ab7; From 7c6c9284f27e867cf5ab11ce1a54895fd419858a Mon Sep 17 00:00:00 2001 From: Thomas Krzero Date: Mon, 1 May 2017 12:19:43 +0200 Subject: [PATCH 18/39] feat(endpoints) - Access exposed containers on endpoint public URL (#826) --- api/http/endpoint_handler.go | 19 +++++++++++++------ api/portainer.go | 1 + app/components/containers/containers.html | 2 +- .../containers/containersController.js | 5 +++-- app/components/endpoint/endpoint.html | 11 +++++++++++ app/components/endpoint/endpointController.js | 1 + .../endpointInit/endpointInitController.js | 3 ++- app/components/endpoints/endpoints.html | 11 +++++++++++ .../endpoints/endpointsController.js | 7 ++++++- app/components/sidebar/sidebarController.js | 4 ++++ app/services/endpointProvider.js | 11 +++++++++++ app/services/endpointService.js | 4 +++- app/services/localStorage.js | 6 ++++++ 13 files changed, 73 insertions(+), 12 deletions(-) diff --git a/api/http/endpoint_handler.go b/api/http/endpoint_handler.go index 162ceaa76..d72c017e3 100644 --- a/api/http/endpoint_handler.go +++ b/api/http/endpoint_handler.go @@ -108,6 +108,7 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht endpoint := &portainer.Endpoint{ Name: req.Name, URL: req.URL, + PublicURL: req.PublicURL, TLS: req.TLS, AuthorizedUsers: []portainer.UserID{}, } @@ -136,9 +137,10 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht } type postEndpointsRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - TLS bool + Name string `valid:"required"` + URL string `valid:"required"` + PublicURL string `valid:"-"` + TLS bool } type postEndpointsResponse struct { @@ -262,6 +264,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpoint.URL = req.URL } + if req.PublicURL != "" { + endpoint.PublicURL = req.PublicURL + } + if req.TLS { endpoint.TLS = true caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA) @@ -296,9 +302,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http } type putEndpointsRequest struct { - Name string `valid:"-"` - URL string `valid:"-"` - TLS bool `valid:"-"` + Name string `valid:"-"` + URL string `valid:"-"` + PublicURL string `valid:"-"` + TLS bool `valid:"-"` } // handleDeleteEndpoint handles DELETE requests on /endpoints/:id diff --git a/api/portainer.go b/api/portainer.go index 6b742246f..5d6bb280f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -72,6 +72,7 @@ type ( ID EndpointID `json:"Id"` Name string `json:"Name"` URL string `json:"URL"` + PublicURL string `json:"PublicURL"` TLS bool `json:"TLS"` TLSCACertPath string `json:"TLSCACert,omitempty"` TLSCertPath string `json:"TLSCert,omitempty"` diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 5153577ec..2f85dd83e 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -112,7 +112,7 @@ {{ container.IP ? container.IP : '-' }} {{ container.hostIP }} - + {{p.public}}:{{ p.private }} - diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 25eb90d25..3d11631f2 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('containers', []) - .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', - function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', 'EndpointProvider', + function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService, EndpointProvider) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); $scope.state.displayAll = Settings.displayAll; @@ -12,6 +12,7 @@ angular.module('containers', []) $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; $scope.sortType = sortType; }; + $scope.PublicURL = EndpointProvider.endpointPublicURL(); $scope.changePaginationCount = function() { Pagination.setPaginationCount('containers', $scope.state.pagination_count); diff --git a/app/components/endpoint/endpoint.html b/app/components/endpoint/endpoint.html index d1c355961..b5d95a714 100644 --- a/app/components/endpoint/endpoint.html +++ b/app/components/endpoint/endpoint.html @@ -31,6 +31,17 @@
+ +
+ +
+ +
+
+
diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index 43a013834..4ef7c0aba 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -22,6 +22,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) var endpointParams = { name: $scope.endpoint.Name, URL: $scope.endpoint.URL, + PublicURL: $scope.endpoint.PublicURL, TLS: $scope.endpoint.TLS, TLSCACert: $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null, TLSCert: $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null, diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js index e2fc30725..7977de3ee 100644 --- a/app/components/endpointInit/endpointInitController.js +++ b/app/components/endpointInit/endpointInitController.js @@ -67,12 +67,13 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.state.error = ''; var name = $scope.formValues.Name; var URL = $scope.formValues.URL; + var PublicURL = URL.split(':')[0]; var TLS = $scope.formValues.TLS; var TLSCAFile = $scope.formValues.TLSCACert; var TLSCertFile = $scope.formValues.TLSCert; var TLSKeyFile = $scope.formValues.TLSKey; - EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { var endpointID = data.Id; updateEndpointState(endpointID); diff --git a/app/components/endpoints/endpoints.html b/app/components/endpoints/endpoints.html index 695a4aeb2..52755893a 100644 --- a/app/components/endpoints/endpoints.html +++ b/app/components/endpoints/endpoints.html @@ -46,6 +46,17 @@
+ +
+ +
+ +
+
+
diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index 7bf2a854a..f9d0082f2 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -13,6 +13,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi $scope.formValues = { Name: '', URL: '', + PublicURL: '', TLS: false, TLSCACert: null, TLSCert: null, @@ -49,11 +50,15 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi $scope.state.error = ''; var name = $scope.formValues.Name; var URL = $scope.formValues.URL; + var PublicURL = $scope.formValues.PublicURL; + if (PublicURL === '') { + PublicURL = URL.split(':')[0]; + } var TLS = $scope.formValues.TLS; var TLSCAFile = $scope.formValues.TLSCACert; var TLSCertFile = $scope.formValues.TLSCert; var TLSKeyFile = $scope.formValues.TLSKey; - EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) { + EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) { Notifications.success("Endpoint created", name); $state.reload(); }, function error(err) { diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index 75b91dd14..eef6a0430 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -11,7 +11,9 @@ function ($scope, $state, Settings, Config, EndpointService, StateManager, Endpo $scope.switchEndpoint = function(endpoint) { var activeEndpointID = EndpointProvider.endpointID(); + var activeEndpointPublicURL = EndpointProvider.endpointPublicURL(); EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); StateManager.updateEndpointState(true) .then(function success() { $state.go('dashboard'); @@ -19,6 +21,7 @@ function ($scope, $state, Settings, Config, EndpointService, StateManager, Endpo .catch(function error(err) { Notifications.error("Failure", err, "Unable to connect to the Docker endpoint"); EndpointProvider.setEndpointID(activeEndpointID); + EndpointProvider.setEndpointPublicURL(activeEndpointPublicURL); StateManager.updateEndpointState(true) .then(function success() {}); }); @@ -32,6 +35,7 @@ function ($scope, $state, Settings, Config, EndpointService, StateManager, Endpo angular.forEach($scope.endpoints, function (endpoint) { if (endpoint.Id === activeEndpointID) { $scope.activeEndpoint = endpoint; + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); } }); }) diff --git a/app/services/endpointProvider.js b/app/services/endpointProvider.js index e6d43f604..4f1c870a9 100644 --- a/app/services/endpointProvider.js +++ b/app/services/endpointProvider.js @@ -5,9 +5,13 @@ angular.module('portainer.services') var service = {}; service.initialize = function() { var endpointID = LocalStorage.getEndpointID(); + var endpointPublicURL = LocalStorage.getEndpointPublicURL(); if (endpointID) { endpoint.ID = endpointID; } + if (endpointPublicURL) { + endpoint.PublicURL = endpointPublicURL; + } }; service.clean = function() { endpoint = {}; @@ -19,5 +23,12 @@ angular.module('portainer.services') endpoint.ID = id; LocalStorage.storeEndpointID(id); }; + service.endpointPublicURL = function() { + return endpoint.PublicURL; + } + service.setEndpointPublicURL = function(publicURL) { + endpoint.PublicURL = publicURL; + LocalStorage.storeEndpointPublicURL(publicURL); + } return service; }]); diff --git a/app/services/endpointService.js b/app/services/endpointService.js index e9486cd29..0b4e88418 100644 --- a/app/services/endpointService.js +++ b/app/services/endpointService.js @@ -18,6 +18,7 @@ angular.module('portainer.services') service.updateEndpoint = function(id, endpointParams) { var query = { name: endpointParams.name, + PublicURL: endpointParams.PublicURL, TLS: endpointParams.TLS, authorizedUsers: endpointParams.authorizedUsers }; @@ -54,10 +55,11 @@ angular.module('portainer.services') return Endpoints.create({}, endpoint).$promise; }; - service.createRemoteEndpoint = function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createRemoteEndpoint = function(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) { var endpoint = { Name: name, URL: 'tcp://' + URL, + PublicURL: PublicURL, TLS: TLS }; var deferred = $q.defer(); diff --git a/app/services/localStorage.js b/app/services/localStorage.js index acc6fe378..ae991ddea 100644 --- a/app/services/localStorage.js +++ b/app/services/localStorage.js @@ -8,6 +8,12 @@ angular.module('portainer.services') getEndpointID: function() { return localStorageService.get('ENDPOINT_ID'); }, + storeEndpointPublicURL: function(publicURL) { + localStorageService.set('ENDPOINT_PUBLIC_URL', publicURL); + }, + getEndpointPublicURL: function() { + return localStorageService.get('ENDPOINT_PUBLIC_URL'); + }, storeEndpointState: function(state) { localStorageService.set('ENDPOINT_STATE', state); }, From 43e1f25f89bae667591624ee0f27ec7231fb11f5 Mon Sep 17 00:00:00 2001 From: Glowbal Date: Thu, 4 May 2017 08:57:08 +0200 Subject: [PATCH 19/39] feat(service-creation): add placement constraints (#837) --- .../createService/createServiceController.js | 25 ++++++++++++--- .../createService/createservice.html | 5 +++ .../createService/includes/placement.html | 31 +++++++++++++++++++ app/components/service/serviceController.js | 15 +-------- app/helpers/serviceHelper.js | 14 ++++++++- 5 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 app/components/createService/includes/placement.html diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index ce304ca4f..007df409c 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', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', -function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ResourceControlService, Notifications) { +.controller('CreateServiceController', ['$scope', '$state', 'Service', 'ServiceHelper', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', +function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, Authentication, ResourceControlService, Notifications) { $scope.formValues = { Ownership: $scope.applicationState.application.authentication ? 'private' : '', @@ -23,6 +23,7 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ExtraNetworks: [], Ports: [], Parallelism: 1, + PlacementConstraints: [], UpdateDelay: 0, FailureAction: 'pause' }; @@ -58,7 +59,18 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, $scope.removeEnvironmentVariable = function(index) { $scope.formValues.Env.splice(index, 1); }; - + $scope.addPlacementConstraint = function() { + $scope.formValues.PlacementConstraints.push({ key: '', operator: '==', value: '' }); + }; + $scope.removePlacementConstraint = function(index) { + $scope.formValues.PlacementConstraints.splice(index, 1); + }; + $scope.addPlacementPreference = function() { + $scope.formValues.PlacementPreferences.push({ key: '', operator: '==', value: '' }); + }; + $scope.removePlacementPreference = function(index) { + $scope.formValues.PlacementPreferences.splice(index, 1); + }; $scope.addLabel = function() { $scope.formValues.Labels.push({ name: '', value: ''}); }; @@ -188,6 +200,9 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, FailureAction: input.FailureAction }; } + function preparePlacementConfig(config, input) { + config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(input.PlacementConstraints); + } function prepareConfiguration() { var input = $scope.formValues; @@ -196,7 +211,8 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, TaskTemplate: { ContainerSpec: { Mounts: [] - } + }, + Placement: {} }, Mode: {}, EndpointSpec: {} @@ -210,6 +226,7 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, prepareVolumes(config, input); prepareNetworks(config, input); prepareUpdateConfig(config, input); + preparePlacementConfig(config, input); return config; } diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index 60f556d5d..cb5e3a431 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -155,6 +155,7 @@
  • Network
  • Labels
  • Update config
  • +
  • Placement
  • @@ -436,6 +437,10 @@
    + + +
    +
    diff --git a/app/components/createService/includes/placement.html b/app/components/createService/includes/placement.html new file mode 100644 index 000000000..1b4f8b7c2 --- /dev/null +++ b/app/components/createService/includes/placement.html @@ -0,0 +1,31 @@ +
    +
    +
    + + + placement constraint + +
    +
    +
    +
    + name + +
    +
    + +
    +
    + value + +
    + +
    +
    +
    +
    diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index e39ed08fa..a183ab322 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -173,7 +173,7 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi if (typeof config.TaskTemplate.Placement === 'undefined') { config.TaskTemplate.Placement = {}; } - config.TaskTemplate.Placement.Constraints = translateKeyValueToConstraints(service.ServiceConstraints); + config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(service.ServiceConstraints); config.TaskTemplate.Resources = { Limits: { @@ -382,18 +382,5 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi return []; } - function translateKeyValueToConstraints(keyValueConstraints) { - if (keyValueConstraints) { - var constraints = []; - keyValueConstraints.forEach(function(keyValueConstraint) { - if (keyValueConstraint.key && keyValueConstraint.key !== '' && keyValueConstraint.value && keyValueConstraint.value !== '') { - constraints.push(keyValueConstraint.key + keyValueConstraint.operator + keyValueConstraint.value); - } - }); - return constraints; - } - return []; - } - fetchServiceDetails(); }]); diff --git a/app/helpers/serviceHelper.js b/app/helpers/serviceHelper.js index c5e4a2e58..1b2f453ce 100644 --- a/app/helpers/serviceHelper.js +++ b/app/helpers/serviceHelper.js @@ -12,6 +12,18 @@ angular.module('portainer.helpers') Networks: service.Spec.Networks, EndpointSpec: service.Spec.EndpointSpec }; - } + }, + translateKeyValueToPlacementConstraints: function(keyValueConstraints) { + if (keyValueConstraints) { + var constraints = []; + keyValueConstraints.forEach(function(keyValueConstraint) { + if (keyValueConstraint.key && keyValueConstraint.key !== '' && keyValueConstraint.value && keyValueConstraint.value !== '') { + constraints.push(keyValueConstraint.key + keyValueConstraint.operator + keyValueConstraint.value); + } + }); + return constraints; + } + return []; + } }; }]); From df3a529f0ab154f314b9bbd70d7d307ea5eceaa2 Mon Sep 17 00:00:00 2001 From: Glowbal Date: Thu, 4 May 2017 09:43:20 +0200 Subject: [PATCH 20/39] feat(services): ability to publish ports using host mode (#838) --- .../createService/createServiceController.js | 5 +++-- app/components/createService/createservice.html | 10 +++++++--- app/components/service/includes/ports.html | 9 +++++++++ app/components/service/serviceController.js | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 007df409c..95a1c22cc 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -29,7 +29,7 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, }; $scope.addPortBinding = function() { - $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp' }); + $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' }); }; $scope.removePortBinding = function(index) { @@ -96,7 +96,8 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, var ports = []; input.Ports.forEach(function (binding) { var port = { - Protocol: binding.Protocol + Protocol: binding.Protocol, + PublishMode: binding.PublishMode }; if (binding.TargetPort) { port.TargetPort = +binding.TargetPort; diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index cb5e3a431..941804e9e 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -74,7 +74,7 @@
    -
    +
    host
    @@ -83,17 +83,21 @@ -
    +
    container
    -
    +
    +
    + + +
    diff --git a/app/components/service/includes/ports.html b/app/components/service/includes/ports.html index d63593d06..7367bfbb7 100644 --- a/app/components/service/includes/ports.html +++ b/app/components/service/includes/ports.html @@ -17,6 +17,7 @@ Host port Container port Protocol + Publish mode Actions @@ -42,6 +43,14 @@
    + +
    + +
    + +
    +
    - +
    @@ -205,45 +223,90 @@
    +
    +
    -
    -
    -
    - - -
    - Items per page: - + +
    - + +
    + + + + + + +
    +
    +
    -
    - -
    {{ tpl.Title }}
    -
    {{ tpl.Description }}
    + +
    +
    + + + + + + + + +
    + + {{ tpl.Title }} + + + + + + +
    + + +
    + + {{ tpl.Description }} + + + {{ tpl.Categories.join(', ') }} + +
    + +
    + +
    +
    Loading...
    -
    +
    No templates available.
    -
    - -
    - +
    +
    diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 42bbe6de5..9cec4c602 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,16 +1,20 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', -function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', +function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, hideDescriptions: $stateParams.hide_descriptions, - pagination_count: Pagination.getPaginationCount('templates') + pagination_count: Pagination.getPaginationCount('templates'), + filters: { + Categories: '!', + Platform: '!' + } }; $scope.formValues = { Ownership: $scope.applicationState.application.authentication ? 'private' : '', - network: "", - name: "", + network: '', + name: '' }; $scope.changePaginationCount = function() { @@ -74,32 +78,38 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ }); }; - var selectedItem = -1; - $scope.selectTemplate = function(idx) { - $('#template_' + idx).toggleClass("container-template--selected"); - if (selectedItem === idx) { - unselectTemplate(); + $scope.unselectTemplate = function() { + var currentTemplateIndex = $scope.state.selectedTemplate.index; + $('#template_' + currentTemplateIndex).toggleClass('template-container--selected'); + $scope.state.selectedTemplate = null; + }; + + $scope.selectTemplate = function(index, pos) { + if ($scope.state.selectedTemplate && $scope.state.selectedTemplate.index !== index) { + $scope.unselectTemplate(); + } + + var templates = $filter('filter')($scope.templates, $scope.state.filters, true); + var template = templates[pos]; + if (template === $scope.state.selectedTemplate) { + $scope.unselectTemplate(); } else { - selectTemplate(idx); + selectTemplate(index, pos, templates); } }; - function unselectTemplate() { - selectedItem = -1; - $scope.state.selectedTemplate = null; - } - - function selectTemplate(idx) { - $('#template_' + selectedItem).toggleClass("container-template--selected"); - selectedItem = idx; - var selectedTemplate = $scope.templates[idx]; + function selectTemplate(index, pos, filteredTemplates) { + $('#template_' + index).toggleClass('template-container--selected'); + var selectedTemplate = filteredTemplates[pos]; $scope.state.selectedTemplate = selectedTemplate; + if (selectedTemplate.Network) { $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === selectedTemplate.Network; }); } else { - $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === "bridge"; }); + $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === 'bridge'; }); } - $anchorScroll('selectedTemplate'); + + $anchorScroll('view-top'); } function createTemplateConfiguration(template) { @@ -114,7 +124,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ var containerMapping = 'BY_CONTAINER_IP'; if (endpointProvider === 'DOCKER_SWARM' && network.Scope === 'global') { containerMapping = 'BY_SWARM_CONTAINER_NAME'; - } else if (network.Name !== "bridge") { + } else if (network.Name !== 'bridge') { containerMapping = 'BY_CONTAINER_NAME'; } return containerMapping; @@ -144,18 +154,19 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ volumes: VolumeService.getVolumes() }) .then(function success(data) { - var templates = data.templates; - if (templatesKey === 'linuxserver.io') { - templates = TemplateService.filterLinuxServerIOTemplates(templates); - } - $scope.templates = templates; + $scope.templates = data.templates; + var availableCategories = []; + angular.forEach($scope.templates, function(template) { + availableCategories = availableCategories.concat(template.Categories); + }); + $scope.availableCategories = _.sortBy(_.uniq(availableCategories)); $scope.runningContainers = data.containers; $scope.availableNetworks = filterNetworksBasedOnProvider(data.networks); $scope.availableVolumes = data.volumes.Volumes; }) .catch(function error(err) { $scope.templates = []; - Notifications.error("Failure", err, "An error occured during apps initialization."); + Notifications.error('Failure', err, 'An error occured during apps initialization.'); }) .finally(function final(){ $('#loadTemplatesSpinner').hide(); diff --git a/app/directives/template-widget.js b/app/directives/template-widget.js new file mode 100644 index 000000000..51f902cbf --- /dev/null +++ b/app/directives/template-widget.js @@ -0,0 +1,13 @@ +angular +.module('portainer') +.directive('rdTemplateWidget', function rdWidget() { + var directive = { + scope: { + 'ngModel': '=' + }, + transclude: true, + template: '
    ', + restrict: 'EA' + }; + return directive; +}); diff --git a/app/directives/widget.js b/app/directives/widget.js index 56fea70e5..6426fe147 100644 --- a/app/directives/widget.js +++ b/app/directives/widget.js @@ -3,7 +3,7 @@ angular .directive('rdWidget', function rdWidget() { var directive = { scope: { - "ngModel": "=" + 'ngModel': '=' }, transclude: true, template: '
    ', diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index 7cdda76fe..a97bdd978 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -28,7 +28,7 @@ angular.module('portainer.helpers') }; ports.forEach(function (p) { if (p.containerPort) { - var key = p.containerPort + "/" + p.protocol; + var key = p.containerPort + '/' + p.protocol; var binding = {}; if (p.hostPort) { binding.HostPort = p.hostPort; @@ -60,7 +60,7 @@ angular.module('portainer.helpers') value = $filter('swarmcontainername')(envvar.value); } } - env.push(envvar.name + "=" + value); + env.push(envvar.name + '=' + value); } }); return env; @@ -108,8 +108,8 @@ angular.module('portainer.helpers') helper.filterLinuxServerIOTemplates = function(templates) { return templates.filter(function f(template) { var valid = false; - if (template.Category) { - angular.forEach(template.Category, function(category) { + if (template.Categories) { + angular.forEach(template.Categories, function(category) { if (_.startsWith(category, 'Network')) { valid = true; } diff --git a/app/models/template.js b/app/models/template.js index 0b8dbf3eb..6d8d5497a 100644 --- a/app/models/template.js +++ b/app/models/template.js @@ -1,8 +1,9 @@ function TemplateViewModel(data) { this.Title = data.title; this.Description = data.description; - this.Note = data.note ? data.note : data.description; - this.Category = data.category; + this.Note = data.note; + this.Categories = data.categories ? data.categories : []; + this.Platform = data.platform ? data.platform : ''; this.Logo = data.logo; this.Image = data.image; this.Registry = data.registry ? data.registry : ''; diff --git a/app/models/templateLinuxServer.js b/app/models/templateLinuxServer.js new file mode 100644 index 000000000..ae65c214d --- /dev/null +++ b/app/models/templateLinuxServer.js @@ -0,0 +1,33 @@ +function TemplateLSIOViewModel(data) { + this.Title = data.title; + this.Note = data.description; + this.Categories = data.category ? data.category : []; + this.Platform = data.platform ? data.platform : 'linux'; + this.Logo = data.logo; + this.Image = data.image; + this.Registry = data.registry ? data.registry : ''; + this.Command = data.command ? data.command : ''; + this.Network = data.network ? data.network : ''; + this.Env = data.env ? data.env : []; + this.Privileged = data.privileged ? data.privileged : false; + this.Volumes = []; + if (data.volumes) { + this.Volumes = data.volumes.map(function (v) { + return { + readOnly: false, + containerPath: v, + type: 'auto' + }; + }); + } + this.Ports = []; + if (data.ports) { + this.Ports = data.ports.map(function (p) { + var portAndProtocol = _.split(p, '/'); + return { + containerPort: portAndProtocol[0], + protocol: portAndProtocol[1] + }; + }); + } +} diff --git a/app/services/templateService.js b/app/services/templateService.js index b339135f6..4be7d7cbe 100644 --- a/app/services/templateService.js +++ b/app/services/templateService.js @@ -8,10 +8,18 @@ angular.module('portainer.services') Template.get({key: key}).$promise .then(function success(data) { var templates = data.map(function (tpl, idx) { - var template = new TemplateViewModel(tpl); + var template; + if (key === 'linuxserver.io') { + template = new TemplateLSIOViewModel(tpl); + } else { + template = new TemplateViewModel(tpl); + } template.index = idx; return template; }); + if (key === 'linuxserver.io') { + templates = TemplateHelper.filterLinuxServerIOTemplates(templates); + } deferred.resolve(templates); }) .catch(function error(err) { @@ -20,10 +28,6 @@ angular.module('portainer.services') return deferred.promise; }; - service.filterLinuxServerIOTemplates = function(templates) { - return TemplateHelper.filterLinuxServerIOTemplates(templates); - }; - service.createTemplateConfiguration = function(template, containerName, network, containerMapping) { var imageConfiguration = ImageHelper.createImageConfigForContainer(template.Image, template.Registry); var containerConfiguration = service.createContainerConfiguration(template, containerName, network, containerMapping); diff --git a/assets/css/app.css b/assets/css/app.css index d0d1cd0f5..13c4d0eb0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -64,6 +64,13 @@ html, body, #content-wrapper, .page-content, #view { color: #777; } +.form-section-title { + border-bottom: 1px solid #777; + margin-top: 5px; + margin-bottom: 15px; + color: #777; +} + .form-horizontal .control-label.text-left{ text-align: left; font-size: 0.9em; @@ -149,11 +156,6 @@ a[ng-click]{ cursor: pointer; } -.template-list { - display: flex; - flex-wrap: wrap; -} - .custom-header-ico { max-width: 32px; max-height: 32px; @@ -176,81 +178,6 @@ a[ng-click]{ } } -/* Underline From Center */ -.hvr-underline-from-center { - display: inline-block; - vertical-align: middle; - -webkit-transform: translateZ(0); - transform: translateZ(0); - box-shadow: 0 0 1px rgba(0, 0, 0, 0); - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - -moz-osx-font-smoothing: grayscale; - position: relative; - overflow: hidden; -} -.hvr-underline-from-center:before { - content: ""; - position: absolute; - z-index: -1; - left: 50%; - right: 50%; - bottom: 0; - background: #85898b; - height: 2px; - -webkit-transition-property: left, right; - transition-property: left, right; - -webkit-transition-duration: 0.3s; - transition-duration: 0.3s; - -webkit-transition-timing-function: ease-out; - transition-timing-function: ease-out; -} -.hvr-underline-from-center:hover:before, .hvr-underline-from-center:focus:before, .hvr-underline-from-center:active:before { - left: 0; - right: 0; -} - -.container-template { - font-size: 1em; - width: 256px; - height: 128px; - margin: 15px; - padding: 5px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 10px; - cursor: pointer; - border: 2px solid #f6f6f6; - color: #30426a; -} - -.container-template--selected { - background-color: #ececec; - color: #2d3e63; -} - -.container-template:hover { - background-color: #ececec; - color: #2d3e63; -} - -.container-template .logo { - max-width: 48px; - max-height: 48px; -} - -.container-template .title { - text-align: center; -} - -.container-template .description { - text-align: center; - font-size: 0.8em; - margin-bottom: 5px; -} - .page-wrapper { height: 100%; width: 100%; @@ -418,3 +345,68 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { #toast-container > div { opacity: 0.9; } + +.template-widget { + height: 100%; +} + +.template-widget-body { + max-height: 86%; + overflow-y: auto; +} + +.template-list { + display: flex; + flex-direction: column; +} + +.template-logo { + width: 100%; + max-width: 60px; + height: 100%; + max-height: 60px; +} + +.template-container { + padding: 0.7rem; + margin-bottom: 0.7rem; + cursor: pointer; + border: 1px solid #333333; + border-radius: 2px; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); +} + +.template-container--selected { + border: 2px solid #333333; + background-color: #ececec; + color: #2d3e63; +} + +.template-container:hover { + background-color: #ececec; + color: #2d3e63; +} + +.template-main { + display: flex; +} + +.template-note { + padding: 0.5em; + font-size: 0.9em; +} + +.template-title { + font-size: 1.8em; + font-weight: bold; +} + +.template-description { + font-size: 0.9em; + padding-right: 1em; +} + +.template-line { + display: flex; + justify-content: space-between; +} From 0ba6645df090f2d11dcc8e9e7fac054f1b6ec8a8 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 18 May 2017 23:17:39 +0200 Subject: [PATCH 26/39] fix(container-details): fix an issue with duplicate env var (#863) --- app/components/container/container.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/container/container.html b/app/components/container/container.html index 1693368a2..d87fab614 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -193,7 +193,7 @@ ENV - + From 782df54570a99754c0e62f550e14350d289e3876 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 18 May 2017 23:32:04 +0200 Subject: [PATCH 27/39] fix(service-details): add missing Arguments field (#864) --- app/models/service.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/service.js b/app/models/service.js index 12c97971c..5f59e2407 100644 --- a/app/models/service.js +++ b/app/models/service.js @@ -51,6 +51,7 @@ function ServiceViewModel(data, runningTasks, nodes) { this.User = containerSpec.User; this.Dir = containerSpec.Dir; this.Command = containerSpec.Command; + this.Arguments = containerSpec.Args; this.Secrets = containerSpec.Secrets; } if (data.Endpoint) { From 2ef1c90248480c98dabdaa7b9e9b0811afeda375 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 19 May 2017 17:48:03 +0200 Subject: [PATCH 28/39] feat(app): disable Angular debug information on release (#867) --- app/app.js | 165 ++++++++++++++++++++++++++------------------------- gruntfile.js | 64 ++++++++++++++------ package.json | 1 + 3 files changed, 131 insertions(+), 99 deletions(-) diff --git a/app/app.js b/app/app.js index 09c01cc21..fef0adf13 100644 --- a/app/app.js +++ b/app/app.js @@ -51,9 +51,14 @@ angular.module('portainer', [ 'user', 'users', 'volumes']) - .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider) { + .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) { 'use strict'; + var environment = '@@ENVIRONMENT'; + if (environment === 'production') { + $compileProvider.debugInfoEnabled(false); + } + localStorageServiceProvider .setStorageType('sessionStorage') .setPrefix('portainer'); @@ -100,7 +105,7 @@ angular.module('portainer', [ error: '' }, views: { - "content@": { + 'content@': { templateUrl: 'app/components/auth/auth.html', controller: 'AuthenticationController' } @@ -113,63 +118,63 @@ angular.module('portainer', [ parent: 'root', url: '/containers/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/containers/containers.html', controller: 'ContainersController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } } }) .state('container', { - url: "^/containers/:id", + url: '^/containers/:id', views: { - "content@": { + 'content@': { templateUrl: 'app/components/container/container.html', controller: 'ContainerController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } } }) .state('stats', { - url: "^/containers/:id/stats", + url: '^/containers/:id/stats', views: { - "content@": { + 'content@': { templateUrl: 'app/components/stats/stats.html', controller: 'StatsController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } } }) .state('logs', { - url: "^/containers/:id/logs", + url: '^/containers/:id/logs', views: { - "content@": { + 'content@': { templateUrl: 'app/components/containerLogs/containerlogs.html', controller: 'ContainerLogsController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } } }) .state('console', { - url: "^/containers/:id/console", + url: '^/containers/:id/console', views: { - "content@": { + 'content@': { templateUrl: 'app/components/containerConsole/containerConsole.html', controller: 'ContainerConsoleController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -179,11 +184,11 @@ angular.module('portainer', [ parent: 'root', url: '/dashboard', views: { - "content@": { + 'content@': { templateUrl: 'app/components/dashboard/dashboard.html', controller: 'DashboardController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -191,75 +196,75 @@ angular.module('portainer', [ }) .state('actions', { abstract: true, - url: "/actions", + url: '/actions', views: { - "content@": { + 'content@': { template: '
    ' }, - "sidebar@": { - template: '
    ' + 'sidebar@': { + template: '
    ' } } }) .state('actions.create', { abstract: true, - url: "/create", + url: '/create', views: { - "content@": { + 'content@': { template: '
    ' }, - "sidebar@": { - template: '
    ' + 'sidebar@': { + template: '
    ' } } }) .state('actions.create.container', { - url: "/container", + url: '/container', views: { - "content@": { + 'content@': { templateUrl: 'app/components/createContainer/createcontainer.html', controller: 'CreateContainerController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } } }) .state('actions.create.network', { - url: "/network", + url: '/network', views: { - "content@": { + 'content@': { templateUrl: 'app/components/createNetwork/createnetwork.html', controller: 'CreateNetworkController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } } }) .state('actions.create.service', { - url: "/service", + url: '/service', views: { - "content@": { + 'content@': { templateUrl: 'app/components/createService/createservice.html', controller: 'CreateServiceController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } } }) .state('actions.create.volume', { - url: "/volume", + url: '/volume', views: { - "content@": { + 'content@': { templateUrl: 'app/components/createVolume/createvolume.html', controller: 'CreateVolumeController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -268,11 +273,11 @@ angular.module('portainer', [ .state('docker', { url: '/docker/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/docker/docker.html', controller: 'DockerController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -281,11 +286,11 @@ angular.module('portainer', [ .state('endpoints', { url: '/endpoints/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/endpoints/endpoints.html', controller: 'EndpointsController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -294,11 +299,11 @@ angular.module('portainer', [ .state('endpoint', { url: '^/endpoints/:id', views: { - "content@": { + 'content@': { templateUrl: 'app/components/endpoint/endpoint.html', controller: 'EndpointController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -307,11 +312,11 @@ angular.module('portainer', [ .state('endpoint.access', { url: '^/endpoints/:id/access', views: { - "content@": { + 'content@': { templateUrl: 'app/components/endpointAccess/endpointAccess.html', controller: 'EndpointAccessController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -320,7 +325,7 @@ angular.module('portainer', [ .state('endpointInit', { url: '/init/endpoint', views: { - "content@": { + 'content@': { templateUrl: 'app/components/endpointInit/endpointInit.html', controller: 'EndpointInitController' } @@ -329,11 +334,11 @@ angular.module('portainer', [ .state('events', { url: '/events/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/events/events.html', controller: 'EventsController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -342,11 +347,11 @@ angular.module('portainer', [ .state('images', { url: '/images/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/images/images.html', controller: 'ImagesController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -355,11 +360,11 @@ angular.module('portainer', [ .state('image', { url: '^/images/:id/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/image/image.html', controller: 'ImageController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -368,11 +373,11 @@ angular.module('portainer', [ .state('networks', { url: '/networks/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/networks/networks.html', controller: 'NetworksController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -381,11 +386,11 @@ angular.module('portainer', [ .state('network', { url: '^/networks/:id/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/network/network.html', controller: 'NetworkController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -394,11 +399,11 @@ angular.module('portainer', [ .state('node', { url: '^/nodes/:id/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/node/node.html', controller: 'NodeController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -407,11 +412,11 @@ angular.module('portainer', [ .state('services', { url: '/services/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/services/services.html', controller: 'ServicesController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -420,11 +425,11 @@ angular.module('portainer', [ .state('service', { url: '^/service/:id/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/service/service.html', controller: 'ServiceController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -433,11 +438,11 @@ angular.module('portainer', [ .state('settings', { url: '/settings/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/settings/settings.html', controller: 'SettingsController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -446,11 +451,11 @@ angular.module('portainer', [ .state('task', { url: '^/task/:id', views: { - "content@": { + 'content@': { templateUrl: 'app/components/task/task.html', controller: 'TaskController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -463,11 +468,11 @@ angular.module('portainer', [ hide_descriptions: false }, views: { - "content@": { + 'content@': { templateUrl: 'app/components/templates/templates.html', controller: 'TemplatesController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -480,11 +485,11 @@ angular.module('portainer', [ hide_descriptions: true }, views: { - "content@": { + 'content@': { templateUrl: 'app/components/templates/templates.html', controller: 'TemplatesController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -493,11 +498,11 @@ angular.module('portainer', [ .state('volumes', { url: '/volumes/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/volumes/volumes.html', controller: 'VolumesController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -506,11 +511,11 @@ angular.module('portainer', [ .state('users', { url: '/users/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/users/users.html', controller: 'UsersController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -519,11 +524,11 @@ angular.module('portainer', [ .state('user', { url: '^/users/:id', views: { - "content@": { + 'content@': { templateUrl: 'app/components/user/user.html', controller: 'UserController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -532,11 +537,11 @@ angular.module('portainer', [ .state('swarm', { url: '/swarm/', views: { - "content@": { + 'content@': { templateUrl: 'app/components/swarm/swarm.html', controller: 'SwarmController' }, - "sidebar@": { + 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } @@ -564,7 +569,7 @@ angular.module('portainer', [ }); } }, function error(err) { - Notifications.error("Failure", err, 'Unable to retrieve application settings'); + Notifications.error('Failure', err, 'Unable to retrieve application settings'); }); $rootScope.$state = $state; diff --git a/gruntfile.js b/gruntfile.js index 953018dc2..4df174b0a 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -14,10 +14,12 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-contrib-cssmin'); grunt.loadNpmTasks('grunt-usemin'); grunt.loadNpmTasks('grunt-replace'); + grunt.loadNpmTasks('grunt-config'); // Default task. grunt.registerTask('default', ['eslint', 'build']); grunt.registerTask('build', [ + 'config:dev', 'clean:app', 'if:unixBinaryNotExist', 'html2js', @@ -25,12 +27,14 @@ module.exports = function (grunt) { 'recess:build', 'concat', 'clean:tmpl', + 'replace', 'copy', 'filerev', 'usemin', 'clean:tmp' ]); grunt.registerTask('release', [ + 'config:prod', 'clean:all', 'if:unixBinaryNotExist', 'html2js', @@ -39,76 +43,80 @@ module.exports = function (grunt) { 'concat', 'clean:tmpl', 'cssmin', + 'replace', 'uglify', 'copy:assets', 'filerev', 'usemin', - 'clean:tmp', - 'replace' + 'clean:tmp' ]); grunt.registerTask('release-win', [ + 'config:prod', 'clean:all', 'if:windowsBinaryNotExist', 'html2js', - 'useminPrepare', + 'useminPrepare:release', 'recess:build', 'concat', 'clean:tmpl', 'cssmin', + 'replace', 'uglify', 'copy', 'filerev', 'usemin', - 'clean:tmp', - 'replace' + 'clean:tmp' ]); grunt.registerTask('release-arm', [ + 'config:prod', 'clean:all', 'if:unixArmBinaryNotExist', 'html2js', - 'useminPrepare', + 'useminPrepare:release', 'recess:build', 'concat', 'clean:tmpl', 'cssmin', + 'replace', 'uglify', 'copy', 'filerev', 'usemin', - 'clean:tmp', - 'replace' + 'clean:tmp' ]); grunt.registerTask('release-arm64', [ + 'config:prod', 'clean:all', 'if:unixArm64BinaryNotExist', 'html2js', - 'useminPrepare', + 'useminPrepare:release', 'recess:build', 'concat', 'clean:tmpl', 'cssmin', + 'replace', 'uglify', 'copy', 'filerev', 'usemin', - 'clean:tmp', - 'replace' + 'clean:tmp' ]); grunt.registerTask('release-macos', [ + 'config:prod', 'clean:all', 'if:darwinBinaryNotExist', 'html2js', - 'useminPrepare', + 'useminPrepare:release', 'recess:build', 'concat', 'clean:tmpl', 'cssmin', + 'replace', 'uglify', 'copy', 'filerev', 'usemin', - 'clean:tmp', - 'replace' + 'clean:tmp' ]); grunt.registerTask('lint', ['eslint']); grunt.registerTask('run', ['if:unixBinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); @@ -127,6 +135,22 @@ module.exports = function (grunt) { grunt.initConfig({ distdir: 'dist', pkg: grunt.file.readJSON('package.json'), + config: { + dev: { + options: { + variables: { + 'environment': 'development' + } + } + }, + prod: { + options: { + variables: { + 'environment': 'production' + } + } + } + }, src: { js: ['app/**/*.js', '!app/**/*.spec.js'], jsTpl: ['<%= distdir %>/templates/**/*.js'], @@ -268,8 +292,6 @@ module.exports = function (grunt) { }, uglify: { dist: { - // options: { - // }, src: ['<%= src.js %>', '<%= src.jsTpl %>'], dest: '<%= distdir %>/js/<%= pkg.name %>.js' }, @@ -450,9 +472,13 @@ module.exports = function (grunt) { } }, replace: { - dist: { + concat: { options: { patterns: [ + { + match: 'ENVIRONMENT', + replacement: '<%= grunt.config.get("environment") %>' + }, { match: 'CONFIG_GA_ID', replacement: '<%= pkg.config.GA_ID %>' @@ -463,8 +489,8 @@ module.exports = function (grunt) { { expand: true, flatten: true, - src: ['dist/js/**.js'], - dest: 'dist/js/' + src: ['.tmp/concat/js/app.js'], + dest: '.tmp/concat/js' } ] } diff --git a/package.json b/package.json index 1e2a267f2..40f2bbadb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bower": "^1.5.2", "eslint": "^3.19.0", "grunt": "~0.4.0", + "grunt-config": "^1.0.0", "grunt-contrib-clean": "~0.4.0", "grunt-contrib-concat": "~0.1.3", "grunt-contrib-copy": "~0.4.0", From 69c7f116b1fb0c94c60ca0a49fbfea9c6e9c3d38 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 19 May 2017 17:51:01 +0200 Subject: [PATCH 29/39] fix(app): fix missing '=' char in state definitions --- app/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app.js b/app/app.js index fef0adf13..fb3ce0604 100644 --- a/app/app.js +++ b/app/app.js @@ -202,7 +202,7 @@ angular.module('portainer', [ template: '
    ' }, 'sidebar@': { - template: '
    ' + template: '
    ' } } }) @@ -214,7 +214,7 @@ angular.module('portainer', [ template: '
    ' }, 'sidebar@': { - template: '
    ' + template: '
    ' } } }) From a2e781fb3f10676358cf006cad30e9027566fe5e Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 20 May 2017 10:02:18 +0200 Subject: [PATCH 30/39] chore(build-system): add support for ppc64le architecture (#870) --- build.sh | 92 +++++++++++++++++++++++++++++----------------------- gruntfile.js | 47 ++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 49 deletions(-) diff --git a/build.sh b/build.sh index cbbdbf0a8..394d4d27d 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash +ARCHIVE_BUILD_FOLDER="/tmp/portainer-builds" VERSION=$1 if [[ $# -ne 1 ]] ; then @@ -7,57 +8,66 @@ if [[ $# -ne 1 ]] ; then exit 1 fi +# parameters platform, architecture +function build_and_push_images() { + PLATFORM=$1 + ARCH=$2 + + docker build -t portainer/portainer:${PLATFORM}-${ARCH}-${VERSION} -f build/linux/Dockerfile . + docker push portainer/portainer:${PLATFORM}-${ARCH}-${VERSION} + docker build -t portainer/portainer:${PLATFORM}-${ARCH} -f build/linux/Dockerfile . + docker push portainer/portainer:${PLATFORM}-${ARCH} +} + +# parameters: platform, architecture +function build_archive() { + PLATFORM=$1 + ARCH=$2 + + BUILD_FOLDER=${ARCHIVE_BUILD_FOLDER}/${PLATFORM}-${ARCH} + + rm -rf ${BUILD_FOLDER} && mkdir -pv ${BUILD_FOLDER}/portainer + mv dist/* ${BUILD_FOLDER}/portainer/ + cd ${BUILD_FOLDER} + tar cvpfz portainer-${VERSION}-${PLATFORM}-${ARCH}.tar.gz portainer + mv portainer-${VERSION}-${PLATFORM}-${ARCH}.tar.gz ${ARCHIVE_BUILD_FOLDER}/ + cd - +} + mkdir -pv /tmp/portainer-builds +PLATFORM="linux" +ARCH="amd64" grunt release -docker build -t portainer/portainer:linux-amd64-${VERSION} -f build/linux/Dockerfile . -docker push portainer/portainer:linux-amd64-${VERSION} -docker build -t portainer/portainer:linux-amd64 -f build/linux/Dockerfile . -docker push portainer/portainer:linux-amd64 -rm -rf /tmp/portainer-builds/unix && mkdir -pv /tmp/portainer-builds/unix/portainer -mv dist/* /tmp/portainer-builds/unix/portainer -cd /tmp/portainer-builds/unix -tar cvpfz portainer-${VERSION}-linux-amd64.tar.gz portainer -mv portainer-${VERSION}-linux-amd64.tar.gz /tmp/portainer-builds/ -cd - +build_and_push_images ${PLATFORM} ${ARCH} +build_archive ${PLATFORM} ${ARCH} +PLATFORM="linux" +ARCH="arm" grunt release-arm -docker build -t portainer/portainer:linux-arm-${VERSION} -f build/linux/Dockerfile . -docker push portainer/portainer:linux-arm-${VERSION} -docker build -t portainer/portainer:linux-arm -f build/linux/Dockerfile . -docker push portainer/portainer:linux-arm -rm -rf /tmp/portainer-builds/arm && mkdir -pv /tmp/portainer-builds/arm/portainer -mv dist/* /tmp/portainer-builds/arm/portainer -cd /tmp/portainer-builds/arm -tar cvpfz portainer-${VERSION}-linux-arm.tar.gz portainer -mv portainer-${VERSION}-linux-arm.tar.gz /tmp/portainer-builds/ -cd - +build_and_push_images ${PLATFORM} ${ARCH} +build_archive ${PLATFORM} ${ARCH} +PLATFORM="linux" +ARCH="arm64" grunt release-arm64 -docker build -t portainer/portainer:linux-arm64-${VERSION} -f build/linux/Dockerfile . -docker push portainer/portainer:linux-arm64-${VERSION} -docker build -t portainer/portainer:linux-arm64 -f build/linux/Dockerfile . -docker push portainer/portainer:linux-arm64 -rm -rf /tmp/portainer-builds/arm64 && mkdir -pv /tmp/portainer-builds/arm64/portainer -mv dist/* /tmp/portainer-builds/arm64/portainer -cd /tmp/portainer-builds/arm64 -tar cvpfz portainer-${VERSION}-linux-arm64.tar.gz portainer -mv portainer-${VERSION}-linux-arm64.tar.gz /tmp/portainer-builds/ -cd - +build_and_push_images ${PLATFORM} ${ARCH} +build_archive ${PLATFORM} ${ARCH} +PLATFORM="linux" +ARCH="ppc64le" +grunt release-ppc64le +build_and_push_images ${PLATFORM} ${ARCH} +build_archive ${PLATFORM} ${ARCH} + +PLATFORM="darwin" +ARCH="amd64" grunt release-macos -rm -rf /tmp/portainer-builds/darwin && mkdir -pv /tmp/portainer-builds/darwin/portainer -mv dist/* /tmp/portainer-builds/darwin/portainer -cd /tmp/portainer-builds/darwin -tar cvpfz portainer-${VERSION}-darwin-amd64.tar.gz portainer -mv portainer-${VERSION}-darwin-amd64.tar.gz /tmp/portainer-builds/ -cd - +build_archive ${PLATFORM} ${ARCH} +PLATFORM="windows" +ARCH="amd64" grunt release-win -rm -rf /tmp/portainer-builds/win && mkdir -pv /tmp/portainer-builds/win/portainer -cp -r dist/* /tmp/portainer-builds/win/portainer -cd /tmp/portainer-builds/win -tar cvpfz portainer-${VERSION}-windows-amd64.tar.gz portainer -mv portainer-${VERSION}-windows-amd64.tar.gz /tmp/portainer-builds/ +build_archive ${PLATFORM} ${ARCH} exit 0 diff --git a/gruntfile.js b/gruntfile.js index 4df174b0a..17378b7c6 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -118,6 +118,23 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); + grunt.registerTask('release-ppc64le', [ + 'config:prod', + 'clean:all', + 'if:unixPpc64leBinaryNotExist', + 'html2js', + 'useminPrepare:release', + 'recess:build', + 'concat', + 'clean:tmpl', + 'cssmin', + 'replace', + 'uglify', + 'copy', + 'filerev', + 'usemin', + 'clean:tmp' + ]); grunt.registerTask('lint', ['eslint']); grunt.registerTask('run', ['if:unixBinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); grunt.registerTask('run-swarm', ['if:unixBinaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarm', 'watch:buildSwarm']); @@ -383,14 +400,22 @@ module.exports = function (grunt) { 'mv api/cmd/portainer/portainer-linux-arm dist/portainer' ].join(' && ') }, - buildUnixArm64Binary: { - command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm64" portainer/golang-builder:cross-platform /src/cmd/portainer', - 'shasum api/cmd/portainer/portainer-linux-arm64 > portainer-checksum.txt', - 'mkdir -p dist', - 'mv api/cmd/portainer/portainer-linux-arm64 dist/portainer' - ].join(' && ') - }, + buildUnixArm64Binary: { + command: [ + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm64" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-linux-arm64 > portainer-checksum.txt', + 'mkdir -p dist', + 'mv api/cmd/portainer/portainer-linux-arm64 dist/portainer' + ].join(' && ') + }, + buildUnixPpc64leBinary: { + command: [ + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="ppc64le" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-linux-ppc64le > portainer-checksum.txt', + 'mkdir -p dist', + 'mv api/cmd/portainer/portainer-linux-ppc64le dist/portainer' + ].join(' && ') + }, buildDarwinBinary: { command: [ 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform /src/cmd/portainer', @@ -458,6 +483,12 @@ module.exports = function (grunt) { }, ifFalse: ['shell:buildUnixArm64Binary'] }, + unixPpc64leBinaryNotExist: { + options: { + executable: 'dist/portainer' + }, + ifFalse: ['shell:buildUnixPpc64leBinary'] + }, darwinBinaryNotExist: { options: { executable: 'dist/portainer' From 6e95e1279a8e35dc761a6d90eb4f7fef1fb8529b Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 20 May 2017 10:27:55 +0200 Subject: [PATCH 31/39] chore(build-system): add support for linux 386 architecture (#871) --- build.sh | 18 ++++++---- gruntfile.js | 94 ++++++++++++++++++++++++++++++++++------------------ 2 files changed, 74 insertions(+), 38 deletions(-) diff --git a/build.sh b/build.sh index 394d4d27d..a7a0a10e4 100755 --- a/build.sh +++ b/build.sh @@ -38,36 +38,42 @@ mkdir -pv /tmp/portainer-builds PLATFORM="linux" ARCH="amd64" -grunt release +grunt release-${PLATFORM}-${ARCH} +build_and_push_images ${PLATFORM} ${ARCH} +build_archive ${PLATFORM} ${ARCH} + +PLATFORM="linux" +ARCH="386" +grunt release-${PLATFORM}-${ARCH} build_and_push_images ${PLATFORM} ${ARCH} build_archive ${PLATFORM} ${ARCH} PLATFORM="linux" ARCH="arm" -grunt release-arm +grunt release-${PLATFORM}-${ARCH} build_and_push_images ${PLATFORM} ${ARCH} build_archive ${PLATFORM} ${ARCH} PLATFORM="linux" ARCH="arm64" -grunt release-arm64 +grunt release-${PLATFORM}-${ARCH} build_and_push_images ${PLATFORM} ${ARCH} build_archive ${PLATFORM} ${ARCH} PLATFORM="linux" ARCH="ppc64le" -grunt release-ppc64le +grunt release-${PLATFORM}-${ARCH} build_and_push_images ${PLATFORM} ${ARCH} build_archive ${PLATFORM} ${ARCH} PLATFORM="darwin" ARCH="amd64" -grunt release-macos +grunt release-${PLATFORM}-${ARCH} build_archive ${PLATFORM} ${ARCH} PLATFORM="windows" ARCH="amd64" -grunt release-win +grunt release-${PLATFORM}-${ARCH} build_archive ${PLATFORM} ${ARCH} exit 0 diff --git a/gruntfile.js b/gruntfile.js index 17378b7c6..844919999 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -16,12 +16,11 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-replace'); grunt.loadNpmTasks('grunt-config'); - // Default task. grunt.registerTask('default', ['eslint', 'build']); grunt.registerTask('build', [ 'config:dev', 'clean:app', - 'if:unixBinaryNotExist', + 'if:linuxAmd64BinaryNotExist', 'html2js', 'useminPrepare:dev', 'recess:build', @@ -33,10 +32,10 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); - grunt.registerTask('release', [ + grunt.registerTask('release-linux-386', [ 'config:prod', 'clean:all', - 'if:unixBinaryNotExist', + 'if:linux386BinaryNotExist', 'html2js', 'useminPrepare:release', 'recess:build', @@ -50,10 +49,27 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); - grunt.registerTask('release-win', [ + grunt.registerTask('release-linux-amd64', [ 'config:prod', 'clean:all', - 'if:windowsBinaryNotExist', + 'if:linuxAmd64BinaryNotExist', + 'html2js', + 'useminPrepare:release', + 'recess:build', + 'concat', + 'clean:tmpl', + 'cssmin', + 'replace', + 'uglify', + 'copy:assets', + 'filerev', + 'usemin', + 'clean:tmp' + ]); + grunt.registerTask('release-linux-arm', [ + 'config:prod', + 'clean:all', + 'if:linuxArmBinaryNotExist', 'html2js', 'useminPrepare:release', 'recess:build', @@ -67,10 +83,10 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); - grunt.registerTask('release-arm', [ + grunt.registerTask('release-linux-arm64', [ 'config:prod', 'clean:all', - 'if:unixArmBinaryNotExist', + 'if:linuxArm64BinaryNotExist', 'html2js', 'useminPrepare:release', 'recess:build', @@ -84,10 +100,10 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); - grunt.registerTask('release-arm64', [ + grunt.registerTask('release-linux-ppc64le', [ 'config:prod', 'clean:all', - 'if:unixArm64BinaryNotExist', + 'if:linuxPpc64leBinaryNotExist', 'html2js', 'useminPrepare:release', 'recess:build', @@ -101,10 +117,10 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); - grunt.registerTask('release-macos', [ + grunt.registerTask('release-windows-amd64', [ 'config:prod', 'clean:all', - 'if:darwinBinaryNotExist', + 'if:windowsAmd64BinaryNotExist', 'html2js', 'useminPrepare:release', 'recess:build', @@ -118,10 +134,10 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); - grunt.registerTask('release-ppc64le', [ + grunt.registerTask('release-darwin-amd64', [ 'config:prod', 'clean:all', - 'if:unixPpc64leBinaryNotExist', + 'if:darwinAmd64BinaryNotExist', 'html2js', 'useminPrepare:release', 'recess:build', @@ -384,7 +400,7 @@ module.exports = function (grunt) { buildImage: { command: 'docker build --rm -t portainer -f build/linux/Dockerfile .' }, - buildBinary: { + buildLinuxAmd64Binary: { command: [ 'docker run --rm -v $(pwd)/api:/src portainer/golang-builder /src/cmd/portainer', 'shasum api/cmd/portainer/portainer > portainer-checksum.txt', @@ -392,7 +408,15 @@ module.exports = function (grunt) { 'mv api/cmd/portainer/portainer dist/' ].join(' && ') }, - buildUnixArmBinary: { + buildLinux386Binary: { + command: [ + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="386" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-linux-386 > portainer-checksum.txt', + 'mkdir -p dist', + 'mv api/cmd/portainer/portainer-linux-386 dist/portainer' + ].join(' && ') + }, + buildLinuxArmBinary: { command: [ 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" portainer/golang-builder:cross-platform /src/cmd/portainer', 'shasum api/cmd/portainer/portainer-linux-arm > portainer-checksum.txt', @@ -400,7 +424,7 @@ module.exports = function (grunt) { 'mv api/cmd/portainer/portainer-linux-arm dist/portainer' ].join(' && ') }, - buildUnixArm64Binary: { + buildLinuxArm64Binary: { command: [ 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm64" portainer/golang-builder:cross-platform /src/cmd/portainer', 'shasum api/cmd/portainer/portainer-linux-arm64 > portainer-checksum.txt', @@ -408,7 +432,7 @@ module.exports = function (grunt) { 'mv api/cmd/portainer/portainer-linux-arm64 dist/portainer' ].join(' && ') }, - buildUnixPpc64leBinary: { + buildLinuxPpc64leBinary: { command: [ 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="ppc64le" portainer/golang-builder:cross-platform /src/cmd/portainer', 'shasum api/cmd/portainer/portainer-linux-ppc64le > portainer-checksum.txt', @@ -416,7 +440,7 @@ module.exports = function (grunt) { 'mv api/cmd/portainer/portainer-linux-ppc64le dist/portainer' ].join(' && ') }, - buildDarwinBinary: { + buildDarwinAmd64Binary: { command: [ 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform /src/cmd/portainer', 'shasum api/cmd/portainer/portainer-darwin-amd64 > portainer-checksum.txt', @@ -424,7 +448,7 @@ module.exports = function (grunt) { 'mv api/cmd/portainer/portainer-darwin-amd64 dist/portainer' ].join(' && ') }, - buildWindowsBinary: { + buildWindowsAmd64Binary: { command: [ 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform /src/cmd/portainer', 'shasum api/cmd/portainer/portainer-windows-amd64 > portainer-checksum.txt', @@ -465,41 +489,47 @@ module.exports = function (grunt) { } }, 'if': { - unixBinaryNotExist: { + linuxAmd64BinaryNotExist: { options: { executable: 'dist/portainer' }, - ifFalse: ['shell:buildBinary'] + ifFalse: ['shell:buildLinuxAmd64Binary'] }, - unixArmBinaryNotExist: { + linux386BinaryNotExist: { options: { executable: 'dist/portainer' }, - ifFalse: ['shell:buildUnixArmBinary'] + ifFalse: ['shell:buildLinux386Binary'] }, - unixArm64BinaryNotExist: { + linuxArmBinaryNotExist: { options: { executable: 'dist/portainer' }, - ifFalse: ['shell:buildUnixArm64Binary'] + ifFalse: ['shell:buildLinuxArmBinary'] }, - unixPpc64leBinaryNotExist: { + linuxArm64BinaryNotExist: { options: { executable: 'dist/portainer' }, - ifFalse: ['shell:buildUnixPpc64leBinary'] + ifFalse: ['shell:buildLinuxArm64Binary'] }, - darwinBinaryNotExist: { + linuxPpc64leBinaryNotExist: { options: { executable: 'dist/portainer' }, - ifFalse: ['shell:buildDarwinBinary'] + ifFalse: ['shell:buildLinuxPpc64leBinary'] }, - windowsBinaryNotExist: { + darwinAmd64BinaryNotExist: { + options: { + executable: 'dist/portainer' + }, + ifFalse: ['shell:buildDarwinAmd64Binary'] + }, + windowsAmd64BinaryNotExist: { options: { executable: 'dist/portainer.exe' }, - ifFalse: ['shell:buildWindowsBinary'] + ifFalse: ['shell:buildWindowsAmd64Binary'] } }, replace: { From 5b47427484b78835cf7fb4f3f1db847d6ef42d4d Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 20 May 2017 11:25:47 +0200 Subject: [PATCH 32/39] fix(build-system): fix broken tasks --- gruntfile.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gruntfile.js b/gruntfile.js index 844919999..11bcdd5e4 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -152,11 +152,11 @@ module.exports = function (grunt) { 'clean:tmp' ]); grunt.registerTask('lint', ['eslint']); - grunt.registerTask('run', ['if:unixBinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); - grunt.registerTask('run-swarm', ['if:unixBinaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarm', 'watch:buildSwarm']); - grunt.registerTask('run-swarm-local', ['if:unixBinaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarmLocal', 'watch:buildSwarm']); - grunt.registerTask('run-dev', ['if:unixBinaryNotExist', 'shell:buildImage', 'shell:run', 'watch:build']); - grunt.registerTask('run-ssl', ['if:unixBinaryNotExist', 'shell:buildImage', 'shell:runSsl', 'watch:buildSsl']); + grunt.registerTask('run', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); + grunt.registerTask('run-swarm', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarm', 'watch:buildSwarm']); + grunt.registerTask('run-swarm-local', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarmLocal', 'watch:buildSwarm']); + grunt.registerTask('run-dev', ['if:linuxAmd64BinaryNotExist', 'shell:buildImage', 'shell:run', 'watch:build']); + grunt.registerTask('run-ssl', ['if:linuxAmd64BinaryNotExist', 'shell:buildImage', 'shell:runSsl', 'watch:buildSsl']); grunt.registerTask('clear', ['clean:app']); // Print a timestamp (useful for when watching) From b9035659d24e2f1c487bdabc8dbe0765012da957 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 15:33:40 +0200 Subject: [PATCH 33/39] chore(build-system): update Gruntfile tasks --- gruntfile.js | 20 ++++++++++++++++---- package.json | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/gruntfile.js b/gruntfile.js index 11bcdd5e4..2d07fbae5 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -32,6 +32,22 @@ module.exports = function (grunt) { 'usemin', 'clean:tmp' ]); + grunt.registerTask('build-webapp', [ + 'config:prod', + 'clean:all', + 'html2js', + 'useminPrepare:release', + 'recess:build', + 'concat', + 'clean:tmpl', + 'cssmin', + 'replace', + 'uglify', + 'copy:assets', + 'filerev', + 'usemin', + 'clean:tmp' + ]); grunt.registerTask('release-linux-386', [ 'config:prod', 'clean:all', @@ -152,11 +168,7 @@ module.exports = function (grunt) { 'clean:tmp' ]); grunt.registerTask('lint', ['eslint']); - grunt.registerTask('run', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); - grunt.registerTask('run-swarm', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarm', 'watch:buildSwarm']); - grunt.registerTask('run-swarm-local', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:runSwarmLocal', 'watch:buildSwarm']); grunt.registerTask('run-dev', ['if:linuxAmd64BinaryNotExist', 'shell:buildImage', 'shell:run', 'watch:build']); - grunt.registerTask('run-ssl', ['if:linuxAmd64BinaryNotExist', 'shell:buildImage', 'shell:runSsl', 'watch:buildSsl']); grunt.registerTask('clear', ['clean:app']); // Print a timestamp (useful for when watching) diff --git a/package.json b/package.json index 40f2bbadb..9324212c8 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bower": "^1.5.2", "eslint": "^3.19.0", "grunt": "~0.4.0", + "grunt-cli": "^1.2.0", "grunt-config": "^1.0.0", "grunt-contrib-clean": "~0.4.0", "grunt-contrib-concat": "~0.1.3", From 6834c20b5d3b257966d4ae035d49af6b4428e5dc Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 17:54:14 +0200 Subject: [PATCH 34/39] docs(README): update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 74b59b532..56193adf7 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Please note that the public demo cluster is **reset every 15min**. **_Portainer_** has full support for the following Docker versions: -* Docker 1.10 to Docker 17.03 (including `swarm-mode`) +* Docker 1.10 to the latest version * Docker Swarm >= 1.2.3 Partial support for the following Docker versions (some features may not be available): From d3ecf1d7a8c7db74b6d352b2ff8f337635649258 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 20:25:56 +0200 Subject: [PATCH 35/39] fix(image-details): fix the ability to pull an image from a tag (#878) --- app/components/image/imageController.js | 18 +++--- app/helpers/imageHelper.js | 73 +++++++++++++++++-------- app/services/imageService.js | 9 ++- 3 files changed, 64 insertions(+), 36 deletions(-) diff --git a/app/components/image/imageController.js b/app/components/image/imageController.js index 6f0c17eae..9a5ac8fba 100644 --- a/app/components/image/imageController.js +++ b/app/components/image/imageController.js @@ -17,7 +17,7 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { $state.go('image', {id: $stateParams.id}, {reload: true}); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to tag image"); + Notifications.error('Failure', err, 'Unable to tag image'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -31,7 +31,7 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { Notifications.success('Image successfully pushed'); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to push image tag"); + Notifications.error('Failure', err, 'Unable to push image tag'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -40,15 +40,13 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { $scope.pullImage = function(tag) { $('#loadingViewSpinner').show(); - var image = $scope.config.Image; - var registry = $scope.config.Registry; - ImageService.pullImage(image, registry) + ImageService.pullTag(tag) .then(function success(data) { - Notifications.success('Image successfully pulled', image); + Notifications.success('Image successfully pulled', tag); }) .catch(function error(err){ - Notifications.error("Failure", err, "Unable to pull image"); + Notifications.error('Failure', err, 'Unable to pull image'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -68,7 +66,7 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { } }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove image'); + Notifications.error('Failure', err, 'Unable to remove image'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -83,7 +81,7 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { $state.go('images', {}, {reload: true}); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove image'); + Notifications.error('Failure', err, 'Unable to remove image'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -97,7 +95,7 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { $scope.image = data; }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve image details"); + Notifications.error('Failure', err, 'Unable to retrieve image details'); $state.go('images'); }) .finally(function final() { diff --git a/app/helpers/imageHelper.js b/app/helpers/imageHelper.js index 864f248bc..03cb21efa 100644 --- a/app/helpers/imageHelper.js +++ b/app/helpers/imageHelper.js @@ -1,30 +1,55 @@ angular.module('portainer.helpers') .factory('ImageHelper', [function ImageHelperFactory() { 'use strict'; - return { - createImageConfigForCommit: function(imageName, registry) { - var imageNameAndTag = imageName.split(':'); - var image = imageNameAndTag[0]; - if (registry) { - image = registry + '/' + imageNameAndTag[0]; - } - var imageConfig = { - repo: image, - tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest' - }; - return imageConfig; - }, - createImageConfigForContainer: function (imageName, registry) { - var imageNameAndTag = imageName.split(':'); - var image = imageNameAndTag[0]; - if (registry) { - image = registry + '/' + imageNameAndTag[0]; - } - var imageConfig = { - fromImage: image, - tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest' - }; - return imageConfig; + + var helper = {}; + + helper.extractImageAndRegistryFromTag = function(tag) { + var slashCount = _.countBy(tag)['/']; + var registry = null; + var image = tag; + if (slashCount > 1) { + // assume something/some/thing[/...] + var registryAndImage = _.split(tag, '/'); + registry = registryAndImage[0]; + image = registryAndImage[1]; } + + return { + registry: registry, + image: image + }; }; + + function extractNameAndTag(imageName, registry) { + var imageNameAndTag = imageName.split(':'); + var image = imageNameAndTag[0]; + var tag = imageNameAndTag[1] ? imageNameAndTag[1] : 'latest'; + if (registry) { + image = registry + '/' + imageNameAndTag[0]; + } + + return { + image: image, + tag: tag + }; + } + + helper.createImageConfigForCommit = function(imageName, registry) { + var imageAndTag = extractNameAndTag(imageName, registry); + return { + repo: imageAndTag.image, + tag: imageAndTag.tag + }; + }; + + helper.createImageConfigForContainer = function (imageName, registry) { + var imageAndTag = extractNameAndTag(imageName, registry); + return { + fromImage: imageAndTag.image, + tag: imageAndTag.tag + }; + }; + + return helper; }]); diff --git a/app/services/imageService.js b/app/services/imageService.js index a8a29cac5..a43f0c53f 100644 --- a/app/services/imageService.js +++ b/app/services/imageService.js @@ -40,10 +40,10 @@ angular.module('portainer.services') var imageConfiguration = ImageHelper.createImageConfigForContainer(image, registry); Image.create(imageConfiguration).$promise .then(function success(data) { - var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error'); + var err = data.length > 0 && data[data.length - 1].hasOwnProperty('message'); if (err) { var detail = data[data.length - 1]; - deferred.reject({ msg: detail.error }); + deferred.reject({ msg: detail.message }); } else { deferred.resolve(data); } @@ -54,6 +54,11 @@ angular.module('portainer.services') return deferred.promise; }; + service.pullTag = function(tag) { + var imageAndRegistry = ImageHelper.extractImageAndRegistryFromTag(tag); + return service.pullImage(imageAndRegistry.image, imageAndRegistry.registry); + }; + service.tagImage = function(id, image, registry) { var imageConfig = ImageHelper.createImageConfigForCommit(image, registry); return Image.tag({id: id, tag: imageConfig.tag, repo: imageConfig.repo}).$promise; From a380fd9adc3062080a0b39dedf4b1f3c74831a2a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 20:43:58 +0200 Subject: [PATCH 36/39] fix(image-details): fix invalid CMD with images using HEALTHCHECK (#879) --- app/components/image/image.html | 4 ++-- app/components/image/imageController.js | 4 ++-- app/models/imageDetails.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/components/image/image.html b/app/components/image/image.html index 491b14c9d..4f10a93e6 100644 --- a/app/components/image/image.html +++ b/app/components/image/image.html @@ -19,10 +19,10 @@
    {{ tag }} - + - + diff --git a/app/components/image/imageController.js b/app/components/image/imageController.js index 9a5ac8fba..907b664c3 100644 --- a/app/components/image/imageController.js +++ b/app/components/image/imageController.js @@ -24,7 +24,7 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { }); }; - $scope.pushImage = function(tag) { + $scope.pushTag = function(tag) { $('#loadingViewSpinner').show(); ImageService.pushImage(tag) .then(function success() { @@ -38,7 +38,7 @@ function ($scope, $stateParams, $state, ImageService, Notifications) { }); }; - $scope.pullImage = function(tag) { + $scope.pullTag = function(tag) { $('#loadingViewSpinner').show(); ImageService.pullTag(tag) diff --git a/app/models/imageDetails.js b/app/models/imageDetails.js index df7da6183..7864f10f6 100644 --- a/app/models/imageDetails.js +++ b/app/models/imageDetails.js @@ -11,7 +11,7 @@ function ImageDetailsViewModel(data) { this.Os = data.Os; this.Architecture = data.Architecture; this.Author = data.Author; - this.Command = data.ContainerConfig.Cmd; + this.Command = data.Config.Cmd; this.Entrypoint = data.ContainerConfig.Entrypoint ? data.ContainerConfig.Entrypoint : ''; this.ExposedPorts = data.ContainerConfig.ExposedPorts ? Object.keys(data.ContainerConfig.ExposedPorts) : []; this.Volumes = data.ContainerConfig.Volumes ? Object.keys(data.ContainerConfig.Volumes) : []; From 5523fc9023c6056338531969f9b27b874a4da7c7 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 20:56:10 +0200 Subject: [PATCH 37/39] feat(global): introduce user teams and new UAC system (#868) --- api/bolt/datastore.go | 32 +- api/bolt/internal/internal.go | 20 + api/bolt/migrate_dbversion0.go | 39 + api/bolt/migrate_dbversion1.go | 103 +++ api/bolt/{data_migration.go => migrator.go} | 55 +- api/bolt/resource_control_service.go | 148 ++++ api/bolt/resourcecontrol_service.go | 110 --- api/bolt/team_membership_service.go | 217 ++++++ api/bolt/team_service.go | 144 ++++ api/cmd/portainer/main.go | 16 +- api/{http => crypto}/tls.go | 6 +- api/errors.go | 26 +- api/http/docker_handler.go | 80 --- api/http/docker_proxy.go | 121 ---- api/http/error/error.go | 30 + api/http/{auth_handler.go => handler/auth.go} | 31 +- api/http/handler/docker.go | 94 +++ .../endpoint.go} | 137 ++-- api/http/{file_handler.go => handler/file.go} | 5 +- api/http/{ => handler}/handler.go | 62 +- api/http/handler/resource_control.go | 256 +++++++ .../settings.go} | 15 +- api/http/handler/team.go | 252 +++++++ api/http/handler/team_membership.go | 240 +++++++ .../templates.go} | 23 +- .../{upload_handler.go => handler/upload.go} | 19 +- api/http/handler/user.go | 490 +++++++++++++ .../websocket.go} | 5 +- api/http/middleware.go | 119 ---- api/http/proxy.go | 67 -- api/http/proxy/access_control.go | 21 + api/http/proxy/containers.go | 98 +++ api/http/proxy/decorator.go | 90 +++ api/http/proxy/factory.go | 55 ++ api/http/proxy/filter.go | 91 +++ api/http/proxy/manager.go | 68 ++ api/http/proxy/response.go | 90 +++ api/http/proxy/reverse_proxy.go | 46 ++ api/http/proxy/service.go | 64 ++ api/http/proxy/socket.go | 40 ++ api/http/proxy/transport.go | 237 +++++++ api/http/proxy/utils.go | 17 + api/http/proxy/volumes.go | 73 ++ api/http/proxy_transport.go | 664 ------------------ api/http/security/authorization.go | 123 ++++ api/http/security/bouncer.go | 176 +++++ api/http/security/context.go | 50 ++ api/http/security/filter.go | 95 +++ api/http/server.go | 73 +- api/http/user_handler.go | 480 ------------- api/portainer.go | 133 +++- app/app.js | 49 +- app/components/auth/authController.js | 8 +- .../accessControlForm/accessControlForm.html | 126 ++++ .../accessControlFormController.js | 55 ++ .../accessControlPanel.html | 178 +++++ .../accessControlPanelController.js | 158 +++++ app/components/container/container.html | 2 + .../container/containerController.js | 96 +-- .../containerConsoleController.js | 14 +- .../containerLogs/containerLogsController.js | 4 +- app/components/containers/containers.html | 37 +- .../containers/containersController.js | 120 +--- .../createContainerController.js | 214 +++--- .../createContainer/createcontainer.html | 29 +- .../createNetwork/createNetworkController.js | 4 +- .../createService/createServiceController.js | 104 +-- .../createService/createservice.html | 29 +- .../createVolume/createVolumeController.js | 54 +- app/components/createVolume/createvolume.html | 27 +- .../dashboard/dashboardController.js | 2 +- app/components/docker/dockerController.js | 4 +- app/components/endpoint/endpointController.js | 6 +- .../endpointAccess/endpointAccess.html | 78 +- .../endpointAccessController.js | 242 ++++--- .../endpointInit/endpointInitController.js | 6 +- .../endpoints/endpointsController.js | 8 +- app/components/events/eventsController.js | 2 +- app/components/images/imagesController.js | 8 +- app/components/network/networkController.js | 18 +- app/components/networks/networksController.js | 12 +- app/components/node/node.html | 14 +- app/components/node/nodeController.js | 13 +- app/components/service/includes/tasks.html | 14 +- app/components/service/service.html | 2 + app/components/service/serviceController.js | 74 +- app/components/services/services.html | 27 +- app/components/services/servicesController.js | 97 +-- app/components/settings/settings.html | 23 +- app/components/settings/settingsController.js | 4 +- app/components/sidebar/sidebar.html | 14 +- app/components/sidebar/sidebarController.js | 61 +- app/components/stats/statsController.js | 42 +- app/components/task/task.html | 14 +- app/components/task/taskController.js | 39 +- app/components/team/team.html | 176 +++++ app/components/team/teamController.js | 229 ++++++ app/components/teams/teams.html | 130 ++++ app/components/teams/teamsController.js | 140 ++++ app/components/templates/templates.html | 18 +- .../templates/templatesController.js | 60 +- app/components/user/user.html | 31 +- app/components/user/userController.js | 28 +- app/components/users/users.html | 50 +- app/components/users/usersController.js | 58 +- app/components/volume/volume.html | 68 ++ app/components/volume/volumeController.js | 37 + app/components/volumes/volumes.html | 43 +- app/components/volumes/volumesController.js | 117 +-- app/directives/header-content.js | 2 +- app/directives/header.js | 2 +- app/filters/filters.js | 25 + app/helpers/infoHelper.js | 14 +- app/helpers/nodeHelper.js | 4 +- app/helpers/resourceControlHelper.js | 42 ++ app/helpers/templateHelper.js | 2 +- app/helpers/userHelper.js | 15 + app/models/api/endpointAccess.js | 11 + app/models/api/resourceControl.js | 19 + app/models/api/team.js | 5 + app/models/api/teamMembership.js | 6 + app/models/{ => api}/template.js | 0 app/models/{ => api}/templateLinuxServer.js | 0 app/models/{ => api}/user.js | 6 +- app/models/{ => docker}/container.js | 5 +- app/models/docker/containerDetails.js | 15 + app/models/{ => docker}/event.js | 0 app/models/{ => docker}/image.js | 0 app/models/{ => docker}/imageDetails.js | 0 app/models/{ => docker}/node.js | 0 app/models/{ => docker}/service.js | 5 +- app/models/docker/task.js | 10 + app/models/{ => docker}/volume.js | 11 +- app/models/task.js | 15 - app/rest/endpoint.js | 2 +- app/rest/resourceControl.js | 10 +- app/rest/response/handlers.js | 4 +- app/rest/team.js | 12 + app/rest/teamMembership.js | 10 + app/rest/user.js | 10 +- app/rest/volume.js | 9 +- app/services/containerService.js | 25 +- app/services/controllerDataPipeline.js | 36 + app/services/endpointProvider.js | 13 +- app/services/endpointService.js | 10 +- app/services/formValidator.js | 24 + app/services/lineChart.js | 10 +- app/services/modalService.js | 29 +- app/services/networkService.js | 6 +- app/services/nodeService.js | 24 + app/services/resourceControlService.js | 120 +++- app/services/serviceService.js | 41 ++ app/services/taskService.js | 39 + app/services/teamMembershipService.js | 44 ++ app/services/teamService.js | 84 +++ app/services/userService.js | 121 +++- app/services/volumeService.js | 56 +- assets/css/app.css | 233 ++++-- bower.json | 5 +- gruntfile.js | 24 +- 160 files changed, 7112 insertions(+), 3166 deletions(-) create mode 100644 api/bolt/migrate_dbversion0.go create mode 100644 api/bolt/migrate_dbversion1.go rename api/bolt/{data_migration.go => migrator.go} (55%) create mode 100644 api/bolt/resource_control_service.go delete mode 100644 api/bolt/resourcecontrol_service.go create mode 100644 api/bolt/team_membership_service.go create mode 100644 api/bolt/team_service.go rename api/{http => crypto}/tls.go (77%) delete mode 100644 api/http/docker_handler.go delete mode 100644 api/http/docker_proxy.go create mode 100644 api/http/error/error.go rename api/http/{auth_handler.go => handler/auth.go} (64%) create mode 100644 api/http/handler/docker.go rename api/http/{endpoint_handler.go => handler/endpoint.go} (58%) rename api/http/{file_handler.go => handler/file.go} (84%) rename api/http/{ => handler}/handler.go (63%) create mode 100644 api/http/handler/resource_control.go rename api/http/{settings_handler.go => handler/settings.go} (58%) create mode 100644 api/http/handler/team.go create mode 100644 api/http/handler/team_membership.go rename api/http/{templates_handler.go => handler/templates.go} (57%) rename api/http/{upload_handler.go => handler/upload.go} (63%) create mode 100644 api/http/handler/user.go rename api/http/{websocket_handler.go => handler/websocket.go} (97%) delete mode 100644 api/http/middleware.go delete mode 100644 api/http/proxy.go create mode 100644 api/http/proxy/access_control.go create mode 100644 api/http/proxy/containers.go create mode 100644 api/http/proxy/decorator.go create mode 100644 api/http/proxy/factory.go create mode 100644 api/http/proxy/filter.go create mode 100644 api/http/proxy/manager.go create mode 100644 api/http/proxy/response.go create mode 100644 api/http/proxy/reverse_proxy.go create mode 100644 api/http/proxy/service.go create mode 100644 api/http/proxy/socket.go create mode 100644 api/http/proxy/transport.go create mode 100644 api/http/proxy/utils.go create mode 100644 api/http/proxy/volumes.go delete mode 100644 api/http/proxy_transport.go create mode 100644 api/http/security/authorization.go create mode 100644 api/http/security/bouncer.go create mode 100644 api/http/security/context.go create mode 100644 api/http/security/filter.go delete mode 100644 api/http/user_handler.go create mode 100644 app/components/common/accessControlForm/accessControlForm.html create mode 100644 app/components/common/accessControlForm/accessControlFormController.js create mode 100644 app/components/common/accessControlPanel/accessControlPanel.html create mode 100644 app/components/common/accessControlPanel/accessControlPanelController.js create mode 100644 app/components/team/team.html create mode 100644 app/components/team/teamController.js create mode 100644 app/components/teams/teams.html create mode 100644 app/components/teams/teamsController.js create mode 100644 app/components/volume/volume.html create mode 100644 app/components/volume/volumeController.js create mode 100644 app/helpers/resourceControlHelper.js create mode 100644 app/helpers/userHelper.js create mode 100644 app/models/api/endpointAccess.js create mode 100644 app/models/api/resourceControl.js create mode 100644 app/models/api/team.js create mode 100644 app/models/api/teamMembership.js rename app/models/{ => api}/template.js (100%) rename app/models/{ => api}/templateLinuxServer.js (100%) rename app/models/{ => api}/user.js (62%) rename app/models/{ => docker}/container.js (85%) create mode 100644 app/models/docker/containerDetails.js rename app/models/{ => docker}/event.js (100%) rename app/models/{ => docker}/image.js (100%) rename app/models/{ => docker}/imageDetails.js (100%) rename app/models/{ => docker}/node.js (100%) rename app/models/{ => docker}/service.js (95%) create mode 100644 app/models/docker/task.js rename app/models/{ => docker}/volume.js (50%) delete mode 100644 app/models/task.js create mode 100644 app/rest/team.js create mode 100644 app/rest/teamMembership.js create mode 100644 app/services/controllerDataPipeline.js create mode 100644 app/services/formValidator.js create mode 100644 app/services/nodeService.js create mode 100644 app/services/serviceService.js create mode 100644 app/services/taskService.js create mode 100644 app/services/teamMembershipService.js create mode 100644 app/services/teamService.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 0b41ee306..96b179b46 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -17,6 +17,8 @@ type Store struct { // Services UserService *UserService + TeamService *TeamService + TeamMembershipService *TeamMembershipService EndpointService *EndpointService ResourceControlService *ResourceControlService VersionService *VersionService @@ -26,13 +28,13 @@ type Store struct { } const ( - databaseFileName = "portainer.db" - versionBucketName = "version" - userBucketName = "users" - endpointBucketName = "endpoints" - containerResourceControlBucketName = "containerResourceControl" - serviceResourceControlBucketName = "serviceResourceControl" - volumeResourceControlBucketName = "volumeResourceControl" + databaseFileName = "portainer.db" + versionBucketName = "version" + userBucketName = "users" + teamBucketName = "teams" + teamMembershipBucketName = "team_membership" + endpointBucketName = "endpoints" + resourceControlBucketName = "resource_control" ) // NewStore initializes a new Store and the associated services @@ -40,11 +42,15 @@ func NewStore(storePath string) (*Store, error) { store := &Store{ Path: storePath, UserService: &UserService{}, + TeamService: &TeamService{}, + TeamMembershipService: &TeamMembershipService{}, EndpointService: &EndpointService{}, ResourceControlService: &ResourceControlService{}, VersionService: &VersionService{}, } store.UserService.store = store + store.TeamService.store = store + store.TeamMembershipService.store = store store.EndpointService.store = store store.ResourceControlService.store = store store.VersionService.store = store @@ -78,19 +84,19 @@ func (store *Store) Open() error { if err != nil { return err } + _, err = tx.CreateBucketIfNotExists([]byte(teamBucketName)) + if err != nil { + return err + } _, err = tx.CreateBucketIfNotExists([]byte(endpointBucketName)) if err != nil { return err } - _, err = tx.CreateBucketIfNotExists([]byte(containerResourceControlBucketName)) + _, err = tx.CreateBucketIfNotExists([]byte(resourceControlBucketName)) if err != nil { return err } - _, err = tx.CreateBucketIfNotExists([]byte(serviceResourceControlBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(volumeResourceControlBucketName)) + _, err = tx.CreateBucketIfNotExists([]byte(teamMembershipBucketName)) if err != nil { return err } diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index 351592963..e02c26ba4 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -17,6 +17,26 @@ func UnmarshalUser(data []byte, user *portainer.User) error { return json.Unmarshal(data, user) } +// MarshalTeam encodes a team to binary format. +func MarshalTeam(team *portainer.Team) ([]byte, error) { + return json.Marshal(team) +} + +// UnmarshalTeam decodes a team from a binary data. +func UnmarshalTeam(data []byte, team *portainer.Team) error { + return json.Unmarshal(data, team) +} + +// MarshalTeamMembership encodes a team membership to binary format. +func MarshalTeamMembership(membership *portainer.TeamMembership) ([]byte, error) { + return json.Marshal(membership) +} + +// UnmarshalTeamMembership decodes a team membership from a binary data. +func UnmarshalTeamMembership(data []byte, membership *portainer.TeamMembership) error { + return json.Unmarshal(data, membership) +} + // MarshalEndpoint encodes an endpoint to binary format. func MarshalEndpoint(endpoint *portainer.Endpoint) ([]byte, error) { return json.Marshal(endpoint) diff --git a/api/bolt/migrate_dbversion0.go b/api/bolt/migrate_dbversion0.go new file mode 100644 index 000000000..f0223ee9e --- /dev/null +++ b/api/bolt/migrate_dbversion0.go @@ -0,0 +1,39 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" +) + +func (m *Migrator) updateAdminUserToDBVersion1() error { + u, err := m.UserService.UserByUsername("admin") + if err == nil { + admin := &portainer.User{ + Username: "admin", + Password: u.Password, + Role: portainer.AdministratorRole, + } + err = m.UserService.CreateUser(admin) + if err != nil { + return err + } + err = m.removeLegacyAdminUser() + if err != nil { + return err + } + } else if err != nil && err != portainer.ErrUserNotFound { + return err + } + return nil +} + +func (m *Migrator) removeLegacyAdminUser() error { + return m.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err := bucket.Delete([]byte("admin")) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/migrate_dbversion1.go b/api/bolt/migrate_dbversion1.go new file mode 100644 index 000000000..b34ba7867 --- /dev/null +++ b/api/bolt/migrate_dbversion1.go @@ -0,0 +1,103 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" +) + +func (m *Migrator) updateResourceControlsToDBVersion2() error { + legacyResourceControls, err := m.retrieveLegacyResourceControls() + if err != nil { + return err + } + + for _, resourceControl := range legacyResourceControls { + resourceControl.SubResourceIDs = []string{} + resourceControl.TeamAccesses = []portainer.TeamResourceAccess{} + + owner, err := m.UserService.User(resourceControl.OwnerID) + if err != nil { + return err + } + + if owner.Role == portainer.AdministratorRole { + resourceControl.AdministratorsOnly = true + resourceControl.UserAccesses = []portainer.UserResourceAccess{} + } else { + resourceControl.AdministratorsOnly = false + userAccess := portainer.UserResourceAccess{ + UserID: resourceControl.OwnerID, + AccessLevel: portainer.ReadWriteAccessLevel, + } + resourceControl.UserAccesses = []portainer.UserResourceAccess{userAccess} + } + + err = m.ResourceControlService.CreateResourceControl(&resourceControl) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointsToDBVersion2() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.AuthorizedTeams = []portainer.TeamID{} + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl, error) { + legacyResourceControls := make([]portainer.ResourceControl, 0) + err := m.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte("containerResourceControl")) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + resourceControl.Type = portainer.ContainerResourceControl + legacyResourceControls = append(legacyResourceControls, resourceControl) + } + + bucket = tx.Bucket([]byte("serviceResourceControl")) + cursor = bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + resourceControl.Type = portainer.ServiceResourceControl + legacyResourceControls = append(legacyResourceControls, resourceControl) + } + + bucket = tx.Bucket([]byte("volumeResourceControl")) + cursor = bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + resourceControl.Type = portainer.VolumeResourceControl + legacyResourceControls = append(legacyResourceControls, resourceControl) + } + return nil + }) + return legacyResourceControls, err +} diff --git a/api/bolt/data_migration.go b/api/bolt/migrator.go similarity index 55% rename from api/bolt/data_migration.go rename to api/bolt/migrator.go index 2cc094e24..b6c4dd4df 100644 --- a/api/bolt/data_migration.go +++ b/api/bolt/migrator.go @@ -1,10 +1,8 @@ package bolt -import ( - "github.com/boltdb/bolt" - "github.com/portainer/portainer" -) +import "github.com/portainer/portainer" +// Migrator defines a service to migrate data after a Portainer version update. type Migrator struct { UserService *UserService EndpointService *EndpointService @@ -14,6 +12,7 @@ type Migrator struct { store *Store } +// NewMigrator creates a new Migrator. func NewMigrator(store *Store, version int) *Migrator { return &Migrator{ UserService: store.UserService, @@ -25,11 +24,24 @@ func NewMigrator(store *Store, version int) *Migrator { } } +// Migrate checks the database version and migrate the existing data to the most recent data model. func (m *Migrator) Migrate() error { // Portainer < 1.12 if m.CurrentDBVersion == 0 { - err := m.updateAdminUser() + err := m.updateAdminUserToDBVersion1() + if err != nil { + return err + } + } + + // Portainer 1.12.x + if m.CurrentDBVersion == 1 { + err := m.updateResourceControlsToDBVersion2() + if err != nil { + return err + } + err = m.updateEndpointsToDBVersion2() if err != nil { return err } @@ -41,36 +53,3 @@ func (m *Migrator) Migrate() error { } return nil } - -func (m *Migrator) updateAdminUser() error { - u, err := m.UserService.UserByUsername("admin") - if err == nil { - admin := &portainer.User{ - Username: "admin", - Password: u.Password, - Role: portainer.AdministratorRole, - } - err = m.UserService.CreateUser(admin) - if err != nil { - return err - } - err = m.removeLegacyAdminUser() - if err != nil { - return err - } - } else if err != nil && err != portainer.ErrUserNotFound { - return err - } - return nil -} - -func (m *Migrator) removeLegacyAdminUser() error { - return m.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err := bucket.Delete([]byte("admin")) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/resource_control_service.go b/api/bolt/resource_control_service.go new file mode 100644 index 000000000..2986d5add --- /dev/null +++ b/api/bolt/resource_control_service.go @@ -0,0 +1,148 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// ResourceControlService represents a service for managing resource controls. +type ResourceControlService struct { + store *Store +} + +// ResourceControl returns a ResourceControl object by ID +func (service *ResourceControlService) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrResourceControlNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var resourceControl portainer.ResourceControl + err = internal.UnmarshalResourceControl(data, &resourceControl) + if err != nil { + return nil, err + } + return &resourceControl, nil +} + +// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal +// to the main ResourceID or in SubResourceIDs +func (service *ResourceControlService) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) { + var resourceControl *portainer.ResourceControl + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var rc portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &rc) + if err != nil { + return err + } + if rc.ResourceID == resourceID { + resourceControl = &rc + } + for _, subResourceID := range rc.SubResourceIDs { + if subResourceID == resourceID { + resourceControl = &rc + } + } + } + + if resourceControl == nil { + return portainer.ErrResourceControlNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return resourceControl, nil +} + +// ResourceControls returns all the ResourceControl objects +func (service *ResourceControlService) ResourceControls() ([]portainer.ResourceControl, error) { + var rcs = make([]portainer.ResourceControl, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + rcs = append(rcs, resourceControl) + } + + return nil + }) + if err != nil { + return nil, err + } + + return rcs, nil +} + +// CreateResourceControl creates a new ResourceControl object +func (service *ResourceControlService) CreateResourceControl(resourceControl *portainer.ResourceControl) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + id, _ := bucket.NextSequence() + resourceControl.ID = portainer.ResourceControlID(id) + data, err := internal.MarshalResourceControl(resourceControl) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(resourceControl.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// UpdateResourceControl saves a ResourceControl object. +func (service *ResourceControlService) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { + data, err := internal.MarshalResourceControl(resourceControl) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// DeleteResourceControl deletes a ResourceControl object by ID +func (service *ResourceControlService) DeleteResourceControl(ID portainer.ResourceControlID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/resourcecontrol_service.go b/api/bolt/resourcecontrol_service.go deleted file mode 100644 index 07b174616..000000000 --- a/api/bolt/resourcecontrol_service.go +++ /dev/null @@ -1,110 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// ResourceControlService represents a service for managing resource controls. -type ResourceControlService struct { - store *Store -} - -func getBucketNameByResourceControlType(rcType portainer.ResourceControlType) string { - bucketName := containerResourceControlBucketName - if rcType == portainer.ServiceResourceControl { - bucketName = serviceResourceControlBucketName - } else if rcType == portainer.VolumeResourceControl { - bucketName = volumeResourceControlBucketName - } - return bucketName -} - -// ResourceControl returns a resource control object by resource ID -func (service *ResourceControlService) ResourceControl(resourceID string, rcType portainer.ResourceControlType) (*portainer.ResourceControl, error) { - var data []byte - bucketName := getBucketNameByResourceControlType(rcType) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - value := bucket.Get([]byte(resourceID)) - if value == nil { - return nil - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - if data == nil { - return nil, nil - } - - var rc portainer.ResourceControl - err = internal.UnmarshalResourceControl(data, &rc) - if err != nil { - return nil, err - } - return &rc, nil -} - -// ResourceControls returns all resource control objects -func (service *ResourceControlService) ResourceControls(rcType portainer.ResourceControlType) ([]portainer.ResourceControl, error) { - var rcs = make([]portainer.ResourceControl, 0) - bucketName := getBucketNameByResourceControlType(rcType) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var rc portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &rc) - if err != nil { - return err - } - rcs = append(rcs, rc) - } - - return nil - }) - if err != nil { - return nil, err - } - - return rcs, nil -} - -// CreateResourceControl creates a new resource control -func (service *ResourceControlService) CreateResourceControl(resourceID string, rc *portainer.ResourceControl, rcType portainer.ResourceControlType) error { - bucketName := getBucketNameByResourceControlType(rcType) - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - data, err := internal.MarshalResourceControl(rc) - if err != nil { - return err - } - - err = bucket.Put([]byte(resourceID), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteResourceControl deletes a resource control object by resource ID -func (service *ResourceControlService) DeleteResourceControl(resourceID string, rcType portainer.ResourceControlType) error { - bucketName := getBucketNameByResourceControlType(rcType) - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - err := bucket.Delete([]byte(resourceID)) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/team_membership_service.go b/api/bolt/team_membership_service.go new file mode 100644 index 000000000..da2b47266 --- /dev/null +++ b/api/bolt/team_membership_service.go @@ -0,0 +1,217 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// TeamMembershipService represents a service for managing TeamMembership objects. +type TeamMembershipService struct { + store *Store +} + +// TeamMembership returns a TeamMembership object by ID +func (service *TeamMembershipService) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrTeamMembershipNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var membership portainer.TeamMembership + err = internal.UnmarshalTeamMembership(data, &membership) + if err != nil { + return nil, err + } + return &membership, nil +} + +// TeamMemberships return an array containing all the TeamMembership objects. +func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + memberships = append(memberships, membership) + } + + return nil + }) + if err != nil { + return nil, err + } + + return memberships, nil +} + +// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present. +func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.UserID == userID { + memberships = append(memberships, membership) + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return memberships, nil +} + +// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present. +func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.TeamID == teamID { + memberships = append(memberships, membership) + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return memberships, nil +} + +// UpdateTeamMembership saves a TeamMembership object. +func (service *TeamMembershipService) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { + data, err := internal.MarshalTeamMembership(membership) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// CreateTeamMembership creates a new TeamMembership object. +func (service *TeamMembershipService) CreateTeamMembership(membership *portainer.TeamMembership) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + id, _ := bucket.NextSequence() + membership.ID = portainer.TeamMembershipID(id) + + data, err := internal.MarshalTeamMembership(membership) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(membership.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTeamMembership deletes a TeamMembership object. +func (service *TeamMembershipService) DeleteTeamMembership(ID portainer.TeamMembershipID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. +func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portainer.UserID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.UserID == userID { + err := bucket.Delete(internal.Itob(int(membership.ID))) + if err != nil { + return err + } + } + } + + return nil + }) +} + +// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. +func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.TeamID == teamID { + err := bucket.Delete(internal.Itob(int(membership.ID))) + if err != nil { + return err + } + } + } + + return nil + }) +} diff --git a/api/bolt/team_service.go b/api/bolt/team_service.go new file mode 100644 index 000000000..2830e7783 --- /dev/null +++ b/api/bolt/team_service.go @@ -0,0 +1,144 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// TeamService represents a service for managing teams. +type TeamService struct { + store *Store +} + +// Team returns a Team by ID +func (service *TeamService) Team(ID portainer.TeamID) (*portainer.Team, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrTeamNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var team portainer.Team + err = internal.UnmarshalTeam(data, &team) + if err != nil { + return nil, err + } + return &team, nil +} + +// TeamByName returns a team by name. +func (service *TeamService) TeamByName(name string) (*portainer.Team, error) { + var team *portainer.Team + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Team + err := internal.UnmarshalTeam(v, &t) + if err != nil { + return err + } + if t.Name == name { + team = &t + } + } + + if team == nil { + return portainer.ErrTeamNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return team, nil +} + +// Teams return an array containing all the teams. +func (service *TeamService) Teams() ([]portainer.Team, error) { + var teams = make([]portainer.Team, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var team portainer.Team + err := internal.UnmarshalTeam(v, &team) + if err != nil { + return err + } + teams = append(teams, team) + } + + return nil + }) + if err != nil { + return nil, err + } + + return teams, nil +} + +// UpdateTeam saves a Team. +func (service *TeamService) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { + data, err := internal.MarshalTeam(team) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// CreateTeam creates a new Team. +func (service *TeamService) CreateTeam(team *portainer.Team) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + + id, _ := bucket.NextSequence() + team.ID = portainer.TeamID(id) + + data, err := internal.MarshalTeam(team) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(team.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTeam deletes a Team. +func (service *TeamService) DeleteTeam(ID portainer.TeamID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 0743fc140..68c6183fb 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -124,12 +124,14 @@ func main() { } if len(endpoints) == 0 { endpoint := &portainer.Endpoint{ - Name: "primary", - URL: *flags.Endpoint, - TLS: *flags.TLSVerify, - TLSCACertPath: *flags.TLSCacert, - TLSCertPath: *flags.TLSCert, - TLSKeyPath: *flags.TLSKey, + Name: "primary", + URL: *flags.Endpoint, + TLS: *flags.TLSVerify, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, } err = store.EndpointService.CreateEndpoint(endpoint) if err != nil { @@ -161,6 +163,8 @@ func main() { AuthDisabled: *flags.NoAuth, EndpointManagement: authorizeEndpointMgmt, UserService: store.UserService, + TeamService: store.TeamService, + TeamMembershipService: store.TeamMembershipService, EndpointService: store.EndpointService, ResourceControlService: store.ResourceControlService, CryptoService: cryptoService, diff --git a/api/http/tls.go b/api/crypto/tls.go similarity index 77% rename from api/http/tls.go rename to api/crypto/tls.go index 20d679ef6..ff47d43dc 100644 --- a/api/http/tls.go +++ b/api/crypto/tls.go @@ -1,4 +1,4 @@ -package http +package crypto import ( "crypto/tls" @@ -6,8 +6,8 @@ import ( "io/ioutil" ) -// createTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key -func createTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) { +// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key +func CreateTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return nil, err diff --git a/api/errors.go b/api/errors.go index 14dbc3156..ad19b05d4 100644 --- a/api/errors.go +++ b/api/errors.go @@ -2,17 +2,39 @@ package portainer // General errors. const ( - ErrUnauthorized = Error("Unauthorized") - ErrResourceAccessDenied = Error("Access denied to resource") + ErrUnauthorized = Error("Unauthorized") + ErrResourceAccessDenied = Error("Access denied to resource") + ErrUnsupportedDockerAPI = Error("Unsupported Docker API response") + ErrMissingSecurityContext = Error("Unable to find security details in request context") ) // User errors. const ( ErrUserNotFound = Error("User not found") ErrUserAlreadyExists = Error("User already exists") + ErrInvalidUsername = Error("Invalid username. White spaces are not allowed.") ErrAdminAlreadyInitialized = Error("Admin user already initialized") ) +// Team errors. +const ( + ErrTeamNotFound = Error("Team not found") + ErrTeamAlreadyExists = Error("Team already exists") +) + +// TeamMembership errors. +const ( + ErrTeamMembershipNotFound = Error("Team membership not found") + ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.") +) + +// ResourceControl errors. +const ( + ErrResourceControlNotFound = Error("Resource control not found") + ErrResourceControlAlreadyExists = Error("A resource control is already applied on this resource") + ErrInvalidResourceControlType = Error("Unsupported resource control type") +) + // Endpoint errors. const ( ErrEndpointNotFound = Error("Endpoint not found") diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go deleted file mode 100644 index c50d3c23b..000000000 --- a/api/http/docker_handler.go +++ /dev/null @@ -1,80 +0,0 @@ -package http - -import ( - "strconv" - - "github.com/portainer/portainer" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. -type DockerHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - ProxyService *ProxyService -} - -// NewDockerHandler returns a new instance of DockerHandler. -func NewDockerHandler(mw *middleWareService, resourceControlService portainer.ResourceControlService) *DockerHandler { - h := &DockerHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/").Handler( - mw.authenticated(http.HandlerFunc(h.proxyRequestsToDockerAPI))) - return h -} - -func checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == userID { - return true - } - } - return false -} - -func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - parsedID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData.Role != portainer.AdministratorRole && !checkEndpointAccessControl(endpoint, tokenData.ID) { - Error(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - var proxy http.Handler - proxy = handler.ProxyService.GetProxy(string(endpointID)) - if proxy == nil { - proxy, err = handler.ProxyService.CreateAndRegisterProxy(endpoint) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - } - - http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) -} diff --git a/api/http/docker_proxy.go b/api/http/docker_proxy.go deleted file mode 100644 index f4644dd36..000000000 --- a/api/http/docker_proxy.go +++ /dev/null @@ -1,121 +0,0 @@ -package http - -import ( - "io" - "net" - "net/http" - "net/http/httputil" - "net/url" - "strings" - - "github.com/portainer/portainer" -) - -// ProxyFactory is a factory to create reverse proxies to Docker endpoints -type ProxyFactory struct { - ResourceControlService portainer.ResourceControlService -} - -// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go -// included here for use in NewSingleHostReverseProxyWithHostHeader -// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go -func singleJoiningSlash(a, b string) string { - aslash := strings.HasSuffix(a, "/") - bslash := strings.HasPrefix(b, "/") - switch { - case aslash && bslash: - return a + b[1:] - case !aslash && !bslash: - return a + "/" + b - } - return a + b -} - -// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy -// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host -// HTTP header, which NewSingleHostReverseProxy deliberately preserves. -// It also adds an extra Transport to the proxy to allow Portainer to rewrite the responses. -func (factory *ProxyFactory) newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { - targetQuery := target.RawQuery - director := func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) - req.Host = req.URL.Host - if targetQuery == "" || req.URL.RawQuery == "" { - req.URL.RawQuery = targetQuery + req.URL.RawQuery - } else { - req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery - } - if _, ok := req.Header["User-Agent"]; !ok { - // explicitly disable User-Agent so it's not set to default value - req.Header.Set("User-Agent", "") - } - } - transport := &proxyTransport{ - ResourceControlService: factory.ResourceControlService, - transport: &http.Transport{}, - } - return &httputil.ReverseProxy{Director: director, Transport: transport} -} - -func (factory *ProxyFactory) newHTTPProxy(u *url.URL) http.Handler { - u.Scheme = "http" - return factory.newSingleHostReverseProxyWithHostHeader(u) -} - -func (factory *ProxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { - u.Scheme = "https" - proxy := factory.newSingleHostReverseProxyWithHostHeader(u) - config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) - if err != nil { - return nil, err - } - - proxy.Transport.(*proxyTransport).transport.TLSClientConfig = config - return proxy, nil -} - -func (factory *ProxyFactory) newSocketProxy(path string) http.Handler { - return &unixSocketHandler{path, &proxyTransport{ - ResourceControlService: factory.ResourceControlService, - }} -} - -// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket -type unixSocketHandler struct { - path string - transport *proxyTransport -} - -func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - defer res.Body.Close() - - err = h.transport.proxyDockerRequests(r, res) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - - for k, vv := range res.Header { - for _, v := range vv { - w.Header().Add(k, v) - } - } - if _, err := io.Copy(w, res.Body); err != nil { - Error(w, err, http.StatusInternalServerError, nil) - } -} diff --git a/api/http/error/error.go b/api/http/error/error.go new file mode 100644 index 000000000..03f5220a8 --- /dev/null +++ b/api/http/error/error.go @@ -0,0 +1,30 @@ +package error + +import ( + "encoding/json" + "log" + "net/http" + "strings" +) + +// errorResponse is a generic response for sending a error. +type errorResponse struct { + Err string `json:"err,omitempty"` +} + +// WriteErrorResponse writes an error message to the response and logger. +func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.Logger) { + if logger != nil { + logger.Printf("http error: %s (code=%d)", err, code) + } + + w.WriteHeader(code) + json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) +} + +// WriteMethodNotAllowedResponse writes an error message to the response and sets the Allow header. +func WriteMethodNotAllowedResponse(w http.ResponseWriter, allowedMethods []string) { + w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)}) +} diff --git a/api/http/auth_handler.go b/api/http/handler/auth.go similarity index 64% rename from api/http/auth_handler.go rename to api/http/handler/auth.go index 0eb0e7559..3b464a165 100644 --- a/api/http/auth_handler.go +++ b/api/http/handler/auth.go @@ -1,4 +1,4 @@ -package http +package handler import ( "github.com/portainer/portainer" @@ -10,6 +10,8 @@ import ( "github.com/asaskevich/govalidator" "github.com/gorilla/mux" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" ) // AuthHandler represents an HTTP API handler for managing authentication. @@ -33,37 +35,38 @@ const ( ) // NewAuthHandler returns a new instance of AuthHandler. -func NewAuthHandler(mw *middleWareService) *AuthHandler { +func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler { h := &AuthHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + authDisabled: authDisabled, } h.Handle("/auth", - mw.public(http.HandlerFunc(h.handlePostAuth))) + bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))) return h } func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) return } if handler.authDisabled { - Error(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) return } var req postAuthRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err := govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) return } @@ -72,16 +75,16 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques u, err := handler.UserService.UserByUsername(username) if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } err = handler.CryptoService.CompareHashAndData(u.Password, password) if err != nil { - Error(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) return } @@ -92,7 +95,7 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques } token, err := handler.JWTService.GenerateToken(tokenData) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -100,7 +103,7 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques } type postAuthRequest struct { - Username string `valid:"alphanum,required"` + Username string `valid:"required"` Password string `valid:"required"` } diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go new file mode 100644 index 000000000..6c4e23636 --- /dev/null +++ b/api/http/handler/docker.go @@ -0,0 +1,94 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. +type DockerHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + TeamMembershipService portainer.TeamMembershipService + ProxyManager *proxy.Manager +} + +// NewDockerHandler returns a new instance of DockerHandler. +func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler { + h := &DockerHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.PathPrefix("/{id}/").Handler( + bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI))) + return h +} + +func (handler *DockerHandler) checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + + memberships, _ := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + for _, authorizedTeamID := range endpoint.AuthorizedTeams { + for _, membership := range memberships { + if membership.TeamID == authorizedTeamID { + return true + } + } + } + return false +} + +func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + parsedID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if tokenData.Role != portainer.AdministratorRole && !handler.checkEndpointAccessControl(endpoint, tokenData.ID) { + httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + } + + http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) +} diff --git a/api/http/endpoint_handler.go b/api/http/handler/endpoint.go similarity index 58% rename from api/http/endpoint_handler.go rename to api/http/handler/endpoint.go index d72c017e3..7118a9d69 100644 --- a/api/http/endpoint_handler.go +++ b/api/http/handler/endpoint.go @@ -1,7 +1,10 @@ -package http +package handler import ( "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" "encoding/json" "log" @@ -20,7 +23,7 @@ type EndpointHandler struct { authorizeEndpointManagement bool EndpointService portainer.EndpointService FileService portainer.FileService - ProxyService *ProxyService + ProxyManager *proxy.Manager } const ( @@ -30,78 +33,67 @@ const ( ) // NewEndpointHandler returns a new instance of EndpointHandler. -func NewEndpointHandler(mw *middleWareService) *EndpointHandler { +func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *EndpointHandler { h := &EndpointHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), + authorizeEndpointManagement: authorizeEndpointManagement, } h.Handle("/endpoints", - mw.administrator(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) h.Handle("/endpoints", - mw.authenticated(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - mw.administrator(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - mw.administrator(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) h.Handle("/endpoints/{id}/access", - mw.administrator(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) h.Handle("/endpoints/{id}", - mw.administrator(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) return h } // handleGetEndpoints handles GET requests on /endpoints func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + endpoints, err := handler.EndpointService.Endpoints() if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - tokenData, err := extractTokenDataFromRequestContext(r) + filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData == nil { - Error(w, portainer.ErrInvalidJWTToken, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - var allowedEndpoints []portainer.Endpoint - if tokenData.Role != portainer.AdministratorRole { - allowedEndpoints = make([]portainer.Endpoint, 0) - for _, endpoint := range endpoints { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == tokenData.ID { - allowedEndpoints = append(allowedEndpoints, endpoint) - break - } - } - } - } else { - allowedEndpoints = endpoints - } - - encodeJSON(w, allowedEndpoints, handler.Logger) + encodeJSON(w, filteredEndpoints, handler.Logger) } // handlePostEndpoints handles POST requests on /endpoints func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { - Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) return } var req postEndpointsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err := govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } @@ -111,11 +103,12 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht PublicURL: req.PublicURL, TLS: req.TLS, AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, } err = handler.EndpointService.CreateEndpoint(endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -128,7 +121,7 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht endpoint.TLSKeyPath = keyPath err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } @@ -154,16 +147,16 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -177,52 +170,63 @@ func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } var req putEndpointAccessRequest if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err = govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + if req.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range req.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpoint.AuthorizedUsers = authorizedUserIDs + } + + if req.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range req.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpoint.AuthorizedTeams = authorizedTeamIDs } - endpoint.AuthorizedUsers = authorizedUserIDs err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } type putEndpointAccessRequest struct { AuthorizedUsers []int `valid:"-"` + AuthorizedTeams []int `valid:"-"` } // handlePutEndpoint handles PUT requests on /endpoints/:id func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { - Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) return } @@ -231,28 +235,28 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } var req putEndpointsRequest if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err = govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -283,20 +287,20 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpoint.TLSKeyPath = "" err = handler.FileService.DeleteTLSFiles(endpoint.ID) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } - _, err = handler.ProxyService.CreateAndRegisterProxy(endpoint) + _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } @@ -311,7 +315,7 @@ type putEndpointsRequest struct { // handleDeleteEndpoint handles DELETE requests on /endpoints/:id func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { - Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) return } @@ -320,32 +324,33 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - handler.ProxyService.DeleteProxy(string(endpointID)) + handler.ProxyManager.DeleteProxy(string(endpointID)) err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } if endpoint.TLS { err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID)) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return } } } diff --git a/api/http/file_handler.go b/api/http/handler/file.go similarity index 84% rename from api/http/file_handler.go rename to api/http/handler/file.go index 09a849b7c..008275932 100644 --- a/api/http/file_handler.go +++ b/api/http/handler/file.go @@ -1,4 +1,4 @@ -package http +package handler import ( "net/http" @@ -10,7 +10,8 @@ type FileHandler struct { http.Handler } -func newFileHandler(assetPath string) *FileHandler { +// NewFileHandler returns a new instance of FileHandler. +func NewFileHandler(assetPath string) *FileHandler { h := &FileHandler{ Handler: http.FileServer(http.Dir(assetPath)), } diff --git a/api/http/handler.go b/api/http/handler/handler.go similarity index 63% rename from api/http/handler.go rename to api/http/handler/handler.go index 7a27925c7..89a963c33 100644 --- a/api/http/handler.go +++ b/api/http/handler/handler.go @@ -1,25 +1,29 @@ -package http +package handler import ( - "github.com/portainer/portainer" - "encoding/json" "log" "net/http" "strings" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" ) // Handler is a collection of all the service handlers. type Handler struct { - AuthHandler *AuthHandler - UserHandler *UserHandler - EndpointHandler *EndpointHandler - SettingsHandler *SettingsHandler - TemplatesHandler *TemplatesHandler - DockerHandler *DockerHandler - WebSocketHandler *WebSocketHandler - UploadHandler *UploadHandler - FileHandler *FileHandler + AuthHandler *AuthHandler + UserHandler *UserHandler + TeamHandler *TeamHandler + TeamMembershipHandler *TeamMembershipHandler + EndpointHandler *EndpointHandler + ResourceHandler *ResourceHandler + SettingsHandler *SettingsHandler + TemplatesHandler *TemplatesHandler + DockerHandler *DockerHandler + WebSocketHandler *WebSocketHandler + UploadHandler *UploadHandler + FileHandler *FileHandler } const ( @@ -30,7 +34,7 @@ const ( // ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid ErrInvalidQueryFormat = portainer.Error("Invalid query format") // ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse - ErrEmptyResponseBody = portainer.Error("Empty response body") + // ErrEmptyResponseBody = portainer.Error("Empty response body") ) // ServeHTTP delegates a request to the appropriate subhandler. @@ -39,8 +43,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/users") { http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/teams") { + http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/team_memberships") { + http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/endpoints") { http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/resource_controls") { + http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/settings") { http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/templates") { @@ -56,33 +66,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// Error writes an API error message to the response and logger. -func Error(w http.ResponseWriter, err error, code int, logger *log.Logger) { - // Log error. - if logger != nil { - logger.Printf("http error: %s (code=%d)", err, code) - } - - // Write generic error response. - w.WriteHeader(code) - json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) -} - -// errorResponse is a generic response for sending a error. -type errorResponse struct { - Err string `json:"err,omitempty"` -} - -// handleNotAllowed writes an API error message to the response and sets the Allow header. -func handleNotAllowed(w http.ResponseWriter, allowedMethods []string) { - w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) - w.WriteHeader(http.StatusMethodNotAllowed) - json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)}) -} - // encodeJSON encodes v to w in JSON format. Error() is called if encoding fails. func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { if err := json.NewEncoder(w).Encode(v); err != nil { - Error(w, err, http.StatusInternalServerError, logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger) } } diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go new file mode 100644 index 000000000..7952cfbda --- /dev/null +++ b/api/http/handler/resource_control.go @@ -0,0 +1,256 @@ +package handler + +import ( + "encoding/json" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// ResourceHandler represents an HTTP API handler for managing resource controls. +type ResourceHandler struct { + *mux.Router + Logger *log.Logger + ResourceControlService portainer.ResourceControlService +} + +// NewResourceHandler returns a new instance of ResourceHandler. +func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler { + h := &ResourceHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/resource_controls", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostResources))).Methods(http.MethodPost) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutResources))).Methods(http.MethodPut) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteResources))).Methods(http.MethodDelete) + + return h +} + +// handlePostResources handles POST requests on /resources +func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) { + var req postResourcesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + var resourceControlType portainer.ResourceControlType + switch req.Type { + case "container": + resourceControlType = portainer.ContainerResourceControl + case "service": + resourceControlType = portainer.ServiceResourceControl + case "volume": + resourceControlType = portainer.VolumeResourceControl + default: + httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) + return + } + + if len(req.Users) == 0 && len(req.Teams) == 0 && !req.AdministratorsOnly { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + rc, err := handler.ResourceControlService.ResourceControlByResourceID(req.ResourceID) + if err != nil && err != portainer.ErrResourceControlNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if rc != nil { + httperror.WriteErrorResponse(w, portainer.ErrResourceControlAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range req.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range req.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + + resourceControl := portainer.ResourceControl{ + ResourceID: req.ResourceID, + SubResourceIDs: req.SubResourceIDs, + Type: resourceControlType, + AdministratorsOnly: req.AdministratorsOnly, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.CreateResourceControl(&resourceControl) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + return +} + +type postResourcesRequest struct { + ResourceID string `valid:"required"` + Type string `valid:"required"` + AdministratorsOnly bool `valid:"-"` + Users []int `valid:"-"` + Teams []int `valid:"-"` + SubResourceIDs []string `valid:"-"` +} + +// handlePutResources handles PUT requests on /resources/:id +func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + resourceControlID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putResourcesRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + + if err == portainer.ErrResourceControlNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + resourceControl.AdministratorsOnly = req.AdministratorsOnly + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range req.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + resourceControl.UserAccesses = userAccesses + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range req.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + resourceControl.TeamAccesses = teamAccesses + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putResourcesRequest struct { + AdministratorsOnly bool `valid:"-"` + Users []int `valid:"-"` + Teams []int `valid:"-"` +} + +// handleDeleteResources handles DELETE requests on /resources/:id +func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + resourceControlID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + + if err == portainer.ErrResourceControlNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/settings_handler.go b/api/http/handler/settings.go similarity index 58% rename from api/http/settings_handler.go rename to api/http/handler/settings.go index db426c071..cfae1dc30 100644 --- a/api/http/settings_handler.go +++ b/api/http/handler/settings.go @@ -1,7 +1,9 @@ -package http +package handler import ( "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" "log" "net/http" @@ -18,13 +20,14 @@ type SettingsHandler struct { } // NewSettingsHandler returns a new instance of SettingsHandler. -func NewSettingsHandler(mw *middleWareService) *SettingsHandler { +func NewSettingsHandler(bouncer *security.RequestBouncer, settings *portainer.Settings) *SettingsHandler { h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + settings: settings, } h.Handle("/settings", - mw.public(http.HandlerFunc(h.handleGetSettings))) + bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))) return h } @@ -32,7 +35,7 @@ func NewSettingsHandler(mw *middleWareService) *SettingsHandler { // handleGetSettings handles GET requests on /settings func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - handleNotAllowed(w, []string{http.MethodGet}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) return } diff --git a/api/http/handler/team.go b/api/http/handler/team.go new file mode 100644 index 000000000..3f4d9fc50 --- /dev/null +++ b/api/http/handler/team.go @@ -0,0 +1,252 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// TeamHandler represents an HTTP API handler for managing teams. +type TeamHandler struct { + *mux.Router + Logger *log.Logger + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewTeamHandler returns a new instance of TeamHandler. +func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler { + h := &TeamHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/teams", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost) + h.Handle("/teams", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutTeam))).Methods(http.MethodPut) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteTeam))).Methods(http.MethodDelete) + h.Handle("/teams/{id}/memberships", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) + + return h +} + +// handlePostTeams handles POST requests on /teams +func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) { + var req postTeamsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + team, err := handler.TeamService.TeamByName(req.Name) + if err != nil && err != portainer.ErrTeamNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if team != nil { + httperror.WriteErrorResponse(w, portainer.ErrTeamAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + team = &portainer.Team{ + Name: req.Name, + } + + err = handler.TeamService.CreateTeam(team) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger) +} + +type postTeamsResponse struct { + ID int `json:"Id"` +} + +type postTeamsRequest struct { + Name string `valid:"required"` +} + +// handleGetTeams handles GET requests on /teams +func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { + teams, err := handler.TeamService.Teams() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, teams, handler.Logger) +} + +// handleGetTeam handles GET requests on /teams/:id +func (handler *TeamHandler) handleGetTeam(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + tid, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + teamID := portainer.TeamID(tid) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + team, err := handler.TeamService.Team(teamID) + if err == portainer.ErrTeamNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &team, handler.Logger) +} + +// handlePutTeam handles PUT requests on /teams/:id +func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + teamID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putTeamRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Name != "" { + team.Name = req.Name + } + + err = handler.TeamService.UpdateTeam(team.ID, team) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putTeamRequest struct { + Name string `valid:"-"` +} + +// handleDeleteTeam handles DELETE requests on /teams/:id +func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + teamID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.TeamService.Team(portainer.TeamID(teamID)) + + if err == portainer.ErrTeamNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handleGetMemberships handles GET requests on /teams/:id/memberships +func (handler *TeamHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + tid, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + teamID := portainer.TeamID(tid) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(teamID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, memberships, handler.Logger) +} diff --git a/api/http/handler/team_membership.go b/api/http/handler/team_membership.go new file mode 100644 index 000000000..e6c9075ef --- /dev/null +++ b/api/http/handler/team_membership.go @@ -0,0 +1,240 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// TeamMembershipHandler represents an HTTP API handler for managing teams. +type TeamMembershipHandler struct { + *mux.Router + Logger *log.Logger + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewTeamMembershipHandler returns a new instance of TeamMembershipHandler. +func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipHandler { + h := &TeamMembershipHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/team_memberships", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostTeamMemberships))).Methods(http.MethodPost) + h.Handle("/team_memberships", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeamsMemberships))).Methods(http.MethodGet) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutTeamMembership))).Methods(http.MethodPut) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteTeamMembership))).Methods(http.MethodDelete) + + return h +} + +// handlePostTeamMemberships handles POST requests on /team_memberships +func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var req postTeamMembershipsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + userID := portainer.UserID(req.UserID) + teamID := portainer.TeamID(req.TeamID) + role := portainer.MembershipRole(req.Role) + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if len(memberships) > 0 { + for _, membership := range memberships { + if membership.UserID == userID && membership.TeamID == teamID { + httperror.WriteErrorResponse(w, portainer.ErrTeamMembershipAlreadyExists, http.StatusConflict, handler.Logger) + return + } + } + } + + membership := &portainer.TeamMembership{ + UserID: userID, + TeamID: teamID, + Role: role, + } + + err = handler.TeamMembershipService.CreateTeamMembership(membership) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger) +} + +type postTeamMembershipsResponse struct { + ID int `json:"Id"` +} + +type postTeamMembershipsRequest struct { + UserID int `valid:"required"` + TeamID int `valid:"required"` + Role int `valid:"required"` +} + +// handleGetTeamsMemberships handles GET requests on /team_memberships +func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMemberships() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, memberships, handler.Logger) +} + +// handlePutTeamMembership handles PUT requests on /team_memberships/:id +func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + membershipID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putTeamMembershipRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + userID := portainer.UserID(req.UserID) + teamID := portainer.TeamID(req.TeamID) + role := portainer.MembershipRole(req.Role) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if securityContext.IsTeamLeader && membership.Role != role { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + membership.UserID = userID + membership.TeamID = teamID + membership.Role = role + + err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putTeamMembershipRequest struct { + UserID int `valid:"required"` + TeamID int `valid:"required"` + Role int `valid:"required"` +} + +// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id +func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + membershipID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/templates_handler.go b/api/http/handler/templates.go similarity index 57% rename from api/http/templates_handler.go rename to api/http/handler/templates.go index be994ddc2..9383e407e 100644 --- a/api/http/templates_handler.go +++ b/api/http/handler/templates.go @@ -1,4 +1,4 @@ -package http +package handler import ( "io/ioutil" @@ -7,6 +7,8 @@ import ( "os" "github.com/gorilla/mux" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" ) // TemplatesHandler represents an HTTP API handler for managing templates. @@ -21,26 +23,27 @@ const ( ) // NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(mw *middleWareService) *TemplatesHandler { +func NewTemplatesHandler(bouncer *security.RequestBouncer, containerTemplatesURL string) *TemplatesHandler { h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + containerTemplatesURL: containerTemplatesURL, } h.Handle("/templates", - mw.authenticated(http.HandlerFunc(h.handleGetTemplates))) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))) return h } // handleGetTemplates handles GET requests on /templates?key= func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - handleNotAllowed(w, []string{http.MethodGet}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) return } key := r.FormValue("key") if key == "" { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } @@ -50,19 +53,19 @@ func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *ht } else if key == "linuxserver.io" { templatesURL = containerTemplatesURLLinuxServerIo } else { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } resp, err := http.Get(templatesURL) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } w.Header().Set("Content-Type", "application/json") diff --git a/api/http/upload_handler.go b/api/http/handler/upload.go similarity index 63% rename from api/http/upload_handler.go rename to api/http/handler/upload.go index a89bf03a4..d96a45c5a 100644 --- a/api/http/upload_handler.go +++ b/api/http/handler/upload.go @@ -1,7 +1,9 @@ -package http +package handler import ( "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" "log" "net/http" @@ -19,19 +21,19 @@ type UploadHandler struct { } // NewUploadHandler returns a new instance of UploadHandler. -func NewUploadHandler(mw *middleWareService) *UploadHandler { +func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler { h := &UploadHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", - mw.authenticated(http.HandlerFunc(h.handlePostUploadTLS))) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUploadTLS))) return h } func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) return } @@ -40,14 +42,14 @@ func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http certificate := vars["certificate"] ID, err := strconv.Atoi(endpointID) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } file, _, err := r.FormFile("file") defer file.Close() if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -60,12 +62,13 @@ func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http case "key": fileType = portainer.TLSFileKey default: - Error(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) return } err = handler.FileService.StoreTLSFile(portainer.EndpointID(ID), fileType, file) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return } } diff --git a/api/http/handler/user.go b/api/http/handler/user.go new file mode 100644 index 000000000..44c15495e --- /dev/null +++ b/api/http/handler/user.go @@ -0,0 +1,490 @@ +package handler + +import ( + "strconv" + "strings" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// UserHandler represents an HTTP API handler for managing users. +type UserHandler struct { + *mux.Router + Logger *log.Logger + UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService +} + +// NewUserHandler returns a new instance of UserHandler. +func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler { + h := &UserHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/users", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) + h.Handle("/users", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) + h.Handle("/users/{id}/memberships", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) + h.Handle("/users/{id}/teams", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) + h.Handle("/users/{id}/passwd", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))) + h.Handle("/users/admin/check", + bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))) + h.Handle("/users/admin/init", + bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))) + + return h +} + +// handlePostUsers handles POST requests on /users +func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { + var req postUsersRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + if securityContext.IsTeamLeader && req.Role == 1 { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + if strings.ContainsAny(req.Username, " ") { + httperror.WriteErrorResponse(w, portainer.ErrInvalidUsername, http.StatusBadRequest, handler.Logger) + return + } + + var role portainer.UserRole + if req.Role == 1 { + role = portainer.AdministratorRole + } else { + role = portainer.StandardUserRole + } + + user, err := handler.UserService.UserByUsername(req.Username) + if err != nil && err != portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if user != nil { + httperror.WriteErrorResponse(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + user = &portainer.User{ + Username: req.Username, + Role: role, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.CreateUser(user) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger) +} + +type postUsersResponse struct { + ID int `json:"Id"` +} + +type postUsersRequest struct { + Username string `valid:"required"` + Password string `valid:"required"` + Role int `valid:"required"` +} + +// handleGetUsers handles GET requests on /users +func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + users, err := handler.UserService.Users() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredUsers := security.FilterUsers(users, securityContext) + + for i := range filteredUsers { + filteredUsers[i].Password = "" + } + + encodeJSON(w, filteredUsers, handler.Logger) +} + +// handlePostUserPasswd handles POST requests on /users/:id/passwd +func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req postUserPasswdRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + var password = req.Password + + u, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + valid := true + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + valid = false + } + + encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) +} + +type postUserPasswdRequest struct { + Password string `valid:"required"` +} + +type postUserPasswdResponse struct { + Valid bool `json:"valid"` +} + +// handleGetUser handles GET requests on /users/:id +func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + user.Password = "" + encodeJSON(w, &user, handler.Logger) +} + +// handlePutUser handles PUT requests on /users/:id +func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + + var req putUserRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + if req.Password == "" && req.Role == 0 { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Password != "" { + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + } + + if req.Role != 0 { + if tokenData.Role != portainer.AdministratorRole { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + if req.Role == 1 { + user.Role = portainer.AdministratorRole + } else { + user.Role = portainer.StandardUserRole + } + } + + err = handler.UserService.UpdateUser(user.ID, user) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putUserRequest struct { + Password string `valid:"-"` + Role int `valid:"-"` +} + +// handlePostAdminInit handles GET requests on /users/admin/check +func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) + return + } + + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if len(users) == 0 { + httperror.WriteErrorResponse(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) + return + } +} + +// handlePostAdminInit handles POST requests on /users/admin/init +func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) + return + } + + var req postAdminInitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user, err := handler.UserService.UserByUsername("admin") + if err == portainer.ErrUserNotFound { + user := &portainer.User{ + Username: "admin", + Role: portainer.AdministratorRole, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.CreateUser(user) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if user != nil { + httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusForbidden, handler.Logger) + return + } +} + +type postAdminInitRequest struct { + Password string `valid:"required"` +} + +// handleDeleteUser handles DELETE requests on /users/:id +func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.UserService.User(portainer.UserID(userID)) + + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handleGetMemberships handles GET requests on /users/:id/memberships +func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, memberships, handler.Logger) +} + +// handleGetTeams handles GET requests on /users/:id/teams +func (handler *UserHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + uid, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + userID := portainer.UserID(uid) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedUserManagement(userID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + teams, err := handler.TeamService.Teams() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredTeams := security.FilterUserTeams(teams, securityContext) + + encodeJSON(w, filteredTeams, handler.Logger) +} diff --git a/api/http/websocket_handler.go b/api/http/handler/websocket.go similarity index 97% rename from api/http/websocket_handler.go rename to api/http/handler/websocket.go index 126fcbed0..39f626a99 100644 --- a/api/http/websocket_handler.go +++ b/api/http/handler/websocket.go @@ -1,4 +1,4 @@ -package http +package handler import ( "bytes" @@ -17,6 +17,7 @@ import ( "github.com/gorilla/mux" "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" "golang.org/x/net/websocket" ) @@ -71,7 +72,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { // Should not be managed here var tlsConfig *tls.Config if endpoint.TLS { - tlsConfig, err = createTLSConfiguration(endpoint.TLSCACertPath, + tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) if err != nil { diff --git a/api/http/middleware.go b/api/http/middleware.go deleted file mode 100644 index 4221a61f4..000000000 --- a/api/http/middleware.go +++ /dev/null @@ -1,119 +0,0 @@ -package http - -import ( - "context" - - "github.com/portainer/portainer" - - "net/http" - "strings" -) - -type ( - // middleWareService represents a service to manage HTTP middlewares - middleWareService struct { - jwtService portainer.JWTService - authDisabled bool - } - contextKey int -) - -const ( - contextAuthenticationKey contextKey = iota -) - -func extractTokenDataFromRequestContext(request *http.Request) (*portainer.TokenData, error) { - contextData := request.Context().Value(contextAuthenticationKey) - if contextData == nil { - return nil, portainer.ErrMissingContextData - } - - tokenData := contextData.(*portainer.TokenData) - return tokenData, nil -} - -// public defines a chain of middleware for public endpoints (no authentication required) -func (service *middleWareService) public(h http.Handler) http.Handler { - h = mwSecureHeaders(h) - return h -} - -// authenticated defines a chain of middleware for private endpoints (authentication required) -func (service *middleWareService) authenticated(h http.Handler) http.Handler { - h = service.mwCheckAuthentication(h) - h = mwSecureHeaders(h) - return h -} - -// administrator defines a chain of middleware for private administrator restricted endpoints -// (authentication and role admin required) -func (service *middleWareService) administrator(h http.Handler) http.Handler { - h = mwCheckAdministratorRole(h) - h = service.mwCheckAuthentication(h) - h = mwSecureHeaders(h) - return h -} - -// mwSecureHeaders provides secure headers middleware for handlers -func mwSecureHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("X-Content-Type-Options", "nosniff") - w.Header().Add("X-Frame-Options", "DENY") - next.ServeHTTP(w, r) - }) -} - -// mwCheckAdministratorRole check the role of the user associated to the request -func mwCheckAdministratorRole(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if tokenData.Role != portainer.AdministratorRole { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - next.ServeHTTP(w, r) - }) -} - -// mwCheckAuthentication provides Authentication middleware for handlers -func (service *middleWareService) mwCheckAuthentication(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var tokenData *portainer.TokenData - if !service.authDisabled { - var token string - - // Get token from the Authorization header - tokens, ok := r.Header["Authorization"] - if ok && len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") - } - - if token == "" { - Error(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) - return - } - - var err error - tokenData, err = service.jwtService.ParseAndVerifyToken(token) - if err != nil { - Error(w, err, http.StatusUnauthorized, nil) - return - } - } else { - tokenData = &portainer.TokenData{ - Role: portainer.AdministratorRole, - } - } - - ctx := context.WithValue(r.Context(), contextAuthenticationKey, tokenData) - next.ServeHTTP(w, r.WithContext(ctx)) - return - }) -} diff --git a/api/http/proxy.go b/api/http/proxy.go deleted file mode 100644 index 053656be5..000000000 --- a/api/http/proxy.go +++ /dev/null @@ -1,67 +0,0 @@ -package http - -import ( - "net/http" - "net/url" - - "github.com/orcaman/concurrent-map" - "github.com/portainer/portainer" -) - -// ProxyService represents a service used to manage Docker proxies. -type ProxyService struct { - proxyFactory *ProxyFactory - proxies cmap.ConcurrentMap -} - -// NewProxyService initializes a new ProxyService -func NewProxyService(resourceControlService portainer.ResourceControlService) *ProxyService { - return &ProxyService{ - proxies: cmap.New(), - proxyFactory: &ProxyFactory{ - ResourceControlService: resourceControlService, - }, - } -} - -// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies. -// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. -func (service *ProxyService) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - var proxy http.Handler - - endpointURL, err := url.Parse(endpoint.URL) - if err != nil { - return nil, err - } - - if endpointURL.Scheme == "tcp" { - if endpoint.TLS { - proxy, err = service.proxyFactory.newHTTPSProxy(endpointURL, endpoint) - if err != nil { - return nil, err - } - } else { - proxy = service.proxyFactory.newHTTPProxy(endpointURL) - } - } else { - // Assume unix:// scheme - proxy = service.proxyFactory.newSocketProxy(endpointURL.Path) - } - - service.proxies.Set(string(endpoint.ID), proxy) - return proxy, nil -} - -// GetProxy returns the proxy associated to a key -func (service *ProxyService) GetProxy(key string) http.Handler { - proxy, ok := service.proxies.Get(key) - if !ok { - return nil - } - return proxy.(http.Handler) -} - -// DeleteProxy deletes the proxy associated to a key -func (service *ProxyService) DeleteProxy(key string) { - service.proxies.Remove(key) -} diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go new file mode 100644 index 000000000..eb26661b5 --- /dev/null +++ b/api/http/proxy/access_control.go @@ -0,0 +1,21 @@ +package proxy + +import "github.com/portainer/portainer" + +func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { + for _, authorizedUserAccess := range resourceControl.UserAccesses { + if userID == authorizedUserAccess.UserID { + return true + } + } + + for _, authorizedTeamAccess := range resourceControl.TeamAccesses { + for _, userTeamID := range userTeamIDs { + if userTeamID == authorizedTeamAccess.TeamID { + return true + } + } + } + + return false +} diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go new file mode 100644 index 000000000..909a7dc0d --- /dev/null +++ b/api/http/proxy/containers.go @@ -0,0 +1,98 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier + ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") + containerIdentifier = "Id" + containerLabelForServiceIdentifier = "com.docker.swarm.service.id" +) + +// containerListOperation extracts the response as a JSON object, loop through the containers array +// decorate and/or filter the containers based on resource controls before rewriting the response +func containerListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + var err error + // ContainerList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if operationContext.isAdmin { + responseArray, err = decorateContainerList(responseArray, operationContext.resourceControls) + } else { + responseArray, err = filterContainerList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// containerInspectOperation extracts the response as a JSON object, verify that the user +// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID) +// and either rewrite an access denied response or a decorated container. +func containerInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + // ContainerInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[containerIdentifier] == nil { + return ErrDockerContainerIdentifierNotFound + } + containerID := responseObject[containerIdentifier].(string) + + resourceControl := getResourceControlByResourceID(containerID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject) + if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { + serviceID := containerLabels[containerLabelForServiceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} + +// extractContainerLabelsFromContainerInspectObject retrieve the Labels of the container if present. +// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect +func extractContainerLabelsFromContainerInspectObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Config.Labels + containerConfigObject := extractJSONField(responseObject, "Config") + if containerConfigObject != nil { + containerLabelsObject := extractJSONField(containerConfigObject, "Labels") + return containerLabelsObject + } + return nil +} + +// extractContainerLabelsFromContainerListObject retrieve the Labels of the container if present. +// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func extractContainerLabelsFromContainerListObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Labels + containerLabelsObject := extractJSONField(responseObject, "Labels") + return containerLabelsObject +} diff --git a/api/http/proxy/decorator.go b/api/http/proxy/decorator.go new file mode 100644 index 000000000..cc35fa7a3 --- /dev/null +++ b/api/http/proxy/decorator.go @@ -0,0 +1,90 @@ +package proxy + +import "github.com/portainer/portainer" + +// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control. +// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedVolumeData := make([]interface{}, 0) + + for _, volume := range volumeData { + + volumeObject := volume.(map[string]interface{}) + if volumeObject[volumeIdentifier] == nil { + return nil, ErrDockerVolumeIdentifierNotFound + } + + volumeID := volumeObject[volumeIdentifier].(string) + resourceControl := getResourceControlByResourceID(volumeID, resourceControls) + if resourceControl != nil { + volumeObject = decorateObject(volumeObject, resourceControl) + } + decoratedVolumeData = append(decoratedVolumeData, volumeObject) + } + + return decoratedVolumeData, nil +} + +// decorateContainerList loops through all containers and will decorate any container with an existing resource control. +// Check is based on the container ID and optional Swarm service ID. +// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedContainerData := make([]interface{}, 0) + + for _, container := range containerData { + + containerObject := container.(map[string]interface{}) + if containerObject[containerIdentifier] == nil { + return nil, ErrDockerContainerIdentifierNotFound + } + + containerID := containerObject[containerIdentifier].(string) + resourceControl := getResourceControlByResourceID(containerID, resourceControls) + if resourceControl != nil { + containerObject = decorateObject(containerObject, resourceControl) + } + + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { + serviceID := containerLabels[containerLabelForServiceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if resourceControl != nil { + containerObject = decorateObject(containerObject, resourceControl) + } + } + + decoratedContainerData = append(decoratedContainerData, containerObject) + } + + return decoratedContainerData, nil +} + +// decorateServiceList loops through all services and will decorate any service with an existing resource control. +// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedServiceData := make([]interface{}, 0) + + for _, service := range serviceData { + + serviceObject := service.(map[string]interface{}) + if serviceObject[serviceIdentifier] == nil { + return nil, ErrDockerServiceIdentifierNotFound + } + + serviceID := serviceObject[serviceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if resourceControl != nil { + serviceObject = decorateObject(serviceObject, resourceControl) + } + decoratedServiceData = append(decoratedServiceData, serviceObject) + } + + return decoratedServiceData, nil +} + +func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { + metadata := make(map[string]interface{}) + metadata["ResourceControl"] = resourceControl + object["Portainer"] = metadata + return object +} diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go new file mode 100644 index 000000000..96d2239d2 --- /dev/null +++ b/api/http/proxy/factory.go @@ -0,0 +1,55 @@ +package proxy + +import ( + "net/http" + "net/http/httputil" + "net/url" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" +) + +// proxyFactory is a factory to create reverse proxies to Docker endpoints +type proxyFactory struct { + ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService +} + +func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { + u.Scheme = "http" + return factory.createReverseProxy(u) +} + +func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { + u.Scheme = "https" + proxy := factory.createReverseProxy(u) + config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + if err != nil { + return nil, err + } + + proxy.Transport.(*proxyTransport).dockerTransport.TLSClientConfig = config + return proxy, nil +} + +func (factory *proxyFactory) newSocketProxy(path string) http.Handler { + proxy := &socketProxy{} + transport := &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + TeamMembershipService: factory.TeamMembershipService, + dockerTransport: newSocketTransport(path), + } + proxy.Transport = transport + return proxy +} + +func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy { + proxy := newSingleHostReverseProxyWithHostHeader(u) + transport := &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + TeamMembershipService: factory.TeamMembershipService, + dockerTransport: newHTTPTransport(), + } + proxy.Transport = transport + return proxy +} diff --git a/api/http/proxy/filter.go b/api/http/proxy/filter.go new file mode 100644 index 000000000..02de684b3 --- /dev/null +++ b/api/http/proxy/filter.go @@ -0,0 +1,91 @@ +package proxy + +import "github.com/portainer/portainer" + +// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with +// any resource control giving access to the user (these volumes will be decorated). +// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredVolumeData := make([]interface{}, 0) + + for _, volume := range volumeData { + volumeObject := volume.(map[string]interface{}) + if volumeObject[volumeIdentifier] == nil { + return nil, ErrDockerVolumeIdentifierNotFound + } + + volumeID := volumeObject[volumeIdentifier].(string) + resourceControl := getResourceControlByResourceID(volumeID, resourceControls) + if resourceControl == nil { + filteredVolumeData = append(filteredVolumeData, volumeObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + volumeObject = decorateObject(volumeObject, resourceControl) + filteredVolumeData = append(filteredVolumeData, volumeObject) + } + } + + return filteredVolumeData, nil +} + +// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with +// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated). +// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredContainerData := make([]interface{}, 0) + + for _, container := range containerData { + containerObject := container.(map[string]interface{}) + if containerObject[containerIdentifier] == nil { + return nil, ErrDockerContainerIdentifierNotFound + } + + containerID := containerObject[containerIdentifier].(string) + resourceControl := getResourceControlByResourceID(containerID, resourceControls) + if resourceControl == nil { + // check if container is part of a Swarm service + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { + serviceID := containerLabels[containerLabelForServiceIdentifier].(string) + serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if serviceResourceControl == nil { + filteredContainerData = append(filteredContainerData, containerObject) + } else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) { + containerObject = decorateObject(containerObject, serviceResourceControl) + filteredContainerData = append(filteredContainerData, containerObject) + } + } else { + filteredContainerData = append(filteredContainerData, containerObject) + } + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + containerObject = decorateObject(containerObject, resourceControl) + filteredContainerData = append(filteredContainerData, containerObject) + } + } + + return filteredContainerData, nil +} + +// filterServiceList loops through all services, filters services without any resource control (public resources) or with +// any resource control giving access to the user (these services will be decorated). +// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredServiceData := make([]interface{}, 0) + + for _, service := range serviceData { + serviceObject := service.(map[string]interface{}) + if serviceObject[serviceIdentifier] == nil { + return nil, ErrDockerServiceIdentifierNotFound + } + + serviceID := serviceObject[serviceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if resourceControl == nil { + filteredServiceData = append(filteredServiceData, serviceObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + serviceObject = decorateObject(serviceObject, resourceControl) + filteredServiceData = append(filteredServiceData, serviceObject) + } + } + + return filteredServiceData, nil +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go new file mode 100644 index 000000000..b90596e10 --- /dev/null +++ b/api/http/proxy/manager.go @@ -0,0 +1,68 @@ +package proxy + +import ( + "net/http" + "net/url" + + "github.com/orcaman/concurrent-map" + "github.com/portainer/portainer" +) + +// Manager represents a service used to manage Docker proxies. +type Manager struct { + proxyFactory *proxyFactory + proxies cmap.ConcurrentMap +} + +// NewManager initializes a new proxy Service +func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService) *Manager { + return &Manager{ + proxies: cmap.New(), + proxyFactory: &proxyFactory{ + ResourceControlService: resourceControlService, + TeamMembershipService: teamMembershipService, + }, + } +} + +// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + var proxy http.Handler + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + if endpointURL.Scheme == "tcp" { + if endpoint.TLS { + proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint) + if err != nil { + return nil, err + } + } else { + proxy = manager.proxyFactory.newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path) + } + + manager.proxies.Set(string(endpoint.ID), proxy) + return proxy, nil +} + +// GetProxy returns the proxy associated to a key +func (manager *Manager) GetProxy(key string) http.Handler { + proxy, ok := manager.proxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// DeleteProxy deletes the proxy associated to a key +func (manager *Manager) DeleteProxy(key string) { + manager.proxies.Remove(key) +} diff --git a/api/http/proxy/response.go b/api/http/proxy/response.go new file mode 100644 index 000000000..ece319672 --- /dev/null +++ b/api/http/proxy/response.go @@ -0,0 +1,90 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "strconv" + + "github.com/portainer/portainer" +) + +const ( + // ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse + ErrEmptyResponseBody = portainer.Error("Empty response body") +) + +func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} { + object := jsonObject[key] + if object != nil { + return object.(map[string]interface{}) + } + return nil +} + +func getResponseAsJSONOBject(response *http.Response) (map[string]interface{}, error) { + responseData, err := getResponseBodyAsGenericJSON(response) + if err != nil { + return nil, err + } + + responseObject := responseData.(map[string]interface{}) + return responseObject, nil +} + +func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) { + responseData, err := getResponseBodyAsGenericJSON(response) + if err != nil { + return nil, err + } + + responseObject := responseData.([]interface{}) + return responseObject, nil +} + +func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) { + var data interface{} + if response.Body != nil { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + return nil, err + } + + return data, nil + } + return nil, ErrEmptyResponseBody +} + +func writeAccessDeniedResponse() (*http.Response, error) { + response := &http.Response{} + err := rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden) + return response, err +} + +func rewriteAccessDeniedResponse(response *http.Response) error { + return rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden) +} + +func rewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error { + jsonData, err := json.Marshal(newResponseData) + if err != nil { + return err + } + body := ioutil.NopCloser(bytes.NewReader(jsonData)) + response.StatusCode = statusCode + response.Body = body + response.ContentLength = int64(len(jsonData)) + response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) + return nil +} diff --git a/api/http/proxy/reverse_proxy.go b/api/http/proxy/reverse_proxy.go new file mode 100644 index 000000000..4862de9a9 --- /dev/null +++ b/api/http/proxy/reverse_proxy.go @@ -0,0 +1,46 @@ +package proxy + +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host +// HTTP header, which NewSingleHostReverseProxy deliberately preserves. +func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { + targetQuery := target.RawQuery + director := func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) + req.Host = req.URL.Host + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + req.Header.Set("User-Agent", "") + } + } + return &httputil.ReverseProxy{Director: director} +} + +// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go +// included here for use in NewSingleHostReverseProxyWithHostHeader +// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} diff --git a/api/http/proxy/service.go b/api/http/proxy/service.go new file mode 100644 index 000000000..fcf604a84 --- /dev/null +++ b/api/http/proxy/service.go @@ -0,0 +1,64 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier + ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found") + serviceIdentifier = "ID" +) + +// serviceListOperation extracts the response as a JSON array, loop through the service array +// decorate and/or filter the services based on resource controls before rewriting the response +func serviceListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + var err error + // ServiceList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if operationContext.isAdmin { + responseArray, err = decorateServiceList(responseArray, operationContext.resourceControls) + } else { + responseArray, err = filterServiceList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// serviceInspectOperation extracts the response as a JSON object, verify that the user +// has access to the service based on resource control and either rewrite an access denied response +// or a decorated service. +func serviceInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + // ServiceInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[serviceIdentifier] == nil { + return ErrDockerServiceIdentifierNotFound + } + serviceID := responseObject[serviceIdentifier].(string) + + resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy/socket.go b/api/http/proxy/socket.go new file mode 100644 index 000000000..740010a63 --- /dev/null +++ b/api/http/proxy/socket.go @@ -0,0 +1,40 @@ +package proxy + +// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket +import ( + "io" + "net/http" + + httperror "github.com/portainer/portainer/http/error" +) + +type socketProxy struct { + Transport *proxyTransport +} + +func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Force URL/domain to http/unixsocket to be able to + // use http.Transport RoundTrip to do the requests via the socket + r.URL.Scheme = "http" + r.URL.Host = "unixsocket" + + res, err := proxy.Transport.proxyDockerRequest(r) + if err != nil { + code := http.StatusInternalServerError + if res != nil && res.StatusCode != 0 { + code = res.StatusCode + } + httperror.WriteErrorResponse(w, err, code, nil) + return + } + defer res.Body.Close() + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + if _, err := io.Copy(w, res.Body); err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + } +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go new file mode 100644 index 000000000..ca18ebcb5 --- /dev/null +++ b/api/http/proxy/transport.go @@ -0,0 +1,237 @@ +package proxy + +import ( + "net" + "net/http" + "path" + "strings" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/security" +) + +type ( + proxyTransport struct { + dockerTransport *http.Transport + ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService + } + restrictedOperationContext struct { + isAdmin bool + userID portainer.UserID + userTeamIDs []portainer.TeamID + resourceControls []portainer.ResourceControl + } + restrictedOperationRequest func(*http.Request, *http.Response, *restrictedOperationContext) error +) + +func newSocketTransport(socketPath string) *http.Transport { + return &http.Transport{ + Dial: func(proto, addr string) (conn net.Conn, err error) { + return net.Dial("unix", socketPath) + }, + } +} + +func newHTTPTransport() *http.Transport { + return &http.Transport{} +} + +func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return p.proxyDockerRequest(request) +} + +func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) { + return p.dockerTransport.RoundTrip(request) +} + +func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { + path := request.URL.Path + + if strings.HasPrefix(path, "/containers") { + return p.proxyContainerRequest(request) + } else if strings.HasPrefix(path, "/services") { + return p.proxyServiceRequest(request) + } else if strings.HasPrefix(path, "/volumes") { + return p.proxyVolumeRequest(request) + } + + return p.executeDockerRequest(request) +} + +func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { + // return p.executeDockerRequest(request) + switch requestPath := request.URL.Path; requestPath { + case "/containers/create": + return p.executeDockerRequest(request) + + case "/containers/prune": + return p.administratorOperation(request) + + case "/containers/json": + return p.rewriteOperation(request, containerListOperation) + + default: + // This section assumes /containers/** + if match, _ := path.Match("/containers/*/*", requestPath); match { + // Handle /containers/{id}/{action} requests + containerID := path.Base(path.Dir(requestPath)) + action := path.Base(requestPath) + + if action == "json" { + return p.rewriteOperation(request, containerInspectOperation) + } + return p.restrictedOperation(request, containerID) + } else if match, _ := path.Match("/containers/*", requestPath); match { + // Handle /containers/{id} requests + containerID := path.Base(requestPath) + return p.restrictedOperation(request, containerID) + } + return p.executeDockerRequest(request) + } +} + +func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/services/create": + return p.executeDockerRequest(request) + + case "/volumes/prune": + return p.administratorOperation(request) + + case "/services": + return p.rewriteOperation(request, serviceListOperation) + + default: + // This section assumes /services/** + if match, _ := path.Match("/services/*/*", requestPath); match { + // Handle /services/{id}/{action} requests + serviceID := path.Base(path.Dir(requestPath)) + return p.restrictedOperation(request, serviceID) + } else if match, _ := path.Match("/services/*", requestPath); match { + // Handle /services/{id} requests + serviceID := path.Base(requestPath) + + if request.Method == http.MethodGet { + return p.rewriteOperation(request, serviceInspectOperation) + } + return p.restrictedOperation(request, serviceID) + } + return p.executeDockerRequest(request) + } +} + +func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/volumes/create": + return p.executeDockerRequest(request) + + case "/volumes/prune": + return p.administratorOperation(request) + + case "/volumes": + return p.rewriteOperation(request, volumeListOperation) + + default: + // assume /volumes/{name} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, volumeInspectOperation) + } + volumeID := path.Base(requestPath) + return p.restrictedOperation(request, volumeID) + } +} + +// restrictedOperation ensures that the current user has the required authorizations +// before executing the original request. +func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + + teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range teamMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + resourceControls, err := p.ResourceControlService.ResourceControls() + if err != nil { + return nil, err + } + + resourceControl := getResourceControlByResourceID(resourceID, resourceControls) + if resourceControl != nil && !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + return writeAccessDeniedResponse() + } + } + + return p.executeDockerRequest(request) +} + +// rewriteOperation will create a new operation context with data that will be used +// to decorate the original request's response. +func (p *proxyTransport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + resourceControls, err := p.ResourceControlService.ResourceControls() + if err != nil { + return nil, err + } + + operationContext := &restrictedOperationContext{ + isAdmin: true, + userID: tokenData.ID, + resourceControls: resourceControls, + } + + if tokenData.Role != portainer.AdministratorRole { + operationContext.isAdmin = false + + teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range teamMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + operationContext.userTeamIDs = userTeamIDs + } + + response, err := p.executeDockerRequest(request) + if err != nil { + return response, err + } + + err = operation(request, response, operationContext) + return response, err +} + +// administratorOperation ensures that the user has administrator privileges +// before executing the original request. +func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + return writeAccessDeniedResponse() + } + + return p.executeDockerRequest(request) +} diff --git a/api/http/proxy/utils.go b/api/http/proxy/utils.go new file mode 100644 index 000000000..36afce97d --- /dev/null +++ b/api/http/proxy/utils.go @@ -0,0 +1,17 @@ +package proxy + +import "github.com/portainer/portainer" + +func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl { + for _, resourceControl := range resourceControls { + if resourceID == resourceControl.ResourceID { + return &resourceControl + } + for _, subResourceID := range resourceControl.SubResourceIDs { + if resourceID == subResourceID { + return &resourceControl + } + } + } + return nil +} diff --git a/api/http/proxy/volumes.go b/api/http/proxy/volumes.go new file mode 100644 index 000000000..c39805de8 --- /dev/null +++ b/api/http/proxy/volumes.go @@ -0,0 +1,73 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier + ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found") + volumeIdentifier = "Name" +) + +// volumeListOperation extracts the response as a JSON object, loop through the volume array +// decorate and/or filter the volumes based on resource controls before rewriting the response +func volumeListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + var err error + // VolumeList response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + // The "Volumes" field contains the list of volumes as an array of JSON objects + // Response schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList + if responseObject["Volumes"] != nil { + volumeData := responseObject["Volumes"].([]interface{}) + + if operationContext.isAdmin { + volumeData, err = decorateVolumeList(volumeData, operationContext.resourceControls) + } else { + volumeData, err = filterVolumeList(volumeData, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + } + if err != nil { + return err + } + + // Overwrite the original volume list + responseObject["Volumes"] = volumeData + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} + +// volumeInspectOperation extracts the response as a JSON object, verify that the user +// has access to the volume based on resource control and either rewrite an access denied response +// or a decorated volume. +func volumeInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + // VolumeInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[volumeIdentifier] == nil { + return ErrDockerVolumeIdentifierNotFound + } + volumeID := responseObject[volumeIdentifier].(string) + + resourceControl := getResourceControlByResourceID(volumeID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy_transport.go b/api/http/proxy_transport.go deleted file mode 100644 index 34130979a..000000000 --- a/api/http/proxy_transport.go +++ /dev/null @@ -1,664 +0,0 @@ -package http - -import ( - "bytes" - "encoding/json" - "io/ioutil" - "net/http" - "path" - "strconv" - "strings" - - "github.com/portainer/portainer" -) - -type ( - proxyTransport struct { - transport *http.Transport - ResourceControlService portainer.ResourceControlService - } - resourceControlMetadata struct { - OwnerID portainer.UserID `json:"OwnerId"` - } -) - -func (p *proxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { - response, err := p.transport.RoundTrip(req) - if err != nil { - return response, err - } - - err = p.proxyDockerRequests(req, response) - return response, err -} - -func (p *proxyTransport) proxyDockerRequests(request *http.Request, response *http.Response) error { - path := request.URL.Path - - if strings.HasPrefix(path, "/containers") { - return p.handleContainerRequests(request, response) - } else if strings.HasPrefix(path, "/services") { - return p.handleServiceRequests(request, response) - } else if strings.HasPrefix(path, "/volumes") { - return p.handleVolumeRequests(request, response) - } - - return nil -} - -func (p *proxyTransport) handleContainerRequests(request *http.Request, response *http.Response) error { - requestPath := request.URL.Path - - tokenData, err := extractTokenDataFromRequestContext(request) - if err != nil { - return err - } - - if requestPath == "/containers/prune" && tokenData.Role != portainer.AdministratorRole { - return writeAccessDeniedResponse(response) - } - if requestPath == "/containers/json" { - if tokenData.Role == portainer.AdministratorRole { - return p.decorateContainerResponse(response) - } - return p.proxyContainerResponseWithResourceControl(response, tokenData.ID) - } - // /containers/{id}/action - if match, _ := path.Match("/containers/*/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(path.Dir(requestPath)) - return p.proxyContainerResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - - return nil -} - -func (p *proxyTransport) handleServiceRequests(request *http.Request, response *http.Response) error { - requestPath := request.URL.Path - - tokenData, err := extractTokenDataFromRequestContext(request) - if err != nil { - return err - } - - if requestPath == "/services" { - if tokenData.Role == portainer.AdministratorRole { - return p.decorateServiceResponse(response) - } - return p.proxyServiceResponseWithResourceControl(response, tokenData.ID) - } - // /services/{id} - if match, _ := path.Match("/services/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(requestPath) - return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - // /services/{id}/action - if match, _ := path.Match("/services/*/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(path.Dir(requestPath)) - return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - - return nil -} - -func (p *proxyTransport) handleVolumeRequests(request *http.Request, response *http.Response) error { - requestPath := request.URL.Path - - tokenData, err := extractTokenDataFromRequestContext(request) - if err != nil { - return err - } - - if requestPath == "/volumes" { - if tokenData.Role == portainer.AdministratorRole { - return p.decorateVolumeResponse(response) - } - return p.proxyVolumeResponseWithResourceControl(response, tokenData.ID) - } - if requestPath == "/volumes/prune" && tokenData.Role != portainer.AdministratorRole { - return writeAccessDeniedResponse(response) - } - // /volumes/{name} - if match, _ := path.Match("/volumes/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(requestPath) - return p.proxyVolumeResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - return nil -} - -func (p *proxyTransport) proxyContainerResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { - rcs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) - if err != nil { - return err - } - - userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return err - } - - if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { - return writeAccessDeniedResponse(response) - } - - return nil -} - -func (p *proxyTransport) proxyServiceResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { - rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return err - } - - userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return err - } - - if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { - return writeAccessDeniedResponse(response) - } - return nil -} - -func (p *proxyTransport) proxyVolumeResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { - rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) - if err != nil { - return err - } - - userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return err - } - - if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { - return writeAccessDeniedResponse(response) - } - return nil -} - -func (p *proxyTransport) decorateContainerResponse(response *http.Response) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - containers, err := p.decorateContainers(responseData) - if err != nil { - return err - } - - err = rewriteContainerResponse(response, containers) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) proxyContainerResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - containers, err := p.filterContainers(userID, responseData) - if err != nil { - return err - } - - err = rewriteContainerResponse(response, containers) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) decorateServiceResponse(response *http.Response) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - services, err := p.decorateServices(responseData) - if err != nil { - return err - } - - err = rewriteServiceResponse(response, services) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) proxyServiceResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - volumes, err := p.filterServices(userID, responseData) - if err != nil { - return err - } - - err = rewriteServiceResponse(response, volumes) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) decorateVolumeResponse(response *http.Response) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - volumes, err := p.decorateVolumes(responseData) - if err != nil { - return err - } - - err = rewriteVolumeResponse(response, volumes) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) proxyVolumeResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - volumes, err := p.filterVolumes(userID, responseData) - if err != nil { - return err - } - - err = rewriteVolumeResponse(response, volumes) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) decorateContainers(responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) - if err != nil { - return nil, err - } - - serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - decoratedResources := make([]interface{}, 0) - - for _, container := range responseDataArray { - jsonObject := container.(map[string]interface{}) - containerID := jsonObject["Id"].(string) - containerRC := getRCByResourceID(containerID, containerRCs) - if containerRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, containerRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - - containerLabels := jsonObject["Labels"] - if containerLabels != nil { - jsonLabels := containerLabels.(map[string]interface{}) - serviceID := jsonLabels["com.docker.swarm.service.id"] - if serviceID != nil { - serviceRC := getRCByResourceID(serviceID.(string), serviceRCs) - if serviceRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, serviceRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - } - } - decoratedResources = append(decoratedResources, container) - } - - return decoratedResources, nil -} - -func (p *proxyTransport) filterContainers(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) - if err != nil { - return nil, err - } - - serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - userOwnedContainerIDs, err := getResourceIDsOwnedByUser(userID, containerRCs) - if err != nil { - return nil, err - } - - userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, serviceRCs) - if err != nil { - return nil, err - } - - publicContainers := getPublicContainers(responseDataArray, containerRCs, serviceRCs) - - filteredResources := make([]interface{}, 0) - - for _, container := range responseDataArray { - jsonObject := container.(map[string]interface{}) - containerID := jsonObject["Id"].(string) - if isStringInArray(containerID, userOwnedContainerIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) - filteredResources = append(filteredResources, decoratedObject) - continue - } - - containerLabels := jsonObject["Labels"] - if containerLabels != nil { - jsonLabels := containerLabels.(map[string]interface{}) - serviceID := jsonLabels["com.docker.swarm.service.id"] - if serviceID != nil && isStringInArray(serviceID.(string), userOwnedServiceIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) - filteredResources = append(filteredResources, decoratedObject) - } - } - } - - filteredResources = append(filteredResources, publicContainers...) - return filteredResources, nil -} - -func decorateWithResourceControlMetadata(object map[string]interface{}, userID portainer.UserID) map[string]interface{} { - metadata := make(map[string]interface{}) - metadata["ResourceControl"] = resourceControlMetadata{ - OwnerID: userID, - } - object["Portainer"] = metadata - return object -} - -func (p *proxyTransport) decorateServices(responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - decoratedResources := make([]interface{}, 0) - - for _, service := range responseDataArray { - jsonResource := service.(map[string]interface{}) - resourceID := jsonResource["ID"].(string) - serviceRC := getRCByResourceID(resourceID, rcs) - if serviceRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, serviceRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - decoratedResources = append(decoratedResources, service) - } - - return decoratedResources, nil -} - -func (p *proxyTransport) filterServices(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return nil, err - } - - publicServices := getPublicResources(responseDataArray, rcs, "ID") - - filteredResources := make([]interface{}, 0) - - for _, res := range responseDataArray { - jsonResource := res.(map[string]interface{}) - resourceID := jsonResource["ID"].(string) - if isStringInArray(resourceID, userOwnedServiceIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) - filteredResources = append(filteredResources, decoratedObject) - } - } - - filteredResources = append(filteredResources, publicServices...) - return filteredResources, nil -} - -func (p *proxyTransport) decorateVolumes(responseData interface{}) ([]interface{}, error) { - var responseDataArray []interface{} - jsonObject := responseData.(map[string]interface{}) - if jsonObject["Volumes"] != nil { - responseDataArray = jsonObject["Volumes"].([]interface{}) - } - - rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) - if err != nil { - return nil, err - } - - decoratedResources := make([]interface{}, 0) - - for _, volume := range responseDataArray { - jsonResource := volume.(map[string]interface{}) - resourceID := jsonResource["Name"].(string) - volumeRC := getRCByResourceID(resourceID, rcs) - if volumeRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, volumeRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - decoratedResources = append(decoratedResources, volume) - } - - return decoratedResources, nil -} - -func (p *proxyTransport) filterVolumes(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { - var responseDataArray []interface{} - jsonObject := responseData.(map[string]interface{}) - if jsonObject["Volumes"] != nil { - responseDataArray = jsonObject["Volumes"].([]interface{}) - } - - rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) - if err != nil { - return nil, err - } - - userOwnedVolumeIDs, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return nil, err - } - - publicVolumes := getPublicResources(responseDataArray, rcs, "Name") - - filteredResources := make([]interface{}, 0) - - for _, res := range responseDataArray { - jsonResource := res.(map[string]interface{}) - resourceID := jsonResource["Name"].(string) - if isStringInArray(resourceID, userOwnedVolumeIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) - filteredResources = append(filteredResources, decoratedObject) - } - } - - filteredResources = append(filteredResources, publicVolumes...) - return filteredResources, nil -} - -func getResourceIDsOwnedByUser(userID portainer.UserID, rcs []portainer.ResourceControl) ([]string, error) { - ownedResources := make([]string, 0) - for _, rc := range rcs { - if rc.OwnerID == userID { - ownedResources = append(ownedResources, rc.ResourceID) - } - } - return ownedResources, nil -} - -func getOwnedServiceContainers(responseData []interface{}, serviceRCs []portainer.ResourceControl) []interface{} { - ownedContainers := make([]interface{}, 0) - for _, res := range responseData { - jsonResource := res.(map[string]map[string]interface{}) - swarmServiceID := jsonResource["Labels"]["com.docker.swarm.service.id"] - if swarmServiceID != nil { - resourceID := swarmServiceID.(string) - if isResourceIDInRCs(resourceID, serviceRCs) { - ownedContainers = append(ownedContainers, res) - } - } - } - return ownedContainers -} - -func getPublicContainers(responseData []interface{}, containerRCs []portainer.ResourceControl, serviceRCs []portainer.ResourceControl) []interface{} { - publicContainers := make([]interface{}, 0) - for _, container := range responseData { - jsonObject := container.(map[string]interface{}) - containerID := jsonObject["Id"].(string) - if !isResourceIDInRCs(containerID, containerRCs) { - containerLabels := jsonObject["Labels"] - if containerLabels != nil { - jsonLabels := containerLabels.(map[string]interface{}) - serviceID := jsonLabels["com.docker.swarm.service.id"] - if serviceID == nil { - publicContainers = append(publicContainers, container) - } else if serviceID != nil && !isResourceIDInRCs(serviceID.(string), serviceRCs) { - publicContainers = append(publicContainers, container) - } - } else { - publicContainers = append(publicContainers, container) - } - } - } - - return publicContainers -} - -func getPublicResources(responseData []interface{}, rcs []portainer.ResourceControl, resourceIDKey string) []interface{} { - publicResources := make([]interface{}, 0) - for _, res := range responseData { - jsonResource := res.(map[string]interface{}) - resourceID := jsonResource[resourceIDKey].(string) - if !isResourceIDInRCs(resourceID, rcs) { - publicResources = append(publicResources, res) - } - } - return publicResources -} - -func isStringInArray(target string, array []string) bool { - for _, element := range array { - if element == target { - return true - } - } - return false -} - -func isResourceIDInRCs(resourceID string, rcs []portainer.ResourceControl) bool { - for _, rc := range rcs { - if resourceID == rc.ResourceID { - return true - } - } - return false -} - -func getRCByResourceID(resourceID string, rcs []portainer.ResourceControl) *portainer.ResourceControl { - for _, rc := range rcs { - if resourceID == rc.ResourceID { - return &rc - } - } - return nil -} - -func getResponseData(response *http.Response) (interface{}, error) { - var data interface{} - if response.Body != nil { - body, err := ioutil.ReadAll(response.Body) - if err != nil { - return nil, err - } - - err = response.Body.Close() - if err != nil { - return nil, err - } - - err = json.Unmarshal(body, &data) - if err != nil { - return nil, err - } - - return data, nil - } - return nil, ErrEmptyResponseBody -} - -func writeAccessDeniedResponse(response *http.Response) error { - return rewriteResponse(response, portainer.ErrResourceAccessDenied, 403) -} - -func rewriteContainerResponse(response *http.Response, responseData interface{}) error { - return rewriteResponse(response, responseData, 200) -} - -func rewriteServiceResponse(response *http.Response, responseData interface{}) error { - return rewriteResponse(response, responseData, 200) -} - -func rewriteVolumeResponse(response *http.Response, responseData interface{}) error { - data := map[string]interface{}{} - data["Volumes"] = responseData - return rewriteResponse(response, data, 200) -} - -func rewriteResponse(response *http.Response, newContent interface{}, statusCode int) error { - jsonData, err := json.Marshal(newContent) - if err != nil { - return err - } - body := ioutil.NopCloser(bytes.NewReader(jsonData)) - response.StatusCode = statusCode - response.Body = body - response.ContentLength = int64(len(jsonData)) - response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) - return nil -} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go new file mode 100644 index 000000000..b19dad1f6 --- /dev/null +++ b/api/http/security/authorization.go @@ -0,0 +1,123 @@ +package security + +import "github.com/portainer/portainer" + +// AuthorizedResourceControlDeletion ensure that the user can delete a resource control object. +// A non-administrator user cannot delete a resource control where: +// * the AdministratorsOnly flag is set +// * he is not one of the users in the user accesses +// * he is not a member of any team within the team accesses +func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + if resourceControl.AdministratorsOnly { + return false + } + + userAccessesCount := len(resourceControl.UserAccesses) + teamAccessesCount := len(resourceControl.TeamAccesses) + + if teamAccessesCount > 0 { + for _, access := range resourceControl.TeamAccesses { + for _, membership := range context.UserMemberships { + if membership.TeamID == access.TeamID && membership.Role == portainer.TeamLeader { + return true + } + } + } + } + + if userAccessesCount > 0 { + for _, access := range resourceControl.UserAccesses { + if access.UserID == context.UserID { + return true + } + } + } + + return false +} + +// AuthorizedResourceControlUpdate ensure that the user can update a resource control object. +// It reuses the creation restrictions and adds extra checks. +// A non-administrator user cannot update a resource control where: +// * he wants to put one or more user in the user accesses +func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + userAccessesCount := len(resourceControl.UserAccesses) + if !context.IsAdmin && userAccessesCount > 0 { + return false + } + + return AuthorizedResourceControlCreation(resourceControl, context) +} + +// AuthorizedResourceControlCreation ensure that the user can create a resource control object. +// A non-administrator user cannot create a resource control where: +// * the AdministratorsOnly flag is set +// * he wants to add more than one user in the user accesses +// * he wants to add a team he is not a member of +func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + if resourceControl.AdministratorsOnly { + return false + } + + userAccessesCount := len(resourceControl.UserAccesses) + teamAccessesCount := len(resourceControl.TeamAccesses) + if userAccessesCount > 1 || (userAccessesCount == 1 && teamAccessesCount == 1) { + return false + } + + if userAccessesCount == 1 { + access := resourceControl.UserAccesses[0] + if access.UserID == context.UserID { + return true + } + } + + if teamAccessesCount > 0 { + for _, access := range resourceControl.TeamAccesses { + isMember := false + for _, membership := range context.UserMemberships { + if membership.TeamID == access.TeamID { + isMember = true + } + } + if !isMember { + return false + } + } + } + + return true +} + +// AuthorizedTeamManagement ensure that access to the management of the specified team is granted. +// It will check if the user is either administrator or leader of that team. +func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + for _, membership := range context.UserMemberships { + if membership.TeamID == teamID && membership.Role == portainer.TeamLeader { + return true + } + } + + return false +} + +// AuthorizedUserManagement ensure that access to the management of the specified user is granted. +// It will check if the user is either administrator or the owner of the user account. +func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedRequestContext) bool { + if context.IsAdmin || context.UserID == userID { + return true + } + return false +} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go new file mode 100644 index 000000000..9f7920c6c --- /dev/null +++ b/api/http/security/bouncer.go @@ -0,0 +1,176 @@ +package security + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + + "net/http" + "strings" +) + +type ( + // RequestBouncer represents an entity that manages API request accesses + RequestBouncer struct { + jwtService portainer.JWTService + teamMembershipService portainer.TeamMembershipService + authDisabled bool + } + + // RestrictedRequestContext is a data structure containing information + // used in RestrictedAccess + RestrictedRequestContext struct { + IsAdmin bool + IsTeamLeader bool + UserID portainer.UserID + UserMemberships []portainer.TeamMembership + } +) + +// NewRequestBouncer initializes a new RequestBouncer +func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer { + return &RequestBouncer{ + jwtService: jwtService, + teamMembershipService: teamMembershipService, + authDisabled: authDisabled, + } +} + +// PublicAccess defines a security check for public endpoints. +// No authentication is required to access these endpoints. +func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { + h = mwSecureHeaders(h) + return h +} + +// AuthenticatedAccess defines a security check for private endpoints. +// Authentication is required to access these endpoints. +func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { + h = bouncer.mwCheckAuthentication(h) + h = mwSecureHeaders(h) + return h +} + +// RestrictedAccess defines defines a security check for restricted endpoints. +// Authentication is required to access these endpoints. +// The request context will be enhanced with a RestrictedRequestContext object +// that might be used later to authorize/filter access to resources. +func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.AuthenticatedAccess(h) + return h +} + +// AdministratorAccess defines a chain of middleware for restricted endpoints. +// Authentication as well as administrator role are required to access these endpoints. +func (bouncer *RequestBouncer) AdministratorAccess(h http.Handler) http.Handler { + h = mwCheckAdministratorRole(h) + h = bouncer.AuthenticatedAccess(h) + return h +} + +// mwSecureHeaders provides secure headers middleware for handlers. +func mwSecureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + +// mwUpgradeToRestrictedRequest will enhance the current request with +// a new RestrictedRequestContext object. +func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenData, err := RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + return + } + + ctx := storeRestrictedRequestContext(r, requestContext) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// mwCheckAdministratorRole check the role of the user associated to the request +func mwCheckAdministratorRole(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenData, err := RetrieveTokenData(r) + if err != nil || tokenData.Role != portainer.AdministratorRole { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + next.ServeHTTP(w, r) + }) +} + +// mwCheckAuthentication provides Authentication middleware for handlers +func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var tokenData *portainer.TokenData + if !bouncer.authDisabled { + var token string + + // Get token from the Authorization header + tokens, ok := r.Header["Authorization"] + if ok && len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } + + if token == "" { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + return + } + + var err error + tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil) + return + } + } else { + tokenData = &portainer.TokenData{ + Role: portainer.AdministratorRole, + } + } + + ctx := storeTokenData(r, tokenData) + next.ServeHTTP(w, r.WithContext(ctx)) + return + }) +} + +func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) { + requestContext := &RestrictedRequestContext{ + IsAdmin: true, + UserID: userID, + } + + if userRole != portainer.AdministratorRole { + requestContext.IsAdmin = false + memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return nil, err + } + + isTeamLeader := false + for _, membership := range memberships { + if membership.Role == portainer.TeamLeader { + isTeamLeader = true + } + } + + requestContext.IsTeamLeader = isTeamLeader + requestContext.UserMemberships = memberships + } + + return requestContext, nil +} diff --git a/api/http/security/context.go b/api/http/security/context.go new file mode 100644 index 000000000..4f6141768 --- /dev/null +++ b/api/http/security/context.go @@ -0,0 +1,50 @@ +package security + +import ( + "context" + "net/http" + + "github.com/portainer/portainer" +) + +type ( + contextKey int +) + +const ( + contextAuthenticationKey contextKey = iota + contextRestrictedRequest +) + +// storeTokenData stores a TokenData object inside the request context and returns the enhanced context. +func storeTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context { + return context.WithValue(request.Context(), contextAuthenticationKey, tokenData) +} + +// RetrieveTokenData returns the TokenData object stored in the request context. +func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) { + contextData := request.Context().Value(contextAuthenticationKey) + if contextData == nil { + return nil, portainer.ErrMissingContextData + } + + tokenData := contextData.(*portainer.TokenData) + return tokenData, nil +} + +// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context +// and returns the enhanced context. +func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context { + return context.WithValue(request.Context(), contextRestrictedRequest, requestContext) +} + +// RetrieveRestrictedRequestContext returns the RestrictedRequestContext object stored in the request context. +func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequestContext, error) { + contextData := request.Context().Value(contextRestrictedRequest) + if contextData == nil { + return nil, portainer.ErrMissingSecurityContext + } + + requestContext := contextData.(*RestrictedRequestContext) + return requestContext, nil +} diff --git a/api/http/security/filter.go b/api/http/security/filter.go new file mode 100644 index 000000000..ec83a1ebc --- /dev/null +++ b/api/http/security/filter.go @@ -0,0 +1,95 @@ +package security + +import "github.com/portainer/portainer" + +// FilterUserTeams filters teams based on user role. +// non-administrator users only have access to team they are member of. +func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { + filteredTeams := teams + + if !context.IsAdmin { + filteredTeams = make([]portainer.Team, 0) + for _, membership := range context.UserMemberships { + for _, team := range teams { + if team.ID == membership.TeamID { + filteredTeams = append(filteredTeams, team) + break + } + } + } + } + + return filteredTeams +} + +// FilterLeaderTeams filters teams based on user role. +// Team leaders only have access to team they lead. +func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { + filteredTeams := teams + + if context.IsTeamLeader { + filteredTeams = make([]portainer.Team, 0) + for _, membership := range context.UserMemberships { + for _, team := range teams { + if team.ID == membership.TeamID && membership.Role == portainer.TeamLeader { + filteredTeams = append(filteredTeams, team) + break + } + } + } + } + + return filteredTeams +} + +// FilterUsers filters users based on user role. +// Non-administrator users only have access to non-administrator users. +func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []portainer.User { + filteredUsers := users + + if !context.IsAdmin { + filteredUsers = make([]portainer.User, 0) + + for _, user := range users { + if user.Role != portainer.AdministratorRole { + filteredUsers = append(filteredUsers, user) + } + } + } + + return filteredUsers +} + +// FilterEndpoints filters endpoints based on user role and team memberships. +// Non administrator users only have access to authorized endpoints. +func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { + filteredEndpoints := endpoints + + if !context.IsAdmin { + filteredEndpoints = make([]portainer.Endpoint, 0) + + for _, endpoint := range endpoints { + if isEndpointAccessAuthorized(&endpoint, context.UserID, context.UserMemberships) { + filteredEndpoints = append(filteredEndpoints, endpoint) + } + } + } + + return filteredEndpoints, nil +} + +func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + for _, membership := range memberships { + for _, authorizedTeamID := range endpoint.AuthorizedTeams { + if membership.TeamID == authorizedTeamID { + return true + } + } + } + return false +} diff --git a/api/http/server.go b/api/http/server.go index 18e37a99a..11d126d33 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -2,6 +2,9 @@ package http import ( "github.com/portainer/portainer" + "github.com/portainer/portainer/http/handler" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" "net/http" ) @@ -13,6 +16,8 @@ type Server struct { AuthDisabled bool EndpointManagement bool UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService EndpointService portainer.EndpointService ResourceControlService portainer.ResourceControlService CryptoService portainer.CryptoService @@ -20,7 +25,7 @@ type Server struct { FileService portainer.FileService Settings *portainer.Settings TemplatesURL string - Handler *Handler + Handler *handler.Handler SSL bool SSLCert string SSLKey string @@ -28,49 +33,55 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { - middleWareService := &middleWareService{ - jwtService: server.JWTService, - authDisabled: server.AuthDisabled, - } - proxyService := NewProxyService(server.ResourceControlService) + requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) + proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService) - var authHandler = NewAuthHandler(middleWareService) + var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService - authHandler.authDisabled = server.AuthDisabled - var userHandler = NewUserHandler(middleWareService) + var userHandler = handler.NewUserHandler(requestBouncer) userHandler.UserService = server.UserService + userHandler.TeamService = server.TeamService + userHandler.TeamMembershipService = server.TeamMembershipService userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService - var settingsHandler = NewSettingsHandler(middleWareService) - settingsHandler.settings = server.Settings - var templatesHandler = NewTemplatesHandler(middleWareService) - templatesHandler.containerTemplatesURL = server.TemplatesURL - var dockerHandler = NewDockerHandler(middleWareService, server.ResourceControlService) + var teamHandler = handler.NewTeamHandler(requestBouncer) + teamHandler.TeamService = server.TeamService + teamHandler.TeamMembershipService = server.TeamMembershipService + var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer) + teamMembershipHandler.TeamMembershipService = server.TeamMembershipService + var settingsHandler = handler.NewSettingsHandler(requestBouncer, server.Settings) + var templatesHandler = handler.NewTemplatesHandler(requestBouncer, server.TemplatesURL) + var dockerHandler = handler.NewDockerHandler(requestBouncer) dockerHandler.EndpointService = server.EndpointService - dockerHandler.ProxyService = proxyService - var websocketHandler = NewWebSocketHandler() + dockerHandler.TeamMembershipService = server.TeamMembershipService + dockerHandler.ProxyManager = proxyManager + var websocketHandler = handler.NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService - var endpointHandler = NewEndpointHandler(middleWareService) - endpointHandler.authorizeEndpointManagement = server.EndpointManagement + var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) endpointHandler.EndpointService = server.EndpointService endpointHandler.FileService = server.FileService - endpointHandler.ProxyService = proxyService - var uploadHandler = NewUploadHandler(middleWareService) + endpointHandler.ProxyManager = proxyManager + var resourceHandler = handler.NewResourceHandler(requestBouncer) + resourceHandler.ResourceControlService = server.ResourceControlService + var uploadHandler = handler.NewUploadHandler(requestBouncer) uploadHandler.FileService = server.FileService - var fileHandler = newFileHandler(server.AssetsPath) + var fileHandler = handler.NewFileHandler(server.AssetsPath) - server.Handler = &Handler{ - AuthHandler: authHandler, - UserHandler: userHandler, - EndpointHandler: endpointHandler, - SettingsHandler: settingsHandler, - TemplatesHandler: templatesHandler, - DockerHandler: dockerHandler, - WebSocketHandler: websocketHandler, - FileHandler: fileHandler, - UploadHandler: uploadHandler, + server.Handler = &handler.Handler{ + AuthHandler: authHandler, + UserHandler: userHandler, + TeamHandler: teamHandler, + TeamMembershipHandler: teamMembershipHandler, + EndpointHandler: endpointHandler, + ResourceHandler: resourceHandler, + SettingsHandler: settingsHandler, + TemplatesHandler: templatesHandler, + DockerHandler: dockerHandler, + WebSocketHandler: websocketHandler, + FileHandler: fileHandler, + UploadHandler: uploadHandler, } if server.SSL { diff --git a/api/http/user_handler.go b/api/http/user_handler.go deleted file mode 100644 index b1ba8f684..000000000 --- a/api/http/user_handler.go +++ /dev/null @@ -1,480 +0,0 @@ -package http - -import ( - "strconv" - - "github.com/portainer/portainer" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// UserHandler represents an HTTP API handler for managing users. -type UserHandler struct { - *mux.Router - Logger *log.Logger - UserService portainer.UserService - ResourceControlService portainer.ResourceControlService - CryptoService portainer.CryptoService -} - -// NewUserHandler returns a new instance of UserHandler. -func NewUserHandler(mw *middleWareService) *UserHandler { - h := &UserHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/users", - mw.administrator(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) - h.Handle("/users", - mw.administrator(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) - h.Handle("/users/{id}", - mw.administrator(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) - h.Handle("/users/{id}", - mw.authenticated(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) - h.Handle("/users/{id}", - mw.administrator(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) - h.Handle("/users/{id}/passwd", - mw.authenticated(http.HandlerFunc(h.handlePostUserPasswd))) - h.Handle("/users/{userId}/resources/{resourceType}", - mw.authenticated(http.HandlerFunc(h.handlePostUserResource))).Methods(http.MethodPost) - h.Handle("/users/{userId}/resources/{resourceType}/{resourceId}", - mw.authenticated(http.HandlerFunc(h.handleDeleteUserResource))).Methods(http.MethodDelete) - h.Handle("/users/admin/check", - mw.public(http.HandlerFunc(h.handleGetAdminCheck))) - h.Handle("/users/admin/init", - mw.public(http.HandlerFunc(h.handlePostAdminInit))) - - return h -} - -// handlePostUsers handles POST requests on /users -func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - var req postUsersRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - var role portainer.UserRole - if req.Role == 1 { - role = portainer.AdministratorRole - } else { - role = portainer.StandardUserRole - } - - user, err := handler.UserService.UserByUsername(req.Username) - if err != nil && err != portainer.ErrUserNotFound { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { - Error(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - user = &portainer.User{ - Username: req.Username, - Role: role, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - - err = handler.UserService.CreateUser(user) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -type postUsersRequest struct { - Username string `valid:"alphanum,required"` - Password string `valid:"required"` - Role int `valid:"required"` -} - -// handleGetUsers handles GET requests on /users -func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { - users, err := handler.UserService.Users() - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for i := range users { - users[i].Password = "" - } - encodeJSON(w, users, handler.Logger) -} - -// handlePostUserPasswd handles POST requests on /users/:id/passwd -func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req postUserPasswdRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - var password = req.Password - - u, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - valid := true - err = handler.CryptoService.CompareHashAndData(u.Password, password) - if err != nil { - valid = false - } - - encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) -} - -type postUserPasswdRequest struct { - Password string `valid:"required"` -} - -type postUserPasswdResponse struct { - Valid bool `json:"valid"` -} - -// handleGetUser handles GET requests on /users/:id -func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - user.Password = "" - encodeJSON(w, &user, handler.Logger) -} - -// handlePutUser handles PUT requests on /users/:id -func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - var req putUserRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.Password == "" && req.Role == 0 { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Password != "" { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - if req.Role != 0 { - if tokenData.Role != portainer.AdministratorRole { - Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - if req.Role == 1 { - user.Role = portainer.AdministratorRole - } else { - user.Role = portainer.StandardUserRole - } - } - - err = handler.UserService.UpdateUser(user.ID, user) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -type putUserRequest struct { - Password string `valid:"-"` - Role int `valid:"-"` -} - -// handlePostAdminInit handles GET requests on /users/admin/check -func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - handleNotAllowed(w, []string{http.MethodGet}) - return - } - - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - Error(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) - return - } -} - -// handlePostAdminInit handles POST requests on /users/admin/init -func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) - return - } - - var req postAdminInitRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.UserByUsername("admin") - if err == portainer.ErrUserNotFound { - user := &portainer.User{ - Username: "admin", - Role: portainer.AdministratorRole, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - - err = handler.UserService.CreateUser(user) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { - Error(w, portainer.ErrAdminAlreadyInitialized, http.StatusForbidden, handler.Logger) - return - } -} - -type postAdminInitRequest struct { - Password string `valid:"required"` -} - -// handleDeleteUser handles DELETE requests on /users/:id -func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.UserService.User(portainer.UserID(userID)) - - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.UserService.DeleteUser(portainer.UserID(userID)) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePostUserResource handles POST requests on /users/:userId/resources/:resourceType -func (handler *UserHandler) handlePostUserResource(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := vars["userId"] - resourceType := vars["resourceType"] - - uid, err := strconv.Atoi(userID) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var rcType portainer.ResourceControlType - if resourceType == "container" { - rcType = portainer.ContainerResourceControl - } else if resourceType == "service" { - rcType = portainer.ServiceResourceControl - } else if resourceType == "volume" { - rcType = portainer.VolumeResourceControl - } else { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData.ID != portainer.UserID(uid) { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - var req postUserResourceRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - resource := portainer.ResourceControl{ - OwnerID: portainer.UserID(uid), - ResourceID: req.ResourceID, - AccessLevel: portainer.RestrictedResourceAccessLevel, - } - - err = handler.ResourceControlService.CreateResourceControl(req.ResourceID, &resource, rcType) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } -} - -type postUserResourceRequest struct { - ResourceID string `valid:"required"` -} - -// handleDeleteUserResource handles DELETE requests on /users/:userId/resources/:resourceType/:resourceId -func (handler *UserHandler) handleDeleteUserResource(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := vars["userId"] - resourceID := vars["resourceId"] - resourceType := vars["resourceType"] - - uid, err := strconv.Atoi(userID) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var rcType portainer.ResourceControlType - if resourceType == "container" { - rcType = portainer.ContainerResourceControl - } else if resourceType == "service" { - rcType = portainer.ServiceResourceControl - } else if resourceType == "volume" { - rcType = portainer.VolumeResourceControl - } else { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(uid) { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.DeleteResourceControl(resourceID, rcType) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/portainer.go b/api/portainer.go index 5d6bb280f..8c1bd29b4 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -41,7 +41,7 @@ type ( EndpointManagement bool `json:"endpointManagement"` } - // User represent a user account. + // User represents a user account. User struct { ID UserID `json:"Id"` Username string `json:"Username"` @@ -53,9 +53,32 @@ type ( UserID int // UserRole represents the role of a user. It can be either an administrator - // or a regular user. + // or a regular user UserRole int + // Team represents a list of user accounts. + Team struct { + ID TeamID `json:"Id"` + Name string `json:"Name"` + } + + // TeamID represents a team identifier + TeamID int + + // TeamMembership represents a membership association between a user and a team + TeamMembership struct { + ID TeamMembershipID `json:"Id"` + UserID UserID `json:"UserID"` + TeamID TeamID `json:"TeamID"` + Role MembershipRole `json:"Role"` + } + + // TeamMembershipID represents a team membership identifier + TeamMembershipID int + + // MembershipRole represents the role of a user within a team + MembershipRole int + // TokenData represents the data embedded in a JWT token. TokenData struct { ID UserID @@ -78,21 +101,46 @@ type ( TLSCertPath string `json:"TLSCert,omitempty"` TLSKeyPath string `json:"TLSKey,omitempty"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` } - // ResourceControl represent a reference to a Docker resource with specific controls + // ResourceControlID represents a resource control identifier. + ResourceControlID int + + // ResourceControl represent a reference to a Docker resource with specific access controls ResourceControl struct { - OwnerID UserID `json:"OwnerId"` - ResourceID string `json:"ResourceId"` + ID ResourceControlID `json:"Id"` + ResourceID string `json:"ResourceId"` + SubResourceIDs []string `json:"SubResourceIds"` + Type ResourceControlType `json:"Type"` + AdministratorsOnly bool `json:"AdministratorsOnly"` + + UserAccesses []UserResourceAccess `json:"UserAccesses"` + TeamAccesses []TeamResourceAccess `json:"TeamAccesses"` + + // Deprecated fields + // Deprecated: OwnerID field is deprecated in DBVersion == 2 + OwnerID UserID `json:"OwnerId"` + // Deprecated: AccessLevel field is deprecated in DBVersion == 2 AccessLevel ResourceAccessLevel `json:"AccessLevel"` } - // ResourceControlType represents a type of resource control. - // Can be one of: container, service or volume. + // ResourceControlType represents the type of resource associated to the resource control (volume, container, service). ResourceControlType int - // ResourceAccessLevel represents the level of control associated to a resource for a specific owner. - // Can be one of: full, restricted, limited. + // UserResourceAccess represents the level of control on a resource for a specific user. + UserResourceAccess struct { + UserID UserID `json:"UserId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // TeamResourceAccess represents the level of control on a resource for a specific team. + TeamResourceAccess struct { + TeamID TeamID `json:"TeamId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // ResourceAccessLevel represents the level of control associated to a resource. ResourceAccessLevel int // TLSFileType represents a type of TLS file required to connect to a Docker endpoint. @@ -128,6 +176,29 @@ type ( DeleteUser(ID UserID) error } + // TeamService represents a service for managing user data. + TeamService interface { + Team(ID TeamID) (*Team, error) + TeamByName(name string) (*Team, error) + Teams() ([]Team, error) + CreateTeam(team *Team) error + UpdateTeam(ID TeamID, team *Team) error + DeleteTeam(ID TeamID) error + } + + // TeamMembershipService represents a service for managing team membership data. + TeamMembershipService interface { + TeamMembership(ID TeamMembershipID) (*TeamMembership, error) + TeamMemberships() ([]TeamMembership, error) + TeamMembershipsByUserID(userID UserID) ([]TeamMembership, error) + TeamMembershipsByTeamID(teamID TeamID) ([]TeamMembership, error) + CreateTeamMembership(membership *TeamMembership) error + UpdateTeamMembership(ID TeamMembershipID, membership *TeamMembership) error + DeleteTeamMembership(ID TeamMembershipID) error + DeleteTeamMembershipByUserID(userID UserID) error + DeleteTeamMembershipByTeamID(teamID TeamID) error + } + // EndpointService represents a service for managing endpoint data. EndpointService interface { Endpoint(ID EndpointID) (*Endpoint, error) @@ -146,10 +217,12 @@ type ( // ResourceControlService represents a service for managing resource control data. ResourceControlService interface { - ResourceControl(resourceID string, rcType ResourceControlType) (*ResourceControl, error) - ResourceControls(rcType ResourceControlType) ([]ResourceControl, error) - CreateResourceControl(resourceID string, rc *ResourceControl, rcType ResourceControlType) error - DeleteResourceControl(resourceID string, rcType ResourceControlType) error + ResourceControl(ID ResourceControlID) (*ResourceControl, error) + ResourceControlByResourceID(resourceID string) (*ResourceControl, error) + ResourceControls() ([]ResourceControl, error) + CreateResourceControl(rc *ResourceControl) error + UpdateResourceControl(ID ResourceControlID, resourceControl *ResourceControl) error + DeleteResourceControl(ID ResourceControlID) error } // CryptoService represents a service for encrypting/hashing data. @@ -178,10 +251,10 @@ type ( ) const ( - // APIVersion is the version number of Portainer API. + // APIVersion is the version number of the Portainer API. APIVersion = "1.12.4" - // DBVersion is the version number of Portainer database. - DBVersion = 1 + // DBVersion is the version number of the Portainer database. + DBVersion = 2 ) const ( @@ -193,6 +266,14 @@ const ( TLSFileKey ) +const ( + _ MembershipRole = iota + // TeamLeader represents a leader role inside a team + TeamLeader + // TeamMember represents a member role inside a team + TeamMember +) + const ( _ UserRole = iota // AdministratorRole represents an administrator user role @@ -202,17 +283,17 @@ const ( ) const ( - _ ResourceControlType = iota - // ContainerResourceControl represents a resource control for a container - ContainerResourceControl - // ServiceResourceControl represents a resource control for a service - ServiceResourceControl - // VolumeResourceControl represents a resource control for a volume - VolumeResourceControl + _ ResourceAccessLevel = iota + // ReadWriteAccessLevel represents an access level with read-write permissions on a resource + ReadWriteAccessLevel ) const ( - _ ResourceAccessLevel = iota - // RestrictedResourceAccessLevel represents a restricted access level on a resource (private ownership) - RestrictedResourceAccessLevel + _ ResourceControlType = iota + // ContainerResourceControl represents a resource control associated to a Docker container + ContainerResourceControl + // ServiceResourceControl represents a resource control associated to a Docker service + ServiceResourceControl + // VolumeResourceControl represents a resource control associated to a Docker volume + VolumeResourceControl ) diff --git a/app/app.js b/app/app.js index fb3ce0604..36d9f693e 100644 --- a/app/app.js +++ b/app/app.js @@ -5,7 +5,7 @@ angular.module('portainer.helpers', []); angular.module('portainer', [ 'ui.bootstrap', 'ui.router', - 'ui.select', + 'isteven-multi-select', 'ngCookies', 'ngSanitize', 'ngFileUpload', @@ -20,6 +20,8 @@ angular.module('portainer', [ 'portainer.services', 'auth', 'dashboard', + 'common.accesscontrol.panel', + 'common.accesscontrol.form', 'container', 'containerConsole', 'containerLogs', @@ -47,9 +49,12 @@ angular.module('portainer', [ 'stats', 'swarm', 'task', + 'team', + 'teams', 'templates', 'user', 'users', + 'volume', 'volumes']) .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) { 'use strict'; @@ -508,6 +513,19 @@ angular.module('portainer', [ } } }) + .state('volume', { + url: '^/volumes/:id', + views: { + 'content@': { + templateUrl: 'app/components/volume/volume.html', + controller: 'VolumeController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('users', { url: '/users/', views: { @@ -534,6 +552,32 @@ angular.module('portainer', [ } } }) + .state('teams', { + url: '/teams/', + views: { + 'content@': { + templateUrl: 'app/components/teams/teams.html', + controller: 'TeamsController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('team', { + url: '^/teams/:id', + views: { + 'content@': { + templateUrl: 'app/components/team/team.html', + controller: 'TeamController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('swarm', { url: '/swarm/', views: { @@ -581,6 +625,9 @@ angular.module('portainer', [ .constant('CONFIG_ENDPOINT', 'api/settings') .constant('AUTH_ENDPOINT', 'api/auth') .constant('USERS_ENDPOINT', 'api/users') + .constant('TEAMS_ENDPOINT', 'api/teams') + .constant('TEAM_MEMBERSHIPS_ENDPOINT', 'api/team_memberships') + .constant('RESOURCE_CONTROL_ENDPOINT', 'api/resource_controls') .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') .constant('TEMPLATES_ENDPOINT', 'api/templates') .constant('PAGINATION_MAX_ITEMS', 10) diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js index 39b64d5c4..d6edeff95 100644 --- a/app/components/auth/authController.js +++ b/app/components/auth/authController.js @@ -26,14 +26,14 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au .then(function success() { $state.go('dashboard'); }, function error(err) { - Notifications.error("Failure", err, 'Unable to connect to the Docker endpoint'); + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); }); } else { $state.go('endpointInit'); } }, function error(err) { - Notifications.error("Failure", err, 'Unable to retrieve endpoints'); + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); }); } else { Users.checkAdminUser({}, function () {}, @@ -41,7 +41,7 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au if (e.status === 404) { $scope.initPassword = true; } else { - Notifications.error("Failure", e, 'Unable to verify administrator account existence'); + Notifications.error('Failure', e, 'Unable to verify administrator account existence'); } }); } @@ -98,7 +98,7 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au .then(function success() { $state.go('dashboard'); }, function error(err) { - Notifications.error("Failure", err, 'Unable to connect to the Docker endpoint'); + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); }); } else if (data.length === 0 && userDetails.role === 1) { diff --git a/app/components/common/accessControlForm/accessControlForm.html b/app/components/common/accessControlForm/accessControlForm.html new file mode 100644 index 000000000..70961858e --- /dev/null +++ b/app/components/common/accessControlForm/accessControlForm.html @@ -0,0 +1,126 @@ +
    +
    + Access control +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + + +
    +
    + + + You have not yet created any user. Head over the users view to manage users. + + +
    +
    + +
    diff --git a/app/components/common/accessControlForm/accessControlFormController.js b/app/components/common/accessControlForm/accessControlFormController.js new file mode 100644 index 000000000..2656b900e --- /dev/null +++ b/app/components/common/accessControlForm/accessControlFormController.js @@ -0,0 +1,55 @@ +angular.module('common.accesscontrol.form', []) +.controller('AccessControlFormController', ['$q', '$scope', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'ControllerDataPipeline', +function ($q, $scope, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, ControllerDataPipeline) { + + $scope.availableTeams = []; + $scope.availableUsers = []; + + $scope.formValues = { + enableAccessControl: true, + Ownership_Teams: [], + Ownership_Users: [], + Ownership: 'private' + }; + + $scope.synchronizeFormData = function() { + ControllerDataPipeline.setAccessControlFormData($scope.formValues.enableAccessControl, + $scope.formValues.Ownership, $scope.formValues.Ownership_Users, $scope.formValues.Ownership_Teams); + }; + + function initAccessControlForm() { + $('#loadingViewSpinner').show(); + + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + + if (isAdmin) { + $scope.formValues.Ownership = 'administrators'; + } + + $q.all({ + availableTeams: UserService.userTeams(userDetails.ID), + availableUsers: isAdmin ? UserService.users(false) : [] + }) + .then(function success(data) { + $scope.availableUsers = data.availableUsers; + + var availableTeams = data.availableTeams; + $scope.availableTeams = availableTeams; + if (!isAdmin && availableTeams.length === 1) { + $scope.formValues.Ownership_Teams = availableTeams; + } + + $scope.synchronizeFormData(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve access control information'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initAccessControlForm(); +}]); diff --git a/app/components/common/accessControlPanel/accessControlPanel.html b/app/components/common/accessControlPanel/accessControlPanel.html new file mode 100644 index 000000000..c4339d38b --- /dev/null +++ b/app/components/common/accessControlPanel/accessControlPanel.html @@ -0,0 +1,178 @@ +
    +
    + + + +
    {{ var|key: '=' }} {{ var|value: '=' }}
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Ownership + + + public + + + + {{ resourceControl.Ownership }} + + + + +
    + + Access control on this resource is inherited from the following service: {{ resourceControl.ResourceId | truncate }} + +
    + + Access control on this resource is inherited from the following container: {{ resourceControl.ResourceId | truncate }} + +
    Authorized users + {{user.Username}}{{$last ? '' : ', '}} +
    Authorized teams + {{team.Name}}{{$last ? '' : ', '}} +
    + Change ownership +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + Teams + + You have not yet created any team. Head over the teams view to manage user teams. + + +
    + Users + + You have not yet created any user. Head over the users view to manage users. + + +
    +
    + Cancel + Update ownership + {{ state.formValidationError }} +
    +
    +
    +
    +
    +
    diff --git a/app/components/common/accessControlPanel/accessControlPanelController.js b/app/components/common/accessControlPanel/accessControlPanelController.js new file mode 100644 index 000000000..8283b6d97 --- /dev/null +++ b/app/components/common/accessControlPanel/accessControlPanelController.js @@ -0,0 +1,158 @@ +angular.module('common.accesscontrol.panel', []) +.controller('AccessControlPanelController', ['$q', '$scope', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'ControllerDataPipeline', 'FormValidator', +function ($q, $scope, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, ControllerDataPipeline, FormValidator) { + + $scope.state = { + displayAccessControlPanel: false, + canEditOwnership: false, + editOwnership: false, + formValidationError: '' + }; + + $scope.formValues = { + Ownership: 'public', + Ownership_Users: [], + Ownership_Teams: [] + }; + + $scope.authorizedUsers = []; + $scope.availableUsers = []; + $scope.authorizedTeams = []; + $scope.availableTeams = []; + + $scope.confirmUpdateOwnership = function (force) { + if (!validateForm()) { + return; + } + ModalService.confirmAccessControlUpdate(function (confirmed) { + if(!confirmed) { return; } + updateOwnership(); + }); + }; + + function processOwnershipFormValues() { + var userIds = []; + angular.forEach($scope.formValues.Ownership_Users, function(user) { + userIds.push(user.Id); + }); + var teamIds = []; + angular.forEach($scope.formValues.Ownership_Teams, function(team) { + teamIds.push(team.Id); + }); + var administratorsOnly = $scope.formValues.Ownership === 'administrators' ? true : false; + + return { + ownership: $scope.formValues.Ownership, + authorizedUserIds: administratorsOnly ? [] : userIds, + authorizedTeamIds: administratorsOnly ? [] : teamIds, + administratorsOnly: administratorsOnly + }; + } + + function validateForm() { + $scope.state.formValidationError = ''; + var error = ''; + + var accessControlData = { + ownership: $scope.formValues.Ownership, + authorizedUsers: $scope.formValues.Ownership_Users, + authorizedTeams: $scope.formValues.Ownership_Teams + }; + var isAdmin = $scope.isAdmin; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + function updateOwnership() { + $('#loadingViewSpinner').show(); + + var accessControlData = ControllerDataPipeline.getAccessControlData(); + var resourceId = accessControlData.resourceId; + var ownershipParameters = processOwnershipFormValues(); + + ResourceControlService.applyResourceControlChange(accessControlData.resourceType, resourceId, + $scope.resourceControl, ownershipParameters) + .then(function success(data) { + Notifications.success('Access control successfully updated'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update access control'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + function initAccessControlPanel() { + $('#loadingViewSpinner').show(); + + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + var userId = userDetails.ID; + $scope.isAdmin = isAdmin; + + var accessControlData = ControllerDataPipeline.getAccessControlData(); + var resourceControl = accessControlData.resourceControl; + $scope.resourceType = accessControlData.resourceType; + $scope.resourceControl = resourceControl; + + if (isAdmin) { + if (resourceControl) { + $scope.formValues.Ownership = resourceControl.Ownership === 'private' ? 'restricted' : resourceControl.Ownership; + } else { + $scope.formValues.Ownership = 'public'; + } + } else { + $scope.formValues.Ownership = 'public'; + } + + ResourceControlService.retrieveOwnershipDetails(resourceControl) + .then(function success(data) { + $scope.authorizedUsers = data.authorizedUsers; + $scope.authorizedTeams = data.authorizedTeams; + return ResourceControlService.retrieveUserPermissionsOnResource(userId, isAdmin, resourceControl); + }) + .then(function success(data) { + $scope.state.canEditOwnership = data.isPartOfRestrictedUsers || data.isLeaderOfAnyRestrictedTeams; + $scope.state.canChangeOwnershipToTeam = data.isPartOfRestrictedUsers; + + return $q.all({ + availableUsers: isAdmin ? UserService.users(false) : [], + availableTeams: isAdmin || data.isPartOfRestrictedUsers ? UserService.userTeams(userId) : [] + }); + }) + .then(function success(data) { + $scope.availableUsers = data.availableUsers; + angular.forEach($scope.availableUsers, function(user) { + var found = _.find($scope.authorizedUsers, { Id: user.Id }); + if (found) { + user.selected = true; + } + }); + $scope.availableTeams = data.availableTeams; + angular.forEach(data.availableTeams, function(team) { + var found = _.find($scope.authorizedTeams, { Id: team.Id }); + if (found) { + team.selected = true; + } + }); + if (data.availableTeams.length === 1) { + $scope.formValues.Ownership_Teams.push(data.availableTeams[0]); + } + $scope.state.displayAccessControlPanel = true; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve access control information'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initAccessControlPanel(); +}]); diff --git a/app/components/container/container.html b/app/components/container/container.html index d87fab614..735fb8f1a 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -87,6 +87,8 @@
    +
    +
    diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 66b3b25cc..d739a6c04 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -1,6 +1,6 @@ angular.module('container', []) -.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService', -function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ImageHelper, Network, Notifications, Pagination, ModalService) { +.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerService', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService', 'ControllerDataPipeline', +function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerService, ImageHelper, Network, Notifications, Pagination, ModalService, ControllerDataPipeline) { $scope.activityTime = 0; $scope.portBindings = []; $scope.config = { @@ -17,25 +17,27 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima var update = function () { $('#loadingViewSpinner').show(); Container.get({id: $stateParams.id}, function (d) { - $scope.container = d; + var container = new ContainerDetailsViewModel(d); + $scope.container = container; + ControllerDataPipeline.setAccessControlData('container', $stateParams.id, container.ResourceControl); $scope.container.edit = false; - $scope.container.newContainerName = $filter('trimcontainername')(d.Name); + $scope.container.newContainerName = $filter('trimcontainername')(container.Name); - if (d.State.Running) { - $scope.activityTime = moment.duration(moment(d.State.StartedAt).utc().diff(moment().utc())).humanize(); - } else if (d.State.Status === "created") { - $scope.activityTime = moment.duration(moment(d.Created).utc().diff(moment().utc())).humanize(); + if (container.State.Running) { + $scope.activityTime = moment.duration(moment(container.State.StartedAt).utc().diff(moment().utc())).humanize(); + } else if (container.State.Status === 'created') { + $scope.activityTime = moment.duration(moment(container.Created).utc().diff(moment().utc())).humanize(); } else { - $scope.activityTime = moment.duration(moment().utc().diff(moment(d.State.FinishedAt).utc())).humanize(); + $scope.activityTime = moment.duration(moment().utc().diff(moment(container.State.FinishedAt).utc())).humanize(); } $scope.portBindings = []; - if (d.NetworkSettings.Ports) { - angular.forEach(Object.keys(d.NetworkSettings.Ports), function(portMapping) { - if (d.NetworkSettings.Ports[portMapping]) { + if (container.NetworkSettings.Ports) { + angular.forEach(Object.keys(container.NetworkSettings.Ports), function(portMapping) { + if (container.NetworkSettings.Ports[portMapping]) { var mapping = {}; mapping.container = portMapping; - mapping.host = d.NetworkSettings.Ports[portMapping][0].HostIp + ':' + d.NetworkSettings.Ports[portMapping][0].HostPort; + mapping.host = container.NetworkSettings.Ports[portMapping][0].HostIp + ':' + container.NetworkSettings.Ports[portMapping][0].HostPort; $scope.portBindings.push(mapping); } }); @@ -43,7 +45,7 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').hide(); }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve container info"); + Notifications.error('Failure', e, 'Unable to retrieve container info'); }); }; @@ -51,10 +53,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.start({id: $scope.container.Id}, {}, function (d) { update(); - Notifications.success("Container started", $stateParams.id); + Notifications.success('Container started', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to start container"); + Notifications.error('Failure', e, 'Unable to start container'); }); }; @@ -62,10 +64,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.stop({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container stopped", $stateParams.id); + Notifications.success('Container stopped', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to stop container"); + Notifications.error('Failure', e, 'Unable to stop container'); }); }; @@ -73,10 +75,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.kill({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container killed", $stateParams.id); + Notifications.success('Container killed', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to kill container"); + Notifications.error('Failure', e, 'Unable to kill container'); }); }; @@ -88,11 +90,11 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) { $('#createImageSpinner').hide(); update(); - Notifications.success("Container commited", $stateParams.id); + Notifications.success('Container commited', $stateParams.id); }, function (e) { $('#createImageSpinner').hide(); update(); - Notifications.error("Failure", e, "Unable to commit container"); + Notifications.error('Failure', e, 'Unable to commit container'); }); }; @@ -100,10 +102,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.pause({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container paused", $stateParams.id); + Notifications.success('Container paused', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to pause container"); + Notifications.error('Failure', e, 'Unable to pause container'); }); }; @@ -111,10 +113,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.unpause({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container unpaused", $stateParams.id); + Notifications.success('Container unpaused', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to unpause container"); + Notifications.error('Failure', e, 'Unable to unpause container'); }); }; @@ -138,18 +140,16 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $scope.remove = function(cleanAssociatedVolumes) { $('#loadingViewSpinner').show(); - Container.remove({id: $stateParams.id, v: (cleanAssociatedVolumes) ? 1 : 0, force: true}, function (d) { - if (d.message) { - $('#loadingViewSpinner').hide(); - Notifications.error("Failure", d, "Unable to remove container"); - } - else { - $state.go('containers', {}, {reload: true}); - Notifications.success("Container removed", $stateParams.id); - } - }, function (e) { - update(); - Notifications.error("Failure", e, "Unable to remove container"); + ContainerService.remove($scope.container, cleanAssociatedVolumes) + .then(function success() { + Notifications.success('Container successfully removed'); + $state.go('containers', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); }); }; @@ -157,24 +157,24 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.restart({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container restarted", $stateParams.id); + Notifications.success('Container restarted', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to restart container"); + Notifications.error('Failure', e, 'Unable to restart container'); }); }; $scope.renameContainer = function () { Container.rename({id: $stateParams.id, 'name': $scope.container.newContainerName}, function (d) { - if (d.message) { + if (container.message) { $scope.container.newContainerName = $scope.container.Name; - Notifications.error("Unable to rename container", {}, d.message); + Notifications.error('Unable to rename container', {}, container.message); } else { $scope.container.Name = $scope.container.newContainerName; - Notifications.success("Container successfully renamed", d.name); + Notifications.success('Container successfully renamed', container.name); } }, function (e) { - Notifications.error("Failure", e, 'Unable to rename container'); + Notifications.error('Failure', e, 'Unable to rename container'); }); $scope.container.edit = false; }; @@ -182,17 +182,17 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) { $('#loadingViewSpinner').show(); Network.disconnect({id: networkId}, { Container: $stateParams.id, Force: false }, function (d) { - if (d.message) { + if (container.message) { $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to disconnect container from network"); + Notifications.error('Error', d, 'Unable to disconnect container from network'); } else { $('#loadingViewSpinner').hide(); - Notifications.success("Container left network", $stateParams.id); + Notifications.success('Container left network', $stateParams.id); $state.go('container', {id: $stateParams.id}, {reload: true}); } }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to disconnect container from network"); + Notifications.error('Failure', e, 'Unable to disconnect container from network'); }); }; diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index 9b731f9a7..d00881c6f 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -17,7 +17,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp Container.get({id: $stateParams.id}, function(d) { $scope.container = d; if (d.message) { - Notifications.error("Error", d, 'Unable to retrieve container details'); + Notifications.error('Error', d, 'Unable to retrieve container details'); $('#loadingViewSpinner').hide(); } else { Image.get({id: d.Image}, function(imgData) { @@ -26,12 +26,12 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp $scope.state.loaded = true; $('#loadingViewSpinner').hide(); }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve image details'); + Notifications.error('Failure', e, 'Unable to retrieve image details'); $('#loadingViewSpinner').hide(); }); } }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve container details'); + Notifications.error('Failure', e, 'Unable to retrieve container details'); $('#loadingViewSpinner').hide(); }); @@ -45,13 +45,13 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp AttachStdout: true, AttachStderr: true, Tty: true, - Cmd: $scope.state.command.replace(" ", ",").split(",") + Cmd: $scope.state.command.replace(' ', ',').split(',') }; Container.exec(execConfig, function(d) { if (d.message) { $('#loadConsoleSpinner').hide(); - Notifications.error("Error", {}, d.message); + Notifications.error('Error', {}, d.message); } else { var execId = d.Id; resizeTTY(execId, termHeight, termWidth); @@ -65,7 +65,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp } }, function (e) { $('#loadConsoleSpinner').hide(); - Notifications.error("Failure", e, 'Unable to start an exec instance'); + Notifications.error('Failure', e, 'Unable to start an exec instance'); }); }; @@ -86,7 +86,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp Notifications.error('Error', {}, 'Unable to resize TTY'); } }, function (e) { - Notifications.error("Failure", {}, 'Unable to resize TTY'); + Notifications.error('Failure', {}, 'Unable to resize TTY'); }); }, 2000); diff --git a/app/components/containerLogs/containerLogsController.js b/app/components/containerLogs/containerLogsController.js index cf92fbb72..86c2eb398 100644 --- a/app/components/containerLogs/containerLogsController.js +++ b/app/components/containerLogs/containerLogsController.js @@ -14,7 +14,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) { $('#loadingViewSpinner').hide(); }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve container info"); + Notifications.error('Failure', e, 'Unable to retrieve container info'); }); function getLogs() { @@ -60,7 +60,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) { getLogs(); var logIntervalId = window.setInterval(getLogs, 5000); - $scope.$on("$destroy", function () { + $scope.$on('$destroy', function () { // clearing interval when view changes clearInterval(logIntervalId); }); diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 2f85dd83e..9d5a86722 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -91,10 +91,10 @@ - + Ownership - - + + @@ -118,34 +118,9 @@ - - - - - Public service - - - Public - - - - - - Private service - - - Private - Switch to public - - - - - - Private service (owner: {{ container.Owner }}) - - - Private (owner: {{ container.Owner }}) - Switch to public - + + + {{ container.ResourceControl.Ownership ? container.ResourceControl.Ownership : container.ResourceControl.Ownership = 'public' }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 3d11631f2..01c50acb9 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('containers', []) - .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', 'EndpointProvider', - function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService, EndpointProvider) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', + function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); $scope.state.displayAll = Settings.displayAll; @@ -20,51 +20,8 @@ angular.module('containers', []) $scope.cleanAssociatedVolumes = false; - function removeContainerResourceControl(container) { - volumeResourceControlQueries = []; - angular.forEach(container.Mounts, function (volume) { - volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(container.Metadata.ResourceControl.OwnerId, volume.Name)); - }); - - $q.all(volumeResourceControlQueries) - .then(function success() { - return ResourceControlService.removeContainerResourceControl(container.Metadata.ResourceControl.OwnerId, container.Id); - }) - .then(function success() { - delete container.Metadata.ResourceControl; - Notifications.success('Ownership changed to public', container.Id); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to change container ownership"); - }); - } - - $scope.switchOwnership = function(container) { - ModalService.confirmContainerOwnershipChange(function (confirmed) { - if(!confirmed) { return; } - removeContainerResourceControl(container); - }); - }; - - function mapUsersToContainers(users) { - angular.forEach($scope.containers, function (container) { - if (container.Metadata) { - var containerRC = container.Metadata.ResourceControl; - if (containerRC && containerRC.OwnerId !== $scope.user.ID) { - angular.forEach(users, function (user) { - if (containerRC.OwnerId === user.Id) { - container.Owner = user.Username; - } - }); - } - } - }); - } - var update = function (data) { $('#loadContainersSpinner').show(); - var userDetails = Authentication.getUserDetails(); - $scope.user = userDetails; $scope.state.selectedItemCount = 0; Container.query(data, function (d) { var containers = d; @@ -87,23 +44,10 @@ angular.module('containers', []) } return model; }); - if (userDetails.role === 1) { - UserService.users() - .then(function success(data) { - mapUsersToContainers(data); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve users"); - }) - .finally(function final() { - $('#loadContainersSpinner').hide(); - }); - } else { - $('#loadContainersSpinner').hide(); - } + $('#loadContainersSpinner').hide(); }, function (e) { $('#loadContainersSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve containers"); + Notifications.error('Failure', e, 'Unable to retrieve containers'); $scope.containers = []; }); }; @@ -123,56 +67,44 @@ angular.module('containers', []) counter = counter + 1; if (action === Container.start) { action({id: c.Id}, {}, function (d) { - Notifications.success("Container " + msg, c.Id); + Notifications.success('Container ' + msg, c.Id); complete(); }, function (e) { - Notifications.error("Failure", e, "Unable to start container"); + Notifications.error('Failure', e, 'Unable to start container'); complete(); }); } else if (action === Container.remove) { - action({id: c.Id, v: ($scope.cleanAssociatedVolumes) ? 1 : 0, force: true}, function (d) { - if (d.message) { - Notifications.error("Error", d, "Unable to remove container"); - } - else { - if (c.Metadata && c.Metadata.ResourceControl) { - ResourceControlService.removeContainerResourceControl(c.Metadata.ResourceControl.OwnerId, c.Id) - .then(function success() { - Notifications.success("Container " + msg, c.Id); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to remove container ownership"); - }); - } else { - Notifications.success("Container " + msg, c.Id); - } - } - complete(); - }, function (e) { - Notifications.error("Failure", e, 'Unable to remove container'); + ContainerService.remove(c, $scope.cleanAssociatedVolumes) + .then(function success() { + Notifications.success('Container successfully removed'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { complete(); }); } else if (action === Container.pause) { action({id: c.Id}, function (d) { if (d.message) { - Notifications.success("Container is already paused", c.Id); + Notifications.success('Container is already paused', c.Id); } else { - Notifications.success("Container " + msg, c.Id); + Notifications.success('Container ' + msg, c.Id); } complete(); }, function (e) { - Notifications.error("Failure", e, 'Unable to pause container'); + Notifications.error('Failure', e, 'Unable to pause container'); complete(); }); } else { action({id: c.Id}, function (d) { - Notifications.success("Container " + msg, c.Id); + Notifications.success('Container ' + msg, c.Id); complete(); }, function (e) { - Notifications.error("Failure", e, 'An error occured'); + Notifications.error('Failure', e, 'An error occured'); complete(); }); @@ -207,31 +139,31 @@ angular.module('containers', []) }; $scope.startAction = function () { - batch($scope.containers, Container.start, "Started"); + batch($scope.containers, Container.start, 'Started'); }; $scope.stopAction = function () { - batch($scope.containers, Container.stop, "Stopped"); + batch($scope.containers, Container.stop, 'Stopped'); }; $scope.restartAction = function () { - batch($scope.containers, Container.restart, "Restarted"); + batch($scope.containers, Container.restart, 'Restarted'); }; $scope.killAction = function () { - batch($scope.containers, Container.kill, "Killed"); + batch($scope.containers, Container.kill, 'Killed'); }; $scope.pauseAction = function () { - batch($scope.containers, Container.pause, "Paused"); + batch($scope.containers, Container.pause, 'Paused'); }; $scope.unpauseAction = function () { - batch($scope.containers, Container.unpause, "Unpaused"); + batch($scope.containers, Container.unpause, 'Unpaused'); }; $scope.removeAction = function () { - batch($scope.containers, Container.remove, "Removed"); + batch($scope.containers, Container.remove, 'Removed'); }; $scope.confirmRemoveAction = function () { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 09808ad01..3f8bfe73a 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,11 +1,10 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createContainer', []) -.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', -function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'ControllerDataPipeline', 'FormValidator', +function ($q, $scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, ControllerDataPipeline, FormValidator) { $scope.formValues = { - Ownership: $scope.applicationState.application.authentication ? 'private' : '', alwaysPull: true, Console: 'none', Volumes: [], @@ -17,7 +16,9 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai IPv6: '' }; - $scope.imageConfig = {}; + $scope.state = { + formValidationError: '' + }; $scope.config = { Image: '', @@ -81,7 +82,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.removeExtraHost = function(index) { $scope.formValues.ExtraHosts.splice(index, 1); }; - + $scope.addDevice = function() { $scope.config.HostConfig.Devices.push({ pathOnHost: '', pathInContainer: '' }); }; @@ -90,98 +91,6 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.config.HostConfig.Devices.splice(index, 1); }; - Config.$promise.then(function (c) { - var containersToHideLabels = c.hiddenLabels; - - Volume.query({}, function (d) { - $scope.availableVolumes = d.Volumes; - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve volumes"); - }); - - Network.query({}, function (d) { - var networks = d; - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - networks = d.filter(function (network) { - if (network.Scope === 'global') { - return network; - } - }); - $scope.globalNetworkCount = networks.length; - networks.push({Name: "bridge"}); - networks.push({Name: "host"}); - networks.push({Name: "none"}); - } - networks.push({Name: "container"}); - $scope.availableNetworks = networks; - if (!_.find(networks, {'Name': 'bridge'})) { - $scope.config.HostConfig.NetworkMode = 'nat'; - } - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve networks"); - }); - - Container.query({}, function (d) { - var containers = d; - if (containersToHideLabels) { - containers = ContainerHelper.hideContainers(d, containersToHideLabels); - } - $scope.runningContainers = containers; - }, function(e) { - Notifications.error("Failure", e, "Unable to retrieve running containers"); - }); - }); - - function startContainer(containerID) { - Container.start({id: containerID}, {}, function (cd) { - if (cd.message) { - $('#createContainerSpinner').hide(); - Notifications.error('Error', {}, cd.message); - } else { - $('#createContainerSpinner').hide(); - Notifications.success('Container Started', containerID); - $state.go('containers', {}, {reload: true}); - } - }, function (e) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", e, 'Unable to start container'); - }); - } - - function createContainer(config) { - Container.create(config, function (d) { - if (d.message) { - $('#createContainerSpinner').hide(); - Notifications.error('Error', {}, d.message); - } else { - if ($scope.formValues.Ownership === 'private') { - ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, d.Id) - .then(function success() { - startContainer(d.Id); - }) - .catch(function error(err) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", err, 'Unable to apply resource control on container'); - }); - } else { - startContainer(d.Id); - } - } - }, function (e) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create container'); - }); - } - - function pullImageAndCreateContainer(config) { - Image.create($scope.imageConfig, function (data) { - createContainer(config); - }, function (e) { - $('#createContainerSpinner').hide(); - Notifications.error('Failure', e, 'Unable to pull image'); - }); - } - function prepareImageConfig(config) { var image = config.Image; var registry = $scope.formValues.Registry; @@ -194,7 +103,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai var bindings = {}; config.HostConfig.PortBindings.forEach(function (portBinding) { if (portBinding.containerPort) { - var key = portBinding.containerPort + "/" + portBinding.protocol; + var key = portBinding.containerPort + '/' + portBinding.protocol; var binding = {}; if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) { var hostAndPort = portBinding.hostPort.split(':'); @@ -230,7 +139,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai var env = []; config.Env.forEach(function (v) { if (v.name && v.value) { - env.push(v.name + "=" + v.value); + env.push(v.name + '=' + v.value); } }); config.Env = env; @@ -295,7 +204,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); config.Labels = labels; } - + function prepareDevices(config) { var path = []; config.HostConfig.Devices.forEach(function (p) { @@ -303,10 +212,10 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai if(p.pathInContainer === '') { p.pathInContainer = p.pathOnHost; } - path.push({PathOnHost:p.pathOnHost,PathInContainer:p.pathInContainer,CgroupPermissions:'rwm'}); + path.push({PathOnHost:p.pathOnHost,PathInContainer:p.pathInContainer,CgroupPermissions:'rwm'}); } }); - config.HostConfig.Devices = path; + config.HostConfig.Devices = path; } function prepareConfiguration() { @@ -323,13 +232,100 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai return config; } - $scope.create = function () { - var config = prepareConfiguration(); - $('#createContainerSpinner').show(); - if ($scope.formValues.alwaysPull) { - pullImageAndCreateContainer(config); - } else { - createContainer(config); + function initView() { + Config.$promise.then(function (c) { + var containersToHideLabels = c.hiddenLabels; + + Volume.query({}, function (d) { + $scope.availableVolumes = d.Volumes; + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve volumes'); + }); + + Network.query({}, function (d) { + var networks = d; + if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + networks = d.filter(function (network) { + if (network.Scope === 'global') { + return network; + } + }); + $scope.globalNetworkCount = networks.length; + networks.push({Name: 'bridge'}); + networks.push({Name: 'host'}); + networks.push({Name: 'none'}); + } + networks.push({Name: 'container'}); + $scope.availableNetworks = networks; + if (!_.find(networks, {'Name': 'bridge'})) { + $scope.config.HostConfig.NetworkMode = 'nat'; + } + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve networks'); + }); + + Container.query({}, function (d) { + var containers = d; + if (containersToHideLabels) { + containers = ContainerHelper.hideContainers(d, containersToHideLabels); + } + $scope.runningContainers = containers; + }, function(e) { + Notifications.error('Failure', e, 'Unable to retrieve running containers'); + }); + }); + } + + 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 () { + $('#createContainerSpinner').show(); + + var accessControlData = ControllerDataPipeline.getAccessControlFormData(); + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createContainerSpinner').hide(); + return; + } + + var config = prepareConfiguration(); + createContainer(config, accessControlData); }; + + function createContainer(config, accessControlData) { + $q.when($scope.formValues.alwaysPull ? ImageService.pullImage($scope.config.Image, $scope.formValues.Registry) : null) + .then(function success() { + return ContainerService.createAndStartContainer(config); + }) + .then(function success(data) { + var containerIdentifier = data.Id; + var userId = Authentication.getUserDetails().ID; + return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Container successfully created'); + $state.go('containers', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create container'); + }) + .finally(function final() { + $('#createContainerSpinner').hide(); + }); + } + + initView(); + }]); diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 530ad0f02..e931126c9 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -107,29 +107,9 @@
    -
    - Access control -
    - -
    -
    - -
    - - -
    -
    -
    - + +
    +
    Actions @@ -139,6 +119,7 @@ Cancel + {{ state.formValidationError }}
    @@ -532,7 +513,7 @@
    - +
    diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js index 14a8b2143..10aaf9049 100644 --- a/app/components/createNetwork/createNetworkController.js +++ b/app/components/createNetwork/createNetworkController.js @@ -44,13 +44,13 @@ function ($scope, $state, Notifications, Network) { $('#createNetworkSpinner').hide(); Notifications.error('Unable to create network', {}, d.message); } else { - Notifications.success("Network created", d.Id); + Notifications.success('Network created', d.Id); $('#createNetworkSpinner').hide(); $state.go('networks', {}, {reload: true}); } }, function (e) { $('#createNetworkSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create network'); + Notifications.error('Failure', e, 'Unable to create network'); }); } diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index a6eb33590..02924bf44 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -1,11 +1,10 @@ // @@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', ['$scope', '$state', 'Service', 'ServiceHelper', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', -function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, Authentication, ResourceControlService, Notifications) { +.controller('CreateServiceController', ['$scope', '$state', 'Service', 'ServiceHelper', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'ControllerDataPipeline', 'FormValidator', +function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, Authentication, ResourceControlService, Notifications, ControllerDataPipeline, FormValidator) { $scope.formValues = { - Ownership: $scope.applicationState.application.authentication ? 'private' : '', Name: '', Image: '', Registry: '', @@ -28,6 +27,10 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, FailureAction: 'pause' }; + $scope.state = { + formValidationError: '' + }; + $scope.addPortBinding = function() { $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' }); }; @@ -121,7 +124,7 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, } function commandToArray(cmd) { - var tokens = [].concat.apply([], cmd.split('"').map(function(v,i) { + var tokens = [].concat.apply([], cmd.split('\'').map(function(v,i) { return i%2 ? v : v.split(' '); })).filter(Boolean); return tokens; @@ -146,7 +149,7 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, var env = []; input.Env.forEach(function (v) { if (v.name) { - env.push(v.name + "=" + v.value); + env.push(v.name + '=' + v.value); } }); config.TaskTemplate.ContainerSpec.Env = env; @@ -231,49 +234,70 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, return config; } - function createNewService(config) { - Service.create(config, function (d) { - if ($scope.formValues.Ownership === 'private') { - ResourceControlService.setServiceResourceControl(Authentication.getUserDetails().ID, d.ID) - .then(function success() { - $('#createServiceSpinner').hide(); - Notifications.success('Service created', d.ID); - $state.go('services', {}, {reload: true}); - }) - .catch(function error(err) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", err, 'Unable to apply resource control on service'); - }); - } else { - $('#createServiceSpinner').hide(); - Notifications.success('Service created', d.ID); - $state.go('services', {}, {reload: true}); - } - }, function (e) { + function createNewService(config, accessControlData) { + Service.create(config).$promise + .then(function success(data) { + var serviceIdentifier = data.ID; + var userId = Authentication.getUserDetails().ID; + return ResourceControlService.applyResourceControl('service', serviceIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Service successfully created'); + $state.go('services', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create service'); + }) + .finally(function final() { $('#createServiceSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create service'); }); } + 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 createService() { $('#createServiceSpinner').show(); + + var accessControlData = ControllerDataPipeline.getAccessControlFormData(); + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createServiceSpinner').hide(); + return; + } + var config = prepareConfiguration(); - createNewService(config); + createNewService(config, accessControlData); }; - Volume.query({}, function (d) { - $scope.availableVolumes = d.Volumes; - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve volumes"); - }); - - Network.query({}, function (d) { - $scope.availableNetworks = d.filter(function (network) { - if (network.Scope === 'swarm') { - return network; - } + function initView() { + Volume.query({}, function (d) { + $scope.availableVolumes = d.Volumes; + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve volumes'); }); - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve networks"); - }); + + Network.query({}, function (d) { + $scope.availableNetworks = d.filter(function (network) { + if (network.Scope === 'swarm') { + return network; + } + }); + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve networks'); + }); + } + + initView(); }]); diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index 941804e9e..8dd1c637c 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -108,29 +108,9 @@ -
    - Access control -
    - -
    -
    - -
    - - -
    -
    -
    - + +
    +
    Actions @@ -140,6 +120,7 @@ Cancel + {{ state.formValidationError }}
    @@ -251,7 +232,7 @@
    - +
    -
    - Access control -
    - -
    -
    - -
    - - -
    -
    -
    - + +
    +
    Actions @@ -96,6 +76,7 @@ Cancel + {{ state.formValidationError }}
    diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 3ece5956d..89f38ce00 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -82,7 +82,7 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume $('#loadingViewSpinner').hide(); }, function(e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to load dashboard data"); + Notifications.error('Failure', e, 'Unable to load dashboard data'); }); } diff --git a/app/components/docker/dockerController.js b/app/components/docker/dockerController.js index 727724d7f..56b80455c 100644 --- a/app/components/docker/dockerController.js +++ b/app/components/docker/dockerController.js @@ -14,11 +14,11 @@ function ($scope, Info, Version, Notifications) { $scope.state.loaded = true; $('#loadingViewSpinner').hide(); }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve engine details'); + Notifications.error('Failure', e, 'Unable to retrieve engine details'); $('#loadingViewSpinner').hide(); }); }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve engine information'); + Notifications.error('Failure', e, 'Unable to retrieve engine information'); $('#loadingViewSpinner').hide(); }); }]); diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index 4ef7c0aba..81444370e 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -32,7 +32,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) EndpointService.updateEndpoint(ID, endpointParams) .then(function success(data) { - Notifications.success("Endpoint updated", $scope.endpoint.Name); + Notifications.success('Endpoint updated', $scope.endpoint.Name); $state.go('endpoints'); }, function error(err) { $scope.state.error = err.msg; @@ -48,7 +48,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) EndpointService.endpoint($stateParams.id).then(function success(data) { $('#loadingViewSpinner').hide(); $scope.endpoint = data; - if (data.URL.indexOf("unix://") === 0) { + if (data.URL.indexOf('unix://') === 0) { $scope.endpointType = 'local'; } else { $scope.endpointType = 'remote'; @@ -59,7 +59,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) $scope.formValues.TLSKey = data.TLSKey; }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve endpoint details"); + Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); }); } diff --git a/app/components/endpointAccess/endpointAccess.html b/app/components/endpointAccess/endpointAccess.html index 042280431..98ee856dc 100644 --- a/app/components/endpointAccess/endpointAccess.html +++ b/app/components/endpointAccess/endpointAccess.html @@ -29,8 +29,8 @@ - You can select which user can access this endpoint by moving them to the authorized users table. Simply click - on a user entry to move it from one table to the other. + You can select which user or team can access this endpoint by moving them to the authorized accesses table. Simply click + on a user or team entry to move it from one table to the other. @@ -44,10 +44,10 @@
    - +
    Items per page: - @@ -58,7 +58,7 @@
    - +
    @@ -70,38 +70,38 @@ - + Name - - + + - - Role - - + + Type + + - - {{ user.Username }} + + {{ user.Name }} - {{ user.RoleName }} - + + {{ user.Type }} - + Loading... - - No users. + + No user or team available. -
    +
    @@ -110,10 +110,10 @@
    - +
    Items per page: - @@ -124,7 +124,7 @@
    - +
    @@ -136,39 +136,39 @@ - + Name - - + + - - Role - - + + Type + + - - {{ user.Username }} + + {{ user.Name }} - {{ user.RoleName }} - + + {{ user.Type }} - + Loading... - - No authorized users. + + No authorized user or team. -
    - +
    +
    diff --git a/app/components/endpointAccess/endpointAccessController.js b/app/components/endpointAccess/endpointAccessController.js index 8ec92e926..549664be6 100644 --- a/app/components/endpointAccess/endpointAccessController.js +++ b/app/components/endpointAccess/endpointAccessController.js @@ -1,148 +1,192 @@ angular.module('endpointAccess', []) -.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'Pagination', 'Notifications', -function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, Pagination, Notifications) { +.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'TeamService', 'Pagination', 'Notifications', +function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, TeamService, Pagination, Notifications) { $scope.state = { - pagination_count_users: Pagination.getPaginationCount('endpoint_access_users'), - pagination_count_authorizedUsers: Pagination.getPaginationCount('endpoint_access_authorizedUsers') + pagination_count_accesses: Pagination.getPaginationCount('endpoint_access_accesses'), + pagination_count_authorizedAccesses: Pagination.getPaginationCount('endpoint_access_authorizedAccesses') }; - $scope.sortTypeUsers = 'Username'; - $scope.sortReverseUsers = true; + $scope.sortTypeAccesses = 'Type'; + $scope.sortReverseAccesses = false; - $scope.orderUsers = function(sortType) { - $scope.sortReverseUsers = ($scope.sortTypeUsers === sortType) ? !$scope.sortReverseUsers : false; - $scope.sortTypeUsers = sortType; + $scope.orderAccesses = function(sortType) { + $scope.sortReverseAccesses = ($scope.sortTypeAccesses === sortType) ? !$scope.sortReverseAccesses : false; + $scope.sortTypeAccesses = sortType; }; - $scope.changePaginationCountUsers = function() { - Pagination.setPaginationCount('endpoint_access_users', $scope.state.pagination_count_users); + $scope.changePaginationCountAccesses = function() { + Pagination.setPaginationCount('endpoint_access_accesses', $scope.state.pagination_count_accesses); }; - $scope.sortTypeAuthorizedUsers = 'Username'; - $scope.sortReverseAuthorizedUsers = true; + $scope.sortTypeAuthorizedAccesses = 'Type'; + $scope.sortReverseAuthorizedAccesses = false; - $scope.orderAuthorizedUsers = function(sortType) { - $scope.sortReverseAuthorizedUsers = ($scope.sortTypeAuthorizedUsers === sortType) ? !$scope.sortReverseAuthorizedUsers : false; - $scope.sortTypeAuthorizedUsers = sortType; + $scope.orderAuthorizedAccesses = function(sortType) { + $scope.sortReverseAuthorizedAccesses = ($scope.sortTypeAuthorizedAccesses === sortType) ? !$scope.sortReverseAuthorizedAccesses : false; + $scope.sortTypeAuthorizedAccesses = sortType; }; - $scope.changePaginationCountAuthorizedUsers = function() { - Pagination.setPaginationCount('endpoint_access_authorizedUsers', $scope.state.pagination_count_authorizedUsers); + $scope.changePaginationCountAuthorizedAccesses = function() { + Pagination.setPaginationCount('endpoint_access_authorizedAccesses', $scope.state.pagination_count_authorizedAccesses); }; - $scope.authorizeAllUsers = function() { - var authorizedUserIDs = []; - angular.forEach($scope.authorizedUsers, function (user) { - authorizedUserIDs.push(user.Id); - }); - angular.forEach($scope.users, function (user) { - authorizedUserIDs.push(user.Id); - }); - EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) - .then(function success(data) { - $scope.authorizedUsers = $scope.authorizedUsers.concat($scope.users); - $scope.users = []; - Notifications.success('Access granted for all users'); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); - }); - }; - - $scope.unauthorizeAllUsers = function() { - EndpointService.updateAuthorizedUsers($stateParams.id, []) - .then(function success(data) { - $scope.users = $scope.users.concat($scope.authorizedUsers); - $scope.authorizedUsers = []; - Notifications.success('Access removed for all users'); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); - }); - }; - - $scope.authorizeUser = function(user) { - var authorizedUserIDs = []; - angular.forEach($scope.authorizedUsers, function (u) { - authorizedUserIDs.push(u.Id); - }); - authorizedUserIDs.push(user.Id); - EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) - .then(function success(data) { - removeUserFromArray(user.Id, $scope.users); - $scope.authorizedUsers.push(user); - Notifications.success('Access granted for user', user.Username); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); - }); - }; - - $scope.unauthorizeUser = function(user) { - var authorizedUserIDs = $scope.authorizedUsers.filter(function (u) { - if (u.Id !== user.Id) { - return u; + $scope.authorizeAllAccesses = function() { + var authorizedUsers = []; + var authorizedTeams = []; + angular.forEach($scope.authorizedAccesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); } - }).map(function (u) { - return u.Id; }); - EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + angular.forEach($scope.accesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); + } + }); + + EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams) .then(function success(data) { - removeUserFromArray(user.Id, $scope.authorizedUsers); - $scope.users.push(user); - Notifications.success('Access removed for user', user.Username); + $scope.authorizedAccesses = $scope.authorizedAccesses.concat($scope.accesses); + $scope.accesses = []; + Notifications.success('Endpoint accesses successfully updated'); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); }); }; - function getEndpointAndUsers(endpointID) { + $scope.unauthorizeAllAccesses = function() { + EndpointService.updateAccess($stateParams.id, [], []) + .then(function success(data) { + $scope.accesses = $scope.accesses.concat($scope.authorizedAccesses); + $scope.authorizedAccesses = []; + Notifications.success('Endpoint accesses successfully updated'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); + }); + }; + + $scope.authorizeAccess = function(access) { + var authorizedUsers = []; + var authorizedTeams = []; + angular.forEach($scope.authorizedAccesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); + } + }); + + if (access.Type === 'user') { + authorizedUsers.push(access.Id); + } else if (access.Type === 'team') { + authorizedTeams.push(access.Id); + } + + EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams) + .then(function success(data) { + removeAccessFromArray(access, $scope.accesses); + $scope.authorizedAccesses.push(access); + Notifications.success('Endpoint accesses successfully updated', access.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); + }); + }; + + $scope.unauthorizeAccess = function(access) { + var authorizedUsers = []; + var authorizedTeams = []; + angular.forEach($scope.authorizedAccesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); + } + }); + + if (access.Type === 'user') { + _.remove(authorizedUsers, function(n) { + return n === access.Id; + }); + } else if (access.Type === 'team') { + _.remove(authorizedTeams, function(n) { + return n === access.Id; + }); + } + + EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams) + .then(function success(data) { + removeAccessFromArray(access, $scope.authorizedAccesses); + $scope.accesses.push(access); + Notifications.success('Endpoint accesses successfully updated', access.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); + }); + }; + + function initView() { $('#loadingViewSpinner').show(); $q.all({ endpoint: EndpointService.endpoint($stateParams.id), - users: UserService.users(), + users: UserService.users(false), + teams: TeamService.teams() }) .then(function success(data) { $scope.endpoint = data.endpoint; - $scope.users = data.users.filter(function (user) { - if (user.Role !== 1) { - return user; - } - }).map(function (user) { - return new UserViewModel(user); + $scope.accesses = []; + var users = data.users.map(function (user) { + return new EndpointAccessUserViewModel(user); }); - $scope.authorizedUsers = []; + var teams = data.teams.map(function (team) { + return new EndpointAccessTeamViewModel(team); + }); + $scope.accesses = $scope.accesses.concat(users, teams); + $scope.authorizedAccesses = []; angular.forEach($scope.endpoint.AuthorizedUsers, function(userID) { - for (var i = 0, l = $scope.users.length; i < l; i++) { - if ($scope.users[i].Id === userID) { - $scope.authorizedUsers.push($scope.users[i]); - $scope.users.splice(i, 1); + for (var i = 0, l = $scope.accesses.length; i < l; i++) { + if ($scope.accesses[i].Type === 'user' && $scope.accesses[i].Id === userID) { + $scope.authorizedAccesses.push($scope.accesses[i]); + $scope.accesses.splice(i, 1); + return; + } + } + }); + angular.forEach($scope.endpoint.AuthorizedTeams, function(teamID) { + for (var i = 0, l = $scope.accesses.length; i < l; i++) { + if ($scope.accesses[i].Type === 'team' && $scope.accesses[i].Id === teamID) { + $scope.authorizedAccesses.push($scope.accesses[i]); + $scope.accesses.splice(i, 1); return; } } }); }) .catch(function error(err) { - $scope.templates = []; - $scope.users = []; - $scope.authorizedUsers = []; - Notifications.error("Failure", err, "Unable to retrieve endpoint details"); + $scope.accesses = []; + $scope.authorizedAccesses = []; + Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); }) .finally(function final(){ $('#loadingViewSpinner').hide(); }); } - function removeUserFromArray(id, users) { - for (var i = 0, l = users.length; i < l; i++) { - if (users[i].Id === id) { - users.splice(i, 1); + function removeAccessFromArray(access, accesses) { + for (var i = 0, l = accesses.length; i < l; i++) { + if (access.Type === accesses[i].Type && access.Id === accesses[i].Id) { + accesses.splice(i, 1); return; } } } - getEndpointAndUsers($stateParams.id); + initView(); }]); diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js index 7977de3ee..c99a9a86c 100644 --- a/app/components/endpointInit/endpointInitController.js +++ b/app/components/endpointInit/endpointInitController.js @@ -7,7 +7,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif }; $scope.formValues = { - endpointType: "remote", + endpointType: 'remote', Name: '', URL: '', TLS: false, @@ -46,8 +46,8 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.createLocalEndpoint = function() { $('#initEndpointSpinner').show(); $scope.state.error = ''; - var name = "local"; - var URL = "unix:///var/run/docker.sock"; + var name = 'local'; + var URL = 'unix:///var/run/docker.sock'; var TLS = false; EndpointService.createLocalEndpoint(name, URL, TLS, true) diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index f9d0082f2..3e11a2f49 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -59,7 +59,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi var TLSCertFile = $scope.formValues.TLSCert; var TLSKeyFile = $scope.formValues.TLSKey; EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) { - Notifications.success("Endpoint created", name); + Notifications.success('Endpoint created', name); $state.reload(); }, function error(err) { $scope.state.uploadInProgress = false; @@ -84,12 +84,12 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi if (endpoint.Checked) { counter = counter + 1; EndpointService.deleteEndpoint(endpoint.Id).then(function success(data) { - Notifications.success("Endpoint deleted", endpoint.Name); + Notifications.success('Endpoint deleted', endpoint.Name); var index = $scope.endpoints.indexOf(endpoint); $scope.endpoints.splice(index, 1); complete(); }, function error(err) { - Notifications.error("Failure", err, 'Unable to remove endpoint'); + Notifications.error('Failure', err, 'Unable to remove endpoint'); complete(); }); } @@ -104,7 +104,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi $scope.activeEndpointID = EndpointProvider.endpointID(); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve endpoints"); + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); $scope.endpoints = []; }) .finally(function final() { diff --git a/app/components/events/eventsController.js b/app/components/events/eventsController.js index 41c2a8340..c0027376f 100644 --- a/app/components/events/eventsController.js +++ b/app/components/events/eventsController.js @@ -27,6 +27,6 @@ function ($scope, Notifications, Events, Pagination) { }, function (e) { $('#loadEventsSpinner').hide(); - Notifications.error("Failure", e, "Unable to load events"); + Notifications.error('Failure', e, 'Unable to load events'); }); }]); diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index 6a12eb7f2..70918b82d 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -47,7 +47,7 @@ function ($scope, $state, Config, ImageService, Notifications, Pagination, Modal $state.reload(); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to pull image"); + Notifications.error('Failure', err, 'Unable to pull image'); }) .finally(function final() { $('#pullImageSpinner').hide(); @@ -76,12 +76,12 @@ function ($scope, $state, Config, ImageService, Notifications, Pagination, Modal counter = counter + 1; ImageService.deleteImage(i.Id, force) .then(function success(data) { - Notifications.success("Image deleted", i.Id); + Notifications.success('Image deleted', i.Id); var index = $scope.images.indexOf(i); $scope.images.splice(index, 1); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove image'); + Notifications.error('Failure', err, 'Unable to remove image'); }) .finally(function final() { complete(); @@ -97,7 +97,7 @@ function ($scope, $state, Config, ImageService, Notifications, Pagination, Modal $scope.images = data; }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve images"); + Notifications.error('Failure', err, 'Unable to retrieve images'); $scope.images = []; }) .finally(function final() { diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index 4c89f343a..f6b44df84 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -7,15 +7,15 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con Network.remove({id: $stateParams.id}, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to remove network"); + Notifications.error('Error', d, 'Unable to remove network'); } else { $('#loadingViewSpinner').hide(); - Notifications.success("Network removed", $stateParams.id); + Notifications.success('Network removed', $stateParams.id); $state.go('networks', {}); } }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to remove network"); + Notifications.error('Failure', e, 'Unable to remove network'); }); }; @@ -24,15 +24,15 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con Network.disconnect({id: $stateParams.id}, { Container: containerId, Force: false }, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to disconnect container from network"); + Notifications.error('Error', d, 'Unable to disconnect container from network'); } else { $('#loadingViewSpinner').hide(); - Notifications.success("Container left network", $stateParams.id); + Notifications.success('Container left network', $stateParams.id); $state.go('network', {id: network.Id}, {reload: true}); } }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to disconnect container from network"); + Notifications.error('Failure', e, 'Unable to disconnect container from network'); }); }; @@ -43,7 +43,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con getContainersInNetwork(data); }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve network info"); + Notifications.error('Failure', err, 'Unable to retrieve network info'); }); } @@ -77,7 +77,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con $('#loadingViewSpinner').hide(); }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve containers in network"); + Notifications.error('Failure', err, 'Unable to retrieve containers in network'); }); } else { Container.query({ @@ -87,7 +87,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con $('#loadingViewSpinner').hide(); }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve containers in network"); + Notifications.error('Failure', err, 'Unable to retrieve containers in network'); }); } } diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 472081ef3..2e8e6e9ed 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -36,13 +36,13 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { $('#createNetworkSpinner').hide(); Notifications.error('Unable to create network', {}, d.message); } else { - Notifications.success("Network created", d.Id); + Notifications.success('Network created', d.Id); $('#createNetworkSpinner').hide(); $state.reload(); } }, function (e) { $('#createNetworkSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create network'); + Notifications.error('Failure', e, 'Unable to create network'); }); }; @@ -82,15 +82,15 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { counter = counter + 1; Network.remove({id: network.Id}, function (d) { if (d.message) { - Notifications.error("Error", d, "Unable to remove network"); + Notifications.error('Error', d, 'Unable to remove network'); } else { - Notifications.success("Network removed", network.Id); + Notifications.success('Network removed', network.Id); var index = $scope.networks.indexOf(network); $scope.networks.splice(index, 1); } complete(); }, function (e) { - Notifications.error("Failure", e, 'Unable to remove network'); + Notifications.error('Failure', e, 'Unable to remove network'); complete(); }); } @@ -104,7 +104,7 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { $('#loadNetworksSpinner').hide(); }, function (e) { $('#loadNetworksSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve networks"); + Notifications.error('Failure', e, 'Unable to retrieve networks'); $scope.networks = []; }); } diff --git a/app/components/node/node.html b/app/components/node/node.html index b4075cb04..480009e57 100644 --- a/app/components/node/node.html +++ b/app/components/node/node.html @@ -239,10 +239,10 @@ - + Image - - + + @@ -257,10 +257,10 @@ {{ task.Id }} - {{ task.Status }} - {{ task.Slot }} - {{ task.Image }} - {{ task.Updated|getisodate }} + {{ task.Status.State }} + {{ task.Slot ? task.Slot : '-' }} + {{ task.Spec.ContainerSpec.Image | hideshasum }} + {{ task.Updated | getisodate }} diff --git a/app/components/node/nodeController.js b/app/components/node/nodeController.js index c09b7c604..a417c462d 100644 --- a/app/components/node/nodeController.js +++ b/app/components/node/nodeController.js @@ -1,3 +1,5 @@ +// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. +// See app/components/templates/templatesController.js as a reference. angular.module('node', []) .controller('NodeController', ['$scope', '$state', '$stateParams', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Pagination', 'Notifications', function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pagination, Notifications) { @@ -6,7 +8,6 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag $scope.state.pagination_count = Pagination.getPaginationCount('node_tasks'); $scope.loading = true; $scope.tasks = []; - $scope.displayNode = false; $scope.sortType = 'Status'; $scope.sortReverse = false; @@ -68,11 +69,11 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag Node.update({ id: node.Id, version: node.Version }, config, function (data) { $('#loadServicesSpinner').hide(); - Notifications.success("Node successfully updated", "Node updated"); + Notifications.success('Node successfully updated', 'Node updated'); $state.go('node', {id: node.Id}, {reload: true}); }, function (e) { $('#loadServicesSpinner').hide(); - Notifications.error("Failure", e, "Failed to update node"); + Notifications.error('Failure', e, 'Failed to update node'); }); }; @@ -81,7 +82,7 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { Node.get({ id: $stateParams.id}, function(d) { if (d.message) { - Notifications.error("Failure", e, "Unable to inspect the node"); + Notifications.error('Failure', e, 'Unable to inspect the node'); } else { var node = new NodeViewModel(d); originalNode = angular.copy(node); @@ -99,10 +100,10 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag if (node) { Task.query({filters: {node: [node.ID]}}, function (tasks) { $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task, [node]); + return new TaskViewModel(task); }); }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve tasks associated to the node"); + Notifications.error('Failure', e, 'Unable to retrieve tasks associated to the node'); }); } } diff --git a/app/components/service/includes/tasks.html b/app/components/service/includes/tasks.html index 48dc1b6f9..51d8fdf9f 100644 --- a/app/components/service/includes/tasks.html +++ b/app/components/service/includes/tasks.html @@ -1,4 +1,4 @@ -
    +
    @@ -24,14 +24,14 @@ - + Slot - + Node @@ -50,10 +50,10 @@ {{ task.Id }} - {{ task.Status }} - {{ task.Slot }} - {{ task.Node }} - {{ task.Updated|getisodate }} + {{ task.Status.State }} + {{ task.Slot }} + {{ task.NodeId | tasknodename: nodes }} + {{ task.Updated | getisodate }} diff --git a/app/components/service/service.html b/app/components/service/service.html index a00ff7ced..a49e9a44a 100644 --- a/app/components/service/service.html +++ b/app/components/service/service.html @@ -116,6 +116,8 @@
    +
    +

    diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 18a2bec01..20a283538 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -1,12 +1,10 @@ angular.module('service', []) -.controller('ServiceController', ['$scope', '$stateParams', '$state', '$location', '$anchorScroll', 'Service', 'ServiceHelper', 'Task', 'Node', 'Notifications', 'Pagination', 'ModalService', -function ($scope, $stateParams, $state, $location, $anchorScroll, Service, ServiceHelper, Task, Node, Notifications, Pagination, ModalService) { +.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$anchorScroll', 'ServiceService', 'Service', 'ServiceHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', 'ControllerDataPipeline', +function ($q, $scope, $stateParams, $state, $location, $anchorScroll, ServiceService, Service, ServiceHelper, TaskService, NodeService, Notifications, Pagination, ModalService, ControllerDataPipeline) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('service_tasks'); - $scope.service = {}; $scope.tasks = []; - $scope.displayNode = false; $scope.sortType = 'Status'; $scope.sortReverse = false; @@ -213,12 +211,12 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadingViewSpinner').hide(); - Notifications.success("Service successfully updated", "Service updated"); + Notifications.success('Service successfully updated', 'Service updated'); $scope.cancelChanges({}); - fetchServiceDetails(); + initView(); }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to update service"); + Notifications.error('Failure', e, 'Unable to update service'); }); }; @@ -234,18 +232,16 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi function removeService() { $('#loadingViewSpinner').show(); - Service.remove({id: $stateParams.id}, function (d) { - if (d.message) { - $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to remove service"); - } else { - $('#loadingViewSpinner').hide(); - Notifications.success("Service removed", $stateParams.id); - $state.go('services', {}); - } - }, function (e) { + ServiceService.remove($scope.service) + .then(function success(data) { + Notifications.success('Service successfully deleted'); + $state.go('services', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to remove service"); }); } @@ -258,10 +254,12 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi service.ServiceConstraints = translateConstraintsToKeyValue(service.Constraints); } - function fetchServiceDetails() { + function initView() { $('#loadingViewSpinner').show(); - Service.get({id: $stateParams.id}, function (d) { - var service = new ServiceViewModel(d); + + ServiceService.service($stateParams.id) + .then(function success(data) { + var service = data; $scope.isUpdating = $scope.lastVersion >= service.Version; if (!$scope.isUpdating) { $scope.lastVersion = service.Version; @@ -269,29 +267,23 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi translateServiceArrays(service); $scope.service = service; + ControllerDataPipeline.setAccessControlData('service', $stateParams.id, service.ResourceControl); originalService = angular.copy(service); - Task.query({filters: {service: [service.Name]}}, function (tasks) { - Node.query({}, function (nodes) { - $scope.displayNode = true; - $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task, nodes); - }); - $('#loadingViewSpinner').hide(); - }, function (e) { - $('#loadingViewSpinner').hide(); - $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task, null); - }); - Notifications.error("Failure", e, "Unable to retrieve node information"); - }); - }, function (e) { - $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve tasks associated to the service"); + return $q.all({ + tasks: TaskService.serviceTasks(service.Name), + nodes: NodeService.nodes() }); - }, function (e) { + }) + .then(function success(data) { + $scope.tasks = data.tasks; + $scope.nodes = data.nodes; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve service details'); + }) + .finally(function final() { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve service details"); }); } @@ -382,5 +374,5 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi return []; } - fetchServiceDetails(); + initView(); }]); diff --git a/app/components/services/services.html b/app/components/services/services.html index 486a47cd0..945c6db40 100644 --- a/app/components/services/services.html +++ b/app/components/services/services.html @@ -73,10 +73,10 @@ - + Ownership - - + + @@ -109,24 +109,9 @@ {{ service.UpdatedAt|getisodate }} - - - - Private - - - Private (owner: {{ service.Owner }}) - - Switch to public - - - - Private - Switch to public - - - - Public + + + {{ service.ResourceControl.Ownership ? service.ResourceControl.Ownership : service.ResourceControl.Ownership = 'public' }} diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 1cdf61da9..0a75cd441 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -1,40 +1,12 @@ angular.module('services', []) -.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'Authentication', 'UserService', 'ModalService', 'ResourceControlService', -function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, Authentication, UserService, ModalService, ResourceControlService) { +.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceService', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'ModalService', 'ResourceControlService', +function ($q, $scope, $stateParams, $state, Service, ServiceService, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, ModalService, ResourceControlService) { $scope.state = {}; $scope.state.selectedItemCount = 0; $scope.state.pagination_count = Pagination.getPaginationCount('services'); $scope.sortType = 'Name'; $scope.sortReverse = false; - function removeServiceResourceControl(service) { - volumeResourceControlQueries = []; - angular.forEach(service.Mounts, function (mount) { - if (mount.Type === 'volume') { - volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(service.Metadata.ResourceControl.OwnerId, mount.Source)); - } - }); - - $q.all(volumeResourceControlQueries) - .then(function success() { - return ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id); - }) - .then(function success() { - delete service.Metadata.ResourceControl; - Notifications.success('Ownership changed to public', service.Id); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to change service ownership"); - }); - } - - $scope.switchOwnership = function(volume) { - ModalService.confirmServiceOwnershipChange(function (confirmed) { - if(!confirmed) { return; } - removeServiceResourceControl(volume); - }); - }; - $scope.changePaginationCount = function() { Pagination.setPaginationCount('services', $scope.state.pagination_count); }; @@ -58,13 +30,13 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification config.Mode.Replicated.Replicas = service.Replicas; Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadServicesSpinner').hide(); - Notifications.success("Service successfully scaled", "New replica count: " + service.Replicas); + Notifications.success('Service successfully scaled', 'New replica count: ' + service.Replicas); $state.reload(); }, function (e) { $('#loadServicesSpinner').hide(); service.Scale = false; service.Replicas = service.ReplicaCount; - Notifications.error("Failure", e, "Unable to scale service"); + Notifications.error('Failure', e, 'Unable to scale service'); }); }; @@ -90,40 +62,22 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification angular.forEach($scope.services, function (service) { if (service.Checked) { counter = counter + 1; - Service.remove({id: service.Id}, function (d) { - if (d.message) { - $('#loadServicesSpinner').hide(); - Notifications.error("Unable to remove service", {}, d[0].message); - } else { - if (service.Metadata && service.Metadata.ResourceControl) { - ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id) - .then(function success() { - Notifications.success("Service deleted", service.Id); - var index = $scope.services.indexOf(service); - $scope.services.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to remove service ownership"); - }); - } else { - Notifications.success("Service deleted", service.Id); - var index = $scope.services.indexOf(service); - $scope.services.splice(index, 1); - } - } - complete(); - }, function (e) { - Notifications.error("Failure", e, 'Unable to remove service'); + ServiceService.remove(service) + .then(function success(data) { + Notifications.success('Service successfully deleted'); + var index = $scope.services.indexOf(service); + $scope.services.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { complete(); }); } }); } - // $scope.removeAction = function () { - // - // }; - function mapUsersToServices(users) { angular.forEach($scope.services, function (service) { if (service.Metadata) { @@ -139,46 +93,33 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification }); } - function fetchServices() { + function initView() { $('#loadServicesSpinner').show(); - - var userDetails = Authentication.getUserDetails(); - $scope.user = userDetails; - $q.all({ services: Service.query({}).$promise, tasks: Task.query({filters: {'desired-state': ['running']}}).$promise, - nodes: Node.query({}).$promise, + nodes: Node.query({}).$promise }) .then(function success(data) { $scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes); $scope.services = data.services.map(function (service) { var serviceTasks = data.tasks.filter(function (task) { - return task.ServiceID === service.ID && task.Status.State === "running"; + return task.ServiceID === service.ID && task.Status.State === 'running'; }); var taskNodes = data.nodes.filter(function (node) { return node.Spec.Availability === 'active' && node.Status.State === 'ready'; }); return new ServiceViewModel(service, serviceTasks, taskNodes); }); - if (userDetails.role === 1) { - UserService.users() - .then(function success(data) { - mapUsersToServices(data); - }) - .finally(function final() { - $('#loadServicesSpinner').hide(); - }); - } }) .catch(function error(err) { $scope.services = []; - Notifications.error("Failure", err, "Unable to retrieve services"); + Notifications.error('Failure', err, 'Unable to retrieve services'); }) .finally(function final() { $('#loadServicesSpinner').hide(); }); } - fetchServices(); + initView(); }]); diff --git a/app/components/settings/settings.html b/app/components/settings/settings.html index bc63db071..f0b235b46 100644 --- a/app/components/settings/settings.html +++ b/app/components/settings/settings.html @@ -21,11 +21,11 @@
    -
    -

    - - Your new password must be at least 8 characters long -

    +
    +
    + + Current password is not valid +
    @@ -38,6 +38,12 @@
    +
    +
    + + Your new password must be at least 8 characters long +
    +
    @@ -51,14 +57,9 @@
    -
    +
    -
    -

    - Current password is not valid -

    -
    diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js index 010634800..602a5612f 100644 --- a/app/components/settings/settingsController.js +++ b/app/components/settings/settingsController.js @@ -15,14 +15,14 @@ function ($scope, $state, $sanitize, Authentication, UserService, Notifications) UserService.updateUserPassword(userID, currentPassword, newPassword) .then(function success() { - Notifications.success("Success", "Password successfully updated"); + Notifications.success('Success', 'Password successfully updated'); $state.reload(); }) .catch(function error(err) { if (err.invalidPassword) { $scope.invalidPassword = true; } else { - Notifications.error("Failure", err, err.msg); + Notifications.error('Failure', err, err.msg); } }); }; diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 85d60dc83..9a30b6b38 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -49,14 +49,16 @@ - - - - diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index 5bd466c70..350d82605 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -1,13 +1,13 @@ angular.module('sidebar', []) -.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', -function ($scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Notifications, Authentication) { +.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', +function ($q, $scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService) { Config.$promise.then(function (c) { $scope.logo = c.logo; }); $scope.uiVersion = Settings.uiVersion; - $scope.userRole = Authentication.getUserDetails().role; + $scope.endpoints = []; $scope.switchEndpoint = function(endpoint) { var activeEndpointID = EndpointProvider.endpointID(); @@ -27,22 +27,47 @@ function ($scope, $state, Settings, Config, EndpointService, StateManager, Endpo }); }; - function fetchEndpoints() { - EndpointService.endpoints() - .then(function success(data) { - $scope.endpoints = data; - var activeEndpointID = EndpointProvider.endpointID(); - angular.forEach($scope.endpoints, function (endpoint) { - if (endpoint.Id === activeEndpointID) { - $scope.activeEndpoint = endpoint; - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - } - }); - }) - .catch(function error(err) { - $scope.endpoints = []; + function setActiveEndpoint(endpoints) { + var activeEndpointID = EndpointProvider.endpointID(); + angular.forEach(endpoints, function (endpoint) { + if (endpoint.Id === activeEndpointID) { + $scope.activeEndpoint = endpoint; + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + } }); } - fetchEndpoints(); + function checkPermissions(memberships) { + var isLeader = false; + angular.forEach(memberships, function(membership) { + if (membership.Role === 1) { + isLeader = true; + } + }); + $scope.isTeamLeader = isLeader; + } + + function initView() { + EndpointService.endpoints() + .then(function success(data) { + var endpoints = data; + $scope.endpoints = endpoints; + setActiveEndpoint(endpoints); + + if (StateManager.getState().application.authentication) { + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + return $q.when(!isAdmin ? UserService.userMemberships(userDetails.ID) : []); + } + }) + .then(function success(data) { + checkPermissions(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); + }); + } + + initView(); }]); diff --git a/app/components/stats/statsController.js b/app/components/stats/statsController.js index a9977bf19..d5a76616e 100644 --- a/app/components/stats/statsController.js +++ b/app/components/stats/statsController.js @@ -42,33 +42,33 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, networkRxData.push(0); } var cpuDataset = { // CPU Usage - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: cpuData }; var memoryDataset = { - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: memoryData }; var networkRxDataset = { - label: "Rx Bytes", - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + label: 'Rx Bytes', + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: networkRxData }; var networkTxDataset = { - label: "Tx Bytes", - fillColor: "rgba(255,180,174,0.5)", - strokeColor: "rgba(255,180,174,1)", - pointColor: "rgba(255,180,174,1)", - pointStrokeColor: "#fff", + label: 'Tx Bytes', + fillColor: 'rgba(255,180,174,0.5)', + strokeColor: 'rgba(255,180,174,1)', + pointColor: 'rgba(255,180,174,1)', + pointStrokeColor: '#fff', data: networkTxData }; var networkLegendData = [ @@ -87,7 +87,7 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, legend($('#network-legend').get(0), networkLegendData); Chart.defaults.global.animationSteps = 30; // Lower from 60 to ease CPU load. - var cpuChart = new Chart($('#cpu-stats-chart').get(0).getContext("2d")).Line({ + var cpuChart = new Chart($('#cpu-stats-chart').get(0).getContext('2d')).Line({ labels: cpuLabels, datasets: [cpuDataset] }, { @@ -108,7 +108,7 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, //scaleStepWidth: Math.ceil(initialStats.memory_stats.limit / 10), //scaleStartValue: 0 }); - var networkChart = new Chart($('#network-stats-chart').get(0).getContext("2d")).Line({ + var networkChart = new Chart($('#network-stats-chart').get(0).getContext('2d')).Line({ labels: networkLabels, datasets: [networkRxDataset, networkTxDataset] }, { @@ -211,7 +211,7 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, Container.get({id: $stateParams.id}, function (d) { $scope.container = d; }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve container info"); + Notifications.error('Failure', e, 'Unable to retrieve container info'); }); $scope.getTop(); }]); diff --git a/app/components/task/task.html b/app/components/task/task.html index 57dd9883d..a657fa63d 100644 --- a/app/components/task/task.html +++ b/app/components/task/task.html @@ -2,12 +2,12 @@ - - Services > {{ serviceName }} > {{ task.ID }} + + Services > {{ service.Name }} > {{ task.Id }} -
    +
    @@ -16,7 +16,7 @@ ID - {{ task.ID }} + {{ task.Id }} State @@ -28,15 +28,15 @@ Image - {{ task.Spec.ContainerSpec.Image }} + {{ task.Spec.ContainerSpec.Image | hideshasum }} - + Slot {{ task.Slot }} Created - {{ task.CreatedAt|getisodate }} + {{ task.Created|getisodate }} Container ID diff --git a/app/components/task/taskController.js b/app/components/task/taskController.js index c705449d6..819c8c0d6 100644 --- a/app/components/task/taskController.js +++ b/app/components/task/taskController.js @@ -1,29 +1,26 @@ angular.module('task', []) -.controller('TaskController', ['$scope', '$stateParams', '$state', 'Task', 'Service', 'Notifications', -function ($scope, $stateParams, $state, Task, Service, Notifications) { +.controller('TaskController', ['$scope', '$stateParams', 'TaskService', 'Service', 'Notifications', +function ($scope, $stateParams, TaskService, Service, Notifications) { - $scope.task = {}; - $scope.serviceName = 'service'; - $scope.isTaskRunning = false; - - function fetchTaskDetails() { + function initView() { $('#loadingViewSpinner').show(); - Task.get({id: $stateParams.id}, function (d) { - $scope.task = d; - fetchAssociatedServiceDetails(d.ServiceID); + TaskService.task($stateParams.id) + .then(function success(data) { + var task = data; + $scope.task = task; + return Service.get({ id: task.ServiceId }).$promise; + }) + .then(function success(data) { + var service = new ServiceViewModel(data); + $scope.service = service; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve task details'); + }) + .finally(function final() { $('#loadingViewSpinner').hide(); - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve task details"); }); } - function fetchAssociatedServiceDetails(serviceId) { - Service.get({id: serviceId}, function (d) { - $scope.serviceName = d.Spec.Name; - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve associated service details"); - }); - } - - fetchTaskDetails(); + initView(); }]); diff --git a/app/components/team/team.html b/app/components/team/team.html new file mode 100644 index 000000000..b11fdd125 --- /dev/null +++ b/app/components/team/team.html @@ -0,0 +1,176 @@ + + + + + + Teams > {{ team.Name }} + + + +
    +
    + + + + + + + + + + + + + + + + + + +
    Name + {{ team.Name }} + +
    Leaders{{ leaderCount }}
    Total users in team{{ teamMembers.length }}
    +
    +
    +
    +
    + +
    +
    + + +
    + Items per page: + +
    +
    + +
    + +
    +
    + +
    +
    + +
    + + + + + + + + + + + + + + + + + +
    + + Name + + + +
    + {{ user.Username }} + + Add + +
    Loading...
    No users.
    +
    + +
    +
    +
    +
    +
    +
    + + +
    + Items per page: + +
    +
    + +
    + +
    +
    + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + +
    + + Name + + + + + + Team Role + + + +
    + {{ user.Username }} + + Remove + + + + + {{ user.TeamRole }} + + Leader + Member + +
    Loading...
    No team members.
    +
    + +
    +
    +
    +
    +
    +
    diff --git a/app/components/team/teamController.js b/app/components/team/teamController.js new file mode 100644 index 000000000..694432253 --- /dev/null +++ b/app/components/team/teamController.js @@ -0,0 +1,229 @@ +angular.module('team', []) +.controller('TeamController', ['$q', '$scope', '$state', '$stateParams', 'TeamService', 'UserService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', +function ($q, $scope, $state, $stateParams, TeamService, UserService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { + + $scope.state = { + pagination_count_users: Pagination.getPaginationCount('team_available_users'), + pagination_count_members: Pagination.getPaginationCount('team_members') + }; + $scope.sortTypeUsers = 'Username'; + $scope.sortReverseUsers = true; + $scope.users = []; + $scope.teamMembers = []; + $scope.leaderCount = 0; + + $scope.orderUsers = function(sortType) { + $scope.sortReverseUsers = ($scope.sortTypeUsers === sortType) ? !$scope.sortReverseUsers : false; + $scope.sortTypeUsers = sortType; + }; + + $scope.changePaginationCountUsers = function() { + Pagination.setPaginationCount('team_available_users', $scope.state.pagination_count_users); + }; + + $scope.sortTypeGroupMembers = 'TeamRole'; + $scope.sortReverseGroupMembers = false; + + $scope.orderGroupMembers = function(sortType) { + $scope.sortReverseGroupMembers = ($scope.sortTypeGroupMembers === sortType) ? !$scope.sortReverseGroupMembers : false; + $scope.sortTypeGroupMembers = sortType; + }; + + $scope.changePaginationCountGroupMembers = function() { + Pagination.setPaginationCount('team_members', $scope.state.pagination_count_members); + }; + + $scope.deleteTeam = function() { + ModalService.confirmDeletion( + 'Do you want to delete this team? Users in this team will not be deleted.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteTeam(); + } + ); + }; + + $scope.promoteToLeader = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 1) + .then(function success(data) { + $scope.leaderCount++; + user.TeamRole = 'Leader'; + Notifications.success('User is now team leader', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update user role'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.demoteToMember = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 2) + .then(function success(data) { + user.TeamRole = 'Member'; + $scope.leaderCount--; + Notifications.success('User is now team member', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update user role'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.addAllUsers = function() { + $('#loadingViewSpinner').show(); + var teamMembershipQueries = []; + angular.forEach($scope.users, function (user) { + teamMembershipQueries.push(TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2)); + }); + $q.all(teamMembershipQueries) + .then(function success(data) { + var users = $scope.users; + for (var i = 0; i < users.length; i++) { + var user = users[i]; + user.MembershipId = data[i].Id; + user.TeamRole = 'Member'; + } + $scope.teamMembers = $scope.teamMembers.concat(users); + $scope.users = []; + Notifications.success('All users successfully added'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.addUser = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2) + .then(function success(data) { + removeUserFromArray(user.Id, $scope.users); + user.TeamRole = 'Member'; + user.MembershipId = data.Id; + $scope.teamMembers.push(user); + Notifications.success('User added to team', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.removeAllUsers = function() { + $('#loadingViewSpinner').show(); + var teamMembershipQueries = []; + angular.forEach($scope.teamMembers, function (user) { + teamMembershipQueries.push(TeamMembershipService.deleteMembership(user.MembershipId)); + }); + $q.all(teamMembershipQueries) + .then(function success(data) { + $scope.users = $scope.users.concat($scope.teamMembers); + $scope.teamMembers = []; + Notifications.success('All users successfully removed'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.removeUser = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.deleteMembership(user.MembershipId) + .then(function success() { + removeUserFromArray(user.Id, $scope.teamMembers); + $scope.users.push(user); + Notifications.success('User removed from team', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function deleteTeam() { + $('#loadingViewSpinner').show(); + TeamService.deleteTeam($scope.team.Id) + .then(function success(data) { + Notifications.success('Team successfully deleted', $scope.team.Name); + $state.go('teams'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove team'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + function removeUserFromArray(id, users) { + for (var i = 0, l = users.length; i < l; i++) { + if (users[i].Id === id) { + users.splice(i, 1); + return; + } + } + } + + function assignUsersAndMembers(users, memberships) { + for (var i = 0; i < users.length; i++) { + var user = users[i]; + var member = false; + for (var j = 0; j < memberships.length; j++) { + var membership = memberships[j]; + if (user.Id === membership.UserId) { + member = true; + if (membership.Role === 1) { + user.TeamRole = 'Leader'; + $scope.leaderCount++; + } else { + user.TeamRole = 'Member'; + } + user.MembershipId = membership.Id; + $scope.teamMembers.push(user); + break; + } + } + if (!member) { + $scope.users.push(user); + } + } + } + + function initView() { + $('#loadingViewSpinner').show(); + $scope.isAdmin = Authentication.getUserDetails().role === 1 ? true: false; + $q.all({ + team: TeamService.team($stateParams.id), + users: UserService.users(false), + memberships: TeamService.userMemberships($stateParams.id) + }) + .then(function success(data) { + var users = data.users; + $scope.team = data.team; + assignUsersAndMembers(users, data.memberships); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve team details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/teams/teams.html b/app/components/teams/teams.html new file mode 100644 index 000000000..99fd2b8ad --- /dev/null +++ b/app/components/teams/teams.html @@ -0,0 +1,130 @@ + + + + + + + + Teams management + + +
    +
    + + + + +
    + +
    + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + + + {{ state.teamCreationError }} + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + + +
    + Items per page: + +
    +
    + +
    + +
    +
    + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + +
    + + + + Name + + + +
    {{ team.Name }} + Edit +
    Loading...
    No teams available.
    +
    + +
    +
    +
    + +
    +
    diff --git a/app/components/teams/teamsController.js b/app/components/teams/teamsController.js new file mode 100644 index 000000000..413091460 --- /dev/null +++ b/app/components/teams/teamsController.js @@ -0,0 +1,140 @@ +angular.module('teams', []) +.controller('TeamsController', ['$q', '$scope', '$state', 'TeamService', 'UserService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', +function ($q, $scope, $state, TeamService, UserService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { + $scope.state = { + userGroupGroupCreationError: '', + selectedItemCount: 0, + validName: false, + pagination_count: Pagination.getPaginationCount('teams') + }; + $scope.sortType = 'Name'; + $scope.sortReverse = false; + + $scope.formValues = { + Name: '', + Leaders: [] + }; + + $scope.order = function(sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.changePaginationCount = function() { + Pagination.setPaginationCount('teams', $scope.state.pagination_count); + }; + + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredTeams, function (team) { + if (team.Checked !== allSelected) { + team.Checked = allSelected; + $scope.selectItem(team); + } + }); + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.checkNameValidity = function() { + var valid = true; + for (var i = 0; i < $scope.teams.length; i++) { + if ($scope.formValues.Name === $scope.teams[i].Name) { + valid = false; + break; + } + } + $scope.state.validName = valid; + $scope.state.teamCreationError = valid ? '' : 'Team name already existing'; + }; + + $scope.addTeam = function() { + $('#createTeamSpinner').show(); + $scope.state.teamCreationError = ''; + var teamName = $scope.formValues.Name; + var leaderIds = []; + angular.forEach($scope.formValues.Leaders, function(user) { + leaderIds.push(user.Id); + }); + + TeamService.createTeam(teamName, leaderIds) + .then(function success(data) { + Notifications.success('Team successfully created', teamName); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create team'); + }) + .finally(function final() { + $('#createTeamSpinner').hide(); + }); + }; + + function deleteSelectedTeams() { + $('#loadingViewSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadingViewSpinner').hide(); + } + }; + angular.forEach($scope.teams, function (team) { + if (team.Checked) { + counter = counter + 1; + TeamService.deleteTeam(team.Id) + .then(function success(data) { + var index = $scope.teams.indexOf(team); + $scope.teams.splice(index, 1); + Notifications.success('Team successfully deleted', team.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove team'); + }) + .finally(function final() { + complete(); + }); + } + }); + } + + $scope.removeAction = function () { + ModalService.confirmDeletion( + 'Do you want to delete the selected team(s)? Users in the team(s) will not be deleted.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteSelectedTeams(); + } + ); + }; + + function initView() { + $('#loadingViewSpinner').show(); + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + $q.all({ + users: UserService.users(false), + teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID) + }) + .then(function success(data) { + $scope.teams = data.teams; + $scope.users = data.users; + }) + .catch(function error(err) { + $scope.teams = []; + $scope.users = []; + Notifications.error('Failure', err, 'Unable to retrieve teams'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 784ef3271..4cadd180c 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -67,21 +67,9 @@
    - -
    -
    - -
    - - -
    -
    -
    - - + +
    +
    diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 9cec4c602..57fb2515a 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,18 +1,19 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', -function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'ControllerDataPipeline', 'FormValidator', +function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, ControllerDataPipeline, FormValidator) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, hideDescriptions: $stateParams.hide_descriptions, pagination_count: Pagination.getPaginationCount('templates'), + formValidationError: '', filters: { Categories: '!', Platform: '!' } }; + $scope.formValues = { - Ownership: $scope.applicationState.application.authentication ? 'private' : '', network: '', name: '' }; @@ -37,38 +38,55 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, Cont $scope.state.selectedTemplate.Ports.splice(index, 1); }; + 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.createTemplate = function() { $('#createContainerSpinner').show(); + + var userDetails = Authentication.getUserDetails(); + var accessControlData = ControllerDataPipeline.getAccessControlFormData(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createContainerSpinner').hide(); + return; + } + var template = $scope.state.selectedTemplate; var templateConfiguration = createTemplateConfiguration(template); var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes); + var generatedVolumeIds = []; VolumeService.createXAutoGeneratedLocalVolumes(generatedVolumeCount) .then(function success(data) { var volumeResourceControlQueries = []; - if ($scope.formValues.Ownership === 'private') { - angular.forEach(data, function (volume) { - volumeResourceControlQueries.push(ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, volume.Name)); - }); - } - TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data); - return $q.all(volumeResourceControlQueries) - .then(function success() { - return ImageService.pullImage(template.Image, template.Registry); + angular.forEach(data, function (volume) { + var volumeId = volume.Id; + generatedVolumeIds.push(volumeId); }); + TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data); + return ImageService.pullImage(template.Image, template.Registry); }) .then(function success(data) { return ContainerService.createAndStartContainer(templateConfiguration); }) .then(function success(data) { - Notifications.success('Container started', data.Id); - if ($scope.formValues.Ownership === 'private') { - ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, data.Id) - .then(function success(data) { - $state.go('containers', {}, {reload: true}); - }); - } else { - $state.go('containers', {}, {reload: true}); - } + var containerIdentifier = data.Id; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, generatedVolumeIds); + }) + .then(function success() { + Notifications.success('Container successfully created'); + $state.go('containers', {}, {reload: true}); }) .catch(function error(err) { Notifications.error('Failure', err, err.msg); diff --git a/app/components/user/user.html b/app/components/user/user.html index 425d25cdf..0f1bc95cd 100644 --- a/app/components/user/user.html +++ b/app/components/user/user.html @@ -15,12 +15,13 @@ - + + +
    Name {{ user.Username }}
    @@ -71,11 +95,6 @@
    -
    -

    - {{ state.updatePasswordError }} -

    -
    diff --git a/app/components/user/userController.js b/app/components/user/userController.js index 6c0040432..348457c51 100644 --- a/app/components/user/userController.js +++ b/app/components/user/userController.js @@ -1,15 +1,15 @@ angular.module('user', []) -.controller('UserController', ['$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', -function ($scope, $state, $stateParams, UserService, ModalService, Notifications) { +.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', +function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications) { $scope.state = { - updatePasswordError: '', + updatePasswordError: '' }; $scope.formValues = { newPassword: '', confirmPassword: '', - Administrator: false, + Administrator: false }; $scope.deleteUser = function() { @@ -32,7 +32,7 @@ function ($scope, $state, $stateParams, UserService, ModalService, Notifications $state.reload(); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to update user permissions'); + Notifications.error('Failure', err, 'Unable to update user permissions'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -47,7 +47,7 @@ function ($scope, $state, $stateParams, UserService, ModalService, Notifications $state.reload(); }) .catch(function error(err) { - $scope.state.updatePasswordError = 'Unable to update password'; + Notifications.error('Failure', err, 'Unable to update user password'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -62,28 +62,30 @@ function ($scope, $state, $stateParams, UserService, ModalService, Notifications $state.go('users'); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove user'); + Notifications.error('Failure', err, 'Unable to remove user'); }) .finally(function final() { $('#loadingViewSpinner').hide(); }); } - function getUser() { + function initView() { $('#loadingViewSpinner').show(); - UserService.user($stateParams.id) + $q.all({ + user: UserService.user($stateParams.id) + }) .then(function success(data) { - var user = new UserViewModel(data); + var user = data.user; $scope.user = user; - $scope.formValues.Administrator = user.RoleId === 1 ? true : false; + $scope.formValues.Administrator = user.Role === 1 ? true : false; }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to retrieve user information'); + Notifications.error('Failure', err, 'Unable to retrieve user information'); }) .finally(function final() { $('#loadingViewSpinner').hide(); }); } - getUser(); + initView(); }]); diff --git a/app/components/users/users.html b/app/components/users/users.html index 9db1ff585..e53ddec03 100644 --- a/app/components/users/users.html +++ b/app/components/users/users.html @@ -49,8 +49,8 @@
    - -
    + +
    - -
    + + +
    - Note: non-administrator users do not have access to any endpoint by default. Head over the endpoints view to manage their accesses. + + + You have not yet created any team. Head over the teams view to manage user teams. + + +
    +
    + +
    +
    + + Note: non-administrator users with no team do not have access to any endpoint by default. Head over the endpoints view to manage their accesses. +
    @@ -98,7 +124,7 @@
    -
    +
    @@ -110,7 +136,7 @@ - - + - + - diff --git a/app/components/users/usersController.js b/app/components/users/usersController.js index 6e35233bf..c9c940ec7 100644 --- a/app/components/users/usersController.js +++ b/app/components/users/usersController.js @@ -1,6 +1,6 @@ angular.module('users', []) -.controller('UsersController', ['$scope', '$state', 'UserService', 'ModalService', 'Notifications', 'Pagination', -function ($scope, $state, UserService, ModalService, Notifications, Pagination) { +.controller('UsersController', ['$q', '$scope', '$state', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', +function ($q, $scope, $state, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { $scope.state = { userCreationError: '', selectedItemCount: 0, @@ -15,6 +15,7 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) Password: '', ConfirmPassword: '', Administrator: false, + Teams: [] }; $scope.order = function(sortType) { @@ -56,20 +57,25 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) }; $scope.addUser = function() { + $('#createUserSpinner').show(); $scope.state.userCreationError = ''; var username = $scope.formValues.Username; var password = $scope.formValues.Password; var role = $scope.formValues.Administrator ? 1 : 2; - UserService.createUser(username, password, role) + var teamIds = []; + angular.forEach($scope.formValues.Teams, function(team) { + teamIds.push(team.Id); + }); + UserService.createUser(username, password, role, teamIds) .then(function success(data) { - Notifications.success("User created", username); + Notifications.success('User successfully created', username); $state.reload(); }) .catch(function error(err) { - $scope.state.userCreationError = err.msg; + Notifications.error('Failure', err, 'Unable to create user'); }) .finally(function final() { - + $('#createUserSpinner').hide(); }); }; @@ -92,7 +98,7 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) Notifications.success('User successfully deleted', user.Username); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove user'); + Notifications.error('Failure', err, 'Unable to remove user'); }) .finally(function final() { complete(); @@ -111,22 +117,46 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) ); }; - function fetchUsers() { + function assignTeamLeaders(users, memberships) { + for (var i = 0; i < users.length; i++) { + var user = users[i]; + user.isTeamLeader = false; + for (var j = 0; j < memberships.length; j++) { + var membership = memberships[j]; + if (user.Id === membership.UserId && membership.Role === 1) { + user.isTeamLeader = true; + user.RoleName = 'team leader'; + break; + } + } + } + } + + function initView() { $('#loadUsersSpinner').show(); - UserService.users() + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + $q.all({ + users: UserService.users(true), + teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID), + memberships: TeamMembershipService.memberships() + }) .then(function success(data) { - $scope.users = data.map(function(user) { - return new UserViewModel(user); - }); + var users = data.users; + assignTeamLeaders(users, data.memberships); + $scope.users = users; + $scope.teams = data.teams; }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve users"); + Notifications.error('Failure', err, 'Unable to retrieve users and teams'); $scope.users = []; + $scope.teams = []; }) .finally(function final() { $('#loadUsersSpinner').hide(); }); } - fetchUsers(); + initView(); }]); diff --git a/app/components/volume/volume.html b/app/components/volume/volume.html new file mode 100644 index 000000000..87eeca0f6 --- /dev/null +++ b/app/components/volume/volume.html @@ -0,0 +1,68 @@ + + + + + + Volumes > {{ volume.Id }} + + + +
    +
    + + + +
    + @@ -127,18 +153,20 @@
    {{ user.Username }} + + + {{ user.RoleName }} - + Edit
    + + + + + + + + + + + + + + + + + + +
    ID + {{ volume.Id }} + +
    Mount path{{ volume.Mountpoint }}
    Driver{{ volume.Driver }}
    Labels + + + + + +
    {{ k }}{{ v }}
    +
    + + +
    +
    + +
    + +
    +
    + + + + + + + + + + +
    {{ key }}{{ value }}
    +
    +
    +
    +
    diff --git a/app/components/volume/volumeController.js b/app/components/volume/volumeController.js new file mode 100644 index 000000000..1a0b5cc01 --- /dev/null +++ b/app/components/volume/volumeController.js @@ -0,0 +1,37 @@ +angular.module('volume', []) +.controller('VolumeController', ['$scope', '$state', '$stateParams', 'VolumeService', 'Notifications', 'ControllerDataPipeline', +function ($scope, $state, $stateParams, VolumeService, Notifications, ControllerDataPipeline) { + + $scope.removeVolume = function removeVolume() { + $('#loadingViewSpinner').show(); + VolumeService.remove($scope.volume) + .then(function success(data) { + Notifications.success('Volume successfully removed', $stateParams.id); + $state.go('volumes', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove volume'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function initView() { + $('#loadingViewSpinner').show(); + VolumeService.volume($stateParams.id) + .then(function success(data) { + var volume = data; + ControllerDataPipeline.setAccessControlData('volume', volume.Id, volume.ResourceControl); + $scope.volume = volume; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volume details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/volumes/volumes.html b/app/components/volumes/volumes.html index 6a6fcbd42..92b318b16 100644 --- a/app/components/volumes/volumes.html +++ b/app/components/volumes/volumes.html @@ -40,10 +40,10 @@ - + Name - - + + @@ -53,18 +53,11 @@ - - - Mountpoint - - - - - + Ownership - - + + @@ -72,28 +65,12 @@ - {{ volume.Name|truncate:50 }} + {{ volume.Id|truncate:50 }} {{ volume.Driver }} - {{ volume.Mountpoint }} - - - - Private - - - Private (owner: {{ volume.Owner }}) - - Switch to public - - - - Private - Switch to public - - - - Public + + + {{ volume.ResourceControl.Ownership ? volume.ResourceControl.Ownership : volume.ResourceControl.Ownership = 'public' }} diff --git a/app/components/volumes/volumesController.js b/app/components/volumes/volumesController.js index 7ec5774ad..89f3bd0c3 100644 --- a/app/components/volumes/volumesController.js +++ b/app/components/volumes/volumesController.js @@ -1,32 +1,11 @@ angular.module('volumes', []) -.controller('VolumesController', ['$scope', '$state', 'Volume', 'Notifications', 'Pagination', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', -function ($scope, $state, Volume, Notifications, Pagination, ModalService, Authentication, ResourceControlService, UserService) { +.controller('VolumesController', ['$q', '$scope', 'VolumeService', 'Notifications', 'Pagination', +function ($q, $scope, VolumeService, Notifications, Pagination) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('volumes'); $scope.state.selectedItemCount = 0; - $scope.sortType = 'Name'; - $scope.sortReverse = true; - $scope.config = { - Name: '' - }; - - function removeVolumeResourceControl(volume) { - ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) - .then(function success() { - delete volume.Metadata.ResourceControl; - Notifications.success('Ownership changed to public', volume.Name); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to change volume ownership"); - }); - } - - $scope.switchOwnership = function(volume) { - ModalService.confirmVolumeOwnershipChange(function (confirmed) { - if(!confirmed) { return; } - removeVolumeResourceControl(volume); - }); - }; + $scope.sortType = 'Id'; + $scope.sortReverse = false; $scope.changePaginationCount = function() { Pagination.setPaginationCount('volumes', $scope.state.pagination_count); @@ -57,88 +36,46 @@ function ($scope, $state, Volume, Notifications, Pagination, ModalService, Authe $scope.removeAction = function () { $('#loadVolumesSpinner').show(); var counter = 0; + var complete = function () { counter = counter - 1; if (counter === 0) { $('#loadVolumesSpinner').hide(); } }; + angular.forEach($scope.volumes, function (volume) { if (volume.Checked) { counter = counter + 1; - Volume.remove({name: volume.Name}, function (d) { - if (d.message) { - Notifications.error("Unable to remove volume", {}, d.message); - } else { - if (volume.Metadata && volume.Metadata.ResourceControl) { - ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) - .then(function success() { - Notifications.success("Volume deleted", volume.Name); - var index = $scope.volumes.indexOf(volume); - $scope.volumes.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to remove volume ownership"); - }); - } else { - Notifications.success("Volume deleted", volume.Name); - var index = $scope.volumes.indexOf(volume); - $scope.volumes.splice(index, 1); - } - } - complete(); - }, function (e) { - Notifications.error("Failure", e, "Unable to remove volume"); + VolumeService.remove(volume) + .then(function success() { + Notifications.success('Volume deleted', volume.Id); + var index = $scope.volumes.indexOf(volume); + $scope.volumes.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove volume'); + }) + .finally(function final() { complete(); }); } }); }; - function mapUsersToVolumes(users) { - angular.forEach($scope.volumes, function (volume) { - if (volume.Metadata) { - var volumeRC = volume.Metadata.ResourceControl; - if (volumeRC && volumeRC.OwnerId !== $scope.user.ID) { - angular.forEach(users, function (user) { - if (volumeRC.OwnerId === user.Id) { - volume.Owner = user.Username; - } - }); - } - } - }); - } - - function fetchVolumes() { + function initView() { $('#loadVolumesSpinner').show(); - var userDetails = Authentication.getUserDetails(); - $scope.user = userDetails; - - Volume.query({}, function (d) { - var volumes = d.Volumes || []; - $scope.volumes = volumes.map(function (v) { - return new VolumeViewModel(v); - }); - if (userDetails.role === 1) { - UserService.users() - .then(function success(data) { - mapUsersToVolumes(data); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve users"); - }) - .finally(function final() { - $('#loadVolumesSpinner').hide(); - }); - } else { - $('#loadVolumesSpinner').hide(); - } - }, function (e) { - $('#loadVolumesSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve volumes"); + VolumeService.volumes() + .then(function success(data) { + $scope.volumes = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volumes'); $scope.volumes = []; + }) + .finally(function final() { + $('#loadVolumesSpinner').hide(); }); } - fetchVolumes(); + initView(); }]); diff --git a/app/directives/header-content.js b/app/directives/header-content.js index e372e28a4..8636c8ebe 100644 --- a/app/directives/header-content.js +++ b/app/directives/header-content.js @@ -7,7 +7,7 @@ angular link: function (scope, iElement, iAttrs) { scope.username = Authentication.getUserDetails().username; }, - template: '', + template: '', restrict: 'E' }; return directive; diff --git a/app/directives/header.js b/app/directives/header.js index 016a25a0c..18451ce9b 100644 --- a/app/directives/header.js +++ b/app/directives/header.js @@ -3,7 +3,7 @@ angular .directive('rdHeader', function rdHeader() { var directive = { scope: { - "ngModel": "=" + 'ngModel': '=' }, transclude: true, template: '
    ', diff --git a/app/filters/filters.js b/app/filters/filters.js index 396e35d45..5f0569cd4 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -246,4 +246,29 @@ angular.module('portainer.filters', []) } return ''; }; +}) +.filter('ownershipicon', function () { + 'use strict'; + return function (ownership) { + switch (ownership) { + case 'private': + return 'fa fa-eye-slash'; + case 'administrators': + return 'fa fa-eye-slash'; + case 'restricted': + return 'fa fa-users'; + default: + return 'fa fa-eye'; + } + }; +}) +.filter('tasknodename', function () { + 'use strict'; + return function (nodeId, nodes) { + var node = _.find(nodes, { Id: nodeId }); + if (node) { + return node.Hostname; + } + return ''; + }; }); diff --git a/app/helpers/infoHelper.js b/app/helpers/infoHelper.js index 5f2ad14cd..fd3aa511a 100644 --- a/app/helpers/infoHelper.js +++ b/app/helpers/infoHelper.js @@ -8,21 +8,21 @@ angular.module('portainer.helpers') role: '' }; if (_.startsWith(info.ServerVersion, 'swarm')) { - mode.provider = "DOCKER_SWARM"; + mode.provider = 'DOCKER_SWARM'; if (info.SystemStatus[0][1] === 'primary') { - mode.role = "PRIMARY"; + mode.role = 'PRIMARY'; } else { - mode.role = "REPLICA"; + mode.role = 'REPLICA'; } } else { if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { - mode.provider = "DOCKER_STANDALONE"; + mode.provider = 'DOCKER_STANDALONE'; } else { - mode.provider = "DOCKER_SWARM_MODE"; + mode.provider = 'DOCKER_SWARM_MODE'; if (info.Swarm.ControlAvailable) { - mode.role = "MANAGER"; + mode.role = 'MANAGER'; } else { - mode.role = "WORKER"; + mode.role = 'WORKER'; } } } diff --git a/app/helpers/nodeHelper.js b/app/helpers/nodeHelper.js index 748f8aa91..b40903cc8 100644 --- a/app/helpers/nodeHelper.js +++ b/app/helpers/nodeHelper.js @@ -13,10 +13,10 @@ angular.module('portainer.helpers') getManagerIP: function(nodes) { var managerIp; for (var n in nodes) { - if (undefined === nodes[n].ManagerStatus || nodes[n].ManagerStatus.Reachability !== "reachable") { + if (undefined === nodes[n].ManagerStatus || nodes[n].ManagerStatus.Reachability !== 'reachable') { continue; } - managerIp = nodes[n].ManagerStatus.Addr.split(":")[0]; + managerIp = nodes[n].ManagerStatus.Addr.split(':')[0]; } return managerIp; } diff --git a/app/helpers/resourceControlHelper.js b/app/helpers/resourceControlHelper.js new file mode 100644 index 000000000..b5ca4d1c7 --- /dev/null +++ b/app/helpers/resourceControlHelper.js @@ -0,0 +1,42 @@ +angular.module('portainer.helpers') +.factory('ResourceControlHelper', [function ResourceControlHelperFactory() { + 'use strict'; + var helper = {}; + + helper.retrieveAuthorizedUsers = function(resourceControl, users) { + var authorizedUserNames = []; + angular.forEach(resourceControl.UserAccesses, function(access) { + var user = _.find(users, { Id: access.UserId }); + if (user) { + authorizedUserNames.push(user); + } + }); + return authorizedUserNames; + }; + + helper.retrieveAuthorizedTeams = function(resourceControl, teams) { + var authorizedTeamNames = []; + angular.forEach(resourceControl.TeamAccesses, function(access) { + var team = _.find(teams, { Id: access.TeamId }); + if (team) { + authorizedTeamNames.push(team); + } + }); + return authorizedTeamNames; + }; + + helper.isLeaderOfAnyRestrictedTeams = function(userMemberships, resourceControl) { + var isTeamLeader = false; + for (var i = 0; i < userMemberships.length; i++) { + var membership = userMemberships[i]; + var found = _.find(resourceControl.TeamAccesses, { TeamId :membership.TeamId }); + if (found && membership.Role === 1) { + isTeamLeader = true; + break; + } + } + return isTeamLeader; + }; + + return helper; +}]); diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index a97bdd978..a4f626fd5 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -83,7 +83,7 @@ angular.module('portainer.helpers') if (volume.containerPath) { var binding; if (volume.type === 'auto') { - binding = generatedVolumesPile.pop().Name + ':' + volume.containerPath; + binding = generatedVolumesPile.pop().Id + ':' + volume.containerPath; } else if (volume.type !== 'auto' && volume.name) { binding = volume.name + ':' + volume.containerPath; } diff --git a/app/helpers/userHelper.js b/app/helpers/userHelper.js new file mode 100644 index 000000000..5962b1bb5 --- /dev/null +++ b/app/helpers/userHelper.js @@ -0,0 +1,15 @@ +angular.module('portainer.helpers') +.factory('UserHelper', [function UserHelperFactory() { + 'use strict'; + var helper = {}; + + helper.filterNonAdministratorUsers = function(users) { + return users.filter(function (user) { + if (user.Role !== 1) { + return user; + } + }); + }; + + return helper; +}]); diff --git a/app/models/api/endpointAccess.js b/app/models/api/endpointAccess.js new file mode 100644 index 000000000..d592522f4 --- /dev/null +++ b/app/models/api/endpointAccess.js @@ -0,0 +1,11 @@ +function EndpointAccessUserViewModel(data) { + this.Id = data.Id; + this.Name = data.Username; + this.Type = 'user'; +} + +function EndpointAccessTeamViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Type = 'team'; +} diff --git a/app/models/api/resourceControl.js b/app/models/api/resourceControl.js new file mode 100644 index 000000000..f7e08c3f1 --- /dev/null +++ b/app/models/api/resourceControl.js @@ -0,0 +1,19 @@ +function ResourceControlViewModel(data) { + this.Id = data.Id; + this.Type = data.Type; + this.ResourceId = data.ResourceId; + this.UserAccesses = data.UserAccesses; + this.TeamAccesses = data.TeamAccesses; + this.AdministratorsOnly = data.AdministratorsOnly; + this.Ownership = determineOwnership(this); +} + +function determineOwnership(resourceControl) { + if (resourceControl.AdministratorsOnly) { + return 'administrators'; + } else if (resourceControl.UserAccesses.length === 1 && resourceControl.TeamAccesses.length === 0) { + return 'private'; + } else if (resourceControl.UserAccesses.length > 1 || resourceControl.TeamAccesses.length > 0) { + return 'restricted'; + } +} diff --git a/app/models/api/team.js b/app/models/api/team.js new file mode 100644 index 000000000..447d2c852 --- /dev/null +++ b/app/models/api/team.js @@ -0,0 +1,5 @@ +function TeamViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Checked = false; +} diff --git a/app/models/api/teamMembership.js b/app/models/api/teamMembership.js new file mode 100644 index 000000000..0d2454547 --- /dev/null +++ b/app/models/api/teamMembership.js @@ -0,0 +1,6 @@ +function TeamMembershipModel(data) { + this.Id = data.Id; + this.UserId = data.UserID; + this.TeamId = data.TeamID; + this.Role = data.Role; +} diff --git a/app/models/template.js b/app/models/api/template.js similarity index 100% rename from app/models/template.js rename to app/models/api/template.js diff --git a/app/models/templateLinuxServer.js b/app/models/api/templateLinuxServer.js similarity index 100% rename from app/models/templateLinuxServer.js rename to app/models/api/templateLinuxServer.js diff --git a/app/models/user.js b/app/models/api/user.js similarity index 62% rename from app/models/user.js rename to app/models/api/user.js index 7b021284b..1177fc137 100644 --- a/app/models/user.js +++ b/app/models/api/user.js @@ -1,11 +1,11 @@ function UserViewModel(data) { this.Id = data.Id; this.Username = data.Username; - this.RoleId = data.Role; + this.Role = data.Role; if (data.Role === 1) { - this.RoleName = "administrator"; + this.RoleName = 'administrator'; } else { - this.RoleName = "user"; + this.RoleName = 'user'; } this.Checked = false; } diff --git a/app/models/container.js b/app/models/docker/container.js similarity index 85% rename from app/models/container.js rename to app/models/docker/container.js index bb4a183f8..8041a24e3 100644 --- a/app/models/container.js +++ b/app/models/docker/container.js @@ -20,11 +20,8 @@ function ContainerViewModel(data) { } } if (data.Portainer) { - this.Metadata = {}; if (data.Portainer.ResourceControl) { - this.Metadata.ResourceControl = { - OwnerId: data.Portainer.ResourceControl.OwnerId - }; + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } } diff --git a/app/models/docker/containerDetails.js b/app/models/docker/containerDetails.js new file mode 100644 index 000000000..58ecd17d7 --- /dev/null +++ b/app/models/docker/containerDetails.js @@ -0,0 +1,15 @@ +function ContainerDetailsViewModel(data) { + this.Id = data.Id; + this.State = data.State; + this.Name = data.Name; + this.NetworkSettings = data.NetworkSettings; + this.Args = data.Args; + this.Image = data.Image; + this.Config = data.Config; + this.HostConfig = data.HostConfig; + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } +} diff --git a/app/models/event.js b/app/models/docker/event.js similarity index 100% rename from app/models/event.js rename to app/models/docker/event.js diff --git a/app/models/image.js b/app/models/docker/image.js similarity index 100% rename from app/models/image.js rename to app/models/docker/image.js diff --git a/app/models/imageDetails.js b/app/models/docker/imageDetails.js similarity index 100% rename from app/models/imageDetails.js rename to app/models/docker/imageDetails.js diff --git a/app/models/node.js b/app/models/docker/node.js similarity index 100% rename from app/models/node.js rename to app/models/docker/node.js diff --git a/app/models/service.js b/app/models/docker/service.js similarity index 95% rename from app/models/service.js rename to app/models/docker/service.js index 5f59e2407..766a4aaee 100644 --- a/app/models/service.js +++ b/app/models/docker/service.js @@ -80,11 +80,8 @@ function ServiceViewModel(data, runningTasks, nodes) { this.EditName = false; if (data.Portainer) { - this.Metadata = {}; if (data.Portainer.ResourceControl) { - this.Metadata.ResourceControl = { - OwnerId: data.Portainer.ResourceControl.OwnerId - }; + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } } diff --git a/app/models/docker/task.js b/app/models/docker/task.js new file mode 100644 index 000000000..ab23be4c0 --- /dev/null +++ b/app/models/docker/task.js @@ -0,0 +1,10 @@ +function TaskViewModel(data) { + this.Id = data.ID; + this.Created = data.CreatedAt; + this.Updated = data.UpdatedAt; + this.Slot = data.Slot; + this.Spec = data.Spec; + this.Status = data.Status; + this.ServiceId = data.ServiceID; + this.NodeId = data.NodeID; +} diff --git a/app/models/volume.js b/app/models/docker/volume.js similarity index 50% rename from app/models/volume.js rename to app/models/docker/volume.js index f46356ce5..fc6dbd848 100644 --- a/app/models/volume.js +++ b/app/models/docker/volume.js @@ -1,14 +1,13 @@ function VolumeViewModel(data) { - this.Id = data.Id; - this.Name = data.Name; + this.Id = data.Name; this.Driver = data.Driver; + this.Options = data.Options; + this.Labels = data.Labels; this.Mountpoint = data.Mountpoint; + if (data.Portainer) { - this.Metadata = {}; if (data.Portainer.ResourceControl) { - this.Metadata.ResourceControl = { - OwnerId: data.Portainer.ResourceControl.OwnerId - }; + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } } diff --git a/app/models/task.js b/app/models/task.js deleted file mode 100644 index d1b82e8a6..000000000 --- a/app/models/task.js +++ /dev/null @@ -1,15 +0,0 @@ -function TaskViewModel(data, node_data) { - this.Id = data.ID; - this.Created = data.CreatedAt; - this.Updated = data.UpdatedAt; - this.Slot = data.Slot; - this.Status = data.Status.State; - this.Image = data.Spec.ContainerSpec ? data.Spec.ContainerSpec.Image : ''; - if (node_data) { - for (var i = 0; i < node_data.length; ++i) { - if (data.NodeID === node_data[i].ID) { - this.Node = node_data[i].Description.Hostname; - } - } - } -} diff --git a/app/rest/endpoint.js b/app/rest/endpoint.js index 5919c0b61..c2cf17fdf 100644 --- a/app/rest/endpoint.js +++ b/app/rest/endpoint.js @@ -7,6 +7,6 @@ angular.module('portainer.rest') get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, - remove: { method: 'DELETE', params: { id: '@id'} }, + remove: { method: 'DELETE', params: { id: '@id'} } }); }]); diff --git a/app/rest/resourceControl.js b/app/rest/resourceControl.js index 7734429e6..bcdebde65 100644 --- a/app/rest/resourceControl.js +++ b/app/rest/resourceControl.js @@ -1,8 +1,10 @@ angular.module('portainer.rest') -.factory('ResourceControl', ['$resource', 'USERS_ENDPOINT', function ResourceControlFactory($resource, USERS_ENDPOINT) { +.factory('ResourceControl', ['$resource', 'RESOURCE_CONTROL_ENDPOINT', function ResourceControlFactory($resource, RESOURCE_CONTROL_ENDPOINT) { 'use strict'; - return $resource(USERS_ENDPOINT + '/:userId/resources/:resourceType/:resourceId', {}, { - create: { method: 'POST', params: { userId: '@userId', resourceType: '@resourceType' } }, - remove: { method: 'DELETE', params: { userId: '@userId', resourceId: '@resourceId', resourceType: '@resourceType' } }, + return $resource(RESOURCE_CONTROL_ENDPOINT + '/:id', {}, { + create: { method: 'POST' }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} } }); }]); diff --git a/app/rest/response/handlers.js b/app/rest/response/handlers.js index 8b60fea6f..03aa6c1d1 100644 --- a/app/rest/response/handlers.js +++ b/app/rest/response/handlers.js @@ -5,7 +5,7 @@ function isJSONArray(jsonString) { function isJSON(jsonString) { try { var o = JSON.parse(jsonString); - if (o && typeof o === "object") { + if (o && typeof o === 'object') { return o; } } @@ -17,7 +17,7 @@ function isJSON(jsonString) { // This handler wrap the JSON objects in an array. // Used by the API in: Image push, Image create, Events query. function jsonObjectsToArrayHandler(data) { - var str = "[" + data.replace(/\n/g, " ").replace(/\}\s*\{/g, "}, {") + "]"; + var str = '[' + data.replace(/\n/g, ' ').replace(/\}\s*\{/g, '}, {') + ']'; return angular.fromJson(str); } diff --git a/app/rest/team.js b/app/rest/team.js new file mode 100644 index 000000000..fd55e95b3 --- /dev/null +++ b/app/rest/team.js @@ -0,0 +1,12 @@ +angular.module('portainer.rest') +.factory('Teams', ['$resource', 'TEAMS_ENDPOINT', function TeamsFactory($resource, TEAMS_ENDPOINT) { + 'use strict'; + return $resource(TEAMS_ENDPOINT + '/:id/:entity/:entityId', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} }, + queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } } + }); +}]); diff --git a/app/rest/teamMembership.js b/app/rest/teamMembership.js new file mode 100644 index 000000000..39b49134c --- /dev/null +++ b/app/rest/teamMembership.js @@ -0,0 +1,10 @@ +angular.module('portainer.rest') +.factory('TeamMemberships', ['$resource', 'TEAM_MEMBERSHIPS_ENDPOINT', function TeamMembershipsFactory($resource, TEAM_MEMBERSHIPS_ENDPOINT) { + 'use strict'; + return $resource(TEAM_MEMBERSHIPS_ENDPOINT + '/:id/:action', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} } + }); +}]); diff --git a/app/rest/user.js b/app/rest/user.js index cc55f448d..6189130cc 100644 --- a/app/rest/user.js +++ b/app/rest/user.js @@ -1,15 +1,17 @@ angular.module('portainer.rest') .factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { 'use strict'; - return $resource(USERS_ENDPOINT + '/:id/:action', {}, { + return $resource(USERS_ENDPOINT + '/:id/:entity/:entityId', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, remove: { method: 'DELETE', params: { id: '@id'} }, + queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } }, + queryTeams: { method: 'GET', isArray: true, params: { id: '@id', entity: 'teams' } }, // RPCs should be moved to a specific endpoint - checkPassword: { method: 'POST', params: { id: '@id', action: 'passwd' } }, - checkAdminUser: { method: 'GET', params: { id: 'admin', action: 'check' }, isArray: true }, - initAdminUser: { method: 'POST', params: { id: 'admin', action: 'init' } } + checkPassword: { method: 'POST', params: { id: '@id', entity: 'passwd' } }, + checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true }, + initAdminUser: { method: 'POST', params: { id: 'admin', entity: 'init' } } }); }]); diff --git a/app/rest/volume.js b/app/rest/volume.js index 132fdec01..e6a5aa2c4 100644 --- a/app/rest/volume.js +++ b/app/rest/volume.js @@ -1,17 +1,16 @@ angular.module('portainer.rest') .factory('Volume', ['$resource', 'Settings', 'EndpointProvider', function VolumeFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/:endpointId/volumes/:name/:action', + return $resource(Settings.url + '/:endpointId/volumes/:id/:action', { - name: '@name', endpointId: EndpointProvider.endpointID }, { - query: {method: 'GET'}, - get: {method: 'GET'}, + query: { method: 'GET' }, + get: { method: 'GET', params: {id: '@id'} }, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, remove: { - method: 'DELETE', transformResponse: genericHandler + method: 'DELETE', transformResponse: genericHandler, params: {id: '@id'} } }); }]); diff --git a/app/services/containerService.js b/app/services/containerService.js index 41ff5c9f1..8271b49b3 100644 --- a/app/services/containerService.js +++ b/app/services/containerService.js @@ -1,5 +1,5 @@ angular.module('portainer.services') -.factory('ContainerService', ['$q', 'Container', 'ContainerHelper', function ContainerServiceFactory($q, Container, ContainerHelper) { +.factory('ContainerService', ['$q', 'Container', 'ContainerHelper', 'ResourceControlService', function ContainerServiceFactory($q, Container, ContainerHelper, ResourceControlService) { 'use strict'; var service = {}; @@ -67,5 +67,28 @@ angular.module('portainer.services') }); return deferred.promise; }; + + service.remove = function(container, removeVolumes) { + var deferred = $q.defer(); + + Container.remove({id: container.Id, v: (removeVolumes) ? 1 : 0, force: true}).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message, err: data.message }); + } + if (container.ResourceControl && container.ResourceControl.Type === 1) { + return ResourceControlService.deleteResourceControl(container.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove container', err: err }); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/services/controllerDataPipeline.js b/app/services/controllerDataPipeline.js new file mode 100644 index 000000000..663bc9dbd --- /dev/null +++ b/app/services/controllerDataPipeline.js @@ -0,0 +1,36 @@ +// ControllerDataPipeline is used to transfer data between multiple controllers. +angular.module('portainer.services') +.factory('ControllerDataPipeline', [function ControllerDataPipelineFactory() { + 'use strict'; + + var pipeline = {}; + + // accessControlData is used to manage the data required by the accessControlPanelController. + var accessControlData = {}; + + pipeline.setAccessControlData = function (type, resourceId, resourceControl) { + accessControlData.resourceType = type; + accessControlData.resourceId = resourceId; + accessControlData.resourceControl = resourceControl; + }; + + pipeline.getAccessControlData = function() { + return accessControlData; + }; + + // accessControlFormData is used to manage the data available in the scope of the accessControlFormController. + var accessControlFormData = {}; + + pipeline.setAccessControlFormData = function(accessControlEnabled, ownership, authorizedUsers, authorizedTeams) { + accessControlFormData.accessControlEnabled = accessControlEnabled; + accessControlFormData.ownership = ownership; + accessControlFormData.authorizedUsers = authorizedUsers; + accessControlFormData.authorizedTeams = authorizedTeams; + }; + + pipeline.getAccessControlFormData = function() { + return accessControlFormData; + }; + + return pipeline; +}]); diff --git a/app/services/endpointProvider.js b/app/services/endpointProvider.js index 4f1c870a9..cc1afc4da 100644 --- a/app/services/endpointProvider.js +++ b/app/services/endpointProvider.js @@ -1,8 +1,9 @@ angular.module('portainer.services') .factory('EndpointProvider', ['LocalStorage', function EndpointProviderFactory(LocalStorage) { 'use strict'; - var endpoint = {}; var service = {}; + var endpoint = {}; + service.initialize = function() { var endpointID = LocalStorage.getEndpointID(); var endpointPublicURL = LocalStorage.getEndpointPublicURL(); @@ -13,22 +14,28 @@ angular.module('portainer.services') endpoint.PublicURL = endpointPublicURL; } }; + service.clean = function() { endpoint = {}; }; + service.endpointID = function() { return endpoint.ID; }; + service.setEndpointID = function(id) { endpoint.ID = id; LocalStorage.storeEndpointID(id); }; + service.endpointPublicURL = function() { return endpoint.PublicURL; - } + }; + service.setEndpointPublicURL = function(publicURL) { endpoint.PublicURL = publicURL; LocalStorage.storeEndpointPublicURL(publicURL); - } + }; + return service; }]); diff --git a/app/services/endpointService.js b/app/services/endpointService.js index 0b4e88418..693f9aea9 100644 --- a/app/services/endpointService.js +++ b/app/services/endpointService.js @@ -11,8 +11,8 @@ angular.module('portainer.services') return Endpoints.query({}).$promise; }; - service.updateAuthorizedUsers = function(id, authorizedUserIDs) { - return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs}).$promise; + service.updateAccess = function(id, authorizedUserIDs, authorizedTeamIDs) { + return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise; }; service.updateEndpoint = function(id, endpointParams) { @@ -23,7 +23,7 @@ angular.module('portainer.services') authorizedUsers: endpointParams.authorizedUsers }; if (endpointParams.type && endpointParams.URL) { - query.URL = endpointParams.type === 'local' ? ("unix://" + endpointParams.URL) : ("tcp://" + endpointParams.URL); + query.URL = endpointParams.type === 'local' ? ('unix://' + endpointParams.URL) : ('tcp://' + endpointParams.URL); } var deferred = $q.defer(); @@ -48,8 +48,8 @@ angular.module('portainer.services') service.createLocalEndpoint = function(name, URL, TLS, active) { var endpoint = { - Name: "local", - URL: "unix:///var/run/docker.sock", + Name: 'local', + URL: 'unix:///var/run/docker.sock', TLS: false }; return Endpoints.create({}, endpoint).$promise; diff --git a/app/services/formValidator.js b/app/services/formValidator.js new file mode 100644 index 000000000..d61419d9a --- /dev/null +++ b/app/services/formValidator.js @@ -0,0 +1,24 @@ +angular.module('portainer.services') +.factory('FormValidator', [function FormValidatorFactory() { + 'use strict'; + + var validator = {}; + + validator.validateAccessControl = function(accessControlData, isAdmin) { + if (!accessControlData.accessControlEnabled) { + return ''; + } + + if (isAdmin && accessControlData.ownership === 'restricted' && + accessControlData.authorizedUsers.length === 0 && + accessControlData.authorizedTeams.length === 0) { + return 'You must specify at least one team or user.'; + } else if (!isAdmin && accessControlData.ownership === 'restricted' && + accessControlData.authorizedTeams.length === 0) { + return 'You must specify at least a team.'; + } + return ''; + }; + + return validator; +}]); diff --git a/app/services/lineChart.js b/app/services/lineChart.js index 12c848ee0..1bfbc8473 100644 --- a/app/services/lineChart.js +++ b/app/services/lineChart.js @@ -3,7 +3,7 @@ angular.module('portainer.services') 'use strict'; return { build: function (id, data, getkey) { - var chart = new Chart($(id).get(0).getContext("2d")); + var chart = new Chart($(id).get(0).getContext('2d')); var map = {}; for (var i = 0; i < data.length; i++) { @@ -33,10 +33,10 @@ angular.module('portainer.services') } var steps = Math.min(max, 10); var dataset = { - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: data }; chart.Line({ diff --git a/app/services/modalService.js b/app/services/modalService.js index c70704935..ad55be8ed 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -46,46 +46,31 @@ angular.module('portainer.services') applyBoxCSS(box); }; - service.confirmOwnershipChange = function(callback, msg) { + service.confirmAccessControlUpdate = function(callback, msg) { service.confirm({ title: 'Are you sure ?', - message: msg, + message: 'Changing the ownership of this resource will potentially restrict its management to some users.', buttons: { confirm: { label: 'Change ownership', className: 'btn-primary' } }, - callback: callback, + callback: callback }); }; - service.confirmContainerOwnershipChange = function(callback) { - var msg = 'You can change the ownership of a container one way only. You will not be able to make this container private again. Changing ownership on this container will also change the ownership on any attached volume.'; - service.confirmOwnershipChange(callback, msg); - }; - - service.confirmServiceOwnershipChange = function(callback) { - var msg = 'You can change the ownership of a service one way only. You will not be able to make this service private again. Changing ownership on this service will also change the ownership on any attached volume.'; - service.confirmOwnershipChange(callback, msg); - }; - - service.confirmVolumeOwnershipChange = function(callback) { - var msg = 'You can change the ownership of a volume one way only. You will not be able to make this volume private again.'; - service.confirmOwnershipChange(callback, msg); - }; - service.confirmImageForceRemoval = function(callback) { service.confirm({ - title: "Are you sure?", - message: "Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.", + title: 'Are you sure?', + message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.', buttons: { confirm: { label: 'Remove the image', className: 'btn-danger' } }, - callback: callback, + callback: callback }); }; @@ -99,7 +84,7 @@ angular.module('portainer.services') className: 'btn-danger' } }, - callback: callback, + callback: callback }); }; diff --git a/app/services/networkService.js b/app/services/networkService.js index fb25eead7..e3abe33e7 100644 --- a/app/services/networkService.js +++ b/app/services/networkService.js @@ -24,9 +24,9 @@ angular.module('portainer.services') }; service.addPredefinedLocalNetworks = function(networks) { - networks.push({Scope: "local", Name: "bridge"}); - networks.push({Scope: "local", Name: "host"}); - networks.push({Scope: "local", Name: "none"}); + networks.push({Scope: 'local', Name: 'bridge'}); + networks.push({Scope: 'local', Name: 'host'}); + networks.push({Scope: 'local', Name: 'none'}); }; return service; diff --git a/app/services/nodeService.js b/app/services/nodeService.js new file mode 100644 index 000000000..2fb394a58 --- /dev/null +++ b/app/services/nodeService.js @@ -0,0 +1,24 @@ +angular.module('portainer.services') +.factory('NodeService', ['$q', 'Node', function NodeServiceFactory($q, Node) { + 'use strict'; + var service = {}; + + service.nodes = function(id) { + var deferred = $q.defer(); + + Node.query({}).$promise + .then(function success(data) { + var nodes = data.map(function (item) { + return new NodeViewModel(item); + }); + deferred.resolve(nodes); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve nodes', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/resourceControlService.js b/app/services/resourceControlService.js index 3b30c2680..d18511e7e 100644 --- a/app/services/resourceControlService.js +++ b/app/services/resourceControlService.js @@ -1,30 +1,124 @@ angular.module('portainer.services') -.factory('ResourceControlService', ['$q', 'ResourceControl', function ResourceControlServiceFactory($q, ResourceControl) { +.factory('ResourceControlService', ['$q', 'ResourceControl', 'UserService', 'TeamService', 'ResourceControlHelper', function ResourceControlServiceFactory($q, ResourceControl, UserService, TeamService, ResourceControlHelper) { 'use strict'; var service = {}; - service.setContainerResourceControl = function(userID, resourceID) { - return ResourceControl.create({ userId: userID, resourceType: 'container' }, { ResourceID: resourceID }).$promise; + service.createResourceControl = function(administratorsOnly, userIDs, teamIDs, resourceID, type, subResourceIDs) { + var payload = { + Type: type, + AdministratorsOnly: administratorsOnly, + ResourceID: resourceID, + Users: userIDs, + Teams: teamIDs, + SubResourceIDs: subResourceIDs + }; + return ResourceControl.create({}, payload).$promise; }; - service.removeContainerResourceControl = function(userID, resourceID) { - return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'container' }).$promise; + service.deleteResourceControl = function(rcID) { + return ResourceControl.remove({id: rcID}).$promise; }; - service.setServiceResourceControl = function(userID, resourceID) { - return ResourceControl.create({ userId: userID, resourceType: 'service' }, { ResourceID: resourceID }).$promise; + service.updateResourceControl = function(admin, userIDs, teamIDs, resourceControlId) { + var payload = { + AdministratorsOnly: admin, + Users: userIDs, + Teams: teamIDs + }; + return ResourceControl.update({id: resourceControlId}, payload).$promise; }; - service.removeServiceResourceControl = function(userID, resourceID) { - return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'service' }).$promise; + service.applyResourceControl = function(resourceControlType, resourceIdentifier, userId, accessControlData, subResources) { + if (!accessControlData.accessControlEnabled) { + return; + } + + var authorizedUserIds = []; + var authorizedTeamIds = []; + var administratorsOnly = false; + switch (accessControlData.ownership) { + case 'administrators': + administratorsOnly = true; + break; + case 'private': + authorizedUserIds.push(userId); + break; + case 'restricted': + angular.forEach(accessControlData.authorizedUsers, function(user) { + authorizedUserIds.push(user.Id); + }); + angular.forEach(accessControlData.authorizedTeams, function(team) { + authorizedTeamIds.push(team.Id); + }); + break; + } + return service.createResourceControl(administratorsOnly, authorizedUserIds, + authorizedTeamIds, resourceIdentifier, resourceControlType, subResources); }; - service.setVolumeResourceControl = function(userID, resourceID) { - return ResourceControl.create({ userId: userID, resourceType: 'volume' }, { ResourceID: resourceID }).$promise; + service.applyResourceControlChange = function(resourceControlType, resourceId, resourceControl, ownershipParameters) { + if (resourceControl) { + if (ownershipParameters.ownership === 'public') { + return service.deleteResourceControl(resourceControl.Id); + } else { + return service.updateResourceControl(ownershipParameters.administratorsOnly, ownershipParameters.authorizedUserIds, + ownershipParameters.authorizedTeamIds, resourceControl.Id); + } + } else { + return service.createResourceControl(ownershipParameters.administratorsOnly, ownershipParameters.authorizedUserIds, + ownershipParameters.authorizedTeamIds, resourceId, resourceControlType); + } }; - service.removeVolumeResourceControl = function(userID, resourceID) { - return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'volume' }).$promise; + service.retrieveOwnershipDetails = function(resourceControl) { + var deferred = $q.defer(); + + if (!resourceControl) { + deferred.resolve({ authorizedUsers: [], authorizedTeams: [] }); + return deferred.promise; + } + + $q.all({ + users: resourceControl.UserAccesses.length > 0 ? UserService.users(false) : [], + teams: resourceControl.TeamAccesses.length > 0 ? TeamService.teams() : [] + }) + .then(function success(data) { + var authorizedUserNames = ResourceControlHelper.retrieveAuthorizedUsers(resourceControl, data.users); + var authorizedTeamNames = ResourceControlHelper.retrieveAuthorizedTeams(resourceControl, data.teams); + deferred.resolve({ authorizedUsers: authorizedUserNames, authorizedTeams: authorizedTeamNames }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user and team information', err: err }); + }); + + return deferred.promise; + }; + + service.retrieveUserPermissionsOnResource = function(userID, isAdministrator, resourceControl) { + var deferred = $q.defer(); + + if (!resourceControl || isAdministrator) { + deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: false }); + return deferred.promise; + } + + var found = _.find(resourceControl.UserAccesses, { UserId: userID }); + if (found) { + deferred.resolve({ isPartOfRestrictedUsers: true, isLeaderOfAnyRestrictedTeams: false }); + } else { + var isTeamLeader = false; + UserService.userMemberships(userID) + .then(function success(data) { + var memberships = data; + isTeamLeader = ResourceControlHelper.isLeaderOfAnyRestrictedTeams(memberships, resourceControl); + deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: isTeamLeader }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships', err: err }); + }); + } + + return deferred.promise; }; return service; diff --git a/app/services/serviceService.js b/app/services/serviceService.js new file mode 100644 index 000000000..939f1875e --- /dev/null +++ b/app/services/serviceService.js @@ -0,0 +1,41 @@ +angular.module('portainer.services') +.factory('ServiceService', ['$q', 'Service', 'ResourceControlService', function ServiceServiceFactory($q, Service, ResourceControlService) { + 'use strict'; + var service = {}; + + service.service = function(id) { + var deferred = $q.defer(); + + Service.get({ id: id }).$promise + .then(function success(data) { + var service = new ServiceViewModel(data); + deferred.resolve(service); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve service details', err: err }); + }); + + return deferred.promise; + }; + + service.remove = function(service) { + var deferred = $q.defer(); + + Service.remove({id: service.Id}).$promise + .then(function success() { + if (service.ResourceControl && service.ResourceControl.Type === 2) { + return ResourceControlService.deleteResourceControl(service.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove service', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/taskService.js b/app/services/taskService.js new file mode 100644 index 000000000..55b9b4f67 --- /dev/null +++ b/app/services/taskService.js @@ -0,0 +1,39 @@ +angular.module('portainer.services') +.factory('TaskService', ['$q', 'Task', function TaskServiceFactory($q, Task) { + 'use strict'; + var service = {}; + + service.task = function(id) { + var deferred = $q.defer(); + + Task.get({ id: id }).$promise + .then(function success(data) { + var task = new TaskViewModel(data); + deferred.resolve(task); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve task details', err: err }); + }); + + return deferred.promise; + }; + + service.serviceTasks = function(serviceName) { + var deferred = $q.defer(); + + Task.query({ filters: { service: [serviceName] } }).$promise + .then(function success(data) { + var tasks = data.map(function (item) { + return new TaskViewModel(item); + }); + deferred.resolve(tasks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve tasks associated to the service', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/teamMembershipService.js b/app/services/teamMembershipService.js new file mode 100644 index 000000000..4b2c43ebd --- /dev/null +++ b/app/services/teamMembershipService.js @@ -0,0 +1,44 @@ +angular.module('portainer.services') +.factory('TeamMembershipService', ['$q', 'TeamMemberships', function TeamMembershipFactory($q, TeamMemberships) { + 'use strict'; + var service = {}; + + service.memberships = function() { + var deferred = $q.defer(); + TeamMemberships.query().$promise + .then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve team memberships', err: err}); + }); + return deferred.promise; + }; + + service.createMembership = function(userId, teamId, role) { + var payload = { + UserID: userId, + TeamID: teamId, + Role: role + }; + return TeamMemberships.create({}, payload).$promise; + }; + + service.deleteMembership = function(id) { + return TeamMemberships.remove({id: id}).$promise; + }; + + service.updateMembership = function(id, userId, teamId, role) { + var payload = { + UserID: userId, + TeamID: teamId, + Role: role + }; + return TeamMemberships.update({id: id}, payload).$promise; + }; + + return service; +}]); diff --git a/app/services/teamService.js b/app/services/teamService.js new file mode 100644 index 000000000..3b21f2bf6 --- /dev/null +++ b/app/services/teamService.js @@ -0,0 +1,84 @@ +angular.module('portainer.services') +.factory('TeamService', ['$q', 'Teams', 'TeamMembershipService', function TeamServiceFactory($q, Teams, TeamMembershipService) { + 'use strict'; + var service = {}; + + service.teams = function() { + var deferred = $q.defer(); + Teams.query().$promise + .then(function success(data) { + var teams = data.map(function (item) { + return new TeamViewModel(item); + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve teams', err: err}); + }); + return deferred.promise; + }; + + service.team = function(id) { + var deferred = $q.defer(); + Teams.get({id: id}).$promise + .then(function success(data) { + var team = new TeamViewModel(data); + deferred.resolve(team); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve team details', err: err}); + }); + return deferred.promise; + }; + + service.createTeam = function(name, leaderIds) { + var deferred = $q.defer(); + var payload = { + Name: name + }; + Teams.create({}, payload).$promise + .then(function success(data) { + var teamId = data.Id; + var teamMembershipQueries = []; + angular.forEach(leaderIds, function(userId) { + teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 1)); + }); + $q.all(teamMembershipQueries) + .then(function success() { + deferred.resolve(); + }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create team', err: err }); + }); + return deferred.promise; + }; + + service.deleteTeam = function(id) { + return Teams.remove({id: id}).$promise; + }; + + service.updateTeam = function(id, name, members, leaders) { + var payload = { + Name: name + }; + return Teams.update({id: id}, payload).$promise; + }; + + service.userMemberships = function(id) { + var deferred = $q.defer(); + Teams.queryMemberships({id: id}).$promise + .then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships for the team', err: err }); + }); + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/userService.js b/app/services/userService.js index a836d298d..24e0f97a2 100644 --- a/app/services/userService.js +++ b/app/services/userService.js @@ -1,17 +1,63 @@ angular.module('portainer.services') -.factory('UserService', ['$q', 'Users', function UserServiceFactory($q, Users) { +.factory('UserService', ['$q', 'Users', 'UserHelper', 'TeamMembershipService', function UserServiceFactory($q, Users, UserHelper, TeamMembershipService) { 'use strict'; var service = {}; - service.users = function() { - return Users.query({}).$promise; + + service.users = function(includeAdministrators) { + var deferred = $q.defer(); + + Users.query({}).$promise + .then(function success(data) { + var users = data.map(function (user) { + return new UserViewModel(user); + }); + if (!includeAdministrators) { + users = UserHelper.filterNonAdministratorUsers(users); + } + deferred.resolve(users); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve users', err: err }); + }); + + return deferred.promise; }; service.user = function(id) { - return Users.get({id: id}).$promise; + var deferred = $q.defer(); + + Users.get({id: id}).$promise + .then(function success(data) { + var user = new UserViewModel(data); + deferred.resolve(user); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user details', err: err }); + }); + + return deferred.promise; }; - service.createUser = function(username, password, role) { - return Users.create({}, {username: username, password: password, role: role}).$promise; + service.createUser = function(username, password, role, teamIds) { + var deferred = $q.defer(); + + Users.create({}, {username: username, password: password, role: role}).$promise + .then(function success(data) { + var userId = data.Id; + var teamMembershipQueries = []; + angular.forEach(teamIds, function(teamId) { + teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 2)); + }); + $q.all(teamMembershipQueries) + .then(function success() { + deferred.resolve(); + }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create user', err: err }); + }); + + return deferred.promise; }; service.deleteUser = function(id) { @@ -28,12 +74,14 @@ angular.module('portainer.services') service.updateUserPassword = function(id, currentPassword, newPassword) { var deferred = $q.defer(); + Users.checkPassword({id: id}, {password: currentPassword}).$promise .then(function success(data) { if (!data.valid) { deferred.reject({invalidPassword: true}); + } else { + return service.updateUser(id, newPassword, undefined); } - return service.updateUser(id, newPassword, undefined); }) .then(function success(data) { deferred.resolve(); @@ -41,6 +89,65 @@ angular.module('portainer.services') .catch(function error(err) { deferred.reject({msg: 'Unable to update user password', err: err}); }); + + return deferred.promise; + }; + + service.userMemberships = function(id) { + var deferred = $q.defer(); + + Users.queryMemberships({id: id}).$promise + .then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships', err: err }); + }); + + return deferred.promise; + }; + + service.userTeams = function(id) { + var deferred = $q.defer(); + + Users.queryTeams({id: id}).$promise + .then(function success(data) { + var teams = data.map(function (item) { + return new TeamViewModel(item); + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user teams', err: err }); + }); + + return deferred.promise; + }; + + service.userLeadingTeams = function(id) { + var deferred = $q.defer(); + + $q.all({ + teams: service.userTeams(id), + memberships: service.userMemberships(id) + }) + .then(function success(data) { + var memberships = data.memberships; + var teams = data.teams.filter(function (team) { + var membership = _.find(memberships, {TeamId: team.Id}); + if (membership && membership.Role === 1) { + return team; + } + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user teams', err: err }); + }); + return deferred.promise; }; diff --git a/app/services/volumeService.js b/app/services/volumeService.js index 0f2a97a68..b326d8acc 100644 --- a/app/services/volumeService.js +++ b/app/services/volumeService.js @@ -1,12 +1,63 @@ angular.module('portainer.services') -.factory('VolumeService', ['$q', 'Volume', 'VolumeHelper', function VolumeServiceFactory($q, Volume, VolumeHelper) { +.factory('VolumeService', ['$q', 'Volume', 'VolumeHelper', 'ResourceControlService', 'UserService', 'TeamService', function VolumeServiceFactory($q, Volume, VolumeHelper, ResourceControlService, UserService, TeamService) { 'use strict'; var service = {}; + service.volumes = function() { + var deferred = $q.defer(); + Volume.query().$promise + .then(function success(data) { + var volumes = data.Volumes || []; + volumes = volumes.map(function (item) { + return new VolumeViewModel(item); + }); + deferred.resolve(volumes); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve volumes', err: err}); + }); + return deferred.promise; + }; + + service.volume = function(id) { + var deferred = $q.defer(); + Volume.get({id: id}).$promise + .then(function success(data) { + var volume = new VolumeViewModel(data); + deferred.resolve(volume); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve volume details', err: err}); + }); + return deferred.promise; + }; + service.getVolumes = function() { return Volume.query({}).$promise; }; + service.remove = function(volume) { + var deferred = $q.defer(); + + Volume.remove({id: volume.Id}).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message, err: data.message }); + } + if (volume.ResourceControl && volume.ResourceControl.Type === 3) { + return ResourceControlService.deleteResourceControl(volume.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove volume', err: err }); + }); + + return deferred.promise; + }; + service.createVolumeConfiguration = function(name, driver, driverOptions) { var volumeConfiguration = { Name: name, @@ -23,7 +74,8 @@ angular.module('portainer.services') if (data.message) { deferred.reject({ msg: data.message }); } else { - deferred.resolve(data); + var volume = new VolumeViewModel(data); + deferred.resolve(volume); } }) .catch(function error(err) { diff --git a/assets/css/app.css b/assets/css/app.css index 13c4d0eb0..db204b5e2 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -64,13 +64,6 @@ html, body, #content-wrapper, .page-content, #view { color: #777; } -.form-section-title { - border-bottom: 1px solid #777; - margin-top: 5px; - margin-bottom: 15px; - color: #777; -} - .form-horizontal .control-label.text-left{ text-align: left; font-size: 0.9em; @@ -81,11 +74,6 @@ input[type="checkbox"] { vertical-align: middle; } -input[type="radio"] { - margin-top: 1px; - vertical-align: middle; -} - a[ng-click]{ cursor: pointer; } @@ -139,6 +127,71 @@ a[ng-click]{ font-size: 90% !important; } +.template-widget { + height: 100%; +} + +.template-widget-body { + max-height: 86%; + overflow-y: auto; +} + +.template-list { + display: flex; + flex-direction: column; +} + +.template-logo { + width: 100%; + max-width: 60px; + height: 100%; + max-height: 60px; +} + +.template-container { + padding: 0.7rem; + margin-bottom: 0.7rem; + cursor: pointer; + border: 1px solid #333333; + border-radius: 2px; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); +} + +.template-container--selected { + border: 2px solid #333333; + background-color: #ececec; + color: #2d3e63; +} + +.template-container:hover { + background-color: #ececec; + color: #2d3e63; +} + +.template-main { + display: flex; +} + +.template-note { + padding: 0.5em; + font-size: 0.9em; +} + +.template-title { + font-size: 1.8em; + font-weight: bold; +} + +.template-description { + font-size: 0.9em; + padding-right: 1em; +} + +.template-line { + display: flex; + justify-content: space-between; +} + .nopadding { padding: 0 !important; } @@ -342,71 +395,113 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { box-shadow: inset 0 0 1px rgba(0,0,0,.5), inset 0 0 40px #337ab7; } -#toast-container > div { - opacity: 0.9; -} - -.template-widget { - height: 100%; -} - -.template-widget-body { - max-height: 86%; - overflow-y: auto; -} - -.template-list { +.ownership_wrapper { display: flex; - flex-direction: column; + flex-flow: row wrap; + margin: 0.5rem; } -.template-logo { - width: 100%; - max-width: 60px; - height: 100%; - max-height: 60px; +.ownership_wrapper > div { + flex: 1; + padding: 0.5rem; } -.template-container { - padding: 0.7rem; - margin-bottom: 0.7rem; - cursor: pointer; - border: 1px solid #333333; - border-radius: 2px; - box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); -} - -.template-container--selected { - border: 2px solid #333333; - background-color: #ececec; - color: #2d3e63; -} - -.template-container:hover { - background-color: #ececec; - color: #2d3e63; -} - -.template-main { - display: flex; -} - -.template-note { - padding: 0.5em; - font-size: 0.9em; -} - -.template-title { - font-size: 1.8em; +.ownership_wrapper .ownership_header { + font-size: 14px; + margin-bottom: 5px; font-weight: bold; } -.template-description { - font-size: 0.9em; - padding-right: 1em; +.ownership_wrapper input[type="radio"] { + display: none; } -.template-line { - display: flex; - justify-content: space-between; +.ownership_wrapper input[type="radio"]:not(:disabled) ~ label { + cursor: pointer; } + +.ownership_wrapper label { + font-weight: normal; + font-size: 12px; + display: block; + background: white; + border: 1px solid #333333; + border-radius: 2px; + padding: 10px 10px 0 10px; + text-align: center; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); + position: relative; +} + +.ownership_wrapper input[type="radio"]:checked + label { + background: #337ab7; + color: white; + padding-top: 2rem; + border-color: #337ab7; +} + +.ownership_wrapper input[type="radio"]:checked + label::after { + color: #337ab7; + font-family: FontAwesome; + border: 2px solid #337ab7; + content: "\f00c"; + font-size: 16px; + font-weight: bold; + position: absolute; + top: -15px; + left: 50%; + transform: translateX(-50%); + height: 30px; + width: 30px; + line-height: 26px; + text-align: center; + border-radius: 50%; + background: white; + box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25); +} + +@media only screen and (max-width: 700px) { + .ownership_wrapper { + flex-direction: column; + } +} + +/*bootbox override*/ +.modal-open { + padding-right: 0 !important; +} +/*!bootbox override*/ + +/*angular-multi-select override*/ +.multiSelect > button { + min-height: 30px !important; + background-color: unset; + background-image: unset; +} + +.multiSelect .multiSelectItem:not(.multiSelectGroup).selected +{ + background-image: linear-gradient( #337ab7, #337ab7 ); + color: #fff; + border: none; +} + +.multiSelect .multiSelectItem:hover, +.multiSelect .multiSelectGroup:hover { + background-image: linear-gradient( #337ab7 , #337ab7 ) !important; + color: #fff !important; +} + +.multiSelect .tickMark, +.widget .widget-body table tbody .multiSelect .tickMark { + top: 2px; + right: 12px; + font-size: 20px !important; +} +/*!angular-multi-select override*/ + +/*toaster override*/ +#toast-container > div { + opacity: 0.9; +} +/*!toaster override*/ diff --git a/bower.json b/bower.json index ced5b086c..b76b1fa0d 100644 --- a/bower.json +++ b/bower.json @@ -32,7 +32,7 @@ "angular-sanitize": "~1.5.0", "angular-mocks": "~1.5.0", "angular-resource": "~1.5.0", - "angular-ui-select": "~0.17.1", + "angular-ui-select": "~0.19.6", "angular-utils-pagination": "~0.11.1", "angular-local-storage": "~0.5.2", "angular-jwt": "~0.1.8", @@ -48,9 +48,10 @@ "ng-file-upload": "~12.2.13", "splitargs": "~0.2.0", "bootbox.js": "bootbox#^4.4.0", + "angular-multi-select": "~4.0.0", "toastr": "~2.1.3" }, "resolutions": { - "angular": "1.5.5" + "angular": "1.5.11" } } diff --git a/gruntfile.js b/gruntfile.js index 2d07fbae5..0fa157722 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -168,6 +168,7 @@ module.exports = function (grunt) { 'clean:tmp' ]); grunt.registerTask('lint', ['eslint']); + grunt.registerTask('run', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); grunt.registerTask('run-dev', ['if:linuxAmd64BinaryNotExist', 'shell:buildImage', 'shell:run', 'watch:build']); grunt.registerTask('clear', ['clean:app']); @@ -209,6 +210,7 @@ module.exports = function (grunt) { 'bower_components/moment/min/moment.min.js', 'bower_components/xterm.js/dist/xterm.js', 'bower_components/bootbox.js/bootbox.js', + 'bower_components/angular-multi-select/isteven-multi-select.js', 'bower_components/toastr/toastr.min.js', 'assets/js/legend.js' // Not a bower package ], @@ -221,6 +223,7 @@ module.exports = function (grunt) { 'bower_components/rdash-ui/dist/css/rdash.min.css', 'bower_components/angular-ui-select/dist/select.min.css', 'bower_components/xterm.js/dist/xterm.css', + 'bower_components/angular-multi-select/isteven-multi-select.css', 'bower_components/toastr/toastr.min.css' ] }, @@ -475,27 +478,6 @@ module.exports = function (grunt) { 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' ].join(';') }, - runSwarm: { - command: [ - 'docker stop portainer', - 'docker rm portainer', - 'docker run -d -p 9000:9000 --name portainer portainer -H tcp://10.0.7.10:2375 --no-analytics' - ].join(';') - }, - runSwarmLocal: { - command: [ - 'docker stop portainer', - 'docker rm portainer', - 'docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' - ].join(';') - }, - runSsl: { - command: [ - 'docker stop portainer', - 'docker rm portainer', - 'docker run -d -p 9000:9000 -v /tmp/portainer:/data -v /tmp/docker-ssl:/certs --name portainer portainer -H tcp://10.0.7.10:2376 --tlsverify --no-analytics' - ].join(';') - }, cleanImages: { command: 'docker rmi $(docker images -q -f dangling=true)' } From 9b9b2731ba84f6673ceb3ac9803ec58fabc478e2 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 21:01:19 +0200 Subject: [PATCH 38/39] refactor(api): fix lint issues --- api/bolt/version_service.go | 10 +++++----- api/cli/cli.go | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api/bolt/version_service.go b/api/bolt/version_service.go index 2da511437..1f35cfc40 100644 --- a/api/bolt/version_service.go +++ b/api/bolt/version_service.go @@ -8,21 +8,21 @@ import ( "github.com/boltdb/bolt" ) -// EndpointService represents a service for managing users. +// VersionService represents a service to manage stored versions. type VersionService struct { store *Store } const ( - DBVersionKey = "DB_VERSION" + dBVersionKey = "DB_VERSION" ) -// DBVersion the stored database version. +// DBVersion retrieves the stored database version. func (service *VersionService) DBVersion() (int, error) { var data []byte err := service.store.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(versionBucketName)) - value := bucket.Get([]byte(DBVersionKey)) + value := bucket.Get([]byte(dBVersionKey)) if value == nil { return portainer.ErrDBVersionNotFound } @@ -49,7 +49,7 @@ func (service *VersionService) StoreDBVersion(version int) error { bucket := tx.Bucket([]byte(versionBucketName)) data := []byte(strconv.Itoa(version)) - err := bucket.Put([]byte(DBVersionKey), data) + err := bucket.Put([]byte(dBVersionKey), data) if err != nil { return err } diff --git a/api/cli/cli.go b/api/cli/cli.go index 72a4560f8..68a017b1d 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -15,12 +15,12 @@ import ( type Service struct{} const ( - errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") - errSocketNotFound = portainer.Error("Unable to locate Unix socket") - errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") - errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") - errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") - errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password") + errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") + errSocketNotFound = portainer.Error("Unable to locate Unix socket") + errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") + errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") + errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") + errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password") ) // ParseFlags parse the CLI flags and return a portainer.Flags struct @@ -75,9 +75,9 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { return err } - if *flags.NoAuth && (*flags.AdminPassword != "") { - return errNoAuthExcludeAdminPassword - } + if *flags.NoAuth && (*flags.AdminPassword != "") { + return errNoAuthExcludeAdminPassword + } return nil } From db1a754b39eb95a14cc551e62ae0cf16fdc6aba2 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 21:13:55 +0200 Subject: [PATCH 39/39] chore(version): bump version number --- api/portainer.go | 2 +- app/app.js | 2 +- bower.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/portainer.go b/api/portainer.go index 8c1bd29b4..ff38b8b5a 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -252,7 +252,7 @@ type ( const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.12.4" + APIVersion = "1.13.0" // DBVersion is the version number of the Portainer database. DBVersion = 2 ) diff --git a/app/app.js b/app/app.js index 36d9f693e..9cfa606fc 100644 --- a/app/app.js +++ b/app/app.js @@ -631,4 +631,4 @@ angular.module('portainer', [ .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') .constant('TEMPLATES_ENDPOINT', 'api/templates') .constant('PAGINATION_MAX_ITEMS', 10) - .constant('UI_VERSION', 'v1.12.4'); + .constant('UI_VERSION', 'v1.13.0'); diff --git a/bower.json b/bower.json index b76b1fa0d..9bc5f84b7 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "portainer", - "version": "1.12.4", + "version": "1.13.0", "homepage": "https://github.com/portainer/portainer", "authors": [ "Anthony Lapenna " diff --git a/package.json b/package.json index 9324212c8..6f93e8a86 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.12.4", + "version": "1.13.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git"