feat(docker): show docker pull rate limits (#4666)

* feat(dockerhub): introduce local status endpoint

* feat(proxy): rewrite request with dockerhub credentials

* feat(endpoint): check env type

* feat(endpoint): check for local endpoint

* feat(docker): introduce client side service to get limits

* feat(container): add info about rate limits in container

* feat(dockerhub): load rate limits just for specific endpoints

* feat(images): show specific dockerhub messages for admin

* feat(service-create): show docker rate limits

* feat(service-edit): show rate limit messages

* fix(images): fix loading of page

* refactor(images): move rate limits check to container

* feat(kubernetes): proxy agent requests

* feat(kubernetes/apps): show pull limits in application creation

* refactor(image-registry): move warning to end of field

* fix(image-registry): show right message for admin

* fix(images): silently fail when loading rate limits

* fix(kube/apps): use new rate limits comp

* fix(images): move rate warning to end

* fix(registry): move search to right place

* fix(service): remove service warning

* fix(endpoints): check if kube endpoint is local
pull/4945/head
Chaim Lev-Ari 2021-03-24 20:27:32 +02:00 committed by GitHub
parent d1a21ef6c1
commit f5aa6c4dc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 605 additions and 139 deletions

View File

@ -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")
}

View File

@ -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
}

View File

@ -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}",

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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' },
}
);
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,34 @@
<div class="form-group" ng-if="$ctrl.isDockerHubRegistry && $ctrl.pullRateLimits">
<div class="col-sm-12 small">
<div ng-if="$ctrl.pullRateLimits.remaining > 0" class="text-muted">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<span ng-if="$ctrl.isAuthenticated">
You are currently using a free account to pull images from DockerHub and will be limited to 200 pulls every 6 hours. Remaining pulls:
<span style="font-weight: bold;">{{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }}</span>
</span>
<span ng-if="!$ctrl.isAuthenticated">
<span ng-if="$ctrl.isAdmin">
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
<a ui-sref="portainer.registries">Registries View</a>. Remaining pulls:
<span style="font-weight: bold;">{{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }}</span>
</span>
<span ng-if="!$ctrl.isAdmin">
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: <span style="font-weight: bold;">{{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }}</span>
</span>
</span>
</div>
<div ng-if="$ctrl.pullRateLimits.remaining <= 0" class="text-warning">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span ng-if="$ctrl.isAuthenticated">
Your authorized pull count quota as a free user is now exceeded.
<span ng-transclude="rateLimitExceeded">You will not be able to pull any image from the DockerHub registry.</span>
</span>
<span ng-if="!$ctrl.isAuthenticated">
Your authorized pull count quota as an anonymous user is now exceeded.
<span ng-transclude="rateLimitExceeded">You will not be able to pull any image from the DockerHub registry.</span>
</span>
</div>
</div>
</div>

View File

@ -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',
});

View File

@ -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, '');

View File

@ -0,0 +1,97 @@
<!-- use registry -->
<div ng-if="$ctrl.model.UseRegistry">
<div class="form-group">
<label for="image_registry" class="control-label text-left" ng-class="$ctrl.labelClass">
Registry
</label>
<div ng-class="$ctrl.inputClass">
<select
ng-options="registry as registry.Name for registry in $ctrl.availableRegistries track by registry.Name"
ng-model="$ctrl.model.Registry"
id="image_registry"
selected-item-id="ctrl.selectedItemId"
class="form-control"
></select>
</div>
<label for="image_name" ng-class="$ctrl.labelClass" class="margin-sm-top control-label text-left">Image</label>
<div ng-class="$ctrl.inputClass" class="margin-sm-top">
<div class="input-group">
<span class="input-group-addon" id="registry-name">{{ $ctrl.displayedRegistryURL() }}</span>
<input
type="text"
class="form-control"
aria-describedby="registry-name"
uib-typeahead="image for image in $ctrl.availableImages | filter:$viewValue | limitTo:5"
ng-model="$ctrl.model.Image"
name="image_name"
placeholder="e.g. myImage:myTag"
ng-change="$ctrl.onImageChange()"
required
/>
<span ng-if="$ctrl.isDockerHubRegistry()" class="input-group-btn">
<a
href="https://hub.docker.com/search?type=image&q={{ $ctrl.model.Image | trimshasum | trimversiontag }}"
class="btn btn-default"
title="Search image on Docker Hub"
target="_blank"
>
<i class="fab fa-docker"></i> Search
</a>
</span>
</div>
</div>
</div>
<!-- ! use registry -->
<!-- don't use registry -->
<div ng-if="!$ctrl.model.UseRegistry">
<div class="form-group">
<span class="small">
<p class="text-muted" style="margin-left: 15px;">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
When using advanced mode, image and repository <b>must be</b> publicly available.
</p>
</span>
<label for="image_name" ng-class="$ctrl.labelClass" class="control-label text-left">Image </label>
<div ng-class="$ctrl.inputClass">
<input type="text" class="form-control" ng-model="$ctrl.model.Image" name="image_name" placeholder="e.g. registry:port/myImage:myTag" required />
</div>
</div>
</div>
<!-- ! don't use registry -->
<!-- info message -->
<div class="form-group" ng-show="$ctrl.form.image_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="$ctrl.form.image_name.$error">
<p ng-message="required">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Image name is required.
<span ng-if="$ctrl.canPull">Tag must be specified otherwise Portainer will pull all tags associated to the image.</span>
</p>
</div>
</div>
</div>
<!-- ! info message -->
<div class="form-group">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-if="!$ctrl.model.UseRegistry" ng-click="$ctrl.model.UseRegistry = true;">
<i class="fa fa-database space-right" aria-hidden="true"></i> Simple mode
</a>
<a class="small interactive" ng-if="$ctrl.model.UseRegistry" ng-click="$ctrl.model.UseRegistry = false;">
<i class="fa fa-globe space-right" aria-hidden="true"></i> Advanced mode
</a>
</p>
</div>
</div>
<div ng-transclude></div>
<por-image-registry-rate-limits
ng-show="$ctrl.checkRateLimits"
is-docker-hub-registry="$ctrl.isDockerHubRegistry()"
endpoint="$ctrl.endpoint"
set-validity="$ctrl.setValidity"
is-authenticated="$ctrl.model.Registry.Authentication"
is-admin="$ctrl.isAdmin"
>
</por-image-registry-rate-limits>
</div>

View File

@ -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,
});

View File

@ -1,83 +0,0 @@
<!-- use registry -->
<div ng-if="$ctrl.model.UseRegistry">
<div class="form-group">
<label for="image_registry" class="control-label text-left" ng-class="$ctrl.labelClass">
Registry
</label>
<div ng-class="$ctrl.inputClass">
<select
ng-options="registry as registry.Name for registry in $ctrl.availableRegistries track by registry.Name"
ng-model="$ctrl.model.Registry"
id="image_registry"
selected-item-id="ctrl.selectedItemId"
class="form-control"
></select>
</div>
<label for="image_name" ng-class="$ctrl.labelClass" class="margin-sm-top control-label text-left">Image</label>
<div ng-class="$ctrl.inputClass" class="margin-sm-top">
<div class="input-group">
<span class="input-group-addon" id="registry-name">{{ $ctrl.displayedRegistryURL() }}</span>
<input
type="text"
class="form-control"
aria-describedby="registry-name"
uib-typeahead="image for image in $ctrl.availableImages | filter:$viewValue | limitTo:5"
ng-model="$ctrl.model.Image"
name="image_name"
placeholder="e.g. myImage:myTag"
ng-change="$ctrl.onImageChange()"
required
/>
<span ng-if="$ctrl.displayedRegistryURL() === 'docker.io'" class="input-group-btn">
<a href="https://hub.docker.com/search?type=image&q={{$ctrl.model.Image | trimshasum | trimversiontag}}"
class="btn btn-default"
title="Search image on Docker Hub"
target="_blank">
<i class="fab fa-docker"></i> Search
</a>
</span>
</div>
</div>
</div>
</div>
<!-- ! use registry -->
<!-- don't use registry -->
<div ng-if="!$ctrl.model.UseRegistry">
<div class="form-group">
<span class="small">
<p class="text-muted" style="margin-left: 15px;">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
When using advanced mode, image and repository <b>must be</b> publicly available.
</p>
</span>
<label for="image_name" ng-class="$ctrl.labelClass" class="control-label text-left">Image </label>
<div ng-class="$ctrl.inputClass">
<input type="text" class="form-control" ng-model="$ctrl.model.Image" name="image_name" placeholder="e.g. registry:port/myImage:myTag" required />
</div>
</div>
</div>
<!-- ! don't use registry -->
<!-- info message -->
<div class="form-group" ng-show="$ctrl.form.image_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="$ctrl.form.image_name.$error">
<p ng-message="required"
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Image name is required.
<span ng-if="$ctrl.canPull">Tag must be specified otherwise Portainer will pull all tags associated to the image.</span></p
>
</div>
</div>
</div>
<!-- ! info message -->
<div class="form-group">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-if="!$ctrl.model.UseRegistry" ng-click="$ctrl.model.UseRegistry = true;">
<i class="fa fa-database space-right" aria-hidden="true"></i> Simple mode
</a>
<a class="small interactive" ng-if="$ctrl.model.UseRegistry" ng-click="$ctrl.model.UseRegistry = false;">
<i class="fa fa-globe space-right" aria-hidden="true"></i> Advanced mode
</a>
</p>
</div>
</div>

View File

@ -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: [],

View File

@ -31,10 +31,10 @@
</div>
<div ng-if="!formValues.RegistryModel.Registry && fromContainer">
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i>
<span class="small text-danger" style="margin-left: 5px;"
>The Docker registry for the <code>{{ config.Image }}</code> image is not registered inside Portainer, you will not be able to create a container. Please register
that registry first.</span
>
<span class="small text-danger" style="margin-left: 5px;">
The Docker registry for the <code>{{ config.Image }}</code> image is not registered inside Portainer, you will not be able to create a container. Please register that
registry first.
</span>
</div>
<div ng-if="formValues.RegistryModel.Registry || !fromContainer">
<!-- image-and-registry -->
@ -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()"
></por-image-registry>
<!-- !image-and-registry -->
<!-- always-pull -->
<div class="form-group">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Always pull the image
<portainer-tooltip
position="bottom"
message="When enabled, Portainer will automatically try to pull the specified image before creating the container."
></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="formValues.alwaysPull" /><i></i> </label>
set-validity="setPullImageValidity"
>
<!-- always-pull -->
<div class="form-group">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Always pull the image
<portainer-tooltip
position="bottom"
message="When enabled, Portainer will automatically try to pull the specified image before creating the container."
></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.alwaysPull" ng-disabled="!state.pullImageValidity" /><i></i>
</label>
</div>
</div>
</div>
<!-- !always-pull -->
<!-- !always-pull -->
</por-image-registry>
<!-- !image-and-registry -->
</div>
<div class="col-sm-12 form-section-title">
Network ports configuration

View File

@ -14,30 +14,41 @@
<rd-widget-body>
<form class="form-horizontal">
<!-- image-and-registry -->
<por-image-registry model="formValues.RegistryModel" auto-complete="true" pull-warning="true" label-class="col-sm-1" input-class="col-sm-11"></por-image-registry>
<por-image-registry
model="formValues.RegistryModel"
auto-complete="true"
pull-warning="true"
label-class="col-sm-1"
input-class="col-sm-11"
endpoint="endpoint"
is-admin="isAdmin"
set-validity="setPullImageValidity"
check-rate-limits="true"
>
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- node-selection -->
<node-selector model="formValues.NodeName"> </node-selector>
<!-- !node-selection -->
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || !state.pullRateValid"
ng-click="pullImage()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Pull the image</span>
<span ng-show="state.actionInProgress">Download in progress...</span>
</button>
</div>
</div>
</por-image-registry>
<!-- !image-and-registry -->
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- node-selection -->
<node-selector model="formValues.NodeName"> </node-selector>
<!-- !node-selection -->
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image"
ng-click="pullImage()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Pull the image</span>
<span ng-show="state.actionInProgress">Download in progress...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>

View File

@ -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();
}

View File

@ -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' });
};

View File

@ -20,7 +20,17 @@
Image configuration
</div>
<!-- image-and-registry -->
<por-image-registry model="formValues.RegistryModel" auto-complete="true" label-class="col-sm-1" input-class="col-sm-11"></por-image-registry>
<por-image-registry
model="formValues.RegistryModel"
auto-complete="true"
label-class="col-sm-1"
input-class="col-sm-11"
endpoint="endpoint"
is-admin="isAdmin"
check-rate-limits="true"
set-validity="setPullImageValidity"
>
</por-image-registry>
<!-- !image-and-registry -->
<div class="col-sm-12 form-section-title">
Scheduling

View File

@ -3,7 +3,17 @@
<rd-widget-header icon="fa-clone" title-text="Change container image"> </rd-widget-header>
<rd-widget-body ng-if="!isUpdating">
<form class="form-horizontal">
<por-image-registry model="formValues.RegistryModel" auto-complete="true" label-class="col-sm-1" input-class="col-sm-11"></por-image-registry>
<por-image-registry
model="formValues.RegistryModel"
auto-complete="true"
label-class="col-sm-1"
input-class="col-sm-11"
endpoint="endpoint"
is-admin="isAdmin"
check-rate-limits="true"
set-validity="setPullImageValidity"
>
</por-image-registry>
</form>
</rd-widget-body>
<rd-widget-body ng-if="isUpdating">

View File

@ -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));

View File

@ -51,6 +51,7 @@
<!-- #endregion -->
<!-- #region IMAGE FIELD -->
<div class="form-group">
<label for="container_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-11">
@ -72,6 +73,14 @@
</div>
</div>
</div>
<por-image-registry-rate-limits
is-docker-hub-registry="true"
endpoint="ctrl.endpoint"
is-authenticated="ctrl.state.isDockerAuthenticated"
is-admin="ctrl.isAdmin"
set-validity="ctrl.setPullImageValidity"
>
</por-image-registry-rate-limits>
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
@ -1516,7 +1525,7 @@
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled()"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.state.pullImageValidity"
ng-click="ctrl.deployApplication()"
button-spinner="ctrl.state.actionInProgress"
>

View File

@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesCreateApplicationVie
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
endpoint: '<',
},
});

View File

@ -40,6 +40,7 @@ class KubernetesCreateApplicationController {
Notifications,
EndpointProvider,
Authentication,
DockerHubService,
ModalService,
KubernetesResourcePoolService,
KubernetesApplicationService,
@ -56,6 +57,7 @@ class KubernetesCreateApplicationController {
this.Notifications = Notifications;
this.EndpointProvider = EndpointProvider;
this.Authentication = Authentication;
this.DockerHubService = DockerHubService;
this.ModalService = ModalService;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.KubernetesApplicationService = KubernetesApplicationService;
@ -77,9 +79,14 @@ class KubernetesCreateApplicationController {
this.updateApplicationAsync = this.updateApplicationAsync.bind(this);
this.deployApplicationAsync = this.deployApplicationAsync.bind(this);
this.setPullImageValidity = this.setPullImageValidity.bind(this);
}
/* #endregion */
setPullImageValidity(validity) {
this.state.pullImageValidity = validity;
}
onChangeName() {
const existingApplication = _.find(this.applications, { Name: this.formValues.Name });
this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication);
@ -915,6 +922,7 @@ class KubernetesCreateApplicationController {
name: this.$transition$.params().name,
},
persistedFoldersUseExistingVolumes: false,
pullImageValidity: false,
};
this.isAdmin = this.Authentication.isAdmin();
@ -984,6 +992,9 @@ class KubernetesCreateApplicationController {
}
this.updateSliders();
const dockerHub = await this.DockerHubService.dockerhub();
this.state.isDockerAuthenticated = dockerHub.Authentication;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {

View File

@ -11,6 +11,16 @@ angular.module('portainer.app').factory('EndpointHelper', [
});
}
helper.isLocalEndpoint = isLocalEndpoint;
function isLocalEndpoint(endpoint) {
return endpoint.URL.includes('unix://') || endpoint.URL.includes('npipe://') || endpoint.Type === 5;
}
helper.isAgentEndpoint = isAgentEndpoint;
function isAgentEndpoint(endpoint) {
return [2, 4, 6, 7].includes(endpoint.Type);
}
helper.mapGroupNameToEndpoint = function (endpoints, groups) {
for (var i = 0; i < endpoints.length; i++) {
var endpoint = endpoints[i];

View File

@ -22,6 +22,7 @@ angular.module('portainer.app').factory('Endpoints', [
snapshot: { method: 'POST', params: { id: '@id', action: 'snapshot' } },
status: { method: 'GET', params: { id: '@id', action: 'status' } },
updateSecuritySettings: { method: 'PUT', params: { id: '@id', action: 'settings' } },
dockerhubLimits: { method: 'GET', params: { id: '@id', action: 'dockerhub' } },
}
);
},

View File

@ -3,7 +3,10 @@ import { DockerHubViewModel } from '../../models/dockerhub';
angular.module('portainer.app').factory('DockerHubService', [
'$q',
'DockerHub',
function DockerHubServiceFactory($q, DockerHub) {
'Endpoints',
'AgentDockerhub',
'EndpointHelper',
function DockerHubServiceFactory($q, DockerHub, Endpoints, AgentDockerhub, EndpointHelper) {
'use strict';
var service = {};
@ -26,6 +29,23 @@ angular.module('portainer.app').factory('DockerHubService', [
return DockerHub.update({}, dockerhub).$promise;
};
service.checkRateLimits = checkRateLimits;
function checkRateLimits(endpoint) {
if (EndpointHelper.isLocalEndpoint(endpoint)) {
return Endpoints.dockerhubLimits({ id: endpoint.Id }).$promise;
}
switch (endpoint.Type) {
case 2: //AgentOnDockerEnvironment
case 4: //EdgeAgentOnDockerEnvironment
return AgentDockerhub.limits({ endpointId: endpoint.Id, endpointType: 'docker' }).$promise;
case 6: //AgentOnKubernetesEnvironment
case 7: //EdgeAgentOnKubernetesEnvironment
return AgentDockerhub.limits({ endpointId: endpoint.Id, endpointType: 'kubernetes' }).$promise;
}
}
return service;
},
]);