diff --git a/api/archive/tar.go b/api/archive/tar.go
new file mode 100644
index 000000000..4040a9ec7
--- /dev/null
+++ b/api/archive/tar.go
@@ -0,0 +1,36 @@
+package archive
+
+import (
+ "archive/tar"
+ "bytes"
+)
+
+// TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content
+// specified in fileContent. Returns the archive as a byte array.
+func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) {
+ var buffer bytes.Buffer
+ tarWriter := tar.NewWriter(&buffer)
+
+ header := &tar.Header{
+ Name: fileName,
+ Mode: 0600,
+ Size: int64(len(fileContent)),
+ }
+
+ err := tarWriter.WriteHeader(header)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = tarWriter.Write(fileContent)
+ if err != nil {
+ return nil, err
+ }
+
+ err = tarWriter.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ return buffer.Bytes(), nil
+}
diff --git a/api/http/proxy/build.go b/api/http/proxy/build.go
new file mode 100644
index 000000000..0deab93b9
--- /dev/null
+++ b/api/http/proxy/build.go
@@ -0,0 +1,56 @@
+package proxy
+
+import (
+ "bytes"
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+ "strings"
+
+ "github.com/portainer/portainer/archive"
+)
+
+type postDockerfileRequest struct {
+ Content string
+}
+
+// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request.
+// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function
+// will extract the file content from the request body, tar it, and rewrite the body.
+// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted
+// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and
+// rewrite the body of the request.
+// In any other case, it will leave the request unaltered.
+func buildOperation(request *http.Request) error {
+ contentTypeHeader := request.Header.Get("Content-Type")
+ if contentTypeHeader != "" && !strings.Contains(contentTypeHeader, "application/json") {
+ return nil
+ }
+
+ var dockerfileContent []byte
+
+ if contentTypeHeader == "" {
+ body, err := ioutil.ReadAll(request.Body)
+ if err != nil {
+ return err
+ }
+ dockerfileContent = body
+ } else {
+ var req postDockerfileRequest
+ if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
+ return err
+ }
+ dockerfileContent = []byte(req.Content)
+ }
+
+ buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile")
+ if err != nil {
+ return err
+ }
+
+ request.Body = ioutil.NopCloser(bytes.NewReader(buffer))
+ request.ContentLength = int64(len(buffer))
+ request.Header.Set("Content-Type", "application/x-tar")
+
+ return nil
+}
diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go
index 83edcaf37..3b0b1fa3c 100644
--- a/api/http/proxy/transport.go
+++ b/api/http/proxy/transport.go
@@ -27,6 +27,7 @@ type (
labelBlackList []portainer.Pair
}
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
+ operationRequest func(*http.Request) error
)
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
@@ -59,6 +60,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
return p.proxyNodeRequest(request)
case strings.HasPrefix(path, "/tasks"):
return p.proxyTaskRequest(request)
+ case strings.HasPrefix(path, "/build"):
+ return p.proxyBuildRequest(request)
default:
return p.executeDockerRequest(request)
}
@@ -228,6 +231,10 @@ func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response
}
}
+func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
+ return p.interceptAndRewriteRequest(request, buildOperation)
+}
+
// restrictedOperation ensures that the current user has the required authorizations
// before executing the original request.
func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) {
@@ -300,6 +307,15 @@ func (p *proxyTransport) rewriteOperation(request *http.Request, operation restr
return p.executeRequestAndRewriteResponse(request, operation, executor)
}
+func (p *proxyTransport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) {
+ err := operation(request)
+ if err != nil {
+ return nil, err
+ }
+
+ return p.executeDockerRequest(request)
+}
+
func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) {
response, err := p.executeDockerRequest(request)
if err != nil {
diff --git a/app/docker/__module.js b/app/docker/__module.js
index 1e15df46e..e4777fcb9 100644
--- a/app/docker/__module.js
+++ b/app/docker/__module.js
@@ -179,6 +179,17 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
+ var imageBuild = {
+ name: 'docker.images.build',
+ url: '/build',
+ views: {
+ 'content@': {
+ templateUrl: 'app/docker/views/images/build/buildimage.html',
+ controller: 'BuildImageController'
+ }
+ }
+ };
+
var networks = {
name: 'docker.networks',
url: '/networks',
@@ -457,6 +468,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(events);
$stateRegistryProvider.register(images);
$stateRegistryProvider.register(image);
+ $stateRegistryProvider.register(imageBuild);
$stateRegistryProvider.register(networks);
$stateRegistryProvider.register(network);
$stateRegistryProvider.register(networkCreation);
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html
index a22ffc57c..89d916dab 100644
--- a/app/docker/components/datatables/images-datatable/imagesDatatable.html
+++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html
@@ -25,6 +25,9 @@
diff --git a/app/docker/models/image.js b/app/docker/models/image.js
index 5dd073800..60e7b0c11 100644
--- a/app/docker/models/image.js
+++ b/app/docker/models/image.js
@@ -8,3 +8,24 @@ function ImageViewModel(data) {
this.VirtualSize = data.VirtualSize;
this.ContainerCount = data.ContainerCount;
}
+
+function ImageBuildModel(data) {
+ this.hasError = false;
+ var buildLogs = [];
+
+ for (var i = 0; i < data.length; i++) {
+ var line = data[i];
+
+ if (line.stream) {
+ line = line.stream.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
+ buildLogs.push(line);
+ }
+
+ if (line.errorDetail) {
+ buildLogs.push(line.errorDetail.message);
+ this.hasError = true;
+ }
+ }
+
+ this.buildLogs = buildLogs;
+}
diff --git a/app/docker/rest/build.js b/app/docker/rest/build.js
new file mode 100644
index 000000000..565c35f4f
--- /dev/null
+++ b/app/docker/rest/build.js
@@ -0,0 +1,18 @@
+angular.module('portainer.docker')
+.factory('Build', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BuildFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
+ 'use strict';
+ return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/build', {
+ endpointId: EndpointProvider.endpointID
+ },
+ {
+ buildImage: {
+ method: 'POST', ignoreLoadingBar: true,
+ transformResponse: jsonObjectsToArrayHandler, isArray: true,
+ headers: { 'Content-Type': 'application/x-tar' }
+ },
+ buildImageOverride: {
+ method: 'POST', ignoreLoadingBar: true,
+ transformResponse: jsonObjectsToArrayHandler, isArray: true
+ }
+ });
+}]);
diff --git a/app/docker/rest/commit.js b/app/docker/rest/commit.js
new file mode 100644
index 000000000..f2cf6dc03
--- /dev/null
+++ b/app/docker/rest/commit.js
@@ -0,0 +1,10 @@
+angular.module('portainer.docker')
+.factory('Commit', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function CommitFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
+ 'use strict';
+ return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/commit', {
+ endpointId: EndpointProvider.endpointID
+ },
+ {
+ commitContainer: {method: 'POST', params: {container: '@id', repo: '@repo', tag: '@tag'}, ignoreLoadingBar: true}
+ });
+}]);
diff --git a/app/docker/rest/containerCommit.js b/app/docker/rest/containerCommit.js
deleted file mode 100644
index 6b3233d9d..000000000
--- a/app/docker/rest/containerCommit.js
+++ /dev/null
@@ -1,10 +0,0 @@
-angular.module('portainer.docker')
-.factory('ContainerCommit', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerCommitFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
- 'use strict';
- return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/commit', {
- endpointId: EndpointProvider.endpointID
- },
- {
- commit: {method: 'POST', params: {container: '@id', repo: '@repo', tag: '@tag'}, ignoreLoadingBar: true}
- });
-}]);
diff --git a/app/docker/services/buildService.js b/app/docker/services/buildService.js
new file mode 100644
index 000000000..60e7383cd
--- /dev/null
+++ b/app/docker/services/buildService.js
@@ -0,0 +1,65 @@
+angular.module('portainer.docker')
+.factory('BuildService', ['$q', 'Build', 'FileUploadService', function BuildServiceFactory($q, Build, FileUploadService) {
+ 'use strict';
+ var service = {};
+
+ service.buildImageFromUpload = function(names, file, path) {
+ var deferred = $q.defer();
+
+ FileUploadService.buildImage(names, file, path)
+ .then(function success(response) {
+ var model = new ImageBuildModel(response.data);
+ deferred.resolve(model);
+ })
+ .catch(function error(err) {
+ deferred.reject(err);
+ });
+
+ return deferred.promise;
+ };
+
+ service.buildImageFromURL = function(names, url, path) {
+ var params = {
+ t: names,
+ remote: url,
+ dockerfile: path
+ };
+
+ var deferred = $q.defer();
+
+ Build.buildImage(params, {}).$promise
+ .then(function success(data) {
+ var model = new ImageBuildModel(data);
+ deferred.resolve(model);
+ })
+ .catch(function error(err) {
+ deferred.reject(err);
+ });
+
+ return deferred.promise;
+ };
+
+ service.buildImageFromDockerfileContent = function(names, content) {
+ var params = {
+ t: names
+ };
+ var payload = {
+ content: content
+ };
+
+ var deferred = $q.defer();
+
+ Build.buildImageOverride(params, payload).$promise
+ .then(function success(data) {
+ var model = new ImageBuildModel(data);
+ deferred.resolve(model);
+ })
+ .catch(function error(err) {
+ deferred.reject(err);
+ });
+
+ return deferred.promise;
+ };
+
+ return service;
+}]);
diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js
index d89f3dfa4..c96578c7b 100644
--- a/app/docker/views/containers/edit/containerController.js
+++ b/app/docker/views/containers/edit/containerController.js
@@ -1,6 +1,6 @@
angular.module('portainer.docker')
-.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
-function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService) {
+.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
+function ($q, $scope, $state, $transition$, $filter, Container, Commit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService) {
$scope.activityTime = 0;
$scope.portBindings = [];
@@ -80,7 +80,7 @@ function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit,
var image = $scope.config.Image;
var registry = $scope.config.Registry;
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL);
- ContainerCommit.commit({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
+ Commit.commitContainer({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
update();
Notifications.success('Container commited', $transition$.params().id);
}, function (e) {
diff --git a/app/docker/views/images/build/buildImageController.js b/app/docker/views/images/build/buildImageController.js
new file mode 100644
index 000000000..4b28b4492
--- /dev/null
+++ b/app/docker/views/images/build/buildImageController.js
@@ -0,0 +1,90 @@
+angular.module('portainer.docker')
+.controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications',
+function ($scope, $state, BuildService, Notifications) {
+
+ $scope.state = {
+ BuildType: 'editor',
+ actionInProgress: false,
+ activeTab: 0
+ };
+
+ $scope.formValues = {
+ ImageNames: [{ Name: '' }],
+ UploadFile: null,
+ DockerFileContent: '',
+ URL: '',
+ Path: 'Dockerfile'
+ };
+
+ $scope.addImageName = function() {
+ $scope.formValues.ImageNames.push({ Name: '' });
+ };
+
+ $scope.removeImageName = function(index) {
+ $scope.formValues.ImageNames.splice(index, 1);
+ };
+
+ function buildImageBasedOnBuildType(method, names) {
+ var buildType = $scope.state.BuildType;
+ var dockerfilePath = $scope.formValues.Path;
+
+ if (buildType === 'upload') {
+ var file = $scope.formValues.UploadFile;
+ return BuildService.buildImageFromUpload(names, file, dockerfilePath);
+ } else if (buildType === 'url') {
+ var URL = $scope.formValues.URL;
+ return BuildService.buildImageFromURL(names, URL, dockerfilePath);
+ } else {
+ var dockerfileContent = $scope.formValues.DockerFileContent;
+ return BuildService.buildImageFromDockerfileContent(names, dockerfileContent);
+ }
+ }
+
+ $scope.buildImage = function() {
+ var buildType = $scope.state.BuildType;
+
+ if (buildType === 'editor' && $scope.formValues.DockerFileContent === '') {
+ $scope.state.formValidationError = 'Dockerfile content must not be empty';
+ return;
+ }
+
+ $scope.state.actionInProgress = true;
+
+ var imageNames = $scope.formValues.ImageNames.filter(function filterNull(x) {
+ return x.Name;
+ }).map(function getNames(x) {
+ return x.Name;
+ });
+
+ buildImageBasedOnBuildType(buildType, imageNames)
+ .then(function success(data) {
+ $scope.buildLogs = data.buildLogs;
+ $scope.state.activeTab = 1;
+ if (data.hasError) {
+ Notifications.error('An error occured during build', { msg: 'Please check build logs output' });
+ } else {
+ Notifications.success('Image successfully built');
+ }
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to build image');
+ })
+ .finally(function final() {
+ $scope.state.actionInProgress = false;
+ });
+ };
+
+ $scope.validImageNames = function() {
+ for (var i = 0; i < $scope.formValues.ImageNames.length; i++) {
+ var item = $scope.formValues.ImageNames[i];
+ if (item.Name !== '') {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ $scope.editorUpdate = function(cm) {
+ $scope.formValues.DockerFileContent = cm.getValue();
+ };
+}]);
diff --git a/app/docker/views/images/build/buildimage.html b/app/docker/views/images/build/buildimage.html
new file mode 100644
index 000000000..cf930f0a5
--- /dev/null
+++ b/app/docker/views/images/build/buildimage.html
@@ -0,0 +1,231 @@
+
+
+
+ Images > Build image
+
+
+
+
+
+
+
+
+
+
+ Builder
+
+
+
+
+
+ Output
+
+
+
+ No build output available.
+
+
+
+
+
+
+
diff --git a/app/portainer/services/codeMirror.js b/app/portainer/services/codeMirror.js
index 38bb10259..a6e43f11e 100644
--- a/app/portainer/services/codeMirror.js
+++ b/app/portainer/services/codeMirror.js
@@ -2,6 +2,8 @@ angular.module('portainer.app')
.factory('CodeMirrorService', function CodeMirrorService() {
'use strict';
+ var service = {};
+
var codeMirrorGenericOptions = {
lineNumbers: true
};
@@ -12,8 +14,6 @@ angular.module('portainer.app')
lint: true
};
- var service = {};
-
service.applyCodeMirrorOnElement = function(element, yamlLint, readOnly) {
var options = angular.copy(codeMirrorGenericOptions);
diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js
index 5ac738884..bcfb73328 100644
--- a/app/portainer/services/fileUpload.js
+++ b/app/portainer/services/fileUpload.js
@@ -8,6 +8,26 @@ angular.module('portainer.app')
return Upload.upload({ url: url, data: { file: file }});
}
+ service.buildImage = function(names, file, path) {
+ var endpointID = EndpointProvider.endpointID();
+ Upload.setDefaults({ ngfMinSize: 10 });
+ return Upload.http({
+ url: 'api/endpoints/' + endpointID + '/docker/build',
+ headers : {
+ 'Content-Type': file.type
+ },
+ data: file,
+ params: {
+ t: names,
+ dockerfile: path
+ },
+ ignoreLoadingBar: true,
+ transformResponse: function(data, headers) {
+ return jsonObjectsToArrayHandler(data);
+ }
+ });
+ };
+
service.createStack = function(stackName, swarmId, file, env) {
var endpointID = EndpointProvider.endpointID();
return Upload.upload({
diff --git a/assets/css/app.css b/assets/css/app.css
index 9840c1f67..e8a6620dc 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -693,6 +693,30 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
margin-bottom: 5px;
}
+.log_viewer {
+ height:100%;
+ overflow-y: scroll;
+ white-space: pre-wrap;
+ color: black;
+ font-size: .85em;
+ background-color: white;
+}
+
+.log_viewer .inner_line {
+ padding: 0 15px;
+ cursor: pointer;
+ margin-bottom: 0;
+}
+
+.log_viewer .inner_line:empty::after {
+ content: '.';
+ visibility: hidden;
+}
+
+.log_viewer .line_selected {
+ background-color: #C5CAE9;
+}
+
/*bootbox override*/
.modal-open {
padding-right: 0 !important;