diff --git a/app/app.js b/app/app.js index eb80ea85b..d7774719f 100644 --- a/app/app.js +++ b/app/app.js @@ -244,7 +244,7 @@ angular.module('portainer', [ } }) .state('actions.create.container', { - url: '/container', + url: '/container/:from', views: { 'content@': { templateUrl: 'app/components/createContainer/createcontainer.html', diff --git a/app/components/container/container.html b/app/components/container/container.html index 39069c781..b399a1eca 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -20,6 +20,8 @@ + + diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 452a835f8..1af934c78 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -1,6 +1,6 @@ angular.module('container', []) -.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', -function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService) { +.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', +function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) { $scope.activityTime = 0; $scope.portBindings = []; $scope.config = { @@ -196,6 +196,73 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con }); }; + $scope.duplicate = function() { + ModalService.confirmExperimentalFeature(function (experimental) { + if(!experimental) { return; } + $state.go('actions.create.container', {from: $stateParams.id}, {reload: true}); + }); + }; + + $scope.recreate = function() { + ModalService.confirmExperimentalFeature(function (experimental) { + if(!experimental) { return; } + ModalService.confirm({ + title: 'Are you sure ?', + message: 'You\'re about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.', + buttons: { + confirm: { + label: 'Recreate', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + if(!confirmed) { return; } + else { + $('#loadingViewSpinner').show(); + var container = $scope.container; + var config = ContainerHelper.configFromContainer(container.Model); + ContainerService.remove(container, true) + .then(function success() { + return RegistryService.retrieveRegistryFromRepository(container.Config.Image); + }) + .then(function success(data) { + return ImageService.pullImage(container.Config.Image, data, true); + }) + .then(function success() { + return ContainerService.createAndStartContainer(config); + }) + .then(function success(data) { + if (!container.ResourceControl) { + return true; + } else { + var containerIdentifier = data.Id; + var resourceControl = container.ResourceControl; + var users = resourceControl.UserAccesses.map(function(u) { + return u.UserId; + }); + var teams = resourceControl.TeamAccesses.map(function(t) { + return t.TeamId; + }); + return ResourceControlService.createResourceControl(resourceControl.AdministratorsOnly, + users, teams, containerIdentifier, 'container', []); + } + }) + .then(function success(data) { + Notifications.success('Container successfully re-created'); + $state.go('containers', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to re-create container'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + } + }); + }); + }; + $scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) { $('#joinNetworkSpinner').show(); Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index bc7163929..ecc556182 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,14 +1,13 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createContainer', []) -.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', -function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', +function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) { $scope.formValues = { alwaysPull: true, Console: 'none', Volumes: [], - Registry: '', NetworkContainer: '', Labels: [], ExtraHosts: [], @@ -92,6 +91,8 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, $scope.config.HostConfig.Devices.splice(index, 1); }; + $scope.fromContainerMultipleNetworks = false; + function prepareImageConfig(config) { var image = config.Image; var registry = $scope.formValues.Registry; @@ -179,6 +180,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, var networkMode = mode; if (containerName) { networkMode += ':' + containerName; + config.Hostname = ''; } config.HostConfig.NetworkMode = networkMode; @@ -233,6 +235,213 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, return config; } + function confirmCreateContainer() { + var deferred = $q.defer(); + Container.query({ all: 1, filters: {name: ['^/' + $scope.config.name + '$'] }}).$promise + .then(function success(data) { + var existingContainer = data[0]; + if (existingContainer) { + ModalService.confirm({ + title: 'Are you sure ?', + message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?', + buttons: { + confirm: { + label: 'Replace', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + if(!confirmed) { deferred.resolve(false); } + else { + // Remove old container + ContainerService.remove(existingContainer, true) + .then(function success(data) { + Notifications.success('Container Removed', existingContainer.Id); + deferred.resolve(true); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove container', err: err }); + }); + } + } + }); + } else { + deferred.resolve(true); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve containers'); + return undefined; + }); + return deferred.promise; + } + + function loadFromContainerCmd(d) { + if ($scope.config.Cmd) { + $scope.config.Cmd = ContainerHelper.commandArrayToString($scope.config.Cmd); + } else { + $scope.config.Cmd = ''; + } + } + + function loadFromContainerPortBindings(d) { + var bindings = []; + for (var p in $scope.config.HostConfig.PortBindings) { + if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) { + var hostPort = ''; + if ($scope.config.HostConfig.PortBindings[p][0].HostIp) { + hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':'; + } + hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort; + var b = { + 'hostPort': hostPort, + 'containerPort': p.split('/')[0], + 'protocol': p.split('/')[1] + }; + bindings.push(b); + } + } + $scope.config.HostConfig.PortBindings = bindings; + } + + function loadFromContainerVolumes(d) { + for (var v in d.Mounts) { + if ({}.hasOwnProperty.call(d.Mounts, v)) { + var mount = d.Mounts[v]; + var volume = { + 'type': mount.Type, + 'name': mount.Name || mount.Source, + 'containerPath': mount.Destination, + 'readOnly': mount.RW === false + }; + $scope.formValues.Volumes.push(volume); + } + } + } + + function loadFromContainerNetworkConfig(d) { + $scope.config.NetworkingConfig = { + EndpointsConfig: {} + }; + var networkMode = d.HostConfig.NetworkMode; + if (networkMode === 'default') { + $scope.config.HostConfig.NetworkMode = 'bridge'; + if (!_.find($scope.availableNetworks, {'Name': 'bridge'})) { + $scope.config.HostConfig.NetworkMode = 'nat'; + } + } + if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) { + var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1]; + $scope.config.HostConfig.NetworkMode = 'container'; + for (var c in $scope.runningContainers) { + if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) { + $scope.formValues.NetworkContainer = $scope.runningContainers[c]; + } + } + } + $scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2; + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) { + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) { + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) { + $scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address; + } + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) { + $scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address; + } + } + } + $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; + // ExtraHosts + for (var h in $scope.config.HostConfig.ExtraHosts) { + if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) { + $scope.formValues.ExtraHosts.push({'value': $scope.config.HostConfig.ExtraHosts[h]}); + $scope.config.HostConfig.ExtraHosts = []; + } + } + } + + function loadFromContainerEnvrionmentVariables(d) { + var envArr = []; + for (var e in $scope.config.Env) { + if ({}.hasOwnProperty.call($scope.config.Env, e)) { + var arr = $scope.config.Env[e].split(/\=(.+)/); + envArr.push({'name': arr[0], 'value': arr[1]}); + } + } + $scope.config.Env = envArr; + } + + function loadFromContainerLabels(d) { + for (var l in $scope.config.Labels) { + if ({}.hasOwnProperty.call($scope.config.Labels, l)) { + $scope.formValues.Labels.push({ name: l, value: $scope.config.Labels[l]}); + } + } + } + + function loadFromContainerConsole(d) { + if ($scope.config.OpenStdin && $scope.config.Tty) { + $scope.formValues.Console = 'both'; + } else if (!$scope.config.OpenStdin && $scope.config.Tty) { + $scope.formValues.Console = 'tty'; + } else if ($scope.config.OpenStdin && !$scope.config.Tty) { + $scope.formValues.Console = 'interactive'; + } else if (!$scope.config.OpenStdin && !$scope.config.Tty) { + $scope.formValues.Console = 'none'; + } + } + + function loadFromContainerDevices(d) { + var path = []; + for (var dev in $scope.config.HostConfig.Devices) { + if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) { + var device = $scope.config.HostConfig.Devices[dev]; + path.push({'pathOnHost': device.PathOnHost, 'pathInContainer': device.PathInContainer}); + } + } + $scope.config.HostConfig.Devices = path; + } + + function loadFromContainerImageConfig(d) { + // If no registry found, we let default DockerHub and let full image path + var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image); + RegistryService.retrieveRegistryFromRepository($scope.config.Image) + .then(function success(data) { + if (data) { + $scope.config.Image = imageInfo.image; + $scope.formValues.Registry = data; + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrive registry'); + }); + } + + function loadFromContainerSpec() { + // Get container + Container.get({ id: $stateParams.from }).$promise + .then(function success(d) { + var fromContainer = new ContainerDetailsViewModel(d); + if (!fromContainer.ResourceControl) { + $scope.formValues.AccessControlData.AccessControlEnabled = false; + } + $scope.fromContainer = fromContainer; + $scope.config = ContainerHelper.configFromContainer(fromContainer.Model); + loadFromContainerCmd(d); + loadFromContainerPortBindings(d); + loadFromContainerVolumes(d); + loadFromContainerNetworkConfig(d); + loadFromContainerEnvrionmentVariables(d); + loadFromContainerLabels(d); + loadFromContainerConsole(d); + loadFromContainerDevices(d); + loadFromContainerImageConfig(d); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve container'); + }); + } + function initView() { Volume.query({}, function (d) { $scope.availableVolumes = d.Volumes; @@ -264,6 +473,12 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Container.query({}, function (d) { var containers = d; $scope.runningContainers = containers; + if ($stateParams.from !== '') { + loadFromContainerSpec(); + } else { + $scope.fromContainer = {}; + $scope.formValues.Registry = {}; + } }, function(e) { Notifications.error('Failure', e, 'Unable to retrieve running containers'); }); @@ -283,19 +498,27 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, } $scope.create = function () { - $('#createContainerSpinner').show(); + confirmCreateContainer() + .then(function success(confirm) { + if (!confirm) { + return false; + } + $('#createContainerSpinner').show(); + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; - var accessControlData = $scope.formValues.AccessControlData; - var userDetails = Authentication.getUserDetails(); - var isAdmin = userDetails.role === 1 ? true : false; + if (!validateForm(accessControlData, isAdmin)) { + $('#createContainerSpinner').hide(); + return; + } - if (!validateForm(accessControlData, isAdmin)) { - $('#createContainerSpinner').hide(); - return; - } - - var config = prepareConfiguration(); - createContainer(config, accessControlData); + var config = prepareConfiguration(); + createContainer(config, accessControlData); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create container'); + }); }; function createContainer(config, accessControlData) { diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index f633c0ed8..fdae6b4de 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -23,7 +23,7 @@
- +
@@ -98,7 +98,7 @@ - +
@@ -110,6 +110,10 @@ Cancel {{ state.formValidationError }} + + + This container is connected to multiple networks, only one network will be kept at creation time. +
diff --git a/app/directives/imageRegistry/porImageRegistryController.js b/app/directives/imageRegistry/porImageRegistryController.js index 3eeb3d0bb..496209be7 100644 --- a/app/directives/imageRegistry/porImageRegistryController.js +++ b/app/directives/imageRegistry/porImageRegistryController.js @@ -12,7 +12,11 @@ function ($q, RegistryService, DockerHubService, Notifications) { var dockerhub = data.dockerhub; var registries = data.registries; ctrl.availableRegistries = [dockerhub].concat(registries); - ctrl.registry = dockerhub; + if (!ctrl.registry.Id) { + ctrl.registry = dockerhub; + } else { + ctrl.registry = _.find(ctrl.availableRegistries, { 'Id': ctrl.registry.Id }); + } }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve registries'); diff --git a/app/helpers/containerHelper.js b/app/helpers/containerHelper.js index 6f74332c7..544bc1ac8 100644 --- a/app/helpers/containerHelper.js +++ b/app/helpers/containerHelper.js @@ -7,5 +7,56 @@ angular.module('portainer.helpers') return splitargs(command); }; + helper.commandArrayToString = function(array) { + return array.map(function(elem) { + return '\'' + elem + '\''; + }).join(' '); + }; + + helper.configFromContainer = function(container) { + var config = container.Config; + // HostConfig + config.HostConfig = container.HostConfig; + // Name + config.name = container.Name.replace(/^\//g, ''); + // Network + var mode = config.HostConfig.NetworkMode; + config.NetworkingConfig = { + 'EndpointsConfig': {} + }; + config.NetworkingConfig.EndpointsConfig = container.NetworkSettings.Networks; + if (mode.indexOf('container:') !== -1) { + delete config.Hostname; + delete config.ExposedPorts; + } + // Set volumes + var binds = []; + var volumes = {}; + for (var v in container.Mounts) { + if ({}.hasOwnProperty.call(container.Mounts, v)) { + var mount = container.Mounts[v]; + var volume = { + 'type': mount.Type, + 'name': mount.Name || mount.Source, + 'containerPath': mount.Destination, + 'readOnly': mount.RW === false + }; + var name = mount.Name || mount.Source; + var containerPath = mount.Destination; + if (name && containerPath) { + var bind = name + ':' + containerPath; + volumes[containerPath] = {}; + if (mount.RW === false) { + bind += ':ro'; + } + binds.push(bind); + } + } + } + config.HostConfig.Binds = binds; + config.Volumes = volumes; + return config; + }; + return helper; }]); diff --git a/app/models/docker/containerDetails.js b/app/models/docker/containerDetails.js index 65ce86069..63945ff41 100644 --- a/app/models/docker/containerDetails.js +++ b/app/models/docker/containerDetails.js @@ -1,4 +1,5 @@ function ContainerDetailsViewModel(data) { + this.Model = data; this.Id = data.Id; this.State = data.State; this.Created = data.Created; diff --git a/app/services/modalService.js b/app/services/modalService.js index ad55be8ed..aa6481e40 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -108,5 +108,19 @@ angular.module('portainer.services') }); }; + service.confirmExperimentalFeature = function(callback) { + service.confirm({ + title: 'Experimental feature', + message: 'This feature is currently experimental, please use with caution.', + buttons: { + confirm: { + label: 'Continue', + className: 'btn-danger' + } + }, + callback: callback + }); + }; + return service; }]);