From 08c5a5a4f65878fff82e6dc98a875a6d443f4cdf Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 20 Jun 2017 13:00:32 +0200 Subject: [PATCH] feat(registries): add registry management (#930) --- api/bolt/datastore.go | 48 ++- api/bolt/dockerhub_service.go | 61 ++++ api/bolt/endpoint_service.go | 2 +- api/bolt/internal/internal.go | 20 ++ api/bolt/registry_service.go | 114 +++++++ api/cmd/portainer/main.go | 23 ++ api/errors.go | 11 + api/http/handler/dockerhub.go | 87 +++++ api/http/handler/handler.go | 6 + api/http/handler/registry.go | 312 ++++++++++++++++++ api/http/security/filter.go | 34 ++ api/http/server.go | 8 + api/portainer.go | 39 +++ app/app.js | 58 ++++ .../accessControlForm/accessControlForm.html | 10 +- .../accessControlPanel.html | 10 +- app/components/container/container.html | 16 +- .../containers/containersController.js | 21 +- .../createContainerController.js | 6 +- .../createContainer/createcontainer.html | 16 +- .../createRegistryController.js | 49 +++ .../createRegistry/createregistry.html | 117 +++++++ .../createService/createServiceController.js | 12 +- .../createService/createservice.html | 16 +- .../createVolume/createVolumeController.js | 6 +- .../dashboard/dashboardController.js | 6 +- app/components/docker/docker.html | 6 +- app/components/docker/dockerController.js | 36 +- .../endpointAccess/endpointAccess.html | 136 +------- .../endpointAccessController.js | 180 +--------- app/components/events/eventsController.js | 32 +- app/components/image/image.html | 18 +- app/components/image/imageController.js | 47 +-- app/components/images/images.html | 18 +- app/components/images/imagesController.js | 7 +- app/components/registries/registries.html | 150 +++++++++ .../registries/registriesController.js | 113 +++++++ app/components/registry/registry.html | 78 +++++ app/components/registry/registryController.js | 37 +++ .../registryAccess/registryAccess.html | 45 +++ .../registryAccessController.js | 24 ++ app/components/sidebar/sidebar.html | 3 + app/components/swarm/swarm.html | 1 + app/components/swarm/swarmController.js | 67 ++-- .../accessManagement/por-access-management.js | 8 + .../accessManagement/porAccessManagement.html | 134 ++++++++ .../porAccessManagementController.js | 157 +++++++++ .../imageRegistry/por-image-registry.js | 8 + .../imageRegistry/porImageRegistry.html | 12 + .../porImageRegistryController.js | 23 ++ app/helpers/imageHelper.js | 11 +- app/helpers/registryHelper.js | 18 + .../api/{endpointAccess.js => access.js} | 4 +- app/models/api/dockerhub.js | 7 + app/models/api/registry.js | 11 + app/rest/api/dockerhub.js | 8 + app/rest/api/registry.js | 12 + app/rest/docker/event.js | 13 - app/rest/docker/image.js | 9 +- app/rest/docker/info.js | 7 - app/rest/docker/service.js | 7 +- app/rest/docker/system.js | 17 + app/rest/docker/version.js | 7 - app/services/api/accessService.js | 58 ++++ app/services/api/dockerhubService.js | 26 ++ app/services/api/registryService.js | 92 ++++++ app/services/api/settingsService.js | 4 +- app/services/docker/imageService.js | 53 +-- app/services/docker/infoService.js | 20 -- app/services/docker/systemService.js | 45 +++ app/services/httpRequestHelper.js | 17 + app/services/notifications.js | 4 + app/services/stateManager.js | 20 +- assets/css/app.css | 18 +- gruntfile.js | 2 +- 75 files changed, 2317 insertions(+), 621 deletions(-) create mode 100644 api/bolt/dockerhub_service.go create mode 100644 api/bolt/registry_service.go create mode 100644 api/http/handler/dockerhub.go create mode 100644 api/http/handler/registry.go create mode 100644 app/components/createRegistry/createRegistryController.js create mode 100644 app/components/createRegistry/createregistry.html create mode 100644 app/components/registries/registries.html create mode 100644 app/components/registries/registriesController.js create mode 100644 app/components/registry/registry.html create mode 100644 app/components/registry/registryController.js create mode 100644 app/components/registryAccess/registryAccess.html create mode 100644 app/components/registryAccess/registryAccessController.js create mode 100644 app/directives/accessManagement/por-access-management.js create mode 100644 app/directives/accessManagement/porAccessManagement.html create mode 100644 app/directives/accessManagement/porAccessManagementController.js create mode 100644 app/directives/imageRegistry/por-image-registry.js create mode 100644 app/directives/imageRegistry/porImageRegistry.html create mode 100644 app/directives/imageRegistry/porImageRegistryController.js create mode 100644 app/helpers/registryHelper.js rename app/models/api/{endpointAccess.js => access.js} (61%) create mode 100644 app/models/api/dockerhub.js create mode 100644 app/models/api/registry.js create mode 100644 app/rest/api/dockerhub.js create mode 100644 app/rest/api/registry.js delete mode 100644 app/rest/docker/event.js delete mode 100644 app/rest/docker/info.js create mode 100644 app/rest/docker/system.js delete mode 100644 app/rest/docker/version.js create mode 100644 app/services/api/accessService.js create mode 100644 app/services/api/dockerhubService.js create mode 100644 app/services/api/registryService.js delete mode 100644 app/services/docker/infoService.js create mode 100644 app/services/docker/systemService.js create mode 100644 app/services/httpRequestHelper.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 3c56ede26..f0357a2a4 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -23,6 +23,8 @@ type Store struct { ResourceControlService *ResourceControlService VersionService *VersionService SettingsService *SettingsService + RegistryService *RegistryService + DockerHubService *DockerHubService db *bolt.DB checkForDataMigration bool @@ -37,6 +39,8 @@ const ( endpointBucketName = "endpoints" resourceControlBucketName = "resource_control" settingsBucketName = "settings" + registryBucketName = "registries" + dockerhubBucketName = "dockerhub" ) // NewStore initializes a new Store and the associated services @@ -50,6 +54,8 @@ func NewStore(storePath string) (*Store, error) { ResourceControlService: &ResourceControlService{}, VersionService: &VersionService{}, SettingsService: &SettingsService{}, + RegistryService: &RegistryService{}, + DockerHubService: &DockerHubService{}, } store.UserService.store = store store.TeamService.store = store @@ -58,6 +64,8 @@ func NewStore(storePath string) (*Store, error) { store.ResourceControlService.store = store store.VersionService.store = store store.SettingsService.store = store + store.RegistryService.store = store + store.DockerHubService.store = store _, err := os.Stat(storePath + "/" + databaseFileName) if err != nil && os.IsNotExist(err) { @@ -74,40 +82,26 @@ func NewStore(storePath string) (*Store, error) { // Open opens and initializes the BoltDB database. func (store *Store) Open() error { path := store.Path + "/" + databaseFileName + db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) if err != nil { return err } store.db = db + + bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, + resourceControlBucketName, teamMembershipBucketName, settingsBucketName, + registryBucketName, dockerhubBucketName} + return db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(versionBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(userBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(teamBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(endpointBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(resourceControlBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(teamMembershipBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(settingsBucketName)) - if err != nil { - return err + + for _, bucket := range bucketsToCreate { + _, err := tx.CreateBucketIfNotExists([]byte(bucket)) + if err != nil { + return err + } } + return nil }) } diff --git a/api/bolt/dockerhub_service.go b/api/bolt/dockerhub_service.go new file mode 100644 index 000000000..34acd5594 --- /dev/null +++ b/api/bolt/dockerhub_service.go @@ -0,0 +1,61 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// DockerHubService represents a service for managing registries. +type DockerHubService struct { + store *Store +} + +const ( + dbDockerHubKey = "DOCKERHUB" +) + +// DockerHub returns the DockerHub object. +func (service *DockerHubService) DockerHub() (*portainer.DockerHub, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(dockerhubBucketName)) + value := bucket.Get([]byte(dbDockerHubKey)) + if value == nil { + return portainer.ErrDockerHubNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var dockerhub portainer.DockerHub + err = internal.UnmarshalDockerHub(data, &dockerhub) + if err != nil { + return nil, err + } + return &dockerhub, nil +} + +// StoreDockerHub persists a DockerHub object. +func (service *DockerHubService) StoreDockerHub(dockerhub *portainer.DockerHub) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(dockerhubBucketName)) + + data, err := internal.MarshalDockerHub(dockerhub) + if err != nil { + return err + } + + err = bucket.Put([]byte(dbDockerHubKey), data) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go index d7c230e9a..a92048d7a 100644 --- a/api/bolt/endpoint_service.go +++ b/api/bolt/endpoint_service.go @@ -7,7 +7,7 @@ import ( "github.com/boltdb/bolt" ) -// EndpointService represents a service for managing users. +// EndpointService represents a service for managing endpoints. type EndpointService struct { store *Store } diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index f55f72118..3378f93b7 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { return json.Unmarshal(data, endpoint) } +// MarshalRegistry encodes a registry to binary format. +func MarshalRegistry(registry *portainer.Registry) ([]byte, error) { + return json.Marshal(registry) +} + +// UnmarshalRegistry decodes a registry from a binary data. +func UnmarshalRegistry(data []byte, registry *portainer.Registry) error { + return json.Unmarshal(data, registry) +} + // MarshalResourceControl encodes a resource control object to binary format. func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) { return json.Marshal(rc) @@ -67,6 +77,16 @@ func UnmarshalSettings(data []byte, settings *portainer.Settings) error { return json.Unmarshal(data, settings) } +// MarshalDockerHub encodes a Dockerhub object to binary format. +func MarshalDockerHub(settings *portainer.DockerHub) ([]byte, error) { + return json.Marshal(settings) +} + +// UnmarshalDockerHub decodes a Dockerhub object from a binary data. +func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error { + return json.Unmarshal(data, settings) +} + // Itob returns an 8-byte big endian representation of v. // This function is typically used for encoding integer IDs to byte slices // so that they can be used as BoltDB keys. diff --git a/api/bolt/registry_service.go b/api/bolt/registry_service.go new file mode 100644 index 000000000..4c0c393ae --- /dev/null +++ b/api/bolt/registry_service.go @@ -0,0 +1,114 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// RegistryService represents a service for managing registries. +type RegistryService struct { + store *Store +} + +// Registry returns an registry by ID. +func (service *RegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(registryBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrRegistryNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var registry portainer.Registry + err = internal.UnmarshalRegistry(data, ®istry) + if err != nil { + return nil, err + } + return ®istry, nil +} + +// Registries returns an array containing all the registries. +func (service *RegistryService) Registries() ([]portainer.Registry, error) { + var registries = make([]portainer.Registry, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(registryBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var registry portainer.Registry + err := internal.UnmarshalRegistry(v, ®istry) + if err != nil { + return err + } + registries = append(registries, registry) + } + + return nil + }) + if err != nil { + return nil, err + } + + return registries, nil +} + +// CreateRegistry creates a new registry. +func (service *RegistryService) CreateRegistry(registry *portainer.Registry) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(registryBucketName)) + + id, _ := bucket.NextSequence() + registry.ID = portainer.RegistryID(id) + + data, err := internal.MarshalRegistry(registry) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(registry.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// UpdateRegistry updates an registry. +func (service *RegistryService) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error { + data, err := internal.MarshalRegistry(registry) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(registryBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteRegistry deletes an registry. +func (service *RegistryService) DeleteRegistry(ID portainer.RegistryID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(registryBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 9876de6aa..a3e265544 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -91,6 +91,22 @@ func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portaine } } +func initDockerHub(dockerHubService portainer.DockerHubService) error { + _, err := dockerHubService.DockerHub() + if err == portainer.ErrDockerHubNotFound { + dockerhub := &portainer.DockerHub{ + Authentication: false, + Username: "", + Password: "", + } + return dockerHubService.StoreDockerHub(dockerhub) + } else if err != nil { + return err + } + + return nil +} + func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error { _, err := settingsService.Settings() if err == portainer.ErrSettingsNotFound { @@ -146,6 +162,11 @@ func main() { log.Fatal(err) } + err = initDockerHub(store.DockerHubService) + if err != nil { + log.Fatal(err) + } + applicationStatus := initStatus(authorizeEndpointMgmt, flags) if *flags.Endpoint != "" { @@ -199,6 +220,8 @@ func main() { EndpointService: store.EndpointService, ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, + RegistryService: store.RegistryService, + DockerHubService: store.DockerHubService, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, diff --git a/api/errors.go b/api/errors.go index 0be2338d8..bf5f5517a 100644 --- a/api/errors.go +++ b/api/errors.go @@ -42,6 +42,12 @@ const ( ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Registry errors. +const ( + ErrRegistryNotFound = Error("Registry not found") + ErrRegistryAlreadyExists = Error("A registry is already defined for this URL") +) + // Version errors. const ( ErrDBVersionNotFound = Error("DB version not found") @@ -52,6 +58,11 @@ const ( ErrSettingsNotFound = Error("Settings not found") ) +// DockerHub errors. +const ( + ErrDockerHubNotFound = Error("Dockerhub not found") +) + // Crypto errors. const ( ErrCryptoHashFailure = Error("Unable to hash data") diff --git a/api/http/handler/dockerhub.go b/api/http/handler/dockerhub.go new file mode 100644 index 000000000..56b8eed4e --- /dev/null +++ b/api/http/handler/dockerhub.go @@ -0,0 +1,87 @@ +package handler + +import ( + "encoding/json" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// DockerHubHandler represents an HTTP API handler for managing DockerHub. +type DockerHubHandler struct { + *mux.Router + Logger *log.Logger + DockerHubService portainer.DockerHubService +} + +// NewDockerHubHandler returns a new instance of OldDockerHubHandler. +func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { + h := &DockerHubHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/dockerhub", + bouncer.PublicAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet) + h.Handle("/dockerhub", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut) + + return h +} + +// handleGetDockerHub handles GET requests on /dockerhub +func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) { + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, dockerhub, handler.Logger) + return +} + +// handlePutDockerHub handles PUT requests on /dockerhub +func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *http.Request) { + var req putDockerHubRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + dockerhub := &portainer.DockerHub{ + Authentication: false, + Username: "", + Password: "", + } + + if req.Authentication { + dockerhub.Authentication = true + dockerhub.Username = req.Username + dockerhub.Password = req.Password + } + + err = handler.DockerHubService.StoreDockerHub(dockerhub) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + } +} + +type putDockerHubRequest struct { + Authentication bool `valid:""` + Username string `valid:""` + Password string `valid:""` +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 0692dc6de..7fcb58c56 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -17,6 +17,8 @@ type Handler struct { TeamHandler *TeamHandler TeamMembershipHandler *TeamMembershipHandler EndpointHandler *EndpointHandler + RegistryHandler *RegistryHandler + DockerHubHandler *DockerHubHandler ResourceHandler *ResourceHandler StatusHandler *StatusHandler SettingsHandler *SettingsHandler @@ -50,6 +52,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/endpoints") { http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/registries") { + http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/dockerhub") { + http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/resource_controls") { http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/settings") { diff --git a/api/http/handler/registry.go b/api/http/handler/registry.go new file mode 100644 index 000000000..164a5f3c1 --- /dev/null +++ b/api/http/handler/registry.go @@ -0,0 +1,312 @@ +package handler + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// RegistryHandler represents an HTTP API handler for managing Docker registries. +type RegistryHandler struct { + *mux.Router + Logger *log.Logger + RegistryService portainer.RegistryService +} + +// NewRegistryHandler returns a new instance of RegistryHandler. +func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler { + h := &RegistryHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/registries", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostRegistries))).Methods(http.MethodPost) + h.Handle("/registries", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetRegistries))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetRegistry))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistry))).Methods(http.MethodPut) + h.Handle("/registries/{id}/access", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistryAccess))).Methods(http.MethodPut) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteRegistry))).Methods(http.MethodDelete) + + return h +} + +// handleGetRegistries handles GET requests on /registries +func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, filteredRegistries, handler.Logger) +} + +// handlePostRegistries handles POST requests on /registries +func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *http.Request) { + var req postRegistriesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + for _, r := range registries { + if r.URL == req.URL { + httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) + return + } + } + + registry := &portainer.Registry{ + Name: req.Name, + URL: req.URL, + Authentication: req.Authentication, + Username: req.Username, + Password: req.Password, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.RegistryService.CreateRegistry(registry) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger) +} + +type postRegistriesRequest struct { + Name string `valid:"required"` + URL string `valid:"required"` + Authentication bool `valid:""` + Username string `valid:""` + Password string `valid:""` +} + +type postRegistriesResponse struct { + ID int `json:"Id"` +} + +// handleGetRegistry handles GET requests on /registries/:id +func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + registryID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrRegistryNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, registry, handler.Logger) +} + +// handlePutRegistryAccess handles PUT requests on /registries/:id/access +func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + registryID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putRegistryAccessRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrRegistryNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range req.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + registry.AuthorizedUsers = authorizedUserIDs + } + + if req.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range req.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + registry.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putRegistryAccessRequest struct { + AuthorizedUsers []int `valid:"-"` + AuthorizedTeams []int `valid:"-"` +} + +// handlePutRegistry handles PUT requests on /registries/:id +func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + registryID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putRegistriesRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrRegistryNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + for _, r := range registries { + if r.URL == req.URL && r.ID != registry.ID { + httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) + return + } + } + + if req.Name != "" { + registry.Name = req.Name + } + + if req.URL != "" { + registry.URL = req.URL + } + + if req.Authentication { + registry.Authentication = true + registry.Username = req.Username + registry.Password = req.Password + } else { + registry.Authentication = false + registry.Username = "" + registry.Password = "" + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putRegistriesRequest struct { + Name string `valid:"required"` + URL string `valid:"required"` + Authentication bool `valid:""` + Username string `valid:""` + Password string `valid:""` +} + +// handleDeleteRegistry handles DELETE requests on /registries/:id +func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + registryID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrRegistryNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/security/filter.go b/api/http/security/filter.go index ec83a1ebc..7e7f56c7c 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -60,6 +60,24 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po return filteredUsers } +// FilterRegistries filters registries based on user role and team memberships. +// Non administrator users only have access to authorized endpoints. +func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) { + + filteredRegistries := registries + if !context.IsAdmin { + filteredRegistries = make([]portainer.Registry, 0) + + for _, registry := range registries { + if isRegistryAccessAuthorized(®istry, context.UserID, context.UserMemberships) { + filteredRegistries = append(filteredRegistries, registry) + } + } + } + + return filteredRegistries, nil +} + // FilterEndpoints filters endpoints based on user role and team memberships. // Non administrator users only have access to authorized endpoints. func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { @@ -78,6 +96,22 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC return filteredEndpoints, nil } +func isRegistryAccessAuthorized(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + for _, authorizedUserID := range registry.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + for _, membership := range memberships { + for _, authorizedTeamID := range registry.AuthorizedTeams { + if membership.TeamID == authorizedTeamID { + return true + } + } + } + return false +} + func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { for _, authorizedUserID := range endpoint.AuthorizedUsers { if authorizedUserID == userID { diff --git a/api/http/server.go b/api/http/server.go index c183e81a5..14a069eae 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -25,6 +25,8 @@ type Server struct { CryptoService portainer.CryptoService JWTService portainer.JWTService FileService portainer.FileService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService Handler *handler.Handler SSL bool SSLCert string @@ -66,6 +68,10 @@ func (server *Server) Start() error { endpointHandler.EndpointService = server.EndpointService endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = proxyManager + var registryHandler = handler.NewRegistryHandler(requestBouncer) + registryHandler.RegistryService = server.RegistryService + var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer) + dockerHubHandler.DockerHubService = server.DockerHubService var resourceHandler = handler.NewResourceHandler(requestBouncer) resourceHandler.ResourceControlService = server.ResourceControlService var uploadHandler = handler.NewUploadHandler(requestBouncer) @@ -78,6 +84,8 @@ func (server *Server) Start() error { TeamHandler: teamHandler, TeamMembershipHandler: teamMembershipHandler, EndpointHandler: endpointHandler, + RegistryHandler: registryHandler, + DockerHubHandler: dockerHubHandler, ResourceHandler: resourceHandler, SettingsHandler: settingsHandler, StatusHandler: statusHandler, diff --git a/api/portainer.go b/api/portainer.go index ec651c3d6..4e3f757d2 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -94,6 +94,30 @@ type ( Role UserRole } + // RegistryID represents a registry identifier. + RegistryID int + + // Registry represents a Docker registry with all the info required + // to connect to it. + Registry struct { + ID RegistryID `json:"Id"` + Name string `json:"Name"` + URL string `json:"URL"` + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + } + + // DockerHub represents all the required information to connect and use the + // Docker Hub. + DockerHub struct { + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password"` + } + // EndpointID represents an endpoint identifier. EndpointID int @@ -217,6 +241,21 @@ type ( Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error } + // RegistryService represents a service for managing registry data. + RegistryService interface { + Registry(ID RegistryID) (*Registry, error) + Registries() ([]Registry, error) + CreateRegistry(registry *Registry) error + UpdateRegistry(ID RegistryID, registry *Registry) error + DeleteRegistry(ID RegistryID) error + } + + // DockerHubService represents a service for managing the DockerHub object. + DockerHubService interface { + DockerHub() (*DockerHub, error) + StoreDockerHub(registry *DockerHub) error + } + // SettingsService represents a service for managing application settings. SettingsService interface { Settings() (*Settings, error) diff --git a/app/app.js b/app/app.js index 446e3dd18..ab86566f4 100644 --- a/app/app.js +++ b/app/app.js @@ -28,6 +28,7 @@ angular.module('portainer', [ 'containers', 'createContainer', 'createNetwork', + 'createRegistry', 'createSecret', 'createService', 'createVolume', @@ -43,6 +44,9 @@ angular.module('portainer', [ 'network', 'networks', 'node', + 'registries', + 'registry', + 'registryAccess', 'secrets', 'secret', 'service', @@ -253,6 +257,19 @@ angular.module('portainer', [ } } }) + .state('actions.create.registry', { + url: '/registry', + views: { + 'content@': { + templateUrl: 'app/components/createRegistry/createregistry.html', + controller: 'CreateRegistryController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('actions.create.secret', { url: '/secret', views: { @@ -431,6 +448,45 @@ angular.module('portainer', [ } } }) + .state('registries', { + url: '/registries/', + views: { + 'content@': { + templateUrl: 'app/components/registries/registries.html', + controller: 'RegistriesController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('registry', { + url: '^/registries/:id', + views: { + 'content@': { + templateUrl: 'app/components/registry/registry.html', + controller: 'RegistryController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('registry.access', { + url: '^/registries/:id/access', + views: { + 'content@': { + templateUrl: 'app/components/registryAccess/registryAccess.html', + controller: 'RegistryAccessController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('secrets', { url: '^/secrets/', views: { @@ -687,6 +743,8 @@ angular.module('portainer', [ .constant('TEAM_MEMBERSHIPS_ENDPOINT', 'api/team_memberships') .constant('RESOURCE_CONTROL_ENDPOINT', 'api/resource_controls') .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') + .constant('DOCKERHUB_ENDPOINT', 'api/dockerhub') + .constant('REGISTRIES_ENDPOINT', 'api/registries') .constant('TEMPLATES_ENDPOINT', 'api/templates') .constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json') .constant('PAGINATION_MAX_ITEMS', 10); diff --git a/app/components/common/accessControlForm/accessControlForm.html b/app/components/common/accessControlForm/accessControlForm.html index 70961858e..21e4c3869 100644 --- a/app/components/common/accessControlForm/accessControlForm.html +++ b/app/components/common/accessControlForm/accessControlForm.html @@ -17,11 +17,11 @@
-
+