diff --git a/api/http/handler/endpointproxy/proxy_kubernetes.go b/api/http/handler/endpointproxy/proxy_kubernetes.go index 7a4e9dc01..ed5b1a28b 100644 --- a/api/http/handler/endpointproxy/proxy_kubernetes.go +++ b/api/http/handler/endpointproxy/proxy_kubernetes.go @@ -3,11 +3,12 @@ package endpointproxy import ( "errors" "fmt" + "strings" "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" "net/http" @@ -66,9 +67,15 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h requestPrefix := fmt.Sprintf("/%d/kubernetes", endpointID) if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { - requestPrefix = fmt.Sprintf("/%d", endpointID) + if isKubernetesRequest(strings.TrimPrefix(r.URL.String(), requestPrefix)) { + requestPrefix = fmt.Sprintf("/%d", endpointID) + } } http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r) return nil } + +func isKubernetesRequest(requestURL string) bool { + return strings.HasPrefix(requestURL, "/api") +} diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go new file mode 100644 index 000000000..793b85715 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -0,0 +1,140 @@ +package endpoints + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +type dockerhubStatusResponse struct { + Remaining int `json:"remaining"` + Limit int `json:"limit"` +} + +// GET request on /api/endpoints/{id}/dockerhub/status +func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if !endpointutils.IsLocalEndpoint(endpoint) { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment type", errors.New("Invalid environment type")} + } + + dockerhub, err := handler.DataStore.DockerHub().DockerHub() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + httpClient := client.NewHTTPClient() + token, err := getDockerHubToken(httpClient, dockerhub) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub token from DockerHub", err} + } + + resp, err := getDockerHubLimits(httpClient, token) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub rate limits from DockerHub", err} + } + + return response.JSON(w, resp) +} + +func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.DockerHub) (string, error) { + type dockerhubTokenResponse struct { + Token string `json:"token"` + } + + requestURL := "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" + + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return "", err + } + + if dockerhub.Authentication { + req.SetBasicAuth(dockerhub.Username, dockerhub.Password) + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", errors.New("failed fetching dockerhub token") + } + + var data dockerhubTokenResponse + err = json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + return "", err + } + + return data.Token, nil +} + +func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhubStatusResponse, error) { + + requestURL := "https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest" + + req, err := http.NewRequest(http.MethodHead, requestURL, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("failed fetching dockerhub limits") + } + + rateLimit, err := parseRateLimitHeader(resp.Header, "RateLimit-Limit") + rateLimitRemaining, err := parseRateLimitHeader(resp.Header, "RateLimit-Remaining") + + return &dockerhubStatusResponse{ + Limit: rateLimit, + Remaining: rateLimitRemaining, + }, nil +} + +func parseRateLimitHeader(headers http.Header, headerKey string) (int, error) { + headerValue := headers.Get(headerKey) + if headerValue == "" { + return 0, fmt.Errorf("Missing %s header", headerKey) + } + + matches := strings.Split(headerValue, ";") + value, err := strconv.Atoi(matches[0]) + if err != nil { + return 0, err + } + + return value, nil +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index ae23f5c9a..e7a41b8c8 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -51,6 +51,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) h.Handle("/endpoints/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) + h.Handle("/endpoints/{id}/dockerhub", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet) h.Handle("/endpoints/{id}/extensions", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/extensions/{extensionType}", diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index 5ac4efb4f..f68da56a6 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -1,9 +1,11 @@ package docker import ( + "bytes" "encoding/base64" "encoding/json" "errors" + "io/ioutil" "log" "net/http" "path" @@ -163,6 +165,21 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, // volume browser request return transport.restrictedResourceOperation(r, resourceID, portainer.VolumeResourceControl, true) + case strings.HasPrefix(requestPath, "/dockerhub"): + dockerhub, err := transport.dataStore.DockerHub().DockerHub() + if err != nil { + return nil, err + } + + newBody, err := json.Marshal(dockerhub) + if err != nil { + return nil, err + } + + r.Method = http.MethodPost + + r.Body = ioutil.NopCloser(bytes.NewReader(newBody)) + r.ContentLength = int64(len(newBody)) } return transport.executeDockerRequest(r) diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go index 2cb09dc62..a1774c0d2 100644 --- a/api/http/proxy/factory/kubernetes.go +++ b/api/http/proxy/factory/kubernetes.go @@ -72,7 +72,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp endpointURL.Scheme = "http" proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) - proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint.ID, tokenManager) + proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.reverseTunnelService, endpoint.ID, tokenManager) return proxy, nil } @@ -103,7 +103,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En } proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager) + proxy.Transport = kubernetes.NewAgentTransport(factory.dataStore, factory.signatureService, tlsConfig, tokenManager) return proxy, nil } diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index c1d0de13b..377c7a3dc 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -1,10 +1,14 @@ package kubernetes import ( + "bytes" "crypto/tls" + "encoding/json" "fmt" + "io/ioutil" "log" "net/http" + "strings" "github.com/portainer/portainer/api/http/security" @@ -20,6 +24,7 @@ type ( } agentTransport struct { + dataStore portainer.DataStore httpTransport *http.Transport tokenManager *tokenManager signatureService portainer.DigitalSignatureService @@ -27,6 +32,7 @@ type ( } edgeTransport struct { + dataStore portainer.DataStore httpTransport *http.Transport tokenManager *tokenManager reverseTunnelService portainer.ReverseTunnelService @@ -64,8 +70,9 @@ func (transport *localTransport) RoundTrip(request *http.Request) (*http.Respons } // NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent -func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport { +func NewAgentTransport(datastore portainer.DataStore, signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport { transport := &agentTransport{ + dataStore: datastore, httpTransport: &http.Transport{ TLSClientConfig: tlsConfig, }, @@ -85,6 +92,10 @@ func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Respons request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + if strings.HasPrefix(request.URL.Path, "/v2") { + decorateAgentRequest(request, transport.dataStore) + } + signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) if err != nil { return nil, err @@ -96,9 +107,10 @@ func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Respons return transport.httpTransport.RoundTrip(request) } -// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent -func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport { +// NewEdgeTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent +func NewEdgeTransport(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport { transport := &edgeTransport{ + dataStore: datastore, httpTransport: &http.Transport{}, tokenManager: tokenManager, reverseTunnelService: reverseTunnelService, @@ -117,6 +129,10 @@ func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + if strings.HasPrefix(request.URL.Path, "/v2") { + decorateAgentRequest(request, transport.dataStore) + } + response, err := transport.httpTransport.RoundTrip(request) if err == nil { @@ -151,3 +167,33 @@ func getRoundTripToken( return token, nil } + +func decorateAgentRequest(r *http.Request, dataStore portainer.DataStore) error { + requestPath := strings.TrimPrefix(r.URL.Path, "/v2") + + switch { + case strings.HasPrefix(requestPath, "/dockerhub"): + decorateAgentDockerHubRequest(r, dataStore) + } + + return nil +} + +func decorateAgentDockerHubRequest(r *http.Request, dataStore portainer.DataStore) error { + dockerhub, err := dataStore.DockerHub().DockerHub() + if err != nil { + return err + } + + newBody, err := json.Marshal(dockerhub) + if err != nil { + return err + } + + r.Method = http.MethodPost + + r.Body = ioutil.NopCloser(bytes.NewReader(newBody)) + r.ContentLength = int64(len(newBody)) + + return nil +} diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go new file mode 100644 index 000000000..249ee11cb --- /dev/null +++ b/api/internal/endpointutils/endpointutils.go @@ -0,0 +1,11 @@ +package endpointutils + +import ( + "strings" + + portainer "github.com/portainer/portainer/api" +) + +func IsLocalEndpoint(endpoint *portainer.Endpoint) bool { + return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5 +} diff --git a/app/agent/rest/dockerhub.js b/app/agent/rest/dockerhub.js new file mode 100644 index 000000000..b48481e8a --- /dev/null +++ b/app/agent/rest/dockerhub.js @@ -0,0 +1,13 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('AgentDockerhub', AgentDockerhub); + +function AgentDockerhub($resource, API_ENDPOINT_ENDPOINTS) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/:endpointType/v2/dockerhub`, + {}, + { + limits: { method: 'GET' }, + } + ); +} diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js new file mode 100644 index 000000000..83e9876e4 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js @@ -0,0 +1,31 @@ +export default class porImageRegistryContainerController { + /* @ngInject */ + constructor(EndpointHelper, DockerHubService, Notifications) { + this.EndpointHelper = EndpointHelper; + this.DockerHubService = DockerHubService; + this.Notifications = Notifications; + + this.pullRateLimits = null; + } + + $onChanges({ isDockerHubRegistry }) { + if (isDockerHubRegistry && isDockerHubRegistry.currentValue) { + this.fetchRateLimits(); + } + } + + async fetchRateLimits() { + this.pullRateLimits = null; + if (this.EndpointHelper.isAgentEndpoint(this.endpoint) || this.EndpointHelper.isLocalEndpoint(this.endpoint)) { + try { + this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint); + this.setValidity(this.pullRateLimits.remaining >= 0); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed loading DockerHub pull rate limits', e); + } + } else { + this.setValidity(true); + } + } +} diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.html b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html new file mode 100644 index 000000000..31d7eaa82 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html @@ -0,0 +1,34 @@ +
+
+
+ + + You are currently using a free account to pull images from DockerHub and will be limited to 200 pulls every 6 hours. Remaining pulls: + {{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }} + + + + You are currently using an anonymous account to pull images from DockerHub and will be limited to 100 pulls every 6 hours. You can configure DockerHub authentication in + the + Registries View. Remaining pulls: + {{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }} + + + You are currently using an anonymous account to pull images from DockerHub and will be limited to 100 pulls every 6 hours. Contact your administrator to configure + DockerHub authentication. Remaining pulls: {{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }} + + +
+
+ + + Your authorized pull count quota as a free user is now exceeded. + You will not be able to pull any image from the DockerHub registry. + + + Your authorized pull count quota as an anonymous user is now exceeded. + You will not be able to pull any image from the DockerHub registry. + +
+
+
diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js new file mode 100644 index 000000000..3418054f6 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js @@ -0,0 +1,18 @@ +import angular from 'angular'; + +import controller from './por-image-registry-rate-limits.controller'; + +angular.module('portainer.docker').component('porImageRegistryRateLimits', { + bindings: { + endpoint: '<', + setValidity: '<', + isAdmin: '<', + isDockerHubRegistry: '<', + isAuthenticated: '<', + }, + controller, + transclude: { + rateLimitExceeded: '?porImageRegistryRateLimitExceeded', + }, + templateUrl: './por-image-registry-rate-limits.html', +}); diff --git a/app/docker/components/imageRegistry/porImageRegistryController.js b/app/docker/components/imageRegistry/por-image-registry.controller.js similarity index 95% rename from app/docker/components/imageRegistry/porImageRegistryController.js rename to app/docker/components/imageRegistry/por-image-registry.controller.js index 2c2ff5e53..edf570f8b 100644 --- a/app/docker/components/imageRegistry/porImageRegistryController.js +++ b/app/docker/components/imageRegistry/por-image-registry.controller.js @@ -48,7 +48,11 @@ class porImageRegistryController { this.availableImages = images; } - onRegistryChange() { + isDockerHubRegistry() { + return this.model.UseRegistry && this.model.Registry.Name === 'DockerHub'; + } + + async onRegistryChange() { this.prepareAutocomplete(); if (this.model.Registry.Type === RegistryTypes.GITLAB && this.model.Image) { this.model.Image = _.replace(this.model.Image, this.model.Registry.Gitlab.ProjectPath, ''); diff --git a/app/docker/components/imageRegistry/por-image-registry.html b/app/docker/components/imageRegistry/por-image-registry.html new file mode 100644 index 000000000..77ca79c82 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry.html @@ -0,0 +1,97 @@ + +
+
+ +
+ +
+ +
+
+ {{ $ctrl.displayedRegistryURL() }} + + + + Search + + +
+
+
+ + +
+
+ +

+ + When using advanced mode, image and repository must be publicly available. +

+
+ +
+ +
+
+
+ + +
+
+
+

+ Image name is required. + Tag must be specified otherwise Portainer will pull all tags associated to the image. +

+
+
+
+ +
+ +
+ +
+ + + +
diff --git a/app/docker/components/imageRegistry/por-image-registry.js b/app/docker/components/imageRegistry/por-image-registry.js index 3377a2bc9..52b001db0 100644 --- a/app/docker/components/imageRegistry/por-image-registry.js +++ b/app/docker/components/imageRegistry/por-image-registry.js @@ -1,5 +1,5 @@ angular.module('portainer.docker').component('porImageRegistry', { - templateUrl: './porImageRegistry.html', + templateUrl: './por-image-registry.html', controller: 'porImageRegistryController', bindings: { model: '=', // must be of type PorImageRegistryModel @@ -7,9 +7,14 @@ angular.module('portainer.docker').component('porImageRegistry', { autoComplete: '<', labelClass: '@', inputClass: '@', + endpoint: '<', + isAdmin: '<', + checkRateLimits: '<', onImageChange: '&', + setValidity: '<', }, require: { form: '^form', }, + transclude: true, }); diff --git a/app/docker/components/imageRegistry/porImageRegistry.html b/app/docker/components/imageRegistry/porImageRegistry.html deleted file mode 100644 index 7f198a38b..000000000 --- a/app/docker/components/imageRegistry/porImageRegistry.html +++ /dev/null @@ -1,83 +0,0 @@ - -
-
- -
- -
- -
-
- {{ $ctrl.displayedRegistryURL() }} - - - - Search - - -
-
-
-
- - -
-
- -

- - When using advanced mode, image and repository must be publicly available. -

-
- -
- -
-
-
- - -
-
-
-

Image name is required. - Tag must be specified otherwise Portainer will pull all tags associated to the image.

-
-
-
- -
-
-

- - Simple mode - - - Advanced mode - -

-
-
diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index ce5f65ecf..fabed0128 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -58,6 +58,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ endpoint ) { $scope.create = create; + $scope.endpoint = endpoint; $scope.formValues = { alwaysPull: true, @@ -90,6 +91,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ formValidationError: '', actionInProgress: false, mode: '', + pullImageValidity: true, }; $scope.refreshSlider = function () { @@ -103,6 +105,14 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.formValues.EntrypointMode = 'default'; }; + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + if (!validity) { + $scope.formValues.alwaysPull = false; + } + $scope.state.pullImageValidity = validity; + } + $scope.config = { Image: '', Env: [], diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index 8252f596c..9c5b63f87 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -31,10 +31,10 @@
- The Docker registry for the {{ config.Image }} image is not registered inside Portainer, you will not be able to create a container. Please register - that registry first. + + The Docker registry for the {{ config.Image }} image is not registered inside Portainer, you will not be able to create a container. Please register that + registry first. +
@@ -45,23 +45,30 @@ auto-complete="true" label-class="col-sm-1" input-class="col-sm-11" + endpoint="endpoint" + is-admin="isAdmin" + check-rate-limits="formValues.alwaysPull" on-image-change="onImageNameChange()" - > - - -
-
- - + set-validity="setPullImageValidity" + > + +
+
+ + +
-
- + + +
Network ports configuration diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 9c7cb36b9..a2f3034c3 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -14,30 +14,41 @@
- + +
+
+ Deployment +
+ + + +
+
+
+ +
+
+
-
-
- Deployment -
- - - -
-
-
- -
-
diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index 1933af39f..9ffcfffa0 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -4,6 +4,7 @@ import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker').controller('ImagesController', [ '$scope', '$state', + 'Authentication', 'ImageService', 'Notifications', 'ModalService', @@ -11,10 +12,15 @@ angular.module('portainer.docker').controller('ImagesController', [ 'FileSaver', 'Blob', 'EndpointProvider', - function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob, EndpointProvider) { + 'endpoint', + function ($scope, $state, Authentication, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob, EndpointProvider, endpoint) { + $scope.endpoint = endpoint; + $scope.isAdmin = Authentication.isAdmin(); + $scope.state = { actionInProgress: false, exportInProgress: false, + pullRateValid: false, }; $scope.formValues = { @@ -140,6 +146,11 @@ angular.module('portainer.docker').controller('ImagesController', [ }); } + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullRateValid = validity; + } + function initView() { getImages(); } diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js index 738b215b5..6e766602d 100644 --- a/app/docker/views/services/create/createServiceController.js +++ b/app/docker/views/services/create/createServiceController.js @@ -104,6 +104,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [ $scope.state = { formValidationError: '', actionInProgress: false, + pullImageValidity: false, }; $scope.allowBindMounts = false; @@ -114,6 +115,11 @@ angular.module('portainer.docker').controller('CreateServiceController', [ }); }; + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + $scope.addPortBinding = function () { $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' }); }; diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index 36e16120b..3e2be2cba 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -20,7 +20,17 @@ Image configuration
- + +
Scheduling diff --git a/app/docker/views/services/edit/includes/image.html b/app/docker/views/services/edit/includes/image.html index 67b57791a..2a3fae3a2 100644 --- a/app/docker/views/services/edit/includes/image.html +++ b/app/docker/views/services/edit/includes/image.html @@ -3,7 +3,17 @@
- + +
diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index f72c05dbb..cb41d94d8 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -85,6 +85,8 @@ angular.module('portainer.docker').controller('ServiceController', [ NetworkService, endpoint ) { + $scope.endpoint = endpoint; + $scope.state = { updateInProgress: false, deletionInProgress: false, @@ -532,6 +534,11 @@ angular.module('portainer.docker').controller('ServiceController', [ }); }; + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + $scope.updateService = function updateService(service) { let config = {}; service, (config = buildChanges(service)); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 1618a9ca9..5d2818c8d 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -51,6 +51,7 @@ +
@@ -72,6 +73,14 @@
+ +
@@ -1516,7 +1525,7 @@