From dc48fa685f576030be80403547dedd2324d9ea30 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 15 Oct 2017 20:47:37 +0200 Subject: [PATCH 01/35] fix(cli): fix default asset directory value --- api/cli/defaults.go | 2 +- api/cli/defaults_windows.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 80ba58c51..1b1c86a3b 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -5,7 +5,7 @@ package cli const ( defaultBindAddress = ":9000" defaultDataDirectory = "/data" - defaultAssetsDirectory = "." + defaultAssetsDirectory = "/" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 5e80489e4..914ee3d2a 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -3,7 +3,7 @@ package cli const ( defaultBindAddress = ":9000" defaultDataDirectory = "C:\\data" - defaultAssetsDirectory = "." + defaultAssetsDirectory = "/" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" From 8ec7b4fcf51dbbf20f616b438c1f88a21b7d7cd9 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 16 Oct 2017 10:32:51 +0200 Subject: [PATCH 02/35] chore(codefresh): add a step to download docker binary (#1283) --- codefresh.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/codefresh.yml b/codefresh.yml index 25413098b..c669dbaef 100644 --- a/codefresh.yml +++ b/codefresh.yml @@ -18,9 +18,17 @@ steps: - grunt build-webapp - mv api/cmd/portainer/portainer dist/ + download_docker_binary: + image: busybox + working_directory: ${{build_frontend}} + commands: + - wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz + - tar -xf /tmp/docker-binaries.tgz -C /tmp + - mv /tmp/docker/docker dist/ + build_image: type: build - working_directory: ${{build_frontend}} + working_directory: ${{download_docker_binary}} dockerfile: ./build/linux/Dockerfile image_name: portainer/portainer tag: ${{CF_BRANCH}} From dc05ad4c8c3c19b515ed84e22baee9b9db35b905 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 16 Oct 2017 18:54:48 +0200 Subject: [PATCH 03/35] fix(templates): add missing NetworkSettings field (#1287) --- app/models/docker/container.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/docker/container.js b/app/models/docker/container.js index 8c2d112f5..c37f2fe93 100644 --- a/app/models/docker/container.js +++ b/app/models/docker/container.js @@ -7,6 +7,7 @@ function ContainerViewModel(data) { if (data.NetworkSettings && !_.isEmpty(data.NetworkSettings.Networks)) { this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; } + this.NetworkSettings = data.NetworkSettings; this.Image = data.Image; this.ImageID = data.ImageID; this.Command = data.Command; From 925326e8aa8f6ddeb234bba8362f3cf2871d038b Mon Sep 17 00:00:00 2001 From: G07cha Date: Tue, 17 Oct 2017 09:45:19 +0300 Subject: [PATCH 04/35] feat(volume-details): show a list of containers using the volume --- app/components/volume/volume.html | 23 +++++++++++++++++++++++ app/components/volume/volumeController.js | 22 ++++++++++++++++++++-- app/services/docker/containerService.js | 4 ++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/app/components/volume/volume.html b/app/components/volume/volume.html index 15ebfc41e..2b9d40af4 100644 --- a/app/components/volume/volume.html +++ b/app/components/volume/volume.html @@ -73,3 +73,26 @@ +
+
+ + + + + + + + + + + + + + + + +
Container NameMounted AtRead-only
{{ container | containername }}{{ container.volumeData.Destination }}{{ !container.volumeData.RW }}
+
+
+
+
diff --git a/app/components/volume/volumeController.js b/app/components/volume/volumeController.js index 16c165980..ac33300de 100644 --- a/app/components/volume/volumeController.js +++ b/app/components/volume/volumeController.js @@ -1,6 +1,6 @@ angular.module('volume', []) -.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'Notifications', -function ($scope, $state, $transition$, VolumeService, Notifications) { +.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', +function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications) { $scope.removeVolume = function removeVolume() { $('#loadingViewSpinner').show(); @@ -16,6 +16,12 @@ function ($scope, $state, $transition$, VolumeService, Notifications) { $('#loadingViewSpinner').hide(); }); }; + + function getVolumeDataFromContainer(container, volumeId) { + return container.Mounts.find(function(volume) { + return volume.Name === volumeId; + }); + } function initView() { $('#loadingViewSpinner').show(); @@ -23,6 +29,18 @@ function ($scope, $state, $transition$, VolumeService, Notifications) { .then(function success(data) { var volume = data; $scope.volume = volume; + return ContainerService.containers(1, { + filters: { + volume: [volume.Id] + } + }); + }) + .then(function success(data) { + var containers = data.map(function(container) { + container.volumeData = getVolumeDataFromContainer(container, $scope.volume.Id); + return container; + }); + $scope.containersUsingVolume = containers; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve volume details'); diff --git a/app/services/docker/containerService.js b/app/services/docker/containerService.js index 21bd1c295..33daf016d 100644 --- a/app/services/docker/containerService.js +++ b/app/services/docker/containerService.js @@ -20,8 +20,8 @@ angular.module('portainer.services') service.containers = function(all, filters) { var deferred = $q.defer(); - - Container.query({ all: all, filters: filters ? filters : {} }).$promise + filters.all = all; + Container.query(filters).$promise .then(function success(data) { var containers = data.map(function (item) { return new ContainerViewModel(item); From 7eaaf9a2a7dde16499277215a4e99de01ad4819f Mon Sep 17 00:00:00 2001 From: G07cha Date: Tue, 17 Oct 2017 09:56:40 +0300 Subject: [PATCH 05/35] feat(container-inspect): add the ability to inspect containers --- app/__module.js | 1 + app/components/container/container.html | 1 + .../containerInspect/containerInspect.html | 19 ++++++++++++++++++ .../containerInspectController.js | 20 +++++++++++++++++++ app/rest/docker/container.js | 3 +++ app/routes.js | 13 ++++++++++++ app/services/docker/containerService.js | 17 +++++----------- 7 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 app/components/containerInspect/containerInspect.html create mode 100644 app/components/containerInspect/containerInspectController.js diff --git a/app/__module.js b/app/__module.js index 843fe2239..45dfe4e31 100644 --- a/app/__module.js +++ b/app/__module.js @@ -20,6 +20,7 @@ angular.module('portainer', [ 'containerConsole', 'containerLogs', 'containerStats', + 'containerInspect', 'serviceLogs', 'containers', 'createContainer', diff --git a/app/components/container/container.html b/app/components/container/container.html index 34590f292..f3dd69639 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -83,6 +83,7 @@ Stats Logs Console + Inspect diff --git a/app/components/containerInspect/containerInspect.html b/app/components/containerInspect/containerInspect.html new file mode 100644 index 000000000..200b7cd58 --- /dev/null +++ b/app/components/containerInspect/containerInspect.html @@ -0,0 +1,19 @@ + + + + + Containers > {{ containerInfo.Name|trimcontainername }} > Inspect + + + +
+
+ + + + +
{{ containerInfo|json:4 }}
+
+
+
+
diff --git a/app/components/containerInspect/containerInspectController.js b/app/components/containerInspect/containerInspectController.js new file mode 100644 index 000000000..ae1740630 --- /dev/null +++ b/app/components/containerInspect/containerInspectController.js @@ -0,0 +1,20 @@ +angular.module('containerInspect', []) +.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', +function ($scope, $transition$, Notifications, ContainerService) { + function initView() { + $('#loadingViewSpinner').show(); + + ContainerService.inspect($transition$.params().id) + .then(function success(d) { + $scope.containerInfo = d; + }) + .catch(function error(e) { + Notifications.error('Failure', e, 'Unable to inspect container'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/rest/docker/container.js b/app/rest/docker/container.js index d8786cb03..25ab3b2da 100644 --- a/app/rest/docker/container.js +++ b/app/rest/docker/container.js @@ -40,6 +40,9 @@ angular.module('portainer.rest') exec: { method: 'POST', params: {id: '@id', action: 'exec'}, transformResponse: genericHandler + }, + inspect: { + method: 'GET', params: { id: '@id', action: 'json' } } }); }]); diff --git a/app/routes.js b/app/routes.js index 0232c3d6d..87adbf0f6 100644 --- a/app/routes.js +++ b/app/routes.js @@ -105,6 +105,19 @@ function configureRoutes($stateProvider) { } } }) + .state('inspect', { + url: '^/containers/:id/inspect', + views: { + 'content@': { + templateUrl: 'app/components/containerInspect/containerInspect.html', + controller: 'ContainerInspectController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('dashboard', { parent: 'root', url: '/dashboard', diff --git a/app/services/docker/containerService.js b/app/services/docker/containerService.js index 33daf016d..4065046b0 100644 --- a/app/services/docker/containerService.js +++ b/app/services/docker/containerService.js @@ -140,18 +140,11 @@ angular.module('portainer.services') }; service.containerTop = function(id) { - var deferred = $q.defer(); - - Container.top({id: id}).$promise - .then(function success(data) { - var containerTop = data; - deferred.resolve(containerTop); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; + return Container.top({id: id}).$promise; + }; + + service.inspect = function(id) { + return Container.inspect({id: id}).$promise; }; return service; From 730925b2866e6eb15d418471f7b82dd395d1decc Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 17 Oct 2017 10:12:16 +0200 Subject: [PATCH 06/35] fix(containers): fix an issue with filters --- app/components/volume/volumeController.js | 9 +++------ app/services/docker/containerService.js | 5 ++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/components/volume/volumeController.js b/app/components/volume/volumeController.js index ac33300de..12b7053a5 100644 --- a/app/components/volume/volumeController.js +++ b/app/components/volume/volumeController.js @@ -16,7 +16,7 @@ function ($scope, $state, $transition$, VolumeService, ContainerService, Notific $('#loadingViewSpinner').hide(); }); }; - + function getVolumeDataFromContainer(container, volumeId) { return container.Mounts.find(function(volume) { return volume.Name === volumeId; @@ -29,11 +29,8 @@ function ($scope, $state, $transition$, VolumeService, ContainerService, Notific .then(function success(data) { var volume = data; $scope.volume = volume; - return ContainerService.containers(1, { - filters: { - volume: [volume.Id] - } - }); + var containerFilter = { volume: [volume.Id] }; + return ContainerService.containers(1, containerFilter); }) .then(function success(data) { var containers = data.map(function(container) { diff --git a/app/services/docker/containerService.js b/app/services/docker/containerService.js index 4065046b0..2b09aedfa 100644 --- a/app/services/docker/containerService.js +++ b/app/services/docker/containerService.js @@ -20,8 +20,7 @@ angular.module('portainer.services') service.containers = function(all, filters) { var deferred = $q.defer(); - filters.all = all; - Container.query(filters).$promise + Container.query({ all : all, filters: filters }).$promise .then(function success(data) { var containers = data.map(function (item) { return new ContainerViewModel(item); @@ -142,7 +141,7 @@ angular.module('portainer.services') service.containerTop = function(id) { return Container.top({id: id}).$promise; }; - + service.inspect = function(id) { return Container.inspect({id: id}).$promise; }; From 0af3c44e9ae3befbcc7897d9d30f1834d034320d Mon Sep 17 00:00:00 2001 From: spezzino Date: Wed, 18 Oct 2017 12:45:17 -0300 Subject: [PATCH 07/35] style(area/settings): replace LDAP URL label (#1288) --- .../settingsAuthentication/settingsAuthentication.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/settingsAuthentication/settingsAuthentication.html b/app/components/settingsAuthentication/settingsAuthentication.html index 92fd050d7..38506d751 100644 --- a/app/components/settingsAuthentication/settingsAuthentication.html +++ b/app/components/settingsAuthentication/settingsAuthentication.html @@ -66,7 +66,7 @@
From f9218768c125a270e5c7b8922a8a141b1ea39ee6 Mon Sep 17 00:00:00 2001 From: G07cha Date: Wed, 18 Oct 2017 18:46:56 +0300 Subject: [PATCH 08/35] chore(build-system): replace individual package load with pattern (#1298) --- gruntfile.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gruntfile.js b/gruntfile.js index d4ff5fede..7a778179a 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -4,8 +4,9 @@ var loadGruntTasks = require('load-grunt-tasks'); module.exports = function (grunt) { - loadGruntTasks(grunt); - grunt.loadNpmTasks('gruntify-eslint'); + loadGruntTasks(grunt, { + pattern: ['grunt-*', 'gruntify-*'] + }); grunt.registerTask('default', ['eslint', 'build']); grunt.registerTask('before-copy', [ From c9ccdaaea447c1f83cc7481ea68f65850f9eeb55 Mon Sep 17 00:00:00 2001 From: Boris Manojlovic Date: Wed, 18 Oct 2017 18:08:09 +0200 Subject: [PATCH 09/35] chore(distribution): add rpm based packaging and system unit file (#1292) --- distribution/portainer.service | 17 ++++++ distribution/portainer.spec | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 distribution/portainer.service create mode 100644 distribution/portainer.spec diff --git a/distribution/portainer.service b/distribution/portainer.service new file mode 100644 index 000000000..557784441 --- /dev/null +++ b/distribution/portainer.service @@ -0,0 +1,17 @@ +[Unit] +Description=Portainer.io management ui +After=docker.service +Wants=docker.service +Wants=docker-latest.service + +[Service] +Type=simple +Restart=always +RestartSec=3 +Environment=ASSETS=/usr/share/portainer +Environment=DBFILES=/var/lib/portainer +EnvironmentFile=-/etc/sysconfig/%p +ExecStart=/usr/sbin/portainer -a $ASSETS -d $DBFILES + +[Install] +WantedBy=multi-user.target diff --git a/distribution/portainer.spec b/distribution/portainer.spec new file mode 100644 index 000000000..b07aea462 --- /dev/null +++ b/distribution/portainer.spec @@ -0,0 +1,96 @@ +Name: portainer +Version: 1.15.0 +Release: 0 +License: Zlib +Summary: A lightweight docker management UI +Url: https://portainer.io +Group: BLAH +Source0: https://github.com/portainer/portainer/releases/download/%{version}/portainer-%{version}-linux-amd64.tar.gz +Source1: portainer.service +BuildRoot: %{_tmppath}/%{name}-%{version}-build +%if 0%{?suse_version} +BuildRequires: help2man +%endif +Requires: docker +%{?systemd_requires} +BuildRequires: systemd + +%description +Portainer is a lightweight management UI which allows you to easily manage +your different Docker environments (Docker hosts or Swarm clusters). +Portainer is meant to be as simple to deploy as it is to use. +It consists of a single container that can run on any Docker engine +(can be deployed as Linux container or a Windows native container). +Portainer allows you to manage your Docker containers, images, volumes, +networks and more ! It is compatible with the standalone Docker engine and with Docker Swarm mode. + +%prep +%setup -qn portainer + +%build +%if 0%{?suse_version} +help2man -N --no-discard-stderr ./portainer > portainer.1 +%endif + +%install +# Create directory structure +install -D -m 0755 portainer %{buildroot}%{_sbindir}/portainer +install -d -m 0755 %{buildroot}%{_datadir}/portainer +install -d -m 0755 %{buildroot}%{_localstatedir}/lib/portainer +install -D -m 0644 %{S:1} %{buildroot}%{_unitdir}/portainer.service +%if 0%{?suse_version} +install -D -m 0644 portainer.1 %{buildroot}%{_mandir}/man1/portainer.1 +( cd %{buildroot}%{_sbindir} ; ln -s service rcportainer ) +%endif +# populate +# don't install docker binary with package use system wide installed one +for src in css fonts ico images index.html js;do + cp -a $src %{buildroot}%{_datadir}/portainer/ +done + +%pre +%if 0%{?suse_version} +%service_add_pre portainer.service +#%%else # this does not work on rhel 7? +#%%systemd_pre portainer.service +true +%endif + +%preun +%if 0%{?suse_version} +%service_del_preun portainer.service +%else +%systemd_preun portainer.service +%endif + +%post +%if 0%{?suse_version} +%service_add_post portainer.service +%else +%systemd_post portainer.service +%endif + +%postun +%if 0%{?suse_version} +%service_del_postun portainer.service +%else +%systemd_postun_with_restart portainer.service +%endif + + +%files +%defattr(-,root,root) +%{_sbindir}/portainer +%dir %{_datadir}/portainer +%{_datadir}/portainer/css +%{_datadir}/portainer/fonts +%{_datadir}/portainer/ico +%{_datadir}/portainer/images +%{_datadir}/portainer/index.html +%{_datadir}/portainer/js +%dir %{_localstatedir}/lib/portainer/ +%{_unitdir}/portainer.service +%if 0%{?suse_version} +%{_mandir}/man1/portainer.1* +%{_sbindir}/rcportainer +%endif \ No newline at end of file From 4a49942ae5945de8a16b340ab05aa4b4097f0ea5 Mon Sep 17 00:00:00 2001 From: spezzino Date: Wed, 18 Oct 2017 14:50:20 -0300 Subject: [PATCH 10/35] feat(endpoints): automatically strip URL's protocol when creating a new endpoint (#1294) --- app/components/endpoints/endpointsController.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index 9a761a164..01e9670e5 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', 'Notifications', 'Pagination', -function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagination) { +.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination', +function ($scope, $state, $filter, EndpointService, EndpointProvider, Notifications, Pagination) { $scope.state = { uploadInProgress: false, selectedItemCount: 0, @@ -44,7 +44,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi $scope.addEndpoint = function() { var name = $scope.formValues.Name; - var URL = $scope.formValues.URL; + var URL = $filter('stripprotocol')($scope.formValues.URL); var PublicURL = $scope.formValues.PublicURL; if (PublicURL === '') { PublicURL = URL.split(':')[0]; From c97f1d24cd6b1328275e79250e7e4ad0292e0835 Mon Sep 17 00:00:00 2001 From: 1138-4EB <1138-4EB@users.noreply.github.com> Date: Mon, 23 Oct 2017 20:19:13 +0200 Subject: [PATCH 11/35] style(images): prevent unused label breaking to multiple lines (#1314) --- app/components/images/images.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/components/images/images.html b/app/components/images/images.html index 8e82ff1a6..818f55b72 100644 --- a/app/components/images/images.html +++ b/app/components/images/images.html @@ -125,10 +125,7 @@ {{ image.Id|truncate:20}} - - Unused - + Unused {{ tag }} From ddd804ee2e0e576e5b6197bc988d3a49cba86d56 Mon Sep 17 00:00:00 2001 From: 1138-4EB <1138-4EB@users.noreply.github.com> Date: Tue, 24 Oct 2017 09:32:21 +0200 Subject: [PATCH 12/35] feat(container-inspect): display content in tree view by default (#1310) --- .../containerInspect/containerInspect.html | 7 ++++++- .../containerInspectController.js | 10 ++++++--- assets/css/app.css | 21 +++++++++++++++---- bower.json | 1 + vendor.yml | 4 ++++ 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/components/containerInspect/containerInspect.html b/app/components/containerInspect/containerInspect.html index 200b7cd58..c0bd20ebb 100644 --- a/app/components/containerInspect/containerInspect.html +++ b/app/components/containerInspect/containerInspect.html @@ -10,9 +10,14 @@
+ + + + -
{{ containerInfo|json:4 }}
+
{{ containerInfo|json:4 }}
+
diff --git a/app/components/containerInspect/containerInspectController.js b/app/components/containerInspect/containerInspectController.js index ae1740630..ea4219814 100644 --- a/app/components/containerInspect/containerInspectController.js +++ b/app/components/containerInspect/containerInspectController.js @@ -1,9 +1,13 @@ -angular.module('containerInspect', []) +angular.module('containerInspect', ['angular-json-tree']) .controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', function ($scope, $transition$, Notifications, ContainerService) { + + $scope.state = { DisplayTextView: false }; + $scope.containerInfo = {}; + function initView() { $('#loadingViewSpinner').show(); - + ContainerService.inspect($transition$.params().id) .then(function success(d) { $scope.containerInfo = d; @@ -15,6 +19,6 @@ function ($scope, $transition$, Notifications, ContainerService) { $('#loadingViewSpinner').hide(); }); } - + initView(); }]); diff --git a/assets/css/app.css b/assets/css/app.css index 9fda8ca7a..adb6116e6 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -7,10 +7,6 @@ html, body, #content-wrapper, .page-content, #view { white-space: normal !important; } -.btn-group button { - margin: 3px; -} - .messages { max-height: 50px; overflow-x: hidden; @@ -615,3 +611,20 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { font-family: monospace; font-weight: 600; } + +/* json-tree */ + +json-tree { + font-size: 13px; + color: #30426a; +} +json-tree .key { + color: #738bc0; + padding-right: 5px; +} + +json-tree .branch-preview { + font-style: normal; + font-size: 11px; + opacity: .5; +} diff --git a/bower.json b/bower.json index bf9bd43fa..5c15dff01 100644 --- a/bower.json +++ b/bower.json @@ -34,6 +34,7 @@ "angular-utils-pagination": "~0.11.1", "angular-local-storage": "~0.5.2", "angular-jwt": "~0.1.8", + "angular-json-tree": "1.0.1", "angular-google-analytics": "~1.1.9", "bootstrap": "~3.3.6", "filesize": "~3.3.0", diff --git a/vendor.yml b/vendor.yml index c052c018d..134914a7d 100644 --- a/vendor.yml +++ b/vendor.yml @@ -47,6 +47,7 @@ css: - bower_components/angularjs-slider/dist/rzslider.css - bower_components/codemirror/lib/codemirror.css - bower_components/codemirror/addon/lint/lint.css + - bower_components/angular-json-tree/dist/angular-json-tree.css minified: - bower_components/bootstrap/dist/css/bootstrap.min.css - bower_components/rdash-ui/dist/css/rdash.min.css @@ -58,6 +59,7 @@ css: - bower_components/angularjs-slider/dist/rzslider.min.css - bower_components/codemirror/lib/codemirror.css - bower_components/codemirror/addon/lint/lint.css + - bower_components/angular-json-tree/dist/angular-json-tree.css angular: regular: - bower_components/angular/angular.js @@ -74,6 +76,7 @@ angular: - bower_components/ng-file-upload/ng-file-upload.js - bower_components/angularjs-slider/dist/rzslider.js - bower_components/angular-multi-select/isteven-multi-select.js + - bower_components/angular-json-tree/dist/angular-json-tree.js minified: - bower_components/angular/angular.min.js - bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js @@ -89,3 +92,4 @@ angular: - bower_components/ng-file-upload/ng-file-upload.min.js - bower_components/angularjs-slider/dist/rzslider.min.js - bower_components/angular-multi-select/isteven-multi-select.js + - bower_components/angular-json-tree/dist/angular-json-tree.min.js From 11feae19b76ce3bc4f4273a3c91ac361497aa473 Mon Sep 17 00:00:00 2001 From: utzb Date: Tue, 24 Oct 2017 10:26:35 +0200 Subject: [PATCH 13/35] chore(build-system): add support for linux s390x platform (#1316) s390x works fine (like other Linux architectures). --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 77271abe9..e8d603292 100755 --- a/build.sh +++ b/build.sh @@ -42,7 +42,7 @@ else if [ `echo "$@" | cut -c1-4` == 'echo' ]; then bash -c "$@"; else - build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le darwin-amd64 windows-amd64' + build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le linux-s390x darwin-amd64 windows-amd64' exit 0 fi fi From aa32213f7c1c9516d3ecfa9ae0bcaa64170365b5 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 24 Oct 2017 19:17:07 +0200 Subject: [PATCH 14/35] fix(dashboard): do not display stack and service info when connected to Swarm worker (#1319) --- app/components/dashboard/dashboard.html | 4 ++-- app/components/dashboard/dashboardController.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index ebc2f7501..4eb2a64ad 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -85,7 +85,7 @@
-
+ -
+
diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 4f9d83094..82ca08184 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -68,6 +68,7 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System $('#loadingViewSpinner').show(); var endpointProvider = $scope.applicationState.endpoint.mode.provider; + var endpointRole = $scope.applicationState.endpoint.mode.role; $q.all([ Container.query({all: 1}).$promise, @@ -75,8 +76,8 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System Volume.query({}).$promise, Network.query({}).$promise, SystemService.info(), - endpointProvider === 'DOCKER_SWARM_MODE' ? ServiceService.services() : [], - endpointProvider === 'DOCKER_SWARM_MODE' ? StackService.stacks(true) : [] + endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? ServiceService.services() : [], + endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? StackService.stacks(true) : [] ]).then(function (d) { prepareContainerData(d[0]); prepareImageData(d[1]); From 711128284e137a0c614838683c6daaf977b9f918 Mon Sep 17 00:00:00 2001 From: utzb Date: Wed, 25 Oct 2017 08:56:57 +0200 Subject: [PATCH 15/35] chore(build-system): use system architecture instead of hardcoded amd64 value --- gruntfile.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/gruntfile.js b/gruntfile.js index 7a778179a..2bae53d25 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -1,6 +1,9 @@ var autoprefixer = require('autoprefixer'); var cssnano = require('cssnano'); var loadGruntTasks = require('load-grunt-tasks'); +var os = require('os'); +var arch = os.arch(); +if ( arch === 'x64' ) arch = 'amd64'; module.exports = function (grunt) { @@ -34,8 +37,8 @@ module.exports = function (grunt) { grunt.registerTask('build', [ 'config:dev', 'clean:app', - 'shell:buildBinary:linux:amd64', - 'shell:downloadDockerBinary:linux:amd64', + 'shell:buildBinary:linux:' + arch, + 'shell:downloadDockerBinary:linux:' + arch, 'vendor:regular', 'html2js', 'useminPrepare:dev', @@ -184,7 +187,7 @@ module.exports = function (grunt) { run: { command: [ 'docker rm -f portainer', - 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-amd64 --no-analytics -a /app' + 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-' + arch + ' --no-analytics -a /app' ].join(';') }, downloadDockerBinary: { From 25f325bbaaa5e541e43c8a6ec7a7a7d14d53dcd1 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 25 Oct 2017 13:37:52 +0200 Subject: [PATCH 16/35] fix(network-details): fix an issue caused by stopped containers (#1328) --- app/components/network/networkController.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index f4ea325e5..87da9d9e8 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -40,12 +40,14 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai var containersInNetwork = []; containers.forEach(function(container) { var containerInNetwork = network.Containers[container.Id]; - containerInNetwork.Id = container.Id; - // Name is not available in Docker 1.9 - if (!containerInNetwork.Name) { - containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]); + if (containerInNetwork) { + containerInNetwork.Id = container.Id; + // Name is not available in Docker 1.9 + if (!containerInNetwork.Name) { + containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]); + } + containersInNetwork.push(containerInNetwork); } - containersInNetwork.push(containerInNetwork); }); $scope.containersInNetwork = containersInNetwork; } @@ -68,7 +70,7 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai }); } else { Container.query({ - filters: {network: [$transition$.params().id]} + filters: { network: [$transition$.params().id] } }, function success(data) { filterContainersInNetwork(network, data); $('#loadingViewSpinner').hide(); From 77503b448ef21a1f4bb20174310062a2c1e45220 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 25 Oct 2017 17:03:40 +0200 Subject: [PATCH 17/35] fix(container-details): use container.Mounts instead of container.HostConfig.Binds (#1329) --- app/components/container/container.html | 13 +++++++------ app/models/docker/containerDetails.js | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/components/container/container.html b/app/components/container/container.html index f3dd69639..2608d9323 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -241,7 +241,7 @@
-
+
@@ -249,14 +249,15 @@ - - + + - - - + + + +
HostContainerHost/volumePath in container
{{ vol|key: ':' }}{{ vol|value: ':' }}
{{ vol.Source }}{{ vol.Name }}{{ vol.Destination }}
diff --git a/app/models/docker/containerDetails.js b/app/models/docker/containerDetails.js index 63945ff41..eae58c105 100644 --- a/app/models/docker/containerDetails.js +++ b/app/models/docker/containerDetails.js @@ -9,6 +9,7 @@ function ContainerDetailsViewModel(data) { this.Image = data.Image; this.Config = data.Config; this.HostConfig = data.HostConfig; + this.Mounts = data.Mounts; if (data.Portainer) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); From c4e75fc8588b60eca9c074889f544016dbb17148 Mon Sep 17 00:00:00 2001 From: Philippe Leblond Date: Thu, 26 Oct 2017 02:15:08 -0400 Subject: [PATCH 18/35] fix(swarm): display node links when authentication is disabled (#1332) --- app/components/swarm/swarm.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html index bb243ef32..d9b8372c7 100644 --- a/app/components/swarm/swarm.html +++ b/app/components/swarm/swarm.html @@ -224,8 +224,8 @@ -
{{ node.Hostname }} - {{ node.Hostname }} + {{ node.Hostname }} + {{ node.Hostname }} {{ node.Role }} {{ node.CPUs / 1000000000 }} From 34d40e48764a49504166666b7b8470be487aa5e6 Mon Sep 17 00:00:00 2001 From: 1138-4EB <1138-4EB@users.noreply.github.com> Date: Thu, 26 Oct 2017 11:17:45 +0200 Subject: [PATCH 19/35] chore(build-system): make assets default relative, serve assets from assets/public (#1309) --- api/cli/cli.go | 10 ++++++++++ api/cli/defaults.go | 2 +- api/cli/defaults_windows.go | 2 +- api/http/handler/file.go | 26 +++----------------------- api/http/server.go | 3 ++- gruntfile.js | 11 ++++++----- 6 files changed, 23 insertions(+), 31 deletions(-) diff --git a/api/cli/cli.go b/api/cli/cli.go index c4cde81ec..a343195f4 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer" "os" + "path/filepath" "strings" "gopkg.in/alecthomas/kingpin.v2" @@ -54,6 +55,15 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { } kingpin.Parse() + + if !filepath.IsAbs(*flags.Assets) { + ex, err := os.Executable() + if err != nil { + panic(err) + } + *flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets) + } + return flags, nil } diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 1b1c86a3b..2d350c1c7 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -5,7 +5,7 @@ package cli const ( defaultBindAddress = ":9000" defaultDataDirectory = "/data" - defaultAssetsDirectory = "/" + defaultAssetsDirectory = "./" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 914ee3d2a..a94657258 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -3,7 +3,7 @@ package cli const ( defaultBindAddress = ":9000" defaultDataDirectory = "C:\\data" - defaultAssetsDirectory = "/" + defaultAssetsDirectory = "./" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/http/handler/file.go b/api/http/handler/file.go index 2191169ac..efc7e2b77 100644 --- a/api/http/handler/file.go +++ b/api/http/handler/file.go @@ -3,35 +3,22 @@ package handler import ( "os" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "log" "net/http" - "path" "strings" ) // FileHandler represents an HTTP API handler for managing static files. type FileHandler struct { http.Handler - Logger *log.Logger - allowedDirectories map[string]bool + Logger *log.Logger } // NewFileHandler returns a new instance of FileHandler. -func NewFileHandler(assetPath string) *FileHandler { +func NewFileHandler(assetPublicPath string) *FileHandler { h := &FileHandler{ - Handler: http.FileServer(http.Dir(assetPath)), + Handler: http.FileServer(http.Dir(assetPublicPath)), Logger: log.New(os.Stderr, "", log.LstdFlags), - allowedDirectories: map[string]bool{ - "/": true, - "/css": true, - "/js": true, - "/images": true, - "/fonts": true, - "/ico": true, - }, } return h } @@ -46,17 +33,10 @@ func isHTML(acceptContent []string) bool { } func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - requestDirectory := path.Dir(r.URL.Path) - if !handler.allowedDirectories[requestDirectory] { - httperror.WriteErrorResponse(w, portainer.ErrResourceNotFound, http.StatusNotFound, handler.Logger) - return - } - if !isHTML(r.Header["Accept"]) { w.Header().Set("Cache-Control", "max-age=31536000") } else { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") } - handler.Handler.ServeHTTP(w, r) } diff --git a/api/http/server.go b/api/http/server.go index e3f31e8d1..93d874cb3 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer/http/security" "net/http" + "path/filepath" ) // Server implements the portainer.Server interface @@ -42,7 +43,7 @@ func (server *Server) Start() error { requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) - var fileHandler = handler.NewFileHandler(server.AssetsPath) + var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService diff --git a/gruntfile.js b/gruntfile.js index 2bae53d25..d7ae868d2 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -57,7 +57,7 @@ module.exports = function (grunt) { // Project configuration. grunt.initConfig({ - distdir: 'dist', + distdir: 'dist/public', shippedDockerVersion: '17.09.0-ce', pkg: grunt.file.readJSON('package.json'), config: { @@ -72,8 +72,8 @@ module.exports = function (grunt) { css: ['assets/css/app.css'] }, clean: { - all: ['<%= distdir %>/*'], - app: ['<%= distdir %>/*', '!<%= distdir %>/portainer*', '!<%= distdir %>/docker*'], + all: ['<%= distdir %>/../*'], + app: ['<%= distdir %>/*', '!<%= distdir %>/../portainer*', '!<%= distdir %>/../docker*'], tmpl: ['<%= distdir %>/templates'], tmp: ['<%= distdir %>/js/*', '!<%= distdir %>/js/app.*.js', '<%= distdir %>/css/*', '!<%= distdir %>/css/app.*.css'] }, @@ -93,7 +93,8 @@ module.exports = function (grunt) { release: { src: '<%= src.html %>', options: { - root: '<%= distdir %>' + root: '<%= distdir %>', + dest: '<%= distdir %>' } } }, @@ -187,7 +188,7 @@ module.exports = function (grunt) { run: { command: [ 'docker rm -f portainer', - 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-' + arch + ' --no-analytics -a /app' + 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-' + arch + ' --no-analytics' ].join(';') }, downloadDockerBinary: { From b5629c5b1adc13b79fc8441c98481949f6e1d8a5 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 26 Oct 2017 14:22:09 +0200 Subject: [PATCH 20/35] feat(stacks): allow to use images from private registries in stacks (#1327) --- api/exec/stack_manager.go | 35 ++++++++- api/http/handler/stack.go | 144 ++++++++++++++++++++++++++++++++++-- api/http/security/filter.go | 2 +- api/http/server.go | 2 + api/portainer.go | 2 + 5 files changed, 175 insertions(+), 10 deletions(-) diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go index 3f8b60b79..61051f01c 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/stack_manager.go @@ -21,7 +21,38 @@ func NewStackManager(binaryPath string) *StackManager { } } -// Deploy will execute the Docker stack deploy command +// Login executes the docker login command against a list of registries (including DockerHub). +func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) error { + command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) + for _, registry := range registries { + if registry.Authentication { + registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) + err := runCommandAndCaptureStdErr(command, registryArgs) + if err != nil { + return err + } + } + } + + if dockerhub.Authentication { + dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password) + err := runCommandAndCaptureStdErr(command, dockerhubArgs) + if err != nil { + return err + } + } + + return nil +} + +// Logout executes the docker logout command. +func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { + command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) + args = append(args, "logout") + return runCommandAndCaptureStdErr(command, args) +} + +// Deploy executes the docker stack deploy command. func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error { stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) @@ -29,7 +60,7 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer. return runCommandAndCaptureStdErr(command, args) } -// Remove will execute the Docker stack rm command +// Remove executes the docker stack rm command. func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "stack", "rm", stack.Name) diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go index e3e13930c..f90f68928 100644 --- a/api/http/handler/stack.go +++ b/api/http/handler/stack.go @@ -5,6 +5,7 @@ import ( "path" "strconv" "strings" + "sync" "github.com/asaskevich/govalidator" "github.com/portainer/portainer" @@ -22,6 +23,8 @@ import ( // StackHandler represents an HTTP API handler for managing Stack. type StackHandler struct { + stackCreationMutex *sync.Mutex + stackDeletionMutex *sync.Mutex *mux.Router Logger *log.Logger FileService portainer.FileService @@ -29,17 +32,21 @@ type StackHandler struct { StackService portainer.StackService EndpointService portainer.EndpointService ResourceControlService portainer.ResourceControlService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService StackManager portainer.StackManager } // NewStackHandler returns a new instance of StackHandler. func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { h := &StackHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + stackCreationMutex: &sync.Mutex{}, + stackDeletionMutex: &sync.Mutex{}, + Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/{endpointId}/stacks", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost) + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost) h.Handle("/{endpointId}/stacks", bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet) h.Handle("/{endpointId}/stacks/{id}", @@ -173,7 +180,31 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, return } - err = handler.StackManager.Deploy(stack, endpoint) + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -275,7 +306,31 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri return } - err = handler.StackManager.Deploy(stack, endpoint) + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -354,7 +409,31 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r return } - err = handler.StackManager.Deploy(stack, endpoint) + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -515,7 +594,31 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque return } - err = handler.StackManager.Deploy(stack, endpoint) + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -589,11 +692,13 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re return } + handler.stackDeletionMutex.Lock() err = handler.StackManager.Remove(stack, endpoint) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } + handler.stackDeletionMutex.Unlock() err = handler.StackService.DeleteStack(portainer.StackID(stackID)) if err != nil { @@ -607,3 +712,28 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re return } } + +func (handler *StackHandler) deployStack(endpoint *portainer.Endpoint, stack *portainer.Stack, dockerhub *portainer.DockerHub, registries []portainer.Registry) error { + handler.stackCreationMutex.Lock() + + err := handler.StackManager.Login(dockerhub, registries, endpoint) + if err != nil { + handler.stackCreationMutex.Unlock() + return err + } + + err = handler.StackManager.Deploy(stack, endpoint) + if err != nil { + handler.stackCreationMutex.Unlock() + return err + } + + err = handler.StackManager.Logout(endpoint) + if err != nil { + handler.stackCreationMutex.Unlock() + return err + } + + handler.stackCreationMutex.Unlock() + return nil +} diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 7e7f56c7c..9f28f19c0 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -61,7 +61,7 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po } // FilterRegistries filters registries based on user role and team memberships. -// Non administrator users only have access to authorized endpoints. +// Non administrator users only have access to authorized registries. func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) { filteredRegistries := registries diff --git a/api/http/server.go b/api/http/server.go index 93d874cb3..d0402bed4 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -94,6 +94,8 @@ func (server *Server) Start() error { stackHandler.ResourceControlService = server.ResourceControlService stackHandler.StackManager = server.StackManager stackHandler.GitService = server.GitService + stackHandler.RegistryService = server.RegistryService + stackHandler.DockerHubService = server.DockerHubService server.Handler = &handler.Handler{ AuthHandler: authHandler, diff --git a/api/portainer.go b/api/portainer.go index e1bdf9455..1e9b2f3a2 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -379,6 +379,8 @@ type ( // StackManager represents a service to manage stacks. StackManager interface { + Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) error + Logout(endpoint *Endpoint) error Deploy(stack *Stack, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error } From f8451e944ad3a4844beb7964461e033b9346d9de Mon Sep 17 00:00:00 2001 From: 1138-4EB <1138-4EB@users.noreply.github.com> Date: Fri, 27 Oct 2017 09:35:35 +0200 Subject: [PATCH 21/35] style(sidebar): make sidebar-header fixed, use flex instead of absolute to position footer (#1315) --- app/components/sidebar/sidebar.html | 24 +++++++-------- assets/css/app.css | 48 +++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index da646ae77..828041ddf 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -1,13 +1,14 @@ diff --git a/assets/css/app.css b/assets/css/app.css index adb6116e6..89cac66ab 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -278,7 +278,9 @@ a[ng-click]{ } ul.sidebar { - bottom: 40px; + position: relative; + overflow: hidden; + flex-shrink: 0; } ul.sidebar .sidebar-title { @@ -292,7 +294,32 @@ ul.sidebar .sidebar-list a.active { background: #2d3e63; } -.sidebar-footer .logo { +.sidebar-header { + height: 60px; + list-style: none; + text-indent: 20px; + font-size: 18px; + background: #2d3e63; +} + +.sidebar-header a { color: #fff; } +.sidebar-header a:hover {text-decoration: none; } + +.sidebar-header .menu-icon { + float: right; + padding-right: 28px; + line-height: 60px; +} + +#page-wrapper:not(.open) .sidebar-footer-content { + display: none; +} + +.sidebar-footer-content { + text-align: center; +} + +.sidebar-footer-content .logo { width: 100%; max-width: 100px; height: 100%; @@ -300,12 +327,27 @@ ul.sidebar .sidebar-list a.active { margin: 2px 0 2px 20px; } -.sidebar-footer .version { +.sidebar-footer-content .version { font-size: 11px; margin: 11px 20px 0 7px; color: #fff; } +#sidebar-wrapper { + display: flex; + flex-flow: column; +} + +.sidebar-content { + display: flex; + flex-direction: column; + justify-content: space-between; + overflow-y: auto; + overflow-x: hidden; + height: 100%; +} + + #image-layers .btn{ padding: 0; } From 1d150414d942f45108d656c7535d61a5060e609c Mon Sep 17 00:00:00 2001 From: Riccardo Capuani Date: Fri, 27 Oct 2017 10:48:11 +0200 Subject: [PATCH 22/35] feat(templates): add /etc/hosts entries support (#1307) --- app/components/templates/templates.html | 24 +++++++++++++++++++ .../templates/templatesController.js | 8 +++++++ app/helpers/templateHelper.js | 3 ++- app/models/api/template.js | 1 + app/services/templateService.js | 1 + 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 8b7172b17..74f773fbe 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -195,6 +195,30 @@
+ +
+
+ + + add additional entry + +
+ +
+
+
+
+ value + +
+ +
+
+
+
+
diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index dac5df747..38fc6a525 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -34,6 +34,14 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer $scope.state.selectedTemplate.Ports.splice(index, 1); }; + $scope.addExtraHost = function() { + $scope.state.selectedTemplate.Hosts.push(''); + }; + + $scope.removeExtraHost = function(index) { + $scope.state.selectedTemplate.Hosts.splice(index, 1); + }; + function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index a4f626fd5..d6ad6bc63 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -15,7 +15,8 @@ angular.module('portainer.helpers') }, PortBindings: {}, Binds: [], - Privileged: false + Privileged: false, + ExtraHosts: [] }, Volumes: {} }; diff --git a/app/models/api/template.js b/app/models/api/template.js index 0123f92e4..e6499ee33 100644 --- a/app/models/api/template.js +++ b/app/models/api/template.js @@ -42,4 +42,5 @@ function TemplateViewModel(data) { }; }); } + this.Hosts = data.hosts ? data.hosts : []; } diff --git a/app/services/templateService.js b/app/services/templateService.js index a28e866ea..57fe4b9b2 100644 --- a/app/services/templateService.js +++ b/app/services/templateService.js @@ -40,6 +40,7 @@ angular.module('portainer.services') configuration.HostConfig.NetworkMode = network.Name; configuration.HostConfig.Privileged = template.Privileged; configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy }; + configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : []; configuration.name = containerName; configuration.Hostname = containerName; configuration.Image = template.Image; From 86e5ca57e95dc6bcd98926d74cabb99335813cf6 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 28 Oct 2017 19:42:55 +0200 Subject: [PATCH 23/35] style(sidebar): automatically adjust sidebar font-size based on height (#1336) --- assets/css/app.css | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/assets/css/app.css b/assets/css/app.css index 89cac66ab..9b1da5269 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -347,7 +347,6 @@ ul.sidebar .sidebar-list a.active { height: 100%; } - #image-layers .btn{ padding: 0; } @@ -369,6 +368,38 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { background: #2d3e63; } +@media (max-height: 683px) { + ul.sidebar .sidebar-title { + line-height: 28px; + } + ul.sidebar .sidebar-list { + height: 28px; + } + ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a { + font-size: 12px; + line-height: 28px; + } + ul.sidebar .sidebar-list .menu-icon { + line-height: 28px; + } +} + +@media(min-height: 684px) and (max-height: 850px) { + ul.sidebar .sidebar-title { + line-height: 30px; + } + ul.sidebar .sidebar-list { + height: 30px; + } + ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a { + font-size: 12px; + line-height: 30px; + } + ul.sidebar .sidebar-list .menu-icon { + line-height: 30px; + } +} + @media(min-width: 768px) and (max-width: 992px) { .margin-sm-top { margin-top: 5px; From a0284134966faa7afe94a7e0fb650b99d1b170bb Mon Sep 17 00:00:00 2001 From: 1138-4EB <1138-4EB@users.noreply.github.com> Date: Mon, 30 Oct 2017 08:56:21 +0100 Subject: [PATCH 24/35] feat(assets): make URLs for favicons relative (#1343) --- assets/ico/browserconfig.xml | 2 +- assets/ico/manifest.json | 4 ++-- index.html | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/assets/ico/browserconfig.xml b/assets/ico/browserconfig.xml index f9aefe5b5..44751e9d4 100644 --- a/assets/ico/browserconfig.xml +++ b/assets/ico/browserconfig.xml @@ -2,7 +2,7 @@ - + #2d89ef diff --git a/assets/ico/manifest.json b/assets/ico/manifest.json index e753aeb6b..843e03e83 100644 --- a/assets/ico/manifest.json +++ b/assets/ico/manifest.json @@ -2,12 +2,12 @@ "name": "Portainer", "icons": [ { - "src": "/ico/android-chrome-192x192.png", + "src": "ico/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/ico/android-chrome-256x256.png", + "src": "ico/android-chrome-256x256.png", "sizes": "256x256", "type": "image/png" } diff --git a/index.html b/index.html index f120e0164..1949e5459 100644 --- a/index.html +++ b/index.html @@ -24,13 +24,13 @@ - - - - - - - + + + + + + + From 42347d714f6f5d62abc5e69d2308ae41717cfc01 Mon Sep 17 00:00:00 2001 From: 1138-4EB <1138-4EB@users.noreply.github.com> Date: Mon, 30 Oct 2017 09:29:22 +0100 Subject: [PATCH 25/35] style(sidebar): automatically adjust title form-control size based on height (#1338) --- assets/css/app.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/css/app.css b/assets/css/app.css index 9b1da5269..048acf83c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -372,6 +372,10 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { ul.sidebar .sidebar-title { line-height: 28px; } + ul.sidebar .sidebar-title .form-control { + height: 28px; + padding: 4px 8px; + } ul.sidebar .sidebar-list { height: 28px; } @@ -388,6 +392,10 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { ul.sidebar .sidebar-title { line-height: 30px; } + ul.sidebar .sidebar-title .form-control { + height: 30px; + padding: 5px 10px; + } ul.sidebar .sidebar-list { height: 30px; } From 693f1319a471760b784b9f372513815781411031 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 1 Nov 2017 10:30:02 +0100 Subject: [PATCH 26/35] feat(stacks): add the ability to specify env vars when deploying stacks (#1345) --- api/exec/stack_manager.go | 24 ++++++++++---- api/http/handler/stack.go | 31 +++++++++++++++---- api/portainer.go | 1 + .../createStack/createStackController.js | 20 +++++++++--- app/components/createStack/createstack.html | 30 ++++++++++++++++++ app/components/stack/stack.html | 30 ++++++++++++++++++ app/components/stack/stackController.js | 15 +++++++-- app/helpers/formHelper.js | 18 +++++++++++ app/models/api/stack.js | 1 + app/services/api/stackService.js | 29 ++++++++++++----- app/services/fileUpload.js | 4 +-- 11 files changed, 173 insertions(+), 30 deletions(-) create mode 100644 app/helpers/formHelper.js diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go index 61051f01c..7e418d70f 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/stack_manager.go @@ -2,6 +2,7 @@ package exec import ( "bytes" + "os" "os/exec" "path" "runtime" @@ -27,7 +28,7 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] for _, registry := range registries { if registry.Authentication { registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) - err := runCommandAndCaptureStdErr(command, registryArgs) + err := runCommandAndCaptureStdErr(command, registryArgs, nil) if err != nil { return err } @@ -36,7 +37,7 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] if dockerhub.Authentication { dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password) - err := runCommandAndCaptureStdErr(command, dockerhubArgs) + err := runCommandAndCaptureStdErr(command, dockerhubArgs, nil) if err != nil { return err } @@ -49,7 +50,7 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "logout") - return runCommandAndCaptureStdErr(command, args) + return runCommandAndCaptureStdErr(command, args, nil) } // Deploy executes the docker stack deploy command. @@ -57,21 +58,32 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer. stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) - return runCommandAndCaptureStdErr(command, args) + + env := make([]string, 0) + for _, envvar := range stack.Env { + env = append(env, envvar.Name+"="+envvar.Value) + } + + return runCommandAndCaptureStdErr(command, args, env) } // Remove executes the docker stack rm command. func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "stack", "rm", stack.Name) - return runCommandAndCaptureStdErr(command, args) + return runCommandAndCaptureStdErr(command, args, nil) } -func runCommandAndCaptureStdErr(command string, args []string) error { +func runCommandAndCaptureStdErr(command string, args []string, env []string) error { var stderr bytes.Buffer cmd := exec.Command(command, args...) cmd.Stderr = &stderr + if env != nil { + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, env...) + } + err := cmd.Run() if err != nil { return portainer.Error(stderr.String()) diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go index f90f68928..1f6b23d6e 100644 --- a/api/http/handler/stack.go +++ b/api/http/handler/stack.go @@ -62,11 +62,12 @@ func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { type ( postStacksRequest struct { - Name string `valid:"required"` - SwarmID string `valid:"required"` - StackFileContent string `valid:""` - GitRepository string `valid:""` - PathInRepository string `valid:""` + Name string `valid:"required"` + SwarmID string `valid:"required"` + StackFileContent string `valid:""` + GitRepository string `valid:""` + PathInRepository string `valid:""` + Env []portainer.Pair `valid:""` } postStacksResponse struct { ID string `json:"Id"` @@ -75,7 +76,8 @@ type ( StackFileContent string `json:"StackFileContent"` } putStackRequest struct { - StackFileContent string `valid:"required"` + StackFileContent string `valid:"required"` + Env []portainer.Pair `valid:""` } ) @@ -165,6 +167,7 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, Name: stackName, SwarmID: swarmID, EntryPoint: file.ComposeFileDefaultName, + Env: req.Env, } projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent) @@ -282,6 +285,7 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri Name: stackName, SwarmID: swarmID, EntryPoint: req.PathInRepository, + Env: req.Env, } projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) @@ -369,6 +373,13 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r return } + envParam := r.FormValue("Env") + var env []portainer.Pair + if err = json.Unmarshal([]byte(envParam), &env); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + stackFile, _, err := r.FormFile("file") if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -394,6 +405,7 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r Name: stackName, SwarmID: swarmID, EntryPoint: file.ComposeFileDefaultName, + Env: env, } projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile) @@ -587,6 +599,7 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } + stack.Env = req.Env _, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent) if err != nil { @@ -594,6 +607,12 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque return } + err = handler.StackService.UpdateStack(stack.ID, stack) + 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) diff --git a/api/portainer.go b/api/portainer.go index 1e9b2f3a2..23de2f91f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -138,6 +138,7 @@ type ( EntryPoint string `json:"EntryPoint"` SwarmID string `json:"SwarmId"` ProjectPath string + Env []Pair `json:"Env"` } // RegistryID represents a registry identifier. diff --git a/app/components/createStack/createStackController.js b/app/components/createStack/createStackController.js index 840b81e8c..7b577373d 100644 --- a/app/components/createStack/createStackController.js +++ b/app/components/createStack/createStackController.js @@ -1,6 +1,6 @@ angular.module('createStack', []) -.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', -function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService) { +.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper', +function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper) { // Store the editor content when switching builder methods var editorContent = ''; @@ -11,6 +11,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica StackFileContent: '# Define or paste the content of your docker-compose file here', StackFile: null, RepositoryURL: '', + Env: [], RepositoryPath: 'docker-compose.yml', AccessControlData: new AccessControlFormData() }; @@ -20,6 +21,14 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica formValidationError: '' }; + $scope.addEnvironmentVariable = function() { + $scope.formValues.Env.push({ name: '', value: ''}); + }; + + $scope.removeEnvironmentVariable = function(index) { + $scope.formValues.Env.splice(index, 1); + }; + function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; @@ -34,20 +43,21 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica function createStack(name) { var method = $scope.state.Method; + var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); if (method === 'editor') { // The codemirror editor does not work with ng-model so we need to retrieve // the value directly from the editor. var stackFileContent = $scope.editor.getValue(); - return StackService.createStackFromFileContent(name, stackFileContent); + return StackService.createStackFromFileContent(name, stackFileContent, env); } else if (method === 'upload') { var stackFile = $scope.formValues.StackFile; - return StackService.createStackFromFileUpload(name, stackFile); + return StackService.createStackFromFileUpload(name, stackFile, env); } else if (method === 'repository') { var gitRepository = $scope.formValues.RepositoryURL; var pathInRepository = $scope.formValues.RepositoryPath; - return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository); + return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository, env); } } diff --git a/app/components/createStack/createstack.html b/app/components/createStack/createstack.html index 4845e58e3..fabbbbab7 100644 --- a/app/components/createStack/createstack.html +++ b/app/components/createStack/createstack.html @@ -131,6 +131,36 @@
+
+ Environment +
+ +
+
+ + + add environment variable + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ diff --git a/app/components/stack/stack.html b/app/components/stack/stack.html index 8795ce307..c98c6bdd1 100644 --- a/app/components/stack/stack.html +++ b/app/components/stack/stack.html @@ -38,6 +38,36 @@ +
+ Environment +
+ +
+
+ + + add environment variable + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+
Actions
diff --git a/app/components/stack/stackController.js b/app/components/stack/stackController.js index b483c1cc7..7d287c0f5 100644 --- a/app/components/stack/stackController.js +++ b/app/components/stack/stackController.js @@ -1,6 +1,6 @@ angular.module('stack', []) -.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', -function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications) { +.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', 'FormHelper', +function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications, FormHelper) { $scope.deployStack = function () { $('#createResourceSpinner').show(); @@ -8,8 +8,9 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService // The codemirror editor does not work with ng-model so we need to retrieve // the value directly from the editor. var stackFile = $scope.editor.getValue(); + var env = FormHelper.removeInvalidEnvVars($scope.stack.Env); - StackService.updateStack($scope.stack.Id, stackFile) + StackService.updateStack($scope.stack.Id, stackFile, env) .then(function success(data) { Notifications.success('Stack successfully deployed'); $state.reload(); @@ -22,6 +23,14 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService }); }; + $scope.addEnvironmentVariable = function() { + $scope.stack.Env.push({ name: '', value: ''}); + }; + + $scope.removeEnvironmentVariable = function(index) { + $scope.stack.Env.splice(index, 1); + }; + function initView() { $('#loadingViewSpinner').show(); var stackId = $stateParams.id; diff --git a/app/helpers/formHelper.js b/app/helpers/formHelper.js new file mode 100644 index 000000000..7cb3456f1 --- /dev/null +++ b/app/helpers/formHelper.js @@ -0,0 +1,18 @@ +angular.module('portainer.helpers') +.factory('FormHelper', [function FormHelperFactory() { + 'use strict'; + var helper = {}; + + helper.removeInvalidEnvVars = function(env) { + for (var i = env.length - 1; i >= 0; i--) { + var envvar = env[i]; + if (!envvar.value || !envvar.name) { + env.splice(i, 1); + } + } + + return env; + }; + + return helper; +}]); diff --git a/app/models/api/stack.js b/app/models/api/stack.js index 3d4645913..30b130943 100644 --- a/app/models/api/stack.js +++ b/app/models/api/stack.js @@ -2,6 +2,7 @@ function StackViewModel(data) { this.Id = data.Id; this.Name = data.Name; this.Checked = false; + this.Env = data.Env; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); } diff --git a/app/services/api/stackService.js b/app/services/api/stackService.js index 6ab6e7f8a..e206b620d 100644 --- a/app/services/api/stackService.js +++ b/app/services/api/stackService.js @@ -100,13 +100,19 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.createStackFromFileContent = function(name, stackFileContent) { + service.createStackFromFileContent = function(name, stackFileContent, env) { var deferred = $q.defer(); SwarmService.swarm() .then(function success(data) { var swarm = data; - return Stack.create({ method: 'string' }, { Name: name, SwarmID: swarm.Id, StackFileContent: stackFileContent }).$promise; + var payload = { + Name: name, + SwarmID: swarm.Id, + StackFileContent: stackFileContent, + Env: env + }; + return Stack.create({ method: 'string' }, payload).$promise; }) .then(function success(data) { deferred.resolve(data); @@ -118,13 +124,20 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.createStackFromGitRepository = function(name, gitRepository, pathInRepository) { + service.createStackFromGitRepository = function(name, gitRepository, pathInRepository, env) { var deferred = $q.defer(); SwarmService.swarm() .then(function success(data) { var swarm = data; - return Stack.create({ method: 'repository' }, { Name: name, SwarmID: swarm.Id, GitRepository: gitRepository, PathInRepository: pathInRepository }).$promise; + var payload = { + Name: name, + SwarmID: swarm.Id, + GitRepository: gitRepository, + PathInRepository: pathInRepository, + Env: env + }; + return Stack.create({ method: 'repository' }, payload).$promise; }) .then(function success(data) { deferred.resolve(data); @@ -136,13 +149,13 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.createStackFromFileUpload = function(name, stackFile) { + service.createStackFromFileUpload = function(name, stackFile, env) { var deferred = $q.defer(); SwarmService.swarm() .then(function success(data) { var swarm = data; - return FileUploadService.createStack(name, swarm.Id, stackFile); + return FileUploadService.createStack(name, swarm.Id, stackFile, env); }) .then(function success(data) { deferred.resolve(data.data); @@ -154,8 +167,8 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.updateStack = function(id, stackFile) { - return Stack.update({ id: id, StackFileContent: stackFile }).$promise; + service.updateStack = function(id, stackFile, env) { + return Stack.update({ id: id, StackFileContent: stackFile, Env: env }).$promise; }; return service; diff --git a/app/services/fileUpload.js b/app/services/fileUpload.js index 0f3f46643..da121eec6 100644 --- a/app/services/fileUpload.js +++ b/app/services/fileUpload.js @@ -8,9 +8,9 @@ angular.module('portainer.services') return Upload.upload({ url: url, data: { file: file }}); } - service.createStack = function(stackName, swarmId, file) { + service.createStack = function(stackName, swarmId, file, env) { var endpointID = EndpointProvider.endpointID(); - return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId } }); + return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId, Env: Upload.json(env) } }); }; service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { From ade66414a465e8873133f10a9cbc6f543baa130a Mon Sep 17 00:00:00 2001 From: Fish2 Date: Sun, 5 Nov 2017 13:51:07 +0000 Subject: [PATCH 27/35] chore(assets): lossless image compression --- assets/ico/android-chrome-192x192.png | Bin 1639 -> 1282 bytes assets/ico/android-chrome-256x256.png | Bin 1976 -> 1588 bytes assets/ico/apple-touch-icon.png | Bin 1244 -> 1006 bytes assets/ico/favicon-16x16.png | Bin 378 -> 215 bytes assets/ico/favicon-32x32.png | Bin 525 -> 358 bytes assets/ico/mstile-150x150.png | Bin 1361 -> 1147 bytes assets/images/logo.png | Bin 2048 -> 1769 bytes assets/images/logo_ico.png | Bin 7174 -> 4253 bytes assets/images/logo_small.png | Bin 1314 -> 1092 bytes 9 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/ico/android-chrome-192x192.png b/assets/ico/android-chrome-192x192.png index abf81c516a9b13cec07ce21f6e7c8d1d4aed67b7..6d31b95f244f1a0f48edaab41b6b989b0190c000 100644 GIT binary patch delta 1254 zcmVpNH6_H*Be>bZwjQ{`u3UpFV zQvh_vsu=$Dw&?ZS+fi}sfoEwOLJx;StH(fA96a0<9!IWn_SMWoB zL(}HV06IU;u0UZJTvGc1-3lD{4B|=VhK@j|aE+fE`U2CWe?q^l_63Gc7yWPD7Z@;I zbQZra&=14`r4zVZ5NJ!mfVttMz@MKk_!2ci7k0>93Lum6D5BdO}_?do80GGfLSOQC62`qsn zumnz`A^~4We@cNJkuijV9)}R-%mFASR9vuVUIAMIOW-dRP}rfxVF%AH;Iab>$qr2* zfb=+m;vqmNg13Wm2xtlMLIMTu0$5apIYbL)5QuLDN*)2}3WPllAZ-e{p(pfj-lRP+(xh=F z<_frm0HaWOc4BrB)ez^n3)szy?fwp2v(VcGHZL$9EAV`Be{&PqD$r~K{KD&r<|EdI z=FJ!qe{QJHj!yVo0xmxVc`5;vALBC#sQet9Nx0}TX+}9DOb>1o5h?b>#7{?ce*o~WwNMnX1Xm@(%MAt!c@pIOaMsP| zySvdqfkN^fq4m~I&FvtyGz=8b<59sw8Uo4&W>x+QH5(&P=wMc3`t=;dZWtjFh^Ei< zr~+dz4QW%Kn($JKZz>j5fsxG&s@8!4+riyZy$XyABJAFDB4Vz)r7|{GKJCioqQPl^ ze;4)%RtBLP1E@lo3~*b5Ku$9*4@d&Zz~JOIr}~dVuk4g7RK{a+1ux@Z2P3kOD_|Lf z0!k%a7AVQPfU$Swybg|?C9V@Xt`Fx-0+l!8gC@D8S*#j*>C=Imk>f6LhgD#6KDKs<@SAvPe1bM`Wo!_g81mBe(h;mEct~Y(X^LF>z*w28bJ9lOi7Dq#UtkeR2U*x4Q*kk z$QeKtR(Ak*#kospA5AcaUK%ihF8zXU{D1)mWsA7X0%NC6vdJsxWg-}RqZ(W6M54;e zTrl7E4Y#W$ Q?EnA(07*qoM6N<$g2AmLQ2+n{ delta 1614 zcmV-U2C?~q3g--v7&HU}0001>@^Lf(000DMK}|sb0I`n?{9y$E0004VQb$4nuFf3k z0000UP)t-sHQWD8X>pM+7=L#nX=eZc019+cPE-E?7^=qMF?3gtpY?EV00009a7bBm z000XU000XU0RWnu7ytkTSxH1eRCwC$o6U3DI26Fu+A?so(=l*#5|VOrnva>@NTJ>K z=H`Q@H%Q3r_J*_VkldiVY13P>9Wvejwzg#(W3Vlxr|i=4V+I40=znKP?@RI%QUwdj z0q}rwv-M0UUjEYsi;9F@Ula0MI`o0F_Dm z2#??adc$q`*eA$cXn?Drj*2?daAJC&3}C<~@4iQ_p;f%TyfZ)ne|sZu6H6wueF6zM z3v`FLin{mp6&zsFpnva+&M1`?Eux?&QD+wn83;g+Ols&&{Yi%aXi>Ec0lwOH`2pIe zs9{aw0qIxC;09bzjdmp9R49~{0??b*muN)W%vM%Jsi1rFLncwyXFLF6v#*n4ve!L) z!4B}fPIAz{&hXtC0NtEXlo)_0YqIY~6dro|zvF4koO_@L%zx|uOkOlQiqrt2pch$* z*Nh&}0@mj0;GE2^o|0(90jm?)BzC$Q69-U?|K@pCbnkvNG5`$}6O1S%l5HHM1GEO> zlwTiU2?nU&w`XM+d+*2oH^;QyH)(sWY%v6!n&iF95j%J?d#fA~jWnGDazGBq0jbi# z14sG-0elKHfUY$%?Y?d9cOKW2C-49~0Xz}{Kyd&b<1wJCm<9N|fWiP}qOL!L0p_gH za=^m@9*Y5Nqc_%j&s9jKd@BIpJ3D_t2oCVw^;PJup??7x0x(2JN5{_VC>EyGDMHpa7b`Uu*=|;D@CNIMt>XUt0d9 z)!Ol|BYz;kFlo>A1rQ){cew}y&=Yr!Wf(v;d0%=20;nYe?mz&;WWZYxz-=->0Rf~u zq9}j>QXWy1AOL#G(+wE{Q28$b*jNTYdFgwQI?2W|Xi=ISNTMxS677pXT!q??H{1Mo zaJ6HFF_Rn6i4DsaS9R2lk6`Y4eX9NU-Qf2#sDFSn6_eefCGC&FrQ3}jP{@qxsavzL zx$Ay9{OrU!uQ&S5w>`eR$eaM$PWx6^J*iLrh6~V%x`s*pq+cSXZJZij(F;3ZCGvb; zGp9X~_U)yGTla2#7f1RY-gI|brP``K4s~mVBYp3ONyK!K0zM5>nv$V@W(Ndgv>&sH zuz%E)vW_wl5L9oB(vmQn1K_8pa%yZS@c?v-OhGNJ1muoqjn*TXXg>{5@`tXtz|wQ2We) zb-HS+HY-4Y<;b8=%Z*`s;DuRwsQpq-{S75ZyN=X9M*=TgS~Gh6)6T zo}3R)sIICDNM_kXJs|w1GD&t_#eb$Z(fD2!1yHrG-=ANes^S2Ak^?g3C%v4eNw6#_ zdjJ3cS9(-fbW&k=AaHVTW@&6?Aar?fWguyAbYlPjc%0+%3K74o@()5al>h($ M07*qoM6N<$f;(-~T>t<8 diff --git a/assets/ico/android-chrome-256x256.png b/assets/ico/android-chrome-256x256.png index 8770d7d3b05a0f4866ad5e33bf6e098074286f2e..a7eb8da10886644ca6e7882e1ed155f3eea2ee32 100644 GIT binary patch delta 1571 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 diff --git a/assets/ico/apple-touch-icon.png b/assets/ico/apple-touch-icon.png index 6ca3086cda092c301de32452d0dd20ddba9b3389..b5aa4b2e72a9fd44f3ee8812eaa73bdcd40a4859 100644 GIT binary patch delta 958 zcmV;v13~=U3GN4w7zqdi0000A6ZQa+J}Q5XNkl1@%CwTf5iWYP9z=p9NY zslQcXl|Fuxr_bz1o?O;AdgL1IzhCTVR0-SUk{@npV{Q!J*O-0$G4qvgXGFP6VCV{D8_%=kd|)gW5T{bAh*{LH0(wY0J{7TV0(n|g9!ZN z_)gd1LJyx)w{%OlbW68%OHVs?aTgaYV?)dag z^e*p3f*yl2Njn}y^GF9Ch4X(%J03>UNHZSBvq&2rhq6d3p1_hwJDxz3NDH38lSnh3 zK$1vPOSkl!GVR?SzpSUVqO{=)Zn#Fc0fTxi* ztjI<4BNimKrJrovP<4?houc&oqDG0qQ&Ju`5~#%buMjhsC?1a@K2j79D32W~z>7p*YWY&W$Qd9ZvW*GEPAWuMnr!bfJ7W>PahJ#S;{B z0ci%x#Y0h0n(;c8A<=)?w@hA9T2L_4jFt5JJOWUyr~_|-E3P!GUZw%9*B*c;=;`DS zFIG`{a7Ae?l<5gql(vGqkGBMb3Oj%}ZIWTp1RU5fjb08`)DqwTokmpxkbI>$R g(=<)fG{1`f0D>qmz!b3`m;e9(07*qoM6N<$g7_T9mH+?% delta 1197 zcmV;e1XBC%2iyse7$yV*0002b;~N10000DMK}|sb0I`n?{9y$E0004VQb$4nuFf3k zks&I7{7FPXRA}Dqo6A$;KoG{WVlc;@gby35B-9|BfcKa-xXzv-EYk|aWe2oWN_U~IHnSm+W_@zC0*yz9@1ekSS= zhb)_f9HEfQ7761Nfvnhs#S=W)pOA^#hDJMoWEz?aOTMDe2cpufR6u1irsSWYuGv`b zL0VoYyHIzuWT*nk4xFH^3653cD|-MaLDK{q!)c*k!%2|IuHr@>@(WCYRCZcBZch%7 z1gY%Qe!}^p=iry!Y06Y*+2@iSA(7ohehnbOBqBByB>rS!A2l*(nK7TkDk;ZEOklBp z;VlsNDUl`TMu$V*bEQ#A%#9opyq@(0{s=y)^Tev zAUJ5|5;zHY)UI?N2Ypdl|K7numo`6t0eWKDcjbu{gQ6!BjXq)@by&1&SIh%_6kb%j z28-6e)oo87)%?3ksRpW$6dF?34E-`igH~e_-3w^A80gdn(SM8faL}ryku80snOnrF z!=e*~4vJp7g~*5AIHb#oqcOcU`VWofVWZ(}ga(4%$lUU1L!h&FpZXB!%S?0)0^P|(S0T`TCR&F;+nHzs0&QiYD-dW? zpjXMY@xc{>sOt_MI^iK&(Czkren1s^-1+v{oT!`u*;CA~cS7t<*BeS>7*j1rS{_QT zG#T5jE))k2+j8zk#c4(IZflyh;qO5pzefc6qla?ZbEn1 zAmiwxq2;_oQ3JSl2SHl|Dp^KmU_Sf^@YbgHbs_Qv2HPVS`8=SL^jjd~}x-M}Al5%#BV=<~j>J6Ahle#`7t z$zvCU9PKPj-YLBa9?!wfG^QfK_#cQ{df)1kgc0Rl&~+sdB1DLM5b>_@7x?6mUr;Ua zP5=M^S9(-fbW&k=AaHVTW@&6?Aar?fWguyAbYlPjc%0+%3K74o@_|`c<=an9y8S!$c-1X?2=1bbN!_EtBAOTsY1Bbqz6Fg< zhj9AS;2`Yu7SX?M?os^;jZMh`=o2zgvI9DSkv3r4ys(6220)m2-sliESZsIdd95vT z3-g1CN1yH@aA%2<{{{dSv1noj3_|b0v8uoR#5Zn*EdWF8EgS#<002ovPDHLkV1gU0 BR;B;| literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFY;1lBNlUZnb`#(YSd+qE?KrOr_L4Lsue=FqMPMc=%Oj`FSEX|MW4N!u! zz$3Dlfq`2Xgc%uT&5-~KCV09yhHzX@PGD$a<`GB`Nnu!adv5n#>4uU^I!jNi%2W`1 zmZZLc=lsW$0&LGG@o+SsWYxMPwTt(VhlofJlku*_P0O51Sh>6!C9gF#UE&omZEE6F zV5?RLX=%*7nW(_Y@aU58?~fNx+y>eeUKJ8i5|mi3P*9YgmYI{PP*Pcts*qVwlFYzR zG3W6o9*)8=4UJR&r_Xpk4Pszc=GIH*7FHJao-D#Ftl-jMayW%qd2@)u=^Iy09657D i<_P=g29E_^dJM0`1xr3TnN9^-!QkoY=d#Wzp$Pyl0g4U) diff --git a/assets/ico/favicon-32x32.png b/assets/ico/favicon-32x32.png index 25c2aeeb1c65b807ddbdd78b1e76b9e2c774f566..ab08671dc0ae6ff016702baa6924d111ea2cb359 100644 GIT binary patch delta 290 zcmV+-0p0$M1m*&e7zqdi0000)q>9FoJ~@9Q1c04m>p;@9kNyqrX;c6J0Od(UK~#9! zeUA%n1R)GVLp~%i_rGt0)ZLaD;AsX4*~AV#X^k#0ljuATlwiFJ6Q5*{)XeVmv~W@J z*=c!LKcREQZsXmlD1j?A;QJVYjDC oes$0m*3zu0+FoX+=9}3+BGC*7zcdbxH2?qr07*qoM6N<$g1py+#sB~S delta 458 zcmV;*0X6>S0*wTa7$yV*0001iRABhu@-378E4000SaNLh0L01FcU01FcV0GgZ_0002@NklYH zfP$F}D1b@9oJj#$K!Ibo5fd_>by-<<08E5QNr5xF!JK2;1h_6Hs|E!VCXU5WzDkL= zn2F0O6X(S324DeYXXEztfGRx%A}+OHDu<@sV)IX zDj3Y&OUh6LOiVm96_5qYOfo_zpsH{wN7LerYN%UzI*OsJfh($07*qoM6N<$g1CFE A-T(jq diff --git a/assets/ico/mstile-150x150.png b/assets/ico/mstile-150x150.png index f3670168ab6e9ec34033991ac9fb0c642c56aa69..60643c1acfe86b1af2403c7eed5523568f546366 100644 GIT binary patch delta 1090 zcmV-I1ikyw3i}9L=d_I|4!@^NY>sa|^gUa^i%e1yV7r--?+j?!}xEfBOvIY$Hg-HY(d96utI z9!T#}f$l{(Eqk@}e<*wS!lFp^MU6+Pc4t*&GYLZ=C_O4uWwm1mq$>L$g;VqBzBbBG zcC0GV`JoKv5Bd%)fK&ZyTl>OmfD&&LZScBybHj|8e>6jL+ns;q3o{Sq^Cx&cu)+VD z1Z{cjJ;2Mi8U0#ZPsQMw2Ww997c9XRG+mIsHu=%uF2Xiwe<42%g~R4js`OCXkR$Z~ zIgh=E+V0WOT<}xjp(l*f@eIntLp@g?g)&z$x-=QvJ# znWRTH>9(hNGMWv9J-+{W{5J1o2d$y@I2QBBjmLK%hglkP>VU`jq`(Jhj{$-~+(Q*e zdmOZc>k%aBe<+W_anhCafK?C#?PXAKCF*h1EKvH#$!17W^TGu=7xXqmvHbYG*Us|p zXf^B=!%vxbu74bw^+h~S-C191_1JsJ&r3_V9{nf{^WutnSv%rT$OBJd!?a3|y8afKdAc;Eq7h-B(<%vF2|e-FyI%4d@Bm~xd{2@e=?l@>^N zgk0rXq+e35a^r|c=l@&}SDp`&@0WUh#HsO+&C}$m+59lpc`CZT4153rT=9Y=*W1X) zb-RE02aXBU;p9SU|8a{mE{GpJASh_0+QWLobm5dY#0&D)50fRF+x37m)2v>~hMqU( zf-9I?fAzoy7sS5SF_ejNj{@(ybTQ-#u}=WDJEn8UHEzImLE?VvjB7*$c4;gXO}Wmk zWf6j-X2^9qmNmRDDSwMDN__sdP6RfgGv0-7JI8jgAnUm5% zCHALvVzRys68C_R-FQw@-p!5RF|-3Zk8e_Ia{I8T-h zb}w}#*t38BD6sUx-$QVsAlG0K^g{Wz)lg&1-y{L6Ab9!wPpmO!FkcfCSD5Jew9^v(Cy6e$8J^l=OU zID#~$65s*^|55&G4-NzX0001h{9hYERaI40RaI40RaI400I4Iddr`$4Jpcdz07*qo IM6N<$f-@x)&Hw-a delta 1303 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

f9_rdMf@Cy&=+9`%KHH7Ll|`H&`0H{kBE? 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 GLSTXr*>7k7 From 407f0f580791106fcda669dc1e1b4f6b3cbe962f Mon Sep 17 00:00:00 2001 From: Thomas Kooi Date: Mon, 6 Nov 2017 09:47:31 +0100 Subject: [PATCH 28/35] feat(configs): add support for docker configs (#996) --- api/http/handler/resource_control.go | 2 + api/http/proxy/configs.go | 107 ++++++++++++++++++ api/http/proxy/transport.go | 20 ++++ api/portainer.go | 2 + app/__module.js | 3 + app/components/config/config.html | 81 +++++++++++++ app/components/config/configController.js | 45 ++++++++ app/components/configs/configs.html | 81 +++++++++++++ app/components/configs/configsController.js | 76 +++++++++++++ .../createConfig/createConfigController.js | 102 +++++++++++++++++ app/components/createConfig/createconfig.html | 74 ++++++++++++ .../createService/createServiceController.js | 32 +++++- .../createService/createservice.html | 4 + .../createService/includes/config.html | 27 +++++ .../createStack/createStackController.js | 2 +- app/components/secrets/secrets.html | 6 +- app/components/secrets/secretsController.js | 4 +- app/components/service/includes/configs.html | 62 ++++++++++ app/components/service/service.html | 2 + app/components/service/serviceController.js | 26 ++++- app/components/sidebar/sidebar.html | 3 + app/components/stack/stackController.js | 2 +- app/helpers/configHelper.js | 34 ++++++ app/helpers/secretHelper.js | 6 +- app/models/docker/config.js | 15 +++ app/models/docker/service.js | 1 + app/rest/docker/config.js | 12 ++ app/routes.js | 39 +++++++ app/services/codeMirror.js | 21 +++- app/services/docker/configService.js | 61 ++++++++++ 30 files changed, 932 insertions(+), 20 deletions(-) create mode 100644 api/http/proxy/configs.go create mode 100644 app/components/config/config.html create mode 100644 app/components/config/configController.js create mode 100644 app/components/configs/configs.html create mode 100644 app/components/configs/configsController.js create mode 100644 app/components/createConfig/createConfigController.js create mode 100644 app/components/createConfig/createconfig.html create mode 100644 app/components/createService/includes/config.html create mode 100644 app/components/service/includes/configs.html create mode 100644 app/helpers/configHelper.js create mode 100644 app/models/docker/config.js create mode 100644 app/rest/docker/config.js create mode 100644 app/services/docker/configService.js diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go index 779431516..fa939bb12 100644 --- a/api/http/handler/resource_control.go +++ b/api/http/handler/resource_control.go @@ -84,6 +84,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht resourceControlType = portainer.SecretResourceControl case "stack": resourceControlType = portainer.StackResourceControl + case "config": + resourceControlType = portainer.ConfigResourceControl default: httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) return diff --git a/api/http/proxy/configs.go b/api/http/proxy/configs.go new file mode 100644 index 000000000..16904c6c2 --- /dev/null +++ b/api/http/proxy/configs.go @@ -0,0 +1,107 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerConfigIdentifierNotFound defines an error raised when Portainer is unable to find a config identifier + ErrDockerConfigIdentifierNotFound = portainer.Error("Docker config identifier not found") + configIdentifier = "ID" +) + +// configListOperation extracts the response as a JSON object, loop through the configs array +// decorate and/or filter the configs based on resource controls before rewriting the response +func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + + // ConfigList response is a JSON array + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if executor.operationContext.isAdmin { + responseArray, err = decorateConfigList(responseArray, executor.operationContext.resourceControls) + } else { + responseArray, err = filterConfigList(responseArray, executor.operationContext) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// configInspectOperation extracts the response as a JSON object, verify that the user +// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID) +// and either rewrite an access denied response or a decorated config. +func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + // ConfigInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[configIdentifier] == nil { + return ErrDockerConfigIdentifierNotFound + } + + configID := responseObject[configIdentifier].(string) + responseObject, access := applyResourceAccessControl(responseObject, configID, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} + +// decorateConfigList loops through all configs and decorates any config with an existing resource control. +// Resource controls checks are based on: resource identifier. +// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList +func decorateConfigList(configData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedConfigData := make([]interface{}, 0) + + for _, config := range configData { + + configObject := config.(map[string]interface{}) + if configObject[configIdentifier] == nil { + return nil, ErrDockerConfigIdentifierNotFound + } + + configID := configObject[configIdentifier].(string) + configObject = decorateResourceWithAccessControl(configObject, configID, resourceControls) + + decoratedConfigData = append(decoratedConfigData, configObject) + } + + return decoratedConfigData, nil +} + +// filterConfigList loops through all configs and filters public configs (no associated resource control) +// as well as authorized configs (access granted to the user based on existing resource control). +// Authorized configs are decorated during the process. +// Resource controls checks are based on: resource identifier. +// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList +func filterConfigList(configData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredConfigData := make([]interface{}, 0) + + for _, config := range configData { + configObject := config.(map[string]interface{}) + if configObject[configIdentifier] == nil { + return nil, ErrDockerConfigIdentifierNotFound + } + + configID := configObject[configIdentifier].(string) + configObject, access := applyResourceAccessControl(configObject, configID, context) + if access { + filteredConfigData = append(filteredConfigData, configObject) + } + } + + return filteredConfigData, nil +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index d5febb22a..83edcaf37 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -41,6 +41,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon path := request.URL.Path switch { + case strings.HasPrefix(path, "/configs"): + return p.proxyConfigRequest(request) case strings.HasPrefix(path, "/containers"): return p.proxyContainerRequest(request) case strings.HasPrefix(path, "/services"): @@ -62,6 +64,24 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon } } +func (p *proxyTransport) proxyConfigRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/configs/create": + return p.executeDockerRequest(request) + + case "/configs": + return p.rewriteOperation(request, configListOperation) + + default: + // assume /configs/{id} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, configInspectOperation) + } + configID := path.Base(requestPath) + return p.restrictedOperation(request, configID) + } +} + func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/containers/create": diff --git a/api/portainer.go b/api/portainer.go index 23de2f91f..63916d08b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -449,4 +449,6 @@ const ( SecretResourceControl // StackResourceControl represents a resource control associated to a stack composed of Docker services StackResourceControl + // ConfigResourceControl represents a resource control associated to a Docker config + ConfigResourceControl ) diff --git a/app/__module.js b/app/__module.js index 45dfe4e31..e4468a0d0 100644 --- a/app/__module.js +++ b/app/__module.js @@ -16,6 +16,8 @@ angular.module('portainer', [ 'portainer.services', 'auth', 'dashboard', + 'config', + 'configs', 'container', 'containerConsole', 'containerLogs', @@ -23,6 +25,7 @@ angular.module('portainer', [ 'containerInspect', 'serviceLogs', 'containers', + 'createConfig', 'createContainer', 'createNetwork', 'createRegistry', diff --git a/app/components/config/config.html b/app/components/config/config.html new file mode 100644 index 000000000..ec7497c79 --- /dev/null +++ b/app/components/config/config.html @@ -0,0 +1,81 @@ + + + + + + + + + Configs > {{ config.Name }} + + + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ config.Name }}
ID + {{ config.Id }} + +
Created{{ config.CreatedAt | getisodate }}
Last updated{{ config.UpdatedAt | getisodate }}
Labels + + + + + +
{{ k }}{{ v }}
+
+
+
+
+
+ + + + + + +
+
+ + + +
+
+
+ +
+
+
+
+
+
+
diff --git a/app/components/config/configController.js b/app/components/config/configController.js new file mode 100644 index 000000000..6ae37c21f --- /dev/null +++ b/app/components/config/configController.js @@ -0,0 +1,45 @@ +angular.module('config', []) +.controller('ConfigController', ['$scope', '$transition$', '$state', '$document', 'ConfigService', 'Notifications', 'CodeMirrorService', +function ($scope, $transition$, $state, $document, ConfigService, Notifications, CodeMirrorService) { + + $scope.removeConfig = function removeConfig(configId) { + $('#loadingViewSpinner').show(); + ConfigService.remove(configId) + .then(function success(data) { + Notifications.success('Config successfully removed'); + $state.go('configs', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove config'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function initEditor() { + $document.ready(function() { + var webEditorElement = $document[0].getElementById('config-editor'); + if (webEditorElement) { + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, true); + } + }); + } + + function initView() { + $('#loadingViewSpinner').show(); + ConfigService.config($transition$.params().id) + .then(function success(data) { + $scope.config = data; + initEditor(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve config details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/configs/configs.html b/app/components/configs/configs.html new file mode 100644 index 000000000..3464a8508 --- /dev/null +++ b/app/components/configs/configs.html @@ -0,0 +1,81 @@ + + + + + + + + Configs + + +
+
+ + + + +
+ + Add config +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + Name + + + + + + Created at + + + + + + Ownership + + + +
{{ config.Name }}{{ config.CreatedAt | getisodate }} + + + {{ config.ResourceControl.Ownership ? config.ResourceControl.Ownership : config.ResourceControl.Ownership = 'public' }} + +
Loading...
No configs available.
+
+ +
+
+
+ +
+
diff --git a/app/components/configs/configsController.js b/app/components/configs/configsController.js new file mode 100644 index 000000000..64f894986 --- /dev/null +++ b/app/components/configs/configsController.js @@ -0,0 +1,76 @@ +angular.module('configs', []) +.controller('ConfigsController', ['$scope', '$stateParams', '$state', 'ConfigService', 'Notifications', 'Pagination', +function ($scope, $stateParams, $state, ConfigService, Notifications, Pagination) { + $scope.state = {}; + $scope.state.selectedItemCount = 0; + $scope.state.pagination_count = Pagination.getPaginationCount('configs'); + $scope.sortType = 'Name'; + $scope.sortReverse = false; + + $scope.order = function (sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredConfigs, function (config) { + if (config.Checked !== allSelected) { + config.Checked = allSelected; + $scope.selectItem(config); + } + }); + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.removeAction = function () { + $('#loadingViewSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadingViewSpinner').hide(); + } + }; + angular.forEach($scope.configs, function (config) { + if (config.Checked) { + counter = counter + 1; + ConfigService.remove(config.Id) + .then(function success() { + Notifications.success('Config deleted', config.Id); + var index = $scope.configs.indexOf(config); + $scope.configs.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove config'); + }) + .finally(function final() { + complete(); + }); + } + }); + }; + + function initView() { + $('#loadingViewSpinner').show(); + ConfigService.configs() + .then(function success(data) { + $scope.configs = data; + }) + .catch(function error(err) { + $scope.configs = []; + Notifications.error('Failure', err, 'Unable to retrieve configs'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/createConfig/createConfigController.js b/app/components/createConfig/createConfigController.js new file mode 100644 index 000000000..f02a334e3 --- /dev/null +++ b/app/components/createConfig/createConfigController.js @@ -0,0 +1,102 @@ +angular.module('createConfig', []) +.controller('CreateConfigController', ['$scope', '$state', '$document', 'Notifications', 'ConfigService', 'Authentication', 'FormValidator', 'ResourceControlService', 'CodeMirrorService', +function ($scope, $state, $document, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService, CodeMirrorService) { + + $scope.formValues = { + Name: '', + Labels: [], + AccessControlData: new AccessControlFormData() + }; + + $scope.state = { + formValidationError: '' + }; + + $scope.addLabel = function() { + $scope.formValues.Labels.push({ name: '', value: ''}); + }; + + $scope.removeLabel = function(index) { + $scope.formValues.Labels.splice(index, 1); + }; + + function prepareLabelsConfig(config) { + var labels = {}; + $scope.formValues.Labels.forEach(function (label) { + if (label.name && label.value) { + labels[label.name] = label.value; + } + }); + config.Labels = labels; + } + + function prepareConfigData(config) { + // The codemirror editor does not work with ng-model so we need to retrieve + // the value directly from the editor. + var configData = $scope.editor.getValue(); + config.Data = btoa(unescape(encodeURIComponent(configData))); + } + + function prepareConfiguration() { + var config = {}; + config.Name = $scope.formValues.Name; + prepareConfigData(config); + prepareLabelsConfig(config); + return config; + } + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + $scope.create = function () { + $('#createResourceSpinner').show(); + + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + var config = prepareConfiguration(); + + ConfigService.create(config) + .then(function success(data) { + var configIdentifier = data.ID; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('config', configIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Config successfully created'); + $state.go('configs', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create config'); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + }; + + function initView() { + $document.ready(function() { + var webEditorElement = $document[0].getElementById('config-editor', false); + if (webEditorElement) { + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, false); + } + }); + } + + initView(); +}]); diff --git a/app/components/createConfig/createconfig.html b/app/components/createConfig/createconfig.html new file mode 100644 index 000000000..5eee991ca --- /dev/null +++ b/app/components/createConfig/createconfig.html @@ -0,0 +1,74 @@ + + + + Configs > Add config + + + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + + add label + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ + + + + +
+ Actions +
+
+
+ + Cancel + +
+
+ +
+
+
+
+
diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 887fe7cb3..3e3c5ecb1 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -1,8 +1,8 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createService', []) -.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService', -function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) { +.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'ConfigService', 'ConfigHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService', +function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, ConfigHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) { $scope.formValues = { Name: '', @@ -28,6 +28,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se UpdateOrder: 'stop-first', FailureAction: 'pause', Secrets: [], + Configs: [], AccessControlData: new AccessControlFormData(), CpuLimit: 0, CpuReservation: 0, @@ -71,6 +72,14 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se $scope.formValues.Volumes.splice(index, 1); }; + $scope.addConfig = function() { + $scope.formValues.Configs.push({}); + }; + + $scope.removeConfig = function(index) { + $scope.formValues.Configs.splice(index, 1); + }; + $scope.addSecret = function() { $scope.formValues.Secrets.push({}); }; @@ -222,6 +231,20 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences); } + function prepareConfigConfig(config, input) { + if (input.Configs) { + var configs = []; + angular.forEach(input.Configs, function(config) { + if (config.model) { + var s = ConfigHelper.configConfig(config.model); + s.File.Name = config.FileName || s.File.Name; + configs.push(s); + } + }); + config.TaskTemplate.ContainerSpec.Configs = configs; + } + } + function prepareSecretConfig(config, input) { if (input.Secrets) { var secrets = []; @@ -294,6 +317,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se prepareVolumes(config, input); prepareNetworks(config, input); prepareUpdateConfig(config, input); + prepareConfigConfig(config, input); prepareSecretConfig(config, input); preparePlacementConfig(config, input); prepareResourcesCpuConfig(config, input); @@ -382,8 +406,9 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se $q.all({ volumes: VolumeService.volumes(), - secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], networks: NetworkService.networks(true, true, false, false), + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], + configs: apiVersion >= 1.30 ? ConfigService.configs() : [], nodes: NodeService.nodes(), settings: SettingsService.publicSettings() }) @@ -391,6 +416,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se $scope.availableVolumes = data.volumes; $scope.availableNetworks = data.networks; $scope.availableSecrets = data.secrets; + $scope.availableConfigs = data.configs; var nodes = data.nodes; initSlidersMaxValuesBasedOnNodeData(nodes); var settings = data.settings; diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index c026df3de..b59053509 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -133,6 +133,7 @@
  • Labels
  • Update config
  • Secrets
  • +
  • Configs
  • Resources & Placement
  • @@ -442,6 +443,9 @@
    + +
    +
    diff --git a/app/components/createService/includes/config.html b/app/components/createService/includes/config.html new file mode 100644 index 000000000..8083ae2f2 --- /dev/null +++ b/app/components/createService/includes/config.html @@ -0,0 +1,27 @@ +
    +
    +
    + + + add a config + +
    +
    +
    +
    + config + +
    +
    + Path in container + +
    + +
    +
    +
    +
    diff --git a/app/components/createStack/createStackController.js b/app/components/createStack/createStackController.js index 7b577373d..bb358ee58 100644 --- a/app/components/createStack/createStackController.js +++ b/app/components/createStack/createStackController.js @@ -101,7 +101,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica $document.ready(function() { var webEditorElement = $document[0].getElementById('web-editor'); if (webEditorElement) { - $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement); + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false); if (value) { $scope.editor.setValue(value); } diff --git a/app/components/secrets/secrets.html b/app/components/secrets/secrets.html index b274ae777..efd4107b3 100644 --- a/app/components/secrets/secrets.html +++ b/app/components/secrets/secrets.html @@ -16,7 +16,7 @@
    - Add secret + Add secret
    @@ -39,8 +39,8 @@ Created at - - + + diff --git a/app/components/secrets/secretsController.js b/app/components/secrets/secretsController.js index 1aa890504..fc71dd58b 100644 --- a/app/components/secrets/secretsController.js +++ b/app/components/secrets/secretsController.js @@ -1,6 +1,6 @@ angular.module('secrets', []) -.controller('SecretsController', ['$scope', '$transition$', '$state', 'SecretService', 'Notifications', 'Pagination', -function ($scope, $transition$, $state, SecretService, Notifications, Pagination) { +.controller('SecretsController', ['$scope', '$state', 'SecretService', 'Notifications', 'Pagination', +function ($scope, $state, SecretService, Notifications, Pagination) { $scope.state = {}; $scope.state.selectedItemCount = 0; $scope.state.pagination_count = Pagination.getPaginationCount('secrets'); diff --git a/app/components/service/includes/configs.html b/app/components/service/includes/configs.html new file mode 100644 index 000000000..e1eba62cd --- /dev/null +++ b/app/components/service/includes/configs.html @@ -0,0 +1,62 @@ +
    + + + + +
    + Add a config: + + + add config + +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    NamePath in containerUIDGIDMode
    {{ config.Name }} + + {{ config.Uid }}{{ config.Gid }}{{ config.Mode }} + +
    No configs associated to this service.
    +
    + + + +
    +
    diff --git a/app/components/service/service.html b/app/components/service/service.html index e11585c62..f54a46b69 100644 --- a/app/components/service/service.html +++ b/app/components/service/service.html @@ -117,6 +117,7 @@
  • Restart policy
  • Update configuration
  • Service labels
  • +
  • Configs
  • Secrets
  • Tasks
  • @@ -164,6 +165,7 @@
    +
    diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 9ff2cb6e2..bf3f6d514 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -1,6 +1,6 @@ angular.module('service', []) -.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', -function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) { +.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', +function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('service_tasks'); @@ -59,6 +59,21 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); } }; + $scope.addConfig = function addConfig(service, config) { + if (config && service.ServiceConfigs.filter(function(serviceConfig) { return serviceConfig.Id === config.Id;}).length === 0) { + service.ServiceConfigs.push({ Id: config.Id, Name: config.Name, FileName: config.Name, Uid: '0', Gid: '0', Mode: 292 }); + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + } + }; + $scope.removeConfig = function removeSecret(service, index) { + var removedElement = service.ServiceConfigs.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + } + }; + $scope.updateConfig = function updateConfig(service) { + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + }; $scope.addSecret = function addSecret(service, secret) { if (secret && service.ServiceSecrets.filter(function(serviceSecret) { return serviceSecret.Id === secret.Id;}).length === 0) { service.ServiceSecrets.push({ Id: secret.Id, Name: secret.Name, FileName: secret.Name, Uid: '0', Gid: '0', Mode: 444 }); @@ -193,6 +208,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels); config.TaskTemplate.ContainerSpec.Image = service.Image; config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : []; + config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : []; if (service.Mode === 'replicated') { config.Mode.Replicated.Replicas = service.Replicas; @@ -289,6 +305,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, function translateServiceArrays(service) { service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : []; + service.ServiceConfigs = service.Configs ? service.Configs.map(ConfigHelper.flattenConfig) : []; service.EnvironmentVariables = ServiceHelper.translateEnvironmentVariables(service.Env); service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels); service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels); @@ -323,12 +340,14 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, return $q.all({ tasks: TaskService.tasks({ service: [service.Name] }), nodes: NodeService.nodes(), - secrets: apiVersion >= 1.25 ? SecretService.secrets() : [] + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], + configs: apiVersion >= 1.30 ? ConfigService.configs() : [] }); }) .then(function success(data) { $scope.tasks = data.tasks; $scope.nodes = data.nodes; + $scope.configs = data.configs; $scope.secrets = data.secrets; // Set max cpu value @@ -350,6 +369,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, }) .catch(function error(err) { $scope.secrets = []; + $scope.configs = []; Notifications.error('Failure', err, 'Unable to retrieve service details'); }) .finally(function final() { diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 828041ddf..db12a7dca 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -44,6 +44,9 @@ + diff --git a/app/components/stack/stackController.js b/app/components/stack/stackController.js index 7d287c0f5..b8147f899 100644 --- a/app/components/stack/stackController.js +++ b/app/components/stack/stackController.js @@ -57,7 +57,7 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService $document.ready(function() { var webEditorElement = $document[0].getElementById('web-editor'); if (webEditorElement) { - $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement); + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false); } }); diff --git a/app/helpers/configHelper.js b/app/helpers/configHelper.js new file mode 100644 index 000000000..f47eacc7e --- /dev/null +++ b/app/helpers/configHelper.js @@ -0,0 +1,34 @@ +angular.module('portainer.helpers') +.factory('ConfigHelper', [function ConfigHelperFactory() { + 'use strict'; + return { + flattenConfig: function(config) { + if (config) { + return { + Id: config.ConfigID, + Name: config.ConfigName, + FileName: config.File.Name, + Uid: config.File.UID, + Gid: config.File.GID, + Mode: config.File.Mode + }; + } + return {}; + }, + configConfig: function(config) { + if (config) { + return { + ConfigID: config.Id, + ConfigName: config.Name, + File: { + Name: config.FileName || config.Name, + UID: config.Uid || '0', + GID: config.Gid || '0', + Mode: config.Mode || 292 + } + }; + } + return {}; + } + }; +}]); diff --git a/app/helpers/secretHelper.js b/app/helpers/secretHelper.js index 9c0f3d65b..afd3b1a56 100644 --- a/app/helpers/secretHelper.js +++ b/app/helpers/secretHelper.js @@ -22,9 +22,9 @@ angular.module('portainer.helpers') SecretName: secret.Name, File: { Name: secret.FileName, - UID: '0', - GID: '0', - Mode: 444 + UID: secret.Uid || '0', + GID: secret.Gid || '0', + Mode: secret.Mode || 444 } }; } diff --git a/app/models/docker/config.js b/app/models/docker/config.js new file mode 100644 index 000000000..214909b5a --- /dev/null +++ b/app/models/docker/config.js @@ -0,0 +1,15 @@ +function ConfigViewModel(data) { + this.Id = data.ID; + this.CreatedAt = data.CreatedAt; + this.UpdatedAt = data.UpdatedAt; + this.Version = data.Version.Index; + this.Name = data.Spec.Name; + this.Labels = data.Spec.Labels; + this.Data = atob(data.Spec.Data); + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } +} diff --git a/app/models/docker/service.js b/app/models/docker/service.js index 28d8609ba..5268bb43e 100644 --- a/app/models/docker/service.js +++ b/app/models/docker/service.js @@ -69,6 +69,7 @@ function ServiceViewModel(data, runningTasks, nodes) { this.Hosts = containerSpec.Hosts; this.DNSConfig = containerSpec.DNSConfig; this.Secrets = containerSpec.Secrets; + this.Configs = containerSpec.Configs; } if (data.Endpoint) { this.Ports = data.Endpoint.Ports; diff --git a/app/rest/docker/config.js b/app/rest/docker/config.js new file mode 100644 index 000000000..330692fbe --- /dev/null +++ b/app/rest/docker/config.js @@ -0,0 +1,12 @@ +angular.module('portainer.rest') +.factory('Config', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ConfigFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/configs/:id/:action', { + endpointId: EndpointProvider.endpointID + }, { + get: { method: 'GET', params: { id: '@id' } }, + query: { method: 'GET', isArray: true }, + create: { method: 'POST', params: { action: 'create' } }, + remove: { method: 'DELETE', params: { id: '@id' } } + }); +}]); diff --git a/app/routes.js b/app/routes.js index 87adbf0f6..265096184 100644 --- a/app/routes.js +++ b/app/routes.js @@ -26,6 +26,32 @@ function configureRoutes($stateProvider) { requiresLogin: false } }) + .state('configs', { + url: '^/configs/', + views: { + 'content@': { + templateUrl: 'app/components/configs/configs.html', + controller: 'ConfigsController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('config', { + url: '^/config/:id/', + views: { + 'content@': { + templateUrl: 'app/components/config/config.html', + controller: 'ConfigController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('containers', { parent: 'root', url: '/containers/', @@ -156,6 +182,19 @@ function configureRoutes($stateProvider) { } } }) + .state('actions.create.config', { + url: '/config', + views: { + 'content@': { + templateUrl: 'app/components/createConfig/createconfig.html', + controller: 'CreateConfigController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('actions.create.container', { url: '/container/:from', views: { diff --git a/app/services/codeMirror.js b/app/services/codeMirror.js index b5807f87e..e4cf3b821 100644 --- a/app/services/codeMirror.js +++ b/app/services/codeMirror.js @@ -2,8 +2,11 @@ angular.module('portainer.services') .factory('CodeMirrorService', function CodeMirrorService() { 'use strict'; - var codeMirrorOptions = { - lineNumbers: true, + var codeMirrorGenericOptions = { + lineNumbers: true + }; + + var codeMirrorYAMLOptions = { mode: 'text/x-yaml', gutters: ['CodeMirror-lint-markers'], lint: true @@ -11,8 +14,18 @@ angular.module('portainer.services') var service = {}; - service.applyCodeMirrorOnElement = function(element) { - var cm = CodeMirror.fromTextArea(element, codeMirrorOptions); + service.applyCodeMirrorOnElement = function(element, yamlLint, readOnly) { + var options = codeMirrorGenericOptions; + + if (yamlLint) { + options = codeMirrorYAMLOptions; + } + + if (readOnly) { + options.readOnly = true; + } + + var cm = CodeMirror.fromTextArea(element, options); cm.setSize('100%', 500); return cm; }; diff --git a/app/services/docker/configService.js b/app/services/docker/configService.js new file mode 100644 index 000000000..530c689e7 --- /dev/null +++ b/app/services/docker/configService.js @@ -0,0 +1,61 @@ +angular.module('portainer.services') +.factory('ConfigService', ['$q', 'Config', function ConfigServiceFactory($q, Config) { + 'use strict'; + var service = {}; + + service.config = function(configId) { + var deferred = $q.defer(); + + Config.get({id: configId}).$promise + .then(function success(data) { + var config = new ConfigViewModel(data); + deferred.resolve(config); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve config details', err: err }); + }); + + return deferred.promise; + }; + + service.configs = function() { + var deferred = $q.defer(); + + Config.query({}).$promise + .then(function success(data) { + var configs = data.map(function (item) { + return new ConfigViewModel(item); + }); + deferred.resolve(configs); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve configs', err: err }); + }); + + return deferred.promise; + }; + + service.remove = function(configId) { + var deferred = $q.defer(); + + Config.remove({ id: configId }).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message }); + } else { + deferred.resolve(); + } + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove config', err: err }); + }); + + return deferred.promise; + }; + + service.create = function(config) { + return Config.create(config).$promise; + }; + + return service; +}]); From b9e535d7a595f7595191f987d0375a98868fb8f0 Mon Sep 17 00:00:00 2001 From: Thomas Krzero Date: Mon, 6 Nov 2017 15:50:59 +0100 Subject: [PATCH 29/35] fix(services): Fix invalid replica count for global services (#1353) --- app/components/services/servicesController.js | 9 ++++++--- app/models/docker/service.js | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 339adb3d1..ca5fbbe6b 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -97,19 +97,22 @@ function ($q, $scope, $transition$, $state, Service, ServiceService, ServiceHelp $('#loadServicesSpinner').show(); $q.all({ services: Service.query({}).$promise, - tasks: Task.query({filters: {'desired-state': ['running']}}).$promise, + tasks: Task.query({filters: {'desired-state': ['running','accepted']}}).$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) { + var runningTasks = data.tasks.filter(function (task) { return task.ServiceID === service.ID && task.Status.State === 'running'; }); + var allTasks = data.tasks.filter(function (task) { + return task.ServiceID === service.ID; + }); var taskNodes = data.nodes.filter(function (node) { return node.Spec.Availability === 'active' && node.Status.State === 'ready'; }); - return new ServiceViewModel(service, serviceTasks, taskNodes); + return new ServiceViewModel(service, runningTasks, allTasks, taskNodes); }); }) .catch(function error(err) { diff --git a/app/models/docker/service.js b/app/models/docker/service.js index 5268bb43e..c83779243 100644 --- a/app/models/docker/service.js +++ b/app/models/docker/service.js @@ -1,4 +1,4 @@ -function ServiceViewModel(data, runningTasks, nodes) { +function ServiceViewModel(data, runningTasks, allTasks, nodes) { this.Model = data; this.Id = data.ID; this.Tasks = []; @@ -12,8 +12,8 @@ function ServiceViewModel(data, runningTasks, nodes) { this.Replicas = data.Spec.Mode.Replicated.Replicas; } else { this.Mode = 'global'; - if (nodes) { - this.Replicas = nodes.length; + if (allTasks) { + this.Replicas = allTasks.length; } } if (runningTasks) { From 1b6b4733bd26682698ecc13a753fabeb29f79a34 Mon Sep 17 00:00:00 2001 From: Yassir Hannoun Date: Tue, 7 Nov 2017 08:05:13 +0100 Subject: [PATCH 30/35] feat(images): enable auto completion for image names when creating a container or a service (#1355) --- app/components/createContainer/createcontainer.html | 2 +- app/components/createService/createservice.html | 2 +- app/directives/imageRegistry/por-image-registry.js | 3 ++- app/directives/imageRegistry/porImageRegistry.html | 4 +++- .../imageRegistry/porImageRegistryController.js | 13 +++++++++---- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index c784d7517..a90f8da59 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -28,7 +28,7 @@
    - +
    diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index b59053509..e2c404fc0 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -25,7 +25,7 @@
    - +
    diff --git a/app/directives/imageRegistry/por-image-registry.js b/app/directives/imageRegistry/por-image-registry.js index f8d004967..ccc65cdc9 100644 --- a/app/directives/imageRegistry/por-image-registry.js +++ b/app/directives/imageRegistry/por-image-registry.js @@ -3,6 +3,7 @@ angular.module('portainer').component('porImageRegistry', { controller: 'porImageRegistryController', bindings: { 'image': '=', - 'registry': '=' + 'registry': '=', + 'autoComplete': '<' } }); diff --git a/app/directives/imageRegistry/porImageRegistry.html b/app/directives/imageRegistry/porImageRegistry.html index 3f1dbcd43..06fc192e9 100644 --- a/app/directives/imageRegistry/porImageRegistry.html +++ b/app/directives/imageRegistry/porImageRegistry.html @@ -1,7 +1,9 @@
    - +