diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 035385346..8c921c696 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -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 +} \ No newline at end of file diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index 00b2c0259..f724d7e2e 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -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, diff --git a/api/http/handler/registries/registry_create_test.go b/api/http/handler/registries/registry_create_test.go new file mode 100644 index 000000000..2e76bce28 --- /dev/null +++ b/api/http/handler/registries/registry_create_test.go @@ -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) +} diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 85540d72d..66c6473d7 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -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 diff --git a/api/http/handler/registries/registry_update_test.go b/api/http/handler/registries/registry_update_test.go new file mode 100644 index 000000000..8e0fdabc7 --- /dev/null +++ b/api/http/handler/registries/registry_update_test.go @@ -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 ®istry, 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) + +} diff --git a/api/portainer.go b/api/portainer.go index ab61f37bf..ccfeeea7a 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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 ( diff --git a/api/swagger.yaml b/api/swagger.yaml index 6a9f489e6..64f2674bc 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -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: diff --git a/app/portainer/components/forms/registry-form-proget/registry-form-proget.html b/app/portainer/components/forms/registry-form-proget/registry-form-proget.html new file mode 100644 index 000000000..a174b3d45 --- /dev/null +++ b/app/portainer/components/forms/registry-form-proget/registry-form-proget.html @@ -0,0 +1,101 @@ +
diff --git a/app/portainer/components/forms/registry-form-proget/registry-form-proget.js b/app/portainer/components/forms/registry-form-proget/registry-form-proget.js new file mode 100644 index 000000000..281d41dba --- /dev/null +++ b/app/portainer/components/forms/registry-form-proget/registry-form-proget.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormProget', { + templateUrl: './registry-form-proget.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + }, +}); diff --git a/app/portainer/models/registry.js b/app/portainer/models/registry.js index 72808e3b9..a7e2ac26e 100644 --- a/app/portainer/models/registry.js +++ b/app/portainer/models/registry.js @@ -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, /\/$/, ''); + } } diff --git a/app/portainer/models/registryTypes.js b/app/portainer/models/registryTypes.js index 7b0dc229b..a427a5954 100644 --- a/app/portainer/models/registryTypes.js +++ b/app/portainer/models/registryTypes.js @@ -3,4 +3,5 @@ export const RegistryTypes = Object.freeze({ AZURE: 2, CUSTOM: 3, GITLAB: 4, + PROGET: 5, }); diff --git a/app/portainer/views/registries/create/createRegistryController.js b/app/portainer/views/registries/create/createRegistryController.js index 02f8e29b9..5d4b0acc2 100644 --- a/app/portainer/views/registries/create/createRegistryController.js +++ b/app/portainer/views/registries/create/createRegistryController.js @@ -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) diff --git a/app/portainer/views/registries/create/createregistry.html b/app/portainer/views/registries/create/createregistry.html index 16350efb7..cecca19d3 100644 --- a/app/portainer/views/registries/create/createregistry.html +++ b/app/portainer/views/registries/create/createregistry.html @@ -26,6 +26,16 @@Quay container registry
+