feat(registry): Add ProGet registry type EE-703 (#5196)

* intermediate commit

* feat(registry): backport ProGet registry to CE (#954)

* backport EE changes

* label updates and remove auth-toggle

Co-authored-by: Dennis Buduev <dennis.buduev@portainer.io>
pull/5266/head
dbuduev 2021-07-01 14:57:15 +12:00 committed by GitHub
parent 8b80eb1731
commit 90a472c08b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 405 additions and 36 deletions

View File

@ -5,7 +5,7 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
)
@ -18,19 +18,28 @@ func hideFields(registry *portainer.Registry) {
// Handler is the HTTP handler used to handle registry operations.
type Handler struct {
*mux.Router
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
}
// NewHandler creates a handler to manage registry operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
h := newHandler(bouncer)
h.initRouter(bouncer)
return h
}
func newHandler(bouncer *security.RequestBouncer) *Handler {
return &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
}
func (h *Handler) initRouter(bouncer accessGuard) {
h.Handle("/registries",
bouncer.AdminAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost)
h.Handle("/registries",
@ -45,5 +54,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete)
h.PathPrefix("/registries/proxies/gitlab").Handler(
bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry)))
return h
}
type accessGuard interface {
AdminAccess(h http.Handler) http.Handler
RestrictedAccess(h http.Handler) http.Handler
AuthenticatedAccess(h http.Handler) http.Handler
}

View File

@ -2,6 +2,7 @@ package registries
import (
"errors"
"fmt"
"net/http"
"github.com/asaskevich/govalidator"
@ -14,10 +15,12 @@ import (
type registryCreatePayload struct {
// Name that will be used to identify this registry
Name string `example:"my-registry" validate:"required"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5"`
// URL or IP address of the Docker registry
URL string `example:"registry.mydomain.tld:2375" validate:"required"`
URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"`
// BaseURL required for ProGet registry
BaseURL string `example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
@ -30,7 +33,7 @@ type registryCreatePayload struct {
Quay portainer.QuayRegistryData
}
func (payload *registryCreatePayload) Validate(r *http.Request) error {
func (payload *registryCreatePayload) Validate(_ *http.Request) error {
if govalidator.IsNull(payload.Name) {
return errors.New("Invalid registry name")
}
@ -40,9 +43,17 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
}
if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry {
return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)")
switch payload.Type {
case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry:
default:
return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)")
}
if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" {
return fmt.Errorf("BaseURL is required for registry type %d (ProGet)", portainer.ProGetRegistry)
}
return nil
}
@ -70,6 +81,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
Type: portainer.RegistryType(payload.Type),
Name: payload.Name,
URL: payload.URL,
BaseURL: payload.BaseURL,
Authentication: payload.Authentication,
Username: payload.Username,
Password: payload.Password,

View File

@ -0,0 +1,104 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func Test_registryCreatePayload_Validate(t *testing.T) {
basePayload := registryCreatePayload{Name: "Test registry", URL: "http://example.com"}
t.Run("Can't create a ProGet registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
err := payload.Validate(nil)
assert.Error(t, err)
})
t.Run("Can create a GitLab registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.GitlabRegistry
err := payload.Validate(nil)
assert.NoError(t, err)
})
t.Run("Can create a ProGet registry if BaseURL is not empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
payload.BaseURL = "http://example.com"
err := payload.Validate(nil)
assert.NoError(t, err)
})
}
type testRegistryService struct {
portainer.RegistryService
createRegistry func(r *portainer.Registry) error
updateRegistry func(ID portainer.RegistryID, r *portainer.Registry) error
getRegistry func(ID portainer.RegistryID) (*portainer.Registry, error)
}
type testDataStore struct {
portainer.DataStore
registry *testRegistryService
}
func (t testDataStore) Registry() portainer.RegistryService {
return t.registry
}
func (t testRegistryService) CreateRegistry(r *portainer.Registry) error {
return t.createRegistry(r)
}
func (t testRegistryService) UpdateRegistry(ID portainer.RegistryID, r *portainer.Registry) error {
return t.updateRegistry(ID, r)
}
func (t testRegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) {
return t.getRegistry(ID)
}
func (t testRegistryService) Registries() ([]portainer.Registry, error) {
return nil, nil
}
func TestHandler_registryCreate(t *testing.T) {
payload := registryCreatePayload{
Name: "Test registry",
Type: portainer.ProGetRegistry,
URL: "http://example.com",
BaseURL: "http://example.com",
Authentication: false,
Username: "username",
Password: "password",
Gitlab: portainer.GitlabRegistryData{},
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
registry := portainer.Registry{}
handler := Handler{}
handler.DataStore = testDataStore{
registry: &testRegistryService{
createRegistry: func(r *portainer.Registry) error {
registry = *r
return nil
},
},
}
handlerError := handler.registryCreate(w, r)
assert.Nil(t, handlerError)
assert.Equal(t, payload.Name, registry.Name)
assert.Equal(t, payload.Type, registry.Type)
assert.Equal(t, payload.URL, registry.URL)
assert.Equal(t, payload.BaseURL, registry.BaseURL)
assert.Equal(t, payload.Authentication, registry.Authentication)
assert.Equal(t, payload.Username, registry.Username)
assert.Equal(t, payload.Password, registry.Password)
}

View File

@ -12,18 +12,14 @@ import (
)
type registryUpdatePayload struct {
// Name that will be used to identify this registry
Name *string `validate:"required" example:"my-registry"`
// URL or IP address of the Docker registry
URL *string `validate:"required" example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication *bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
Username *string `example:"registry_user"`
// Password used to authenticate against this registry. required when Authentication is true
Password *string `example:"registry_password"`
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
Name *string `json:",omitempty" example:"my-registry" validate:"required"`
URL *string `json:",omitempty" example:"registry.mydomain.tld:2375/feed" validate:"required"`
BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"`
Authentication *bool `json:",omitempty" example:"false" validate:"required"`
Username *string `json:",omitempty" example:"registry_user"`
Password *string `json:",omitempty" example:"registry_password"`
UserAccessPolicies portainer.UserAccessPolicies `json:",omitempty"`
TeamAccessPolicies portainer.TeamAccessPolicies `json:",omitempty"`
Quay *portainer.QuayRegistryData
}
@ -84,6 +80,10 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
registry.URL = *payload.URL
}
if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil {
registry.BaseURL = *payload.BaseURL
}
if payload.Authentication != nil {
if *payload.Authentication {
registry.Authentication = true

View File

@ -0,0 +1,79 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func ps(s string) *string {
return &s
}
func pb(b bool) *bool {
return &b
}
type TestBouncer struct{}
func (t TestBouncer) AdminAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) RestrictedAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
return h
}
func TestHandler_registryUpdate(t *testing.T) {
payload := registryUpdatePayload{
Name: ps("Updated test registry"),
URL: ps("http://example.org/feed"),
BaseURL: ps("http://example.org"),
Authentication: pb(true),
Username: ps("username"),
Password: ps("password"),
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
registry := portainer.Registry{Type: portainer.ProGetRegistry, ID: 5}
r := httptest.NewRequest(http.MethodPut, "/registries/5", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
updatedRegistry := portainer.Registry{}
handler := newHandler(nil)
handler.initRouter(TestBouncer{})
handler.DataStore = testDataStore{
registry: &testRegistryService{
getRegistry: func(_ portainer.RegistryID) (*portainer.Registry, error) {
return &registry, nil
},
updateRegistry: func(ID portainer.RegistryID, r *portainer.Registry) error {
assert.Equal(t, ID, r.ID)
updatedRegistry = *r
return nil
},
},
}
handler.Router.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
// Registry type should remain intact
assert.Equal(t, registry.Type, updatedRegistry.Type)
assert.Equal(t, *payload.Name, updatedRegistry.Name)
assert.Equal(t, *payload.URL, updatedRegistry.URL)
assert.Equal(t, *payload.BaseURL, updatedRegistry.BaseURL)
assert.Equal(t, *payload.Authentication, updatedRegistry.Authentication)
assert.Equal(t, *payload.Username, updatedRegistry.Username)
assert.Equal(t, *payload.Password, updatedRegistry.Password)
}

View File

@ -511,12 +511,14 @@ type (
Registry struct {
// Registry Identifier
ID RegistryID `json:"Id" example:"1"`
// Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab)
Type RegistryType `json:"Type" enums:"1,2,3,4"`
// Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet)
Type RegistryType `json:"Type" enums:"1,2,3,4,5"`
// Registry Name
Name string `json:"Name" example:"my-registry"`
// URL or IP address of the Docker registry
URL string `json:"URL" example:"registry.mydomain.tld:2375"`
// Base URL, introduced for ProGet registry
BaseURL string `json:"BaseURL" example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication bool `json:"Authentication" example:"true"`
// Username used to authenticate against this registry
@ -1489,6 +1491,8 @@ const (
CustomRegistry
// GitlabRegistry represents a gitlab registry
GitlabRegistry
// ProGetRegistry represents a proget registry
ProGetRegistry
)
const (

View File

@ -1187,17 +1187,22 @@ definitions:
TeamAccessPolicies:
$ref: '#/definitions/portainer.TeamAccessPolicies'
Type:
description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab)
description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet)
enum:
- 1
- 2
- 3
- 4
- 5
type: integer
URL:
description: URL or IP address of the Docker registry
example: registry.mydomain.tld:2375
type: string
BaseURL:
description: Base URL or IP address of the ProGet registry
example: registry.mydomain.tld:2375
type: string
UserAccessPolicies:
$ref: '#/definitions/portainer.UserAccessPolicies'
Username:
@ -1827,18 +1832,23 @@ definitions:
type: string
type:
description: 'Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container
registry), 3 (custom registry) or 4 (Gitlab registry)'
registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)'
enum:
- 1
- 2
- 3
- 4
- 5
example: 1
type: integer
url:
description: URL or IP address of the Docker registry
example: registry.mydomain.tld:2375
type: string
baseUrl:
description: Base URL or IP address of the ProGet registry
example: registry.mydomain.tld:2375
type: string
username:
description: Username used to authenticate against this registry. Required
when Authentication is true
@ -1871,6 +1881,10 @@ definitions:
description: URL or IP address of the Docker registry
example: registry.mydomain.tld:2375
type: string
baseUrl:
description: Base URL or IP address of the ProGet registry
example: registry.mydomain.tld:2375
type: string
userAccessPolicies:
$ref: '#/definitions/portainer.UserAccessPolicies'
username:

View File

@ -0,0 +1,101 @@
<form class="form-horizontal" name="registryFormProGet" ng-submit="$ctrl.formAction()">
<div class="col-sm-12 form-section-title">
ProGet registry details
</div>
<!-- name-input -->
<div class="form-group">
<label for="registry_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="registry_name" name="registry_name" ng-model="$ctrl.model.Name" placeholder="proget-registry" required auto-focus />
</div>
</div>
<div class="form-group" ng-show="registryFormProGet.registry_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="registryFormProGet.registry_name.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i>This field is required.</p>
</div>
</div>
</div>
<!-- !name-input -->
<!-- url-input -->
<div class="form-group">
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
Registry URL
<portainer-tooltip position="bottom" message="The URL of the ProGet registry including the Feed name"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="registry_url" name="registry_url" ng-model="$ctrl.model.URL" placeholder="proget.example.com/example-registry" required />
</div>
</div>
<div class="form-group" ng-show="registryFormProGet.registry_url.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="registryFormProGet.registry_url.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- url-input -->
<!-- base-url-input -->
<div class="form-group">
<label for="registry_base_url" class="col-sm-3 col-lg-2 control-label text-left">
Base URL
<portainer-tooltip position="bottom" message="The base URL of the ProGet registry"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="registry_base_url" name="registry_base_url" ng-model="$ctrl.model.BaseURL" placeholder="proget.example.com" required />
</div>
</div>
<div class="form-group" ng-show="registryFormProGet.registry_base_url.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="registryFormProGet.registry_base_url.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !base-url-input -->
<div>
<!-- credentials-user -->
<div class="form-group">
<label for="registry_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="registry_username" name="registry_username" ng-model="$ctrl.model.Username" required />
</div>
</div>
<div class="form-group" ng-show="registryFormProGet.registry_username.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="registryFormProGet.registry_username.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !credentials-user -->
<!-- credentials-password -->
<div class="form-group">
<label for="registry_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="registry_password" name="registry_password" ng-model="$ctrl.model.Password" required />
</div>
</div>
<div class="form-group" ng-show="registryFormProGet.registry_password.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="registryFormProGet.registry_password.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !credentials-password -->
</div>
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="$ctrl.actionInProgress || !registryFormProGet.$valid" button-spinner="$ctrl.actionInProgress">
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
<span ng-show="$ctrl.actionInProgress">In progress...</span>
</button>
</div>
</div>
<!-- !actions -->
</form>

View File

@ -0,0 +1,9 @@
angular.module('portainer.app').component('registryFormProget', {
templateUrl: './registry-form-proget.html',
bindings: {
model: '=',
formAction: '<',
formActionLabel: '@',
actionInProgress: '<',
},
});

View File

@ -1,11 +1,12 @@
import _ from 'lodash-es';
import { RegistryTypes } from '@/portainer/models/registryTypes';
import { RegistryTypes } from './registryTypes';
export function RegistryViewModel(data) {
this.Id = data.Id;
this.Type = data.Type;
this.Name = data.Name;
this.URL = data.URL;
this.BaseURL = data.BaseURL;
this.Authentication = data.Authentication;
this.Username = data.Username;
this.Password = data.Password;
@ -33,7 +34,7 @@ export function RegistryManagementConfigurationDefaultModel(registry) {
this.TLS = true;
}
if (registry.Type === RegistryTypes.CUSTOM && registry.Authentication) {
if ((registry.Type === RegistryTypes.CUSTOM || registry.Type === RegistryTypes.PROGET) && registry.Authentication) {
this.Authentication = true;
this.Username = registry.Username;
}
@ -71,4 +72,8 @@ export function RegistryCreateRequest(model) {
organisationName: model.Quay.organisationName,
};
}
if (model.Type === RegistryTypes.PROGET) {
this.BaseURL = _.replace(model.BaseURL, /^https?\:\/\//i, '');
this.BaseURL = _.replace(this.BaseURL, /\/$/, '');
}
}

View File

@ -3,4 +3,5 @@ export const RegistryTypes = Object.freeze({
AZURE: 2,
CUSTOM: 3,
GITLAB: 4,
PROGET: 5,
});

View File

@ -1,5 +1,5 @@
import { RegistryTypes } from '@/portainer/models/registryTypes';
import { RegistryDefaultModel } from '../../../models/registry';
import { RegistryDefaultModel } from '@/portainer/models/registry';
angular.module('portainer.app').controller('CreateRegistryController', [
'$scope',
@ -11,6 +11,7 @@ angular.module('portainer.app').controller('CreateRegistryController', [
$scope.selectQuayRegistry = selectQuayRegistry;
$scope.selectAzureRegistry = selectAzureRegistry;
$scope.selectCustomRegistry = selectCustomRegistry;
$scope.selectProGetRegistry = selectProGetRegistry;
$scope.selectGitlabRegistry = selectGitlabRegistry;
$scope.create = createRegistry;
$scope.useDefaultGitlabConfiguration = useDefaultGitlabConfiguration;
@ -65,6 +66,13 @@ angular.module('portainer.app').controller('CreateRegistryController', [
$scope.model.Authentication = false;
}
function selectProGetRegistry() {
$scope.model.Name = '';
$scope.model.URL = '';
$scope.model.BaseURL = '';
$scope.model.Authentication = true;
}
function retrieveGitlabRegistries() {
$scope.state.actionInProgress = true;
RegistryGitlabService.projects($scope.model.Gitlab.InstanceURL, $scope.model.Token)

View File

@ -26,6 +26,16 @@
<p>Quay container registry</p>
</label>
</div>
<div>
<input type="radio" id="registry_proget" ng-model="model.Type" ng-value="RegistryTypes.PROGET" />
<label for="registry_proget" ng-click="selectProGetRegistry()">
<div class="boxselector_header">
<i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i>
ProGet
</div>
<p>ProGet container registry</p>
</label>
</div>
<div>
<input type="radio" id="registry_azure" ng-model="model.Type" ng-value="RegistryTypes.AZURE" />
<label for="registry_azure" ng-click="selectAzureRegistry()">
@ -83,6 +93,14 @@
action-in-progress="state.actionInProgress"
></registry-form-custom>
<registry-form-proget
ng-if="model.Type === RegistryTypes.PROGET"
model="model"
form-action="create"
form-action-label="Add registry"
action-in-progress="state.actionInProgress"
></registry-form-proget>
<registry-form-gitlab
ng-if="model.Type === RegistryTypes.GITLAB"
model="model"

View File

@ -33,7 +33,7 @@
</div>
<!-- !registry-url-input -->
<!-- authentication-checkbox -->
<div class="form-group">
<div class="form-group" ng-if="registry.Type !== RegistryTypes.PROGET">
<div class="col-sm-12">
<label for="registry_auth" class="control-label text-left">
Authentication