From 179df0626724dc0695eb6a3b4c074e1cb8b0abe5 Mon Sep 17 00:00:00 2001 From: LP B <xAt0mZ@users.noreply.github.com> Date: Wed, 14 Jul 2021 11:15:21 +0200 Subject: [PATCH] feat(app): rework private registries and support private registries in kubernetes EE-30 (#5131) * feat(app): rework private registries and support private registries in kubernetes [EE-30] feat(api): backport private registries backend changes (#5072) * feat(api/bolt): backport bolt changes * feat(api/exec): backport exec changes * feat(api/http): backport http/handler/dockerhub changes * feat(api/http): backport http/handler/endpoints changes * feat(api/http): backport http/handler/registries changes * feat(api/http): backport http/handler/stacks changes * feat(api/http): backport http/handler changes * feat(api/http): backport http/proxy/factory/azure changes * feat(api/http): backport http/proxy/factory/docker changes * feat(api/http): backport http/proxy/factory/utils changes * feat(api/http): backport http/proxy/factory/kubernetes changes * feat(api/http): backport http/proxy/factory changes * feat(api/http): backport http/security changes * feat(api/http): backport http changes * feat(api/internal): backport internal changes * feat(api): backport api changes * feat(api/kubernetes): backport kubernetes changes * fix(api/http): changes on backend following backport feat(app): backport private registries frontend changes (#5056) * feat(app/docker): backport docker/components changes * feat(app/docker): backport docker/helpers changes * feat(app/docker): backport docker/views/container changes * feat(app/docker): backport docker/views/images changes * feat(app/docker): backport docker/views/registries changes * feat(app/docker): backport docker/views/services changes * feat(app/docker): backport docker changes * feat(app/kubernetes): backport kubernetes/components changes * feat(app/kubernetes): backport kubernetes/converters changes * feat(app/kubernetes): backport kubernetes/models changes * feat(app/kubernetes): backport kubernetes/registries changes * feat(app/kubernetes): backport kubernetes/services changes * feat(app/kubernetes): backport kubernetes/views/applications changes * feat(app/kubernetes): backport kubernetes/views/configurations changes * feat(app/kubernetes): backport kubernetes/views/configure changes * feat(app/kubernetes): backport kubernetes/views/resource-pools changes * feat(app/kubernetes): backport kubernetes/views changes * feat(app/portainer): backport portainer/components/accessManagement changes * feat(app/portainer): backport portainer/components/datatables changes * feat(app/portainer): backport portainer/components/forms changes * feat(app/portainer): backport portainer/components/registry-details changes * feat(app/portainer): backport portainer/models changes * feat(app/portainer): backport portainer/rest changes * feat(app/portainer): backport portainer/services changes * feat(app/portainer): backport portainer/views changes * feat(app/portainer): backport portainer changes * feat(app): backport app changes * config(project): gitignore + jsconfig changes gitignore all files under api/cmd/portainer but main.go and enable Code Editor autocomplete on import ... from '@/...' fix(app): fix pull rate limit checker fix(app/registries): sidebar menus and registry accesses users filtering fix(api): add missing kube client factory fix(kube): fetch dockerhub pull limits (#5133) fix(app): pre review fixes (#5142) * fix(app/registries): remove checkbox for endpointRegistries view * fix(endpoints): allow access to default namespace * fix(docker): fetch pull limits * fix(kube/ns): show selected registries for non admin Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com> chore(webpack): ignore missing sourcemaps fix(registries): fetch registry config from url feat(kube/registries): ignore not found when deleting secret feat(db): move migration to db 31 fix(registries): fix bugs in PR EE-869 (#5169) * fix(registries): hide role * fix(endpoints): set empty access policy to edge endpoint * fix(registry): remove double arguments * fix(admin): ignore warning * feat(kube/configurations): tag registry secrets (#5157) * feat(kube/configurations): tag registry secrets * feat(kube/secrets): show registry secrets for admins * fix(registries): move dockerhub to beginning * refactor(registries): use endpoint scoped registries feat(registries): filter by namespace if supplied feat(access-managment): filter users for registry (#5191) * refactor(access-manage): move users selector to component * feat(access-managment): filter users for registry refactor(registries): sync code with CE (#5200) * refactor(registry): add inspect handler under endpoints * refactor(endpoint): sync endpoint_registries_list * refactor(endpoints): sync registry_access * fix(db): rename migration functions * fix(registries): show accesses for admin * fix(kube): set token on transport * refactor(kube): move secret help to bottom * fix(kuberentes): remove shouldLog parameter * style(auth): add description of security.IsAdmin * feat(security): allow admin access to registry * feat(edge): connect to edge endpoint when creating client * style(portainer): change deprecation version * refactor(sidebar): hide manage * refactor(containers): revert changes * style(container): remove whitespace * fix(endpoint): add handler to registy on endpointService * refactor(image): use endpointService.registries * fix(kueb/namespaces): rename resource pool to namespace * fix(kube/namespace): move selected registries * fix(api/registries): hide accesses on registry creation Co-authored-by: LP B <xAt0mZ@users.noreply.github.com> refactor(api): remove code duplication after rebase fix(app/registries): replace last registry api usage by endpoint registry api fix(api/endpoints): update registry access policies on endpoint deletion (#5226) [EE-1027] fix(db): update db version * fix(dockerhub): fetch rate limits * fix(registry/tests): supply restricred context * fix(registries): show proget registry only when selected * fix(registry): create dockerhub registry * feat(db): move migrations to db 32 Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com> --- api/bolt/datastore.go | 1 + api/bolt/init.go | 16 - api/bolt/log/log.go | 41 ++ api/bolt/log/log.test.go | 1 + api/bolt/migrator/migrate_dbversion32.go | 124 ++++ api/bolt/migrator/migrator.go | 15 + api/bolt/services.go | 5 - api/cmd/portainer/main.go | 6 +- api/exec/swarm_stack.go | 9 +- api/go.mod | 1 + .../handler/dockerhub/dockerhub_inspect.go | 28 - .../handler/dockerhub/dockerhub_update.go | 68 -- api/http/handler/dockerhub/handler.go | 33 - api/http/handler/endpoints/endpoint_create.go | 4 +- api/http/handler/endpoints/endpoint_delete.go | 16 + .../endpoints/endpoint_dockerhub_status.go | 31 +- .../endpoints/endpoint_registries_inspect.go | 50 ++ .../endpoints/endpoint_registries_list.go | 130 ++++ .../endpoints/endpoint_registry_access.go | 149 +++++ api/http/handler/endpoints/handler.go | 10 +- api/http/handler/handler.go | 6 - api/http/handler/registries/handler.go | 32 +- .../handler/registries/registry_configure.go | 14 +- .../handler/registries/registry_create.go | 53 +- .../registries/registry_create_test.go | 9 + .../handler/registries/registry_delete.go | 10 + .../handler/registries/registry_inspect.go | 9 +- api/http/handler/registries/registry_list.go | 22 +- .../handler/registries/registry_update.go | 113 ++-- .../registries/registry_update_test.go | 9 + .../handler/stacks/create_compose_stack.go | 15 +- api/http/handler/stacks/create_swarm_stack.go | 15 +- .../proxy/factory/azure/containergroup.go | 18 +- .../proxy/factory/azure/containergroups.go | 8 +- .../proxy/factory/docker/access_control.go | 10 +- api/http/proxy/factory/docker/configs.go | 12 +- api/http/proxy/factory/docker/containers.go | 14 +- api/http/proxy/factory/docker/networks.go | 10 +- api/http/proxy/factory/docker/registry.go | 24 +- api/http/proxy/factory/docker/secrets.go | 12 +- api/http/proxy/factory/docker/services.go | 12 +- api/http/proxy/factory/docker/swarm.go | 6 +- api/http/proxy/factory/docker/tasks.go | 12 +- api/http/proxy/factory/docker/transport.go | 55 +- api/http/proxy/factory/docker/volumes.go | 14 +- api/http/proxy/factory/kubernetes.go | 6 +- .../factory/kubernetes/agent_transport.go | 60 ++ .../factory/kubernetes/edge_transport.go | 57 ++ .../factory/kubernetes/local_transport.go | 45 ++ .../proxy/factory/kubernetes/namespaces.go | 45 ++ api/http/proxy/factory/kubernetes/secrets.go | 170 +++++ .../proxy/factory/kubernetes/transport.go | 236 ++++--- api/http/proxy/factory/responseutils/json.go | 11 - api/http/proxy/factory/utils/json.go | 91 +++ api/http/proxy/factory/utils/request.go | 45 ++ .../{responseutils => utils}/response.go | 68 +- api/http/security/authorization.go | 35 +- api/http/security/bouncer.go | 27 +- api/http/security/context.go | 6 +- api/http/security/filter.go | 19 +- api/http/server.go | 7 +- api/internal/authorization/authorizations.go | 7 +- api/internal/endpoint/endpoint.go | 4 +- api/internal/testhelpers/datastore.go | 2 - api/kubernetes/cli/access.go | 44 +- api/kubernetes/cli/client.go | 29 +- api/kubernetes/cli/registries.go | 96 +++ api/kubernetes/privateregistries/labels.go | 5 + api/portainer.go | 37 +- app/agent/rest/dockerhub.js | 4 +- app/constants.js | 1 - app/docker/__module.js | 22 + .../dockerSidebarContent.html | 24 +- ...r-image-registry-rate-limits.controller.js | 38 +- .../por-image-registry-rate-limits.html | 2 +- .../por-image-registry-rate-limits.js | 2 + .../por-image-registry.controller.js | 67 +- .../imageRegistry/por-image-registry.html | 8 +- .../imageRegistry/por-image-registry.js | 2 +- app/docker/helpers/imageHelper.js | 140 +++-- .../create/createContainerController.js | 2 +- .../containers/create/createcontainer.html | 3 +- .../views/containers/edit/container.html | 2 +- .../containers/edit/containerController.js | 5 +- app/docker/views/images/edit/image.html | 2 +- .../views/images/edit/imageController.js | 67 +- app/docker/views/images/images.html | 1 - .../registries/access/registryAccess.html | 16 + .../views/registries/access/registryAccess.js | 7 + .../access/registryAccessController.js | 67 ++ .../views/services/edit/serviceController.js | 6 +- .../editEdgeGroupViewController.js | 3 +- app/kubernetes/__module.js | 26 +- .../configurationsDatatableController.js | 2 +- .../kubernetesSidebarContent.html | 14 +- app/kubernetes/converters/application.js | 11 +- app/kubernetes/converters/configuration.js | 1 + app/kubernetes/converters/daemonSet.js | 12 +- app/kubernetes/converters/deployment.js | 8 +- app/kubernetes/converters/resourcePool.js | 11 +- app/kubernetes/converters/secret.js | 3 + app/kubernetes/converters/statefulSet.js | 10 +- .../models/application/formValues.js | 55 +- app/kubernetes/models/daemon-set/models.js | 2 +- app/kubernetes/models/deployment/models.js | 2 +- .../models/resource-pool/formValues.js | 14 +- app/kubernetes/models/stateful-set/models.js | 2 +- app/kubernetes/registries/index.js | 5 + .../kube-registry-access-view/index.js | 9 + .../kube-registry-access-view.controller.js | 70 +++ .../kube-registry-access-view.html | 72 +++ .../services/resourcePoolService.js | 24 +- app/kubernetes/services/statefulSetService.js | 1 + .../create/createApplication.html | 99 ++- .../applications/create/createApplication.js | 1 - .../create/createApplicationController.js | 142 ++--- .../configurations/edit/configuration.html | 5 +- .../edit/configurationController.js | 21 +- .../views/configure/configureController.js | 11 +- .../create/createResourcePool.html | 122 +++- .../create/createResourcePool.js | 9 +- .../create/createResourcePoolController.js | 196 +++--- .../resource-pools/edit/resourcePool.html | 55 ++ .../views/resource-pools/edit/resourcePool.js | 2 +- .../edit/resourcePoolController.js | 241 ++++---- app/portainer/__module.js | 17 +- .../components/accessManagement/index.js | 9 + .../index.js | 7 + .../por-access-management-users-selector.html | 23 + .../accessManagement/por-access-management.js | 5 +- .../accessManagement/porAccessManagement.html | 26 +- .../porAccessManagementController.js | 6 + .../registriesDatatable.html | 31 +- .../registriesDatatable.js | 5 +- .../registriesDatatableController.js | 85 +++ .../datatables/strings-datatable/index.js | 20 + .../strings-datatable/strings-datatable.html | 65 ++ .../registry-form-dockerhub.html | 64 ++ .../registry-form-dockerhub.js | 9 + .../forms/template-form/template-form.js | 13 - .../forms/template-form/templateForm.html | 580 ------------------ .../template-form/templateFormController.js | 55 -- app/portainer/components/index.js | 3 +- .../components/registry-details/index.js | 10 + .../registry-details/registry-details.html | 25 + app/portainer/helpers/endpointHelper.js | 57 +- app/portainer/models/dockerhub.js | 13 +- app/portainer/models/registry.js | 7 +- app/portainer/models/registryTypes.js | 2 + app/portainer/rest/dockerhub.js | 15 - app/portainer/rest/endpoint.js | 21 +- app/portainer/rest/registry.js | 3 +- app/portainer/services/api/accessService.js | 15 +- .../services/api/dockerhubService.js | 68 +- app/portainer/services/api/endpointService.js | 15 + app/portainer/services/api/registryService.js | 105 ++-- app/portainer/services/api/templateService.js | 152 +++-- .../views/endpoint-registries/registries.html | 21 + .../views/endpoint-registries/registries.js | 7 + .../registriesController.js | 51 ++ .../views/endpoints/endpointsController.js | 3 +- app/portainer/views/home/homeController.js | 3 +- .../views/init/admin/initAdminController.js | 7 +- .../registries/access/registryAccess.html | 35 -- .../access/registryAccessController.js | 34 - ...reateregistry.html => createRegistry.html} | 86 ++- .../views/registries/create/createRegistry.js | 10 + .../create/createRegistryController.js | 209 +++---- .../registries/edit/registryController.js | 6 +- .../views/registries/registries.html | 74 --- .../views/registries/registriesController.js | 35 +- app/portainer/views/sidebar/sidebar.html | 4 +- .../views/sidebar/sidebarController.js | 25 +- jsconfig.json | 3 +- webpack/webpack.common.js | 4 +- 175 files changed, 3757 insertions(+), 2544 deletions(-) create mode 100644 api/bolt/log/log.go create mode 100644 api/bolt/log/log.test.go create mode 100644 api/bolt/migrator/migrate_dbversion32.go delete mode 100644 api/http/handler/dockerhub/dockerhub_inspect.go delete mode 100644 api/http/handler/dockerhub/dockerhub_update.go delete mode 100644 api/http/handler/dockerhub/handler.go create mode 100644 api/http/handler/endpoints/endpoint_registries_inspect.go create mode 100644 api/http/handler/endpoints/endpoint_registries_list.go create mode 100644 api/http/handler/endpoints/endpoint_registry_access.go create mode 100644 api/http/proxy/factory/kubernetes/agent_transport.go create mode 100644 api/http/proxy/factory/kubernetes/edge_transport.go create mode 100644 api/http/proxy/factory/kubernetes/local_transport.go create mode 100644 api/http/proxy/factory/kubernetes/namespaces.go create mode 100644 api/http/proxy/factory/kubernetes/secrets.go delete mode 100644 api/http/proxy/factory/responseutils/json.go create mode 100644 api/http/proxy/factory/utils/json.go create mode 100644 api/http/proxy/factory/utils/request.go rename api/http/proxy/factory/{responseutils => utils}/response.go (62%) create mode 100644 api/kubernetes/cli/registries.go create mode 100644 api/kubernetes/privateregistries/labels.go create mode 100644 app/docker/views/registries/access/registryAccess.html create mode 100644 app/docker/views/registries/access/registryAccess.js create mode 100644 app/docker/views/registries/access/registryAccessController.js create mode 100644 app/kubernetes/registries/index.js create mode 100644 app/kubernetes/registries/kube-registry-access-view/index.js create mode 100644 app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.controller.js create mode 100644 app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html create mode 100644 app/portainer/components/accessManagement/index.js create mode 100644 app/portainer/components/accessManagement/por-access-management-users-selector/index.js create mode 100644 app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html create mode 100644 app/portainer/components/datatables/registries-datatable/registriesDatatableController.js create mode 100644 app/portainer/components/datatables/strings-datatable/index.js create mode 100644 app/portainer/components/datatables/strings-datatable/strings-datatable.html create mode 100644 app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.html create mode 100644 app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.js delete mode 100644 app/portainer/components/forms/template-form/template-form.js delete mode 100644 app/portainer/components/forms/template-form/templateForm.html delete mode 100644 app/portainer/components/forms/template-form/templateFormController.js create mode 100644 app/portainer/components/registry-details/index.js create mode 100644 app/portainer/components/registry-details/registry-details.html delete mode 100644 app/portainer/rest/dockerhub.js create mode 100644 app/portainer/views/endpoint-registries/registries.html create mode 100644 app/portainer/views/endpoint-registries/registries.js create mode 100644 app/portainer/views/endpoint-registries/registriesController.js delete mode 100644 app/portainer/views/registries/access/registryAccess.html delete mode 100644 app/portainer/views/registries/access/registryAccessController.js rename app/portainer/views/registries/create/{createregistry.html => createRegistry.html} (52%) create mode 100644 app/portainer/views/registries/create/createRegistry.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index b0904102c..09fcf89c7 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -169,6 +169,7 @@ func (store *Store) MigrateData(force bool) error { UserService: store.UserService, VersionService: store.VersionService, FileService: store.fileService, + DockerhubService: store.DockerHubService, AuthorizationService: authorization.NewService(store), } migrator := migrator.NewMigrator(migratorParams) diff --git a/api/bolt/init.go b/api/bolt/init.go index 7ce23f138..a91a6b2e6 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -55,22 +55,6 @@ func (store *Store) Init() error { return err } - _, err = store.DockerHubService.DockerHub() - if err == errors.ErrObjectNotFound { - defaultDockerHub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - - err := store.DockerHubService.UpdateDockerHub(defaultDockerHub) - if err != nil { - return err - } - } else if err != nil { - return err - } - groups, err := store.EndpointGroupService.EndpointGroups() if err != nil { return err diff --git a/api/bolt/log/log.go b/api/bolt/log/log.go new file mode 100644 index 000000000..5ae90946a --- /dev/null +++ b/api/bolt/log/log.go @@ -0,0 +1,41 @@ +package log + +import ( + "fmt" + "log" +) + +const ( + INFO = "INFO" + ERROR = "ERROR" + DEBUG = "DEBUG" + FATAL = "FATAL" +) + +type ScopedLog struct { + scope string +} + +func NewScopedLog(scope string) *ScopedLog { + return &ScopedLog{scope: scope} +} + +func (slog *ScopedLog) print(kind string, message string) { + log.Printf("[%s] [%s] %s", kind, slog.scope, message) +} + +func (slog *ScopedLog) Debug(message string) { + slog.print(DEBUG, fmt.Sprintf("[message: %s]", message)) +} + +func (slog *ScopedLog) Info(message string) { + slog.print(INFO, fmt.Sprintf("[message: %s]", message)) +} + +func (slog *ScopedLog) Error(message string, err error) { + slog.print(ERROR, fmt.Sprintf("[message: %s] [error: %s]", message, err)) +} + +func (slog *ScopedLog) NotImplemented(method string) { + log.Fatalf("[%s] [%s] [%s]", FATAL, slog.scope, fmt.Sprintf("%s is not yet implemented", method)) +} diff --git a/api/bolt/log/log.test.go b/api/bolt/log/log.test.go new file mode 100644 index 000000000..7330d5405 --- /dev/null +++ b/api/bolt/log/log.test.go @@ -0,0 +1 @@ +package log diff --git a/api/bolt/migrator/migrate_dbversion32.go b/api/bolt/migrator/migrate_dbversion32.go new file mode 100644 index 000000000..3d800bd36 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion32.go @@ -0,0 +1,124 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" +) + +func (m *Migrator) migrateDBVersionTo32() error { + err := m.updateRegistriesToDB32() + if err != nil { + return err + } + + err = m.updateDockerhubToDB32() + if err != nil { + return err + } + + return nil +} + +func (m *Migrator) updateRegistriesToDB32() error { + registries, err := m.registryService.Registries() + if err != nil { + return err + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, registry := range registries { + + registry.RegistryAccesses = portainer.RegistryAccesses{} + + for _, endpoint := range endpoints { + + filteredUserAccessPolicies := portainer.UserAccessPolicies{} + for userId, registryPolicy := range registry.UserAccessPolicies { + if _, found := endpoint.UserAccessPolicies[userId]; found { + filteredUserAccessPolicies[userId] = registryPolicy + } + } + + filteredTeamAccessPolicies := portainer.TeamAccessPolicies{} + for teamId, registryPolicy := range registry.TeamAccessPolicies { + if _, found := endpoint.TeamAccessPolicies[teamId]; found { + filteredTeamAccessPolicies[teamId] = registryPolicy + } + } + + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{ + UserAccessPolicies: filteredUserAccessPolicies, + TeamAccessPolicies: filteredTeamAccessPolicies, + Namespaces: []string{}, + } + } + m.registryService.UpdateRegistry(registry.ID, ®istry) + } + return nil +} + +func (m *Migrator) updateDockerhubToDB32() error { + dockerhub, err := m.dockerhubService.DockerHub() + if err == errors.ErrObjectNotFound { + return nil + } else if err != nil { + return err + } + + if !dockerhub.Authentication { + return nil + } + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + Name: "Dockerhub (authenticated - migrated)", + URL: "docker.io", + Authentication: true, + Username: dockerhub.Username, + Password: dockerhub.Password, + RegistryAccesses: portainer.RegistryAccesses{}, + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + + if endpoint.Type != portainer.KubernetesLocalEnvironment && + endpoint.Type != portainer.AgentOnKubernetesEnvironment && + endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment { + + userAccessPolicies := portainer.UserAccessPolicies{} + for userId := range endpoint.UserAccessPolicies { + if _, found := endpoint.UserAccessPolicies[userId]; found { + userAccessPolicies[userId] = portainer.AccessPolicy{ + RoleID: 0, + } + } + } + + teamAccessPolicies := portainer.TeamAccessPolicies{} + for teamId := range endpoint.TeamAccessPolicies { + if _, found := endpoint.TeamAccessPolicies[teamId]; found { + teamAccessPolicies[teamId] = portainer.AccessPolicy{ + RoleID: 0, + } + } + } + + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{ + UserAccessPolicies: userAccessPolicies, + TeamAccessPolicies: teamAccessPolicies, + Namespaces: []string{}, + } + } + } + + return m.registryService.CreateRegistry(registry) +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 8d99b5bfa..daae8b184 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -3,10 +3,12 @@ package migrator import ( "github.com/boltdb/bolt" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/dockerhub" "github.com/portainer/portainer/api/bolt/endpoint" "github.com/portainer/portainer/api/bolt/endpointgroup" "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/extension" + plog "github.com/portainer/portainer/api/bolt/log" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" "github.com/portainer/portainer/api/bolt/role" @@ -20,6 +22,8 @@ import ( "github.com/portainer/portainer/api/internal/authorization" ) +var migrateLog = plog.NewScopedLog("bolt, migrate") + type ( // Migrator defines a service to migrate data after a Portainer version update. Migrator struct { @@ -41,6 +45,7 @@ type ( versionService *version.Service fileService portainer.FileService authorizationService *authorization.Service + dockerhubService *dockerhub.Service } // Parameters represents the required parameters to create a new Migrator instance. @@ -63,6 +68,7 @@ type ( VersionService *version.Service FileService portainer.FileService AuthorizationService *authorization.Service + DockerhubService *dockerhub.Service } ) @@ -87,6 +93,7 @@ func NewMigrator(parameters *Parameters) *Migrator { versionService: parameters.VersionService, fileService: parameters.FileService, authorizationService: parameters.AuthorizationService, + dockerhubService: parameters.DockerhubService, } } @@ -366,5 +373,13 @@ func (m *Migrator) Migrate() error { } } + // Portainer 2.9.0 + if m.currentDBVersion < 32 { + err := m.migrateDBVersionTo32() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/services.go b/api/bolt/services.go index 4cdc84069..ec4c8ecc6 100644 --- a/api/bolt/services.go +++ b/api/bolt/services.go @@ -167,11 +167,6 @@ func (store *Store) CustomTemplate() portainer.CustomTemplateService { return store.CustomTemplateService } -// DockerHub gives access to the DockerHub data management layer -func (store *Store) DockerHub() portainer.DockerHubService { - return store.DockerHubService -} - // EdgeGroup gives access to the EdgeGroup data management layer func (store *Store) EdgeGroup() portainer.EdgeGroupService { return store.EdgeGroupService diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index a852abd56..d056f9908 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -134,8 +134,8 @@ func initDockerClientFactory(signatureService portainer.DigitalSignatureService, return docker.NewClientFactory(signatureService, reverseTunnelService) } -func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *kubecli.ClientFactory { - return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID) +func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *kubecli.ClientFactory { + return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID, dataStore) } func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory, shutdownCtx context.Context) (portainer.SnapshotService, error) { @@ -382,7 +382,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { } dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService) - kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID) + kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID, dataStore) snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx) if err != nil { diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index cf59f7607..faf6cf723 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -10,7 +10,7 @@ import ( "path" "runtime" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) // SwarmStackManager represents a service for managing stacks. @@ -42,7 +42,7 @@ func NewSwarmStackManager(binaryPath, dataPath string, signatureService portaine } // Login executes the docker login command against a list of registries (including DockerHub). -func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { +func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) { command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) for _, registry := range registries { if registry.Authentication { @@ -50,11 +50,6 @@ func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registri runCommandAndCaptureStdErr(command, registryArgs, nil, "") } } - - if dockerhub.Authentication { - dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password) - runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "") - } } // Logout executes the docker logout command. diff --git a/api/go.mod b/api/go.mod index bfafa47fa..2f4e2e7b2 100644 --- a/api/go.mod +++ b/api/go.mod @@ -34,6 +34,7 @@ require ( golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 gopkg.in/alecthomas/kingpin.v2 v2.2.6 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c k8s.io/api v0.17.2 k8s.io/apimachinery v0.17.2 k8s.io/client-go v0.17.2 diff --git a/api/http/handler/dockerhub/dockerhub_inspect.go b/api/http/handler/dockerhub/dockerhub_inspect.go deleted file mode 100644 index e7dc713f8..000000000 --- a/api/http/handler/dockerhub/dockerhub_inspect.go +++ /dev/null @@ -1,28 +0,0 @@ -package dockerhub - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/response" -) - -// @id DockerHubInspect -// @summary Retrieve DockerHub information -// @description Use this endpoint to retrieve the information used to connect to the DockerHub -// @description **Access policy**: authenticated -// @tags dockerhub -// @security jwt -// @produce json -// @success 200 {object} portainer.DockerHub -// @failure 500 "Server error" -// @router /dockerhub [get] -func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - dockerhub, err := handler.DataStore.DockerHub().DockerHub() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} - } - - hideFields(dockerhub) - return response.JSON(w, dockerhub) -} diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go deleted file mode 100644 index 536b84420..000000000 --- a/api/http/handler/dockerhub/dockerhub_update.go +++ /dev/null @@ -1,68 +0,0 @@ -package dockerhub - -import ( - "errors" - "net/http" - - "github.com/asaskevich/govalidator" - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - portainer "github.com/portainer/portainer/api" -) - -type dockerhubUpdatePayload struct { - // Enable authentication against DockerHub - Authentication bool `validate:"required" example:"false"` - // Username used to authenticate against the DockerHub - Username string `validate:"required" example:"hub_user"` - // Password used to authenticate against the DockerHub - Password string `validate:"required" example:"hub_password"` -} - -func (payload *dockerhubUpdatePayload) 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") - } - return nil -} - -// @id DockerHubUpdate -// @summary Update DockerHub information -// @description Use this endpoint to update the information used to connect to the DockerHub -// @description **Access policy**: administrator -// @tags dockerhub -// @security jwt -// @accept json -// @produce json -// @param body body dockerhubUpdatePayload true "DockerHub information" -// @success 204 "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" -// @router /dockerhub [put] -func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload dockerhubUpdatePayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - dockerhub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - - if payload.Authentication { - dockerhub.Authentication = true - dockerhub.Username = payload.Username - dockerhub.Password = payload.Password - } - - err = handler.DataStore.DockerHub().UpdateDockerHub(dockerhub) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go deleted file mode 100644 index f1328acb8..000000000 --- a/api/http/handler/dockerhub/handler.go +++ /dev/null @@ -1,33 +0,0 @@ -package dockerhub - -import ( - "net/http" - - "github.com/gorilla/mux" - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" -) - -func hideFields(dockerHub *portainer.DockerHub) { - dockerHub.Password = "" -} - -// Handler is the HTTP handler used to handle DockerHub operations. -type Handler struct { - *mux.Router - DataStore portainer.DataStore -} - -// NewHandler creates a handler to manage Dockerhub operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { - h := &Handler{ - Router: mux.NewRouter(), - } - h.Handle("/dockerhub", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) - h.Handle("/dockerhub", - bouncer.AdminAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) - - return h -} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 946fa7d7e..50e462869 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -322,8 +322,8 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) TLSConfig: portainer.TLSConfiguration{ TLS: false, }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, Extensions: []portainer.EndpointExtension{}, TagIDs: payload.TagIDs, Status: portainer.EndpointStatusUp, diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 9fda9b74c..a2dcf1924 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -103,6 +103,22 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } } + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + + for idx := range registries { + registry := ®istries[idx] + if _, ok := registry.RegistryAccesses[endpoint.ID]; ok { + delete(registry.RegistryAccesses, endpoint.ID) + err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update registry accesses", Err: err} + } + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go index 793b85715..92cfbef9c 100644 --- a/api/http/handler/endpoints/endpoint_dockerhub_status.go +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -22,7 +22,7 @@ type dockerhubStatusResponse struct { Limit int `json:"limit"` } -// GET request on /api/endpoints/{id}/dockerhub/status +// GET request on /api/endpoints/{id}/dockerhub/{registryId} func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { @@ -40,13 +40,30 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment type", errors.New("Invalid environment type")} } - dockerhub, err := handler.DataStore.DockerHub().DockerHub() + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var registry *portainer.Registry + + if registryID == 0 { + registry = &portainer.Registry{} + } else { + registry, err = handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + if registry.Type != portainer.DockerHubRegistry { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry type", errors.New("Invalid registry type")} + } } httpClient := client.NewHTTPClient() - token, err := getDockerHubToken(httpClient, dockerhub) + token, err := getDockerHubToken(httpClient, registry) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub token from DockerHub", err} } @@ -59,7 +76,7 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R return response.JSON(w, resp) } -func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.DockerHub) (string, error) { +func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Registry) (string, error) { type dockerhubTokenResponse struct { Token string `json:"token"` } @@ -71,8 +88,8 @@ func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.Docke return "", err } - if dockerhub.Authentication { - req.SetBasicAuth(dockerhub.Username, dockerhub.Password) + if registry.Authentication { + req.SetBasicAuth(registry.Username, registry.Password) } resp, err := httpClient.Do(req) diff --git a/api/http/handler/endpoints/endpoint_registries_inspect.go b/api/http/handler/endpoints/endpoint_registries_inspect.go new file mode 100644 index 000000000..405fe9495 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_inspect.go @@ -0,0 +1,50 @@ +package endpoints + +import ( + "net/http" + + 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" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +// GET request on /endpoints/{id}/registries/{registryId} +func (handler *Handler) endpointRegistryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err} + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err} + } + + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a registry with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a registry with the specified identifier inside the database", Err: err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} + } + + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user from the database", Err: err} + } + + if !security.AuthorizedRegistryAccess(registry, user, securityContext.UserMemberships, portainer.EndpointID(endpointID)) { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied} + } + + hideRegistryFields(registry, !securityContext.IsAdmin) + return response.JSON(w, registry) +} diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go new file mode 100644 index 000000000..93233fada --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -0,0 +1,130 @@ +package endpoints + +import ( + "net/http" + + "github.com/pkg/errors" + 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/security" + endpointutils "github.com/portainer/portainer/api/internal/endpoint" +) + +// GET request on /endpoints/{id}/registries?namespace +func (handler *Handler) endpointRegistriesList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user from the database", err} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: 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} + } + + isAdmin := securityContext.IsAdmin + + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + + if endpointutils.IsKubernetesEndpoint(endpoint) { + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + + if namespace == "" && !isAdmin { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Missing namespace query parameter", Err: errors.New("missing namespace query parameter")} + } + + if namespace != "" { + + authorized, err := handler.isNamespaceAuthorized(endpoint, namespace, user.ID, securityContext.UserMemberships, isAdmin) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to check for namespace authorization", err} + } + + if !authorized { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized to use namespace", Err: errors.New("user is not authorized to use namespace")} + } + + registries = filterRegistriesByNamespace(registries, endpoint.ID, namespace) + } + + } else if !isAdmin { + registries = security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + } + + for idx := range registries { + hideRegistryFields(®istries[idx], !isAdmin) + } + + return response.JSON(w, registries) +} + +func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) { + if isAdmin || namespace == "" { + return true, nil + } + + if namespace == "default" { + return true, nil + } + + kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint) + if err != nil { + return false, errors.Wrap(err, "unable to retrieve kubernetes client") + } + + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return false, errors.Wrap(err, "unable to retrieve endpoint's namespaces policies") + } + + namespacePolicy, ok := accessPolicies[namespace] + if !ok { + return false, nil + } + + return security.AuthorizedAccess(userId, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies), nil +} + +func filterRegistriesByNamespace(registries []portainer.Registry, endpointId portainer.EndpointID, namespace string) []portainer.Registry { + if namespace == "" { + return registries + } + + filteredRegistries := []portainer.Registry{} + + for _, registry := range registries { + for _, authorizedNamespace := range registry.RegistryAccesses[endpointId].Namespaces { + if authorizedNamespace == namespace { + filteredRegistries = append(filteredRegistries, registry) + } + } + } + + return filteredRegistries +} + +func hideRegistryFields(registry *portainer.Registry, hideAccesses bool) { + registry.Password = "" + registry.ManagementConfiguration = nil + if hideAccesses { + registry.RegistryAccesses = nil + } +} diff --git a/api/http/handler/endpoints/endpoint_registry_access.go b/api/http/handler/endpoints/endpoint_registry_access.go new file mode 100644 index 000000000..8dde41d35 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registry_access.go @@ -0,0 +1,149 @@ +package endpoints + +import ( + "net/http" + + 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/security" +) + +type registryAccessPayload struct { + UserAccessPolicies portainer.UserAccessPolicies + TeamAccessPolicies portainer.TeamAccessPolicies + Namespaces []string +} + +func (payload *registryAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /endpoints/{id}/registries/{registryId} +func (handler *Handler) endpointRegistryAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err} + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + if !securityContext.IsAdmin { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized", Err: err} + } + + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } + + var payload registryAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} + } + + if registry.RegistryAccesses == nil { + registry.RegistryAccesses = portainer.RegistryAccesses{} + } + + if _, ok := registry.RegistryAccesses[endpoint.ID]; !ok { + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{} + } + + registryAccess := registry.RegistryAccesses[endpoint.ID] + + if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err := handler.updateKubeAccess(endpoint, registry, registryAccess.Namespaces, payload.Namespaces) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update kube access policies", Err: err} + } + + registryAccess.Namespaces = payload.Namespaces + } else { + registryAccess.UserAccessPolicies = payload.UserAccessPolicies + registryAccess.TeamAccessPolicies = payload.TeamAccessPolicies + } + + registry.RegistryAccesses[portainer.EndpointID(endpointID)] = registryAccess + + handler.DataStore.Registry().UpdateRegistry(registry.ID, registry) + + return response.Empty(w) +} + +func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error { + oldNamespacesSet := toSet(oldNamespaces) + newNamespacesSet := toSet(newNamespaces) + + namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet) + namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet) + + cli, err := handler.K8sClientFactory.GetKubeClient(endpoint) + if err != nil { + return err + } + + for namespace := range namespacesToRemove { + err := cli.DeleteRegistrySecret(registry, namespace) + if err != nil { + return err + } + } + + for namespace := range namespacesToAdd { + err := cli.CreateRegistrySecret(registry, namespace) + if err != nil { + return err + } + } + + return nil +} + +type stringSet map[string]bool + +func toSet(list []string) stringSet { + set := stringSet{} + for _, el := range list { + set[el] = true + } + return set +} + +// setDifference returns the set difference tagsA - tagsB +func setDifference(setA stringSet, setB stringSet) stringSet { + set := stringSet{} + + for el := range setA { + if !setB[el] { + set[el] = true + } + } + + return set +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index cd82092e7..bee2eb1d6 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -6,6 +6,7 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/kubernetes/cli" "net/http" @@ -28,6 +29,7 @@ type Handler struct { ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService SnapshotService portainer.SnapshotService + K8sClientFactory *cli.ClientFactory ComposeStackManager portainer.ComposeStackManager AuthorizationService *authorization.Service } @@ -53,7 +55,7 @@ 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", + h.Handle("/endpoints/{id}/dockerhub/{registryId}", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet) h.Handle("/endpoints/{id}/extensions", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) @@ -63,5 +65,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/registries", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistriesList))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/registries/{registryId}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/registries/{registryId}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryAccess))).Methods(http.MethodPut) return h } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 2942c3a17..f4d1eceea 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/portainer/api/http/handler/auth" "github.com/portainer/portainer/api/http/handler/backup" "github.com/portainer/portainer/api/http/handler/customtemplates" - "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" @@ -39,7 +38,6 @@ type Handler struct { AuthHandler *auth.Handler BackupHandler *backup.Handler CustomTemplatesHandler *customtemplates.Handler - DockerHubHandler *dockerhub.Handler EdgeGroupsHandler *edgegroups.Handler EdgeJobsHandler *edgejobs.Handler EdgeStacksHandler *edgestacks.Handler @@ -88,8 +86,6 @@ type Handler struct { // @tag.description Authenticate against Portainer HTTP API // @tag.name custom_templates // @tag.description Manage Custom Templates -// @tag.name dockerhub -// @tag.description Manage how Portainer connects to the DockerHub // @tag.name edge_groups // @tag.description Manage Edge Groups // @tag.name edge_jobs @@ -146,8 +142,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/restore"): http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r) - case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): - http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/custom_templates"): http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"): diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 8c921c696..e8dbaeefc 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -8,20 +8,25 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" ) -func hideFields(registry *portainer.Registry) { +func hideFields(registry *portainer.Registry, hideAccesses bool) { registry.Password = "" registry.ManagementConfiguration = nil + if hideAccesses { + registry.RegistryAccesses = nil + } } // 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 + K8sClientFactory *cli.ClientFactory } // NewHandler creates a handler to manage registry operations. @@ -34,8 +39,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { func newHandler(bouncer *security.RequestBouncer) *Handler { return &Handler{ - Router: mux.NewRouter(), - requestBouncer: bouncer, + Router: mux.NewRouter(), + requestBouncer: bouncer, } } @@ -60,4 +65,15 @@ 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 +} + +func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Registry) bool { + hasSameUrl := r1.URL == r2.URL + hasSameCredentials := r1.Authentication == r2.Authentication && (!r1.Authentication || (r1.Authentication && r1.Username == r2.Username)) + + if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry { + return hasSameUrl && hasSameCredentials + } + + return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath +} diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index 307101177..cc9398a61 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -10,6 +10,8 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) type registryConfigurePayload struct { @@ -93,9 +95,12 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { // @failure 500 "Server error" // @router /registries/{id}/configure [post] func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to configure registry", httperrors.ErrResourceAccessDenied} } payload := ®istryConfigurePayload{} @@ -104,6 +109,11 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index f724d7e2e..017121887 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -10,13 +10,15 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) 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), 4 (Gitlab registry) or 5 (ProGet registry) - Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5"` + // Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub) + Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6"` // URL or IP address of the Docker registry URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"` // BaseURL required for ProGet registry @@ -45,9 +47,9 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error { } switch payload.Type { - case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry: + case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry: 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)") + return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)") } if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" { @@ -71,24 +73,41 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error { // @failure 500 "Server error" // @router /registries [post] func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create registry", httperrors.ErrResourceAccessDenied} + } + var payload registryCreatePayload - err := request.DecodeAndValidateJSONPayload(r, &payload) + err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } registry := &portainer.Registry{ - Type: portainer.RegistryType(payload.Type), - Name: payload.Name, - URL: payload.URL, - BaseURL: payload.BaseURL, - Authentication: payload.Authentication, - Username: payload.Username, - Password: payload.Password, - UserAccessPolicies: portainer.UserAccessPolicies{}, - TeamAccessPolicies: portainer.TeamAccessPolicies{}, - Gitlab: payload.Gitlab, - Quay: payload.Quay, + Type: portainer.RegistryType(payload.Type), + Name: payload.Name, + URL: payload.URL, + BaseURL: payload.BaseURL, + Authentication: payload.Authentication, + Username: payload.Username, + Password: payload.Password, + RegistryAccesses: portainer.RegistryAccesses{}, + Gitlab: payload.Gitlab, + Quay: payload.Quay, + } + + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if handler.registriesHaveSameURLAndCredentials(&r, registry) { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")} + } } err = handler.DataStore.Registry().CreateRegistry(registry) @@ -96,6 +115,6 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err} } - hideFields(registry) + hideFields(registry, true) return response.JSON(w, registry) } diff --git a/api/http/handler/registries/registry_create_test.go b/api/http/handler/registries/registry_create_test.go index 2e76bce28..722301164 100644 --- a/api/http/handler/registries/registry_create_test.go +++ b/api/http/handler/registries/registry_create_test.go @@ -8,6 +8,7 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" "github.com/stretchr/testify/assert" ) @@ -82,6 +83,14 @@ func TestHandler_registryCreate(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes)) w := httptest.NewRecorder() + restrictedContext := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: portainer.UserID(1), + } + + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + registry := portainer.Registry{} handler := Handler{} handler.DataStore = testDataStore{ diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index a5cf8d417..d5db6769a 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -8,6 +8,8 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) // @id RegistryDelete @@ -23,6 +25,14 @@ import ( // @failure 500 "Server error" // @router /registries/{id} [delete] func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete registry", httperrors.ErrResourceAccessDenied} + } + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index 7803f420d..29b57fbc2 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -5,7 +5,6 @@ import ( portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" - "github.com/portainer/portainer/api/http/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -27,6 +26,7 @@ import ( // @failure 500 "Server error" // @router /registries/{id} [get] func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} @@ -39,11 +39,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } - err = handler.requestBouncer.RegistryAccess(r, registry) - if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", errors.ErrEndpointAccessDenied} - } - - hideFields(registry) + hideFields(registry, false) return response.JSON(w, registry) } diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go index a387f7f32..8e9519f68 100644 --- a/api/http/handler/registries/registry_list.go +++ b/api/http/handler/registries/registry_list.go @@ -5,6 +5,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -21,21 +22,18 @@ import ( // @failure 500 "Server error" // @router /registries [get] func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list registries, use /endpoints/:endpointId/registries route instead", httperrors.ErrResourceAccessDenied} + } + registries, err := handler.DataStore.Registry().Registries() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - filteredRegistries := security.FilterRegistries(registries, securityContext) - - for idx := range filteredRegistries { - hideFields(&filteredRegistries[idx]) - } - - return response.JSON(w, filteredRegistries) + return response.JSON(w, registries) } diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 66c6473d7..bf89fab82 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -9,18 +9,25 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) type registryUpdatePayload struct { - 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 + // 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"` + // BaseURL is used for quay registry + BaseURL *string `json:",omitempty" 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"` + RegistryAccesses *portainer.RegistryAccesses + Quay *portainer.QuayRegistryData } func (payload *registryUpdatePayload) Validate(r *http.Request) error { @@ -44,17 +51,19 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error { // @failure 500 "Server error" // @router /registries/{id} [put] func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update registry", httperrors.ErrResourceAccessDenied} + } + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} } - var payload registryUpdatePayload - err = request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} @@ -62,23 +71,17 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } + var payload registryUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + if payload.Name != nil { registry.Name = *payload.Name } - if payload.URL != nil { - registries, err := handler.DataStore.Registry().Registries() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} - } - for _, r := range registries { - if r.ID != registry.ID && hasSameURL(&r, registry) { - return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", errors.New("A registry is already defined for this URL")} - } - } - - registry.URL = *payload.URL - } + shouldUpdateSecrets := false if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil { registry.BaseURL = *payload.BaseURL @@ -87,6 +90,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * if payload.Authentication != nil { if *payload.Authentication { registry.Authentication = true + shouldUpdateSecrets = shouldUpdateSecrets || (payload.Username != nil && *payload.Username != registry.Username) || (payload.Password != nil && *payload.Password != registry.Password) if payload.Username != nil { registry.Username = *payload.Username @@ -103,12 +107,35 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * } } - if payload.UserAccessPolicies != nil { - registry.UserAccessPolicies = payload.UserAccessPolicies + if payload.URL != nil { + shouldUpdateSecrets = shouldUpdateSecrets || (*payload.URL != registry.URL) + + registry.URL = *payload.URL + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")} + } + } } - if payload.TeamAccessPolicies != nil { - registry.TeamAccessPolicies = payload.TeamAccessPolicies + if shouldUpdateSecrets { + for endpointID, endpointAccess := range registry.RegistryAccesses { + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err} + } + + if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err = handler.updateEndpointRegistryAccess(endpoint, registry, endpointAccess) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err} + } + } + } } if payload.Quay != nil { @@ -123,10 +150,24 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return response.JSON(w, registry) } -func hasSameURL(r1, r2 *portainer.Registry) bool { - if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry { - return r1.URL == r2.URL +func (handler *Handler) updateEndpointRegistryAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, endpointAccess portainer.RegistryAccessPolicies) error { + + cli, err := handler.K8sClientFactory.GetKubeClient(endpoint) + if err != nil { + return err } - return r1.URL == r2.URL && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath + for _, namespace := range endpointAccess.Namespaces { + err := cli.DeleteRegistrySecret(registry, namespace) + if err != nil { + return err + } + + err = cli.CreateRegistrySecret(registry, namespace) + if err != nil { + return err + } + } + + return nil } diff --git a/api/http/handler/registries/registry_update_test.go b/api/http/handler/registries/registry_update_test.go index 8e0fdabc7..d2767ff4f 100644 --- a/api/http/handler/registries/registry_update_test.go +++ b/api/http/handler/registries/registry_update_test.go @@ -8,6 +8,7 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" "github.com/stretchr/testify/assert" ) @@ -48,6 +49,14 @@ func TestHandler_registryUpdate(t *testing.T) { r := httptest.NewRequest(http.MethodPut, "/registries/5", bytes.NewReader(payloadBytes)) w := httptest.NewRecorder() + restrictedContext := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: portainer.UserID(1), + } + + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + updatedRegistry := portainer.Registry{} handler := newHandler(nil) handler.initRouter(TestBouncer{}) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 0a3b8e526..f66c2572d 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -290,7 +290,6 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, type composeStackDeploymentConfig struct { stack *portainer.Stack endpoint *portainer.Endpoint - dockerhub *portainer.DockerHub registries []portainer.Registry isAdmin bool user *portainer.User @@ -302,26 +301,20 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} } - dockerhub, err := handler.DataStore.DockerHub().DockerHub() + user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { - return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve DockerHub details from the database", Err: err} + return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} } registries, err := handler.DataStore.Registry().Registries() if err != nil { return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve registries from the database", Err: err} } - filteredRegistries := security.FilterRegistries(registries, securityContext) - - user, err := handler.DataStore.User().User(securityContext.UserID) - if err != nil { - return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} - } + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) config := &composeStackDeploymentConfig{ stack: stack, endpoint: endpoint, - dockerhub: dockerhub, registries: filteredRegistries, isAdmin: securityContext.IsAdmin, user: user, @@ -366,7 +359,7 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() - handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + handler.SwarmStackManager.Login(config.registries, config.endpoint) err = handler.ComposeStackManager.Up(config.stack, config.endpoint) if err != nil { diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 747f1b456..b439addbf 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -300,7 +300,6 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r type swarmStackDeploymentConfig struct { stack *portainer.Stack endpoint *portainer.Endpoint - dockerhub *portainer.DockerHub registries []portainer.Registry prune bool isAdmin bool @@ -313,26 +312,20 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - dockerhub, err := handler.DataStore.DockerHub().DockerHub() + user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} } registries, err := handler.DataStore.Registry().Registries() if err != nil { return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } - filteredRegistries := security.FilterRegistries(registries, securityContext) - - user, err := handler.DataStore.User().User(securityContext.UserID) - if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} - } + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) config := &swarmStackDeploymentConfig{ stack: stack, endpoint: endpoint, - dockerhub: dockerhub, registries: filteredRegistries, prune: prune, isAdmin: securityContext.IsAdmin, @@ -367,7 +360,7 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() - handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + handler.SwarmStackManager.Login(config.registries, config.endpoint) err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) if err != nil { diff --git a/api/http/proxy/factory/azure/containergroup.go b/api/http/proxy/factory/azure/containergroup.go index 242968de5..99c7b2a88 100644 --- a/api/http/proxy/factory/azure/containergroup.go +++ b/api/http/proxy/factory/azure/containergroup.go @@ -5,7 +5,7 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) // proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/* @@ -49,7 +49,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) errObj := map[string]string{ "message": "A container instance with the same name already exists inside the selected resource group", } - err = responseutils.RewriteResponse(resp, errObj, http.StatusConflict) + err = utils.RewriteResponse(resp, errObj, http.StatusConflict) return resp, err } @@ -58,7 +58,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) return response, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return response, err } @@ -80,7 +80,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) responseObject = decorateObject(responseObject, resourceControl) - err = responseutils.RewriteResponse(response, responseObject, http.StatusOK) + err = utils.RewriteResponse(response, responseObject, http.StatusOK) if err != nil { return response, err } @@ -94,7 +94,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request) return response, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return nil, err } @@ -106,7 +106,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request) responseObject = transport.decorateContainerGroup(responseObject, context) - responseutils.RewriteResponse(response, responseObject, http.StatusOK) + utils.RewriteResponse(response, responseObject, http.StatusOK) return response, nil } @@ -118,7 +118,7 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque } if !transport.userCanDeleteContainerGroup(request, context) { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } response, err := http.DefaultTransport.RoundTrip(request) @@ -126,14 +126,14 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque return response, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return nil, err } transport.removeResourceControl(responseObject, context) - responseutils.RewriteResponse(response, responseObject, http.StatusOK) + utils.RewriteResponse(response, responseObject, http.StatusOK) return response, nil } diff --git a/api/http/proxy/factory/azure/containergroups.go b/api/http/proxy/factory/azure/containergroups.go index ccb441b3b..e567ec5b7 100644 --- a/api/http/proxy/factory/azure/containergroups.go +++ b/api/http/proxy/factory/azure/containergroups.go @@ -4,7 +4,7 @@ import ( "fmt" "net/http" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) // proxy for /subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups @@ -23,7 +23,7 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request return nil, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return nil, err } @@ -39,10 +39,10 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request filteredValue := transport.filterContainerGroups(decoratedValue, context) responseObject["value"] = filteredValue - responseutils.RewriteResponse(response, responseObject, http.StatusOK) + utils.RewriteResponse(response, responseObject, http.StatusOK) } else { return nil, fmt.Errorf("The container groups response has no value property") } return response, nil -} \ No newline at end of file +} diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index 7f1cb4157..8db016ed9 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -7,7 +7,7 @@ import ( "github.com/portainer/portainer/api/internal/stackutils" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" portainer "github.com/portainer/portainer/api" @@ -162,7 +162,7 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe systemResourceControl := findSystemNetworkResourceControl(responseObject) if systemResourceControl != nil { responseObject = decorateObject(responseObject, systemResourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } } @@ -175,15 +175,15 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe } if resourceControl == nil && (executor.operationContext.isAdmin) { - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } if executor.operationContext.isAdmin || (resourceControl != nil && authorization.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) { responseObject = decorateObject(responseObject, resourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } - return responseutils.RewriteAccessDeniedResponse(response) + return utils.RewriteAccessDeniedResponse(response) } func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []interface{}, executor *operationExecutor) ([]interface{}, error) { diff --git a/api/http/proxy/factory/docker/configs.go b/api/http/proxy/factory/docker/configs.go index 74d10759d..4820b74c6 100644 --- a/api/http/proxy/factory/docker/configs.go +++ b/api/http/proxy/factory/docker/configs.go @@ -7,7 +7,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -34,7 +34,7 @@ func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, en func (transport *Transport) configListOperation(response *http.Response, executor *operationExecutor) error { // ConfigList response is a JSON array // https://docs.docker.com/engine/api/v1.30/#operation/ConfigList - responseArray, err := responseutils.GetResponseAsJSONArray(response) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -50,7 +50,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // configInspectOperation extracts the response as a JSON object, verify that the user @@ -58,7 +58,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo func (transport *Transport) configInspectOperation(response *http.Response, executor *operationExecutor) error { // ConfigInspect response is a JSON object // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -78,9 +78,9 @@ func (transport *Transport) configInspectOperation(response *http.Response, exec // https://docs.docker.com/engine/api/v1.37/#operation/ConfigList // https://docs.docker.com/engine/api/v1.37/#operation/ConfigInspect func selectorConfigLabels(responseObject map[string]interface{}) map[string]interface{} { - secretSpec := responseutils.GetJSONObject(responseObject, "Spec") + secretSpec := utils.GetJSONObject(responseObject, "Spec") if secretSpec != nil { - secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels") + secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels") return secretLabelsObject } return nil diff --git a/api/http/proxy/factory/docker/containers.go b/api/http/proxy/factory/docker/containers.go index 97108355e..dc92ae379 100644 --- a/api/http/proxy/factory/docker/containers.go +++ b/api/http/proxy/factory/docker/containers.go @@ -10,7 +10,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -46,7 +46,7 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, func (transport *Transport) containerListOperation(response *http.Response, executor *operationExecutor) error { // ContainerList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList - responseArray, err := responseutils.GetResponseAsJSONArray(response) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -69,7 +69,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec } } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // containerInspectOperation extracts the response as a JSON object, verify that the user @@ -77,7 +77,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error { //ContainerInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -96,9 +96,9 @@ func (transport *Transport) containerInspectOperation(response *http.Response, e // Labels are available under the "Config.Labels" property. // API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect func selectorContainerLabelsFromContainerInspectOperation(responseObject map[string]interface{}) map[string]interface{} { - containerConfigObject := responseutils.GetJSONObject(responseObject, "Config") + containerConfigObject := utils.GetJSONObject(responseObject, "Config") if containerConfigObject != nil { - containerLabelsObject := responseutils.GetJSONObject(containerConfigObject, "Labels") + containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels") return containerLabelsObject } return nil @@ -109,7 +109,7 @@ func selectorContainerLabelsFromContainerInspectOperation(responseObject map[str // Labels are available under the "Labels" property. // API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList func selectorContainerLabelsFromContainerListOperation(responseObject map[string]interface{}) map[string]interface{} { - containerLabelsObject := responseutils.GetJSONObject(responseObject, "Labels") + containerLabelsObject := utils.GetJSONObject(responseObject, "Labels") return containerLabelsObject } diff --git a/api/http/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go index b38ce68ec..05df57589 100644 --- a/api/http/proxy/factory/docker/networks.go +++ b/api/http/proxy/factory/docker/networks.go @@ -10,7 +10,7 @@ import ( "github.com/docker/docker/client" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -38,7 +38,7 @@ func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, e func (transport *Transport) networkListOperation(response *http.Response, executor *operationExecutor) error { // NetworkList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList - responseArray, err := responseutils.GetResponseAsJSONArray(response) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -54,7 +54,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // networkInspectOperation extracts the response as a JSON object, verify that the user @@ -62,7 +62,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut func (transport *Transport) networkInspectOperation(response *http.Response, executor *operationExecutor) error { // NetworkInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -99,5 +99,5 @@ func findSystemNetworkResourceControl(networkObject map[string]interface{}) *por // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList func selectorNetworkLabels(responseObject map[string]interface{}) map[string]interface{} { - return responseutils.GetJSONObject(responseObject, "Labels") + return utils.GetJSONObject(responseObject, "Labels") } diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go index c07ebae3d..38f0bd903 100644 --- a/api/http/proxy/factory/docker/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -1,39 +1,43 @@ package docker import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) type ( registryAccessContext struct { isAdmin bool - userID portainer.UserID + user *portainer.User + endpointID portainer.EndpointID teamMemberships []portainer.TeamMembership registries []portainer.Registry - dockerHub *portainer.DockerHub } + registryAuthenticationHeader struct { Username string `json:"username"` Password string `json:"password"` Serveraddress string `json:"serveraddress"` } + + portainerRegistryAuthenticationHeader struct { + RegistryId portainer.RegistryID `json:"registryId"` + } ) -func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader { +func createRegistryAuthenticationHeader(registryId portainer.RegistryID, accessContext *registryAccessContext) *registryAuthenticationHeader { var authenticationHeader *registryAuthenticationHeader - if serverAddress == "" { + if registryId == 0 { // dockerhub (anonymous) authenticationHeader = ®istryAuthenticationHeader{ - Username: accessContext.dockerHub.Username, - Password: accessContext.dockerHub.Password, Serveraddress: "docker.io", } - } else { + } else { // any "custom" registry var matchingRegistry *portainer.Registry for _, registry := range accessContext.registries { - if registry.URL == serverAddress && - (accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(®istry, accessContext.userID, accessContext.teamMemberships))) { + if registry.ID == registryId && + (accessContext.isAdmin || + security.AuthorizedRegistryAccess(®istry, accessContext.user, accessContext.teamMemberships, accessContext.endpointID)) { matchingRegistry = ®istry break } diff --git a/api/http/proxy/factory/docker/secrets.go b/api/http/proxy/factory/docker/secrets.go index 148073c02..6f7c203f8 100644 --- a/api/http/proxy/factory/docker/secrets.go +++ b/api/http/proxy/factory/docker/secrets.go @@ -7,7 +7,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -34,7 +34,7 @@ func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, en func (transport *Transport) secretListOperation(response *http.Response, executor *operationExecutor) error { // SecretList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/SecretList - responseArray, err := responseutils.GetResponseAsJSONArray(response) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -50,7 +50,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // secretInspectOperation extracts the response as a JSON object, verify that the user @@ -58,7 +58,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo func (transport *Transport) secretInspectOperation(response *http.Response, executor *operationExecutor) error { // SecretInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -78,9 +78,9 @@ func (transport *Transport) secretInspectOperation(response *http.Response, exec // https://docs.docker.com/engine/api/v1.37/#operation/SecretList // https://docs.docker.com/engine/api/v1.37/#operation/SecretInspect func selectorSecretLabels(responseObject map[string]interface{}) map[string]interface{} { - secretSpec := responseutils.GetJSONObject(responseObject, "Spec") + secretSpec := utils.GetJSONObject(responseObject, "Spec") if secretSpec != nil { - secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels") + secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels") return secretLabelsObject } return nil diff --git a/api/http/proxy/factory/docker/services.go b/api/http/proxy/factory/docker/services.go index 683859f73..205c48c60 100644 --- a/api/http/proxy/factory/docker/services.go +++ b/api/http/proxy/factory/docker/services.go @@ -12,7 +12,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -39,7 +39,7 @@ func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, e func (transport *Transport) serviceListOperation(response *http.Response, executor *operationExecutor) error { // ServiceList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList - responseArray, err := responseutils.GetResponseAsJSONArray(response) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -55,7 +55,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // serviceInspectOperation extracts the response as a JSON object, verify that the user @@ -63,7 +63,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut func (transport *Transport) serviceInspectOperation(response *http.Response, executor *operationExecutor) error { //ServiceInspect response is a JSON object //https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -83,9 +83,9 @@ func (transport *Transport) serviceInspectOperation(response *http.Response, exe // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList func selectorServiceLabels(responseObject map[string]interface{}) map[string]interface{} { - serviceSpecObject := responseutils.GetJSONObject(responseObject, "Spec") + serviceSpecObject := utils.GetJSONObject(responseObject, "Spec") if serviceSpecObject != nil { - return responseutils.GetJSONObject(serviceSpecObject, "Labels") + return utils.GetJSONObject(serviceSpecObject, "Labels") } return nil } diff --git a/api/http/proxy/factory/docker/swarm.go b/api/http/proxy/factory/docker/swarm.go index bc3ff9c4d..be39a4b0f 100644 --- a/api/http/proxy/factory/docker/swarm.go +++ b/api/http/proxy/factory/docker/swarm.go @@ -3,7 +3,7 @@ package docker import ( "net/http" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) // swarmInspectOperation extracts the response as a JSON object and rewrites the response based @@ -11,7 +11,7 @@ import ( func swarmInspectOperation(response *http.Response, executor *operationExecutor) error { // SwarmInspect response is a JSON object // https://docs.docker.com/engine/api/v1.30/#operation/SwarmInspect - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -21,5 +21,5 @@ func swarmInspectOperation(response *http.Response, executor *operationExecutor) delete(responseObject, "TLSInfo") } - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } diff --git a/api/http/proxy/factory/docker/tasks.go b/api/http/proxy/factory/docker/tasks.go index ad13398fd..f91c1a81c 100644 --- a/api/http/proxy/factory/docker/tasks.go +++ b/api/http/proxy/factory/docker/tasks.go @@ -4,7 +4,7 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) const ( @@ -16,7 +16,7 @@ const ( func (transport *Transport) taskListOperation(response *http.Response, executor *operationExecutor) error { // TaskList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/TaskList - responseArray, err := responseutils.GetResponseAsJSONArray(response) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -32,18 +32,18 @@ func (transport *Transport) taskListOperation(response *http.Response, executor return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // selectorServiceLabels retrieve the labels object associated to the task object. // Labels are available under the "Spec.ContainerSpec.Labels" property. // API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList func selectorTaskLabels(responseObject map[string]interface{}) map[string]interface{} { - taskSpecObject := responseutils.GetJSONObject(responseObject, "Spec") + taskSpecObject := utils.GetJSONObject(responseObject, "Spec") if taskSpecObject != nil { - containerSpecObject := responseutils.GetJSONObject(taskSpecObject, "ContainerSpec") + containerSpecObject := utils.GetJSONObject(taskSpecObject, "ContainerSpec") if containerSpecObject != nil { - return responseutils.GetJSONObject(containerSpecObject, "Labels") + return utils.GetJSONObject(containerSpecObject, "Labels") } } return nil diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index daeebdc4b..501a521fa 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -5,17 +5,19 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io/ioutil" "log" "net/http" "path" "regexp" + "strconv" "strings" "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/docker" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -169,12 +171,31 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, // volume browser request return transport.restrictedResourceOperation(r, resourceID, volumeName, portainer.VolumeResourceControl, true) case strings.HasPrefix(requestPath, "/dockerhub"): - dockerhub, err := transport.dataStore.DockerHub().DockerHub() + requestPath, registryIdString := path.Split(r.URL.Path) + + registryID, err := strconv.Atoi(registryIdString) if err != nil { - return nil, err + return nil, fmt.Errorf("missing registry id: %w", err) } - newBody, err := json.Marshal(dockerhub) + r.URL.Path = strings.TrimSuffix(requestPath, "/") + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + } + + if registryID != 0 { + registry, err = transport.dataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err != nil { + return nil, fmt.Errorf("failed fetching registry: %w", err) + } + } + + if registry.Type != portainer.DockerHubRegistry { + return nil, errors.New("Invalid registry type") + } + + newBody, err := json.Marshal(registry) if err != nil { return nil, err } @@ -397,13 +418,13 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re return nil, err } - var originalHeaderData registryAuthenticationHeader + var originalHeaderData portainerRegistryAuthenticationHeader err = json.Unmarshal(decodedHeaderData, &originalHeaderData) if err != nil { return nil, err } - authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext) + authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.RegistryId, accessContext) headerData, err := json.Marshal(authenticationHeader) if err != nil { @@ -433,7 +454,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if !securitySettings.AllowVolumeBrowserForRegularUsers { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } } @@ -468,12 +489,12 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } } if resourceControl != nil && !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } } @@ -537,7 +558,7 @@ func (transport *Transport) interceptAndRewriteRequest(request *http.Request, op // https://docs.docker.com/engine/api/v1.37/#operation/SecretCreate // https://docs.docker.com/engine/api/v1.37/#operation/ConfigCreate func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error { - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -556,7 +577,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht responseObject = decorateObject(responseObject, resourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { @@ -619,7 +640,7 @@ func (transport *Transport) administratorOperation(request *http.Request) (*http } if tokenData.Role != portainer.AdministratorRole { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } return transport.executeDockerRequest(request) @@ -632,15 +653,15 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) ( } accessContext := ®istryAccessContext{ - isAdmin: true, - userID: tokenData.ID, + isAdmin: true, + endpointID: transport.endpoint.ID, } - hub, err := transport.dataStore.DockerHub().DockerHub() + user, err := transport.dataStore.User().User(tokenData.ID) if err != nil { return nil, err } - accessContext.dockerHub = hub + accessContext.user = user registries, err := transport.dataStore.Registry().Registries() if err != nil { @@ -648,7 +669,7 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) ( } accessContext.registries = registries - if tokenData.Role != portainer.AdministratorRole { + if user.Role != portainer.AdministratorRole { accessContext.isAdmin = false teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go index 1f77c4585..2c0b304f7 100644 --- a/api/http/proxy/factory/docker/volumes.go +++ b/api/http/proxy/factory/docker/volumes.go @@ -9,7 +9,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -37,7 +37,7 @@ func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, en func (transport *Transport) volumeListOperation(response *http.Response, executor *operationExecutor) error { // VolumeList response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -68,7 +68,7 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo responseObject["Volumes"] = volumeData } - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } // volumeInspectOperation extracts the response as a JSON object, verify that the user @@ -76,7 +76,7 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo func (transport *Transport) volumeInspectOperation(response *http.Response, executor *operationExecutor) error { // VolumeInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -101,7 +101,7 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList func selectorVolumeLabels(responseObject map[string]interface{}) map[string]interface{} { - return responseutils.GetJSONObject(responseObject, "Labels") + return utils.GetJSONObject(responseObject, "Labels") } func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { @@ -142,7 +142,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt } func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error { - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -159,7 +159,7 @@ func (transport *Transport) decorateVolumeCreationResponse(response *http.Respon responseObject = decorateObject(responseObject, resourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } func (transport *Transport) restrictedVolumeOperation(requestPath string, request *http.Request) (*http.Response, error) { diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go index a1774c0d2..d4aba1769 100644 --- a/api/http/proxy/factory/kubernetes.go +++ b/api/http/proxy/factory/kubernetes.go @@ -39,7 +39,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin return nil, err } - transport, err := kubernetes.NewLocalTransport(tokenManager) + transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore) if err != nil { return nil, err } @@ -72,7 +72,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp endpointURL.Scheme = "http" proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) - proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.reverseTunnelService, endpoint.ID, tokenManager) + proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.dataStore) return proxy, nil } @@ -103,7 +103,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En } proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = kubernetes.NewAgentTransport(factory.dataStore, factory.signatureService, tlsConfig, tokenManager) + proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore) return proxy, nil } diff --git a/api/http/proxy/factory/kubernetes/agent_transport.go b/api/http/proxy/factory/kubernetes/agent_transport.go new file mode 100644 index 000000000..ea5af40a1 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/agent_transport.go @@ -0,0 +1,60 @@ +package kubernetes + +import ( + "crypto/tls" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +type agentTransport struct { + *baseTransport + signatureService portainer.DigitalSignatureService +} + +// 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, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *agentTransport { + transport := &agentTransport{ + baseTransport: newBaseTransport( + &http.Transport{ + TLSClientConfig: tlsConfig, + }, + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + ), + signatureService: signatureService, + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { + token, err := getRoundTripToken(request, transport.tokenManager) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + if strings.HasPrefix(request.URL.Path, "/v2") { + err := decorateAgentRequest(request, transport.dataStore) + if err != nil { + return nil, err + } + } + + signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey()) + request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) + + return transport.baseTransport.RoundTrip(request) +} diff --git a/api/http/proxy/factory/kubernetes/edge_transport.go b/api/http/proxy/factory/kubernetes/edge_transport.go new file mode 100644 index 000000000..22178fa05 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/edge_transport.go @@ -0,0 +1,57 @@ +package kubernetes + +import ( + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +type edgeTransport struct { + *baseTransport + reverseTunnelService portainer.ReverseTunnelService +} + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent +func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *edgeTransport { + transport := &edgeTransport{ + baseTransport: newBaseTransport( + &http.Transport{}, + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + ), + reverseTunnelService: reverseTunnelService, + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { + token, err := getRoundTripToken(request, transport.tokenManager) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + if strings.HasPrefix(request.URL.Path, "/v2") { + err := decorateAgentRequest(request, transport.dataStore) + if err != nil { + return nil, err + } + } + + response, err := transport.baseTransport.RoundTrip(request) + + if err == nil { + transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID) + } else { + transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID) + } + + return response, err +} diff --git a/api/http/proxy/factory/kubernetes/local_transport.go b/api/http/proxy/factory/kubernetes/local_transport.go new file mode 100644 index 000000000..916d1f6c1 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/local_transport.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +type localTransport struct { + *baseTransport +} + +// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API +func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) (*localTransport, error) { + config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true) + if err != nil { + return nil, err + } + + transport := &localTransport{ + baseTransport: newBaseTransport( + &http.Transport{ + TLSClientConfig: config, + }, + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + ), + } + + return transport, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { + _, err := transport.prepareRoundTrip(request) + if err != nil { + return nil, err + } + + return transport.baseTransport.RoundTrip(request) +} diff --git a/api/http/proxy/factory/kubernetes/namespaces.go b/api/http/proxy/factory/kubernetes/namespaces.go new file mode 100644 index 000000000..3272649d7 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/namespaces.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Request, namespace string) (*http.Response, error) { + registries, err := transport.dataStore.Registry().Registries() + if err != nil { + return nil, err + } + + for _, registry := range registries { + for endpointID, registryAccessPolicies := range registry.RegistryAccesses { + if endpointID != transport.endpoint.ID { + continue + } + + namespaces := []string{} + for _, ns := range registryAccessPolicies.Namespaces { + if ns == namespace { + continue + } + namespaces = append(namespaces, ns) + } + + if len(namespaces) != len(registryAccessPolicies.Namespaces) { + updatedAccessPolicies := portainer.RegistryAccessPolicies{ + Namespaces: namespaces, + UserAccessPolicies: registryAccessPolicies.UserAccessPolicies, + TeamAccessPolicies: registryAccessPolicies.TeamAccessPolicies, + } + + registry.RegistryAccesses[endpointID] = updatedAccessPolicies + err := transport.dataStore.Registry().UpdateRegistry(registry.ID, ®istry) + if err != nil { + return nil, err + } + } + } + } + return transport.executeKubernetesRequest(request) +} diff --git a/api/http/proxy/factory/kubernetes/secrets.go b/api/http/proxy/factory/kubernetes/secrets.go new file mode 100644 index 000000000..6fc2ed7f6 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/secrets.go @@ -0,0 +1,170 @@ +package kubernetes + +import ( + "net/http" + "path" + + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/privateregistries" + v1 "k8s.io/api/core/v1" +) + +func (transport *baseTransport) proxySecretRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { + switch request.Method { + case "POST": + return transport.proxySecretCreationOperation(request) + case "GET": + if path.Base(requestPath) == "secrets" { + return transport.proxySecretListOperation(request) + } + return transport.proxySecretInspectOperation(request) + case "PUT": + return transport.proxySecretUpdateOperation(request) + case "DELETE": + return transport.proxySecretDeleteOperation(request, namespace) + default: + return transport.executeKubernetesRequest(request) + } +} + +func (transport *baseTransport) proxySecretCreationOperation(request *http.Request) (*http.Response, error) { + body, err := utils.GetRequestAsMap(request) + if err != nil { + return nil, err + } + + if isSecretRepresentPrivateRegistry(body) { + return utils.WriteAccessDeniedResponse() + } + + err = utils.RewriteRequest(request, body) + if err != nil { + return nil, err + } + + return transport.executeKubernetesRequest(request) +} + +func (transport *baseTransport) proxySecretListOperation(request *http.Request) (*http.Response, error) { + response, err := transport.executeKubernetesRequest(request) + if err != nil { + return nil, err + } + + isAdmin, err := security.IsAdmin(request) + if err != nil { + return nil, err + } + + if isAdmin { + return response, nil + } + + body, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + items := utils.GetArrayObject(body, "items") + + if items == nil { + utils.RewriteResponse(response, body, response.StatusCode) + return response, nil + } + + filteredItems := []interface{}{} + for _, item := range items { + itemObj := item.(map[string]interface{}) + if !isSecretRepresentPrivateRegistry(itemObj) { + filteredItems = append(filteredItems, item) + } + } + + body["items"] = filteredItems + + utils.RewriteResponse(response, body, response.StatusCode) + return response, nil +} + +func (transport *baseTransport) proxySecretInspectOperation(request *http.Request) (*http.Response, error) { + response, err := transport.executeKubernetesRequest(request) + if err != nil { + return nil, err + } + + isAdmin, err := security.IsAdmin(request) + if err != nil { + return nil, err + } + + if isAdmin { + return response, nil + } + + body, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + if isSecretRepresentPrivateRegistry(body) { + return utils.WriteAccessDeniedResponse() + } + + err = utils.RewriteResponse(response, body, response.StatusCode) + if err != nil { + return nil, err + } + + return response, nil +} + +func (transport *baseTransport) proxySecretUpdateOperation(request *http.Request) (*http.Response, error) { + body, err := utils.GetRequestAsMap(request) + if err != nil { + return nil, err + } + + if isSecretRepresentPrivateRegistry(body) { + return utils.WriteAccessDeniedResponse() + } + + err = utils.RewriteRequest(request, body) + if err != nil { + return nil, err + } + + return transport.executeKubernetesRequest(request) +} + +func (transport *baseTransport) proxySecretDeleteOperation(request *http.Request, namespace string) (*http.Response, error) { + kcl, err := transport.k8sClientFactory.GetKubeClient(transport.endpoint) + if err != nil { + return nil, err + } + + secretName := path.Base(request.RequestURI) + + isRegistry, err := kcl.IsRegistrySecret(namespace, secretName) + if err != nil { + return nil, err + } + + if isRegistry { + return utils.WriteAccessDeniedResponse() + } + + return transport.executeKubernetesRequest(request) +} + +func isSecretRepresentPrivateRegistry(secret map[string]interface{}) bool { + if secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) { + return false + } + + metadata := utils.GetJSONObject(secret, "metadata") + annotations := utils.GetJSONObject(metadata, "annotations") + _, ok := annotations[privateregistries.RegistryIDLabel] + + return ok +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 377c7a3dc..b71ea906c 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -2,153 +2,107 @@ package kubernetes import ( "bytes" - "crypto/tls" "encoding/json" + "errors" "fmt" "io/ioutil" "log" "net/http" + "path" + "regexp" + "strconv" "strings" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/crypto" ) -type ( - localTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager - endpointIdentifier portainer.EndpointID - } - - agentTransport struct { - dataStore portainer.DataStore - httpTransport *http.Transport - tokenManager *tokenManager - signatureService portainer.DigitalSignatureService - endpointIdentifier portainer.EndpointID - } - - edgeTransport struct { - dataStore portainer.DataStore - httpTransport *http.Transport - tokenManager *tokenManager - reverseTunnelService portainer.ReverseTunnelService - endpointIdentifier portainer.EndpointID - } -) - -// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API -func NewLocalTransport(tokenManager *tokenManager) (*localTransport, error) { - config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true) - if err != nil { - return nil, err - } - - transport := &localTransport{ - httpTransport: &http.Transport{ - TLSClientConfig: config, - }, - tokenManager: tokenManager, - } - - return transport, nil +type baseTransport struct { + httpTransport *http.Transport + tokenManager *tokenManager + endpoint *portainer.Endpoint + k8sClientFactory *cli.ClientFactory + dataStore portainer.DataStore } -// RoundTrip is the implementation of the the http.RoundTripper interface -func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) +func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *baseTransport { + return &baseTransport{ + httpTransport: httpTransport, + tokenManager: tokenManager, + endpoint: endpoint, + k8sClientFactory: k8sClientFactory, + dataStore: dataStore, + } +} + +// #region KUBERNETES PROXY + +// proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based +// on the requested operation. +func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) { + apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/api/v[0-9](\.[0-9])?`) + requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") + + switch { + case strings.EqualFold(requestPath, "/namespaces"): + return transport.executeKubernetesRequest(request) + case strings.HasPrefix(requestPath, "/namespaces"): + return transport.proxyNamespacedRequest(request, requestPath) + default: + return transport.executeKubernetesRequest(request) + } +} + +func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) { + requestPath := strings.TrimPrefix(fullRequestPath, "/namespaces/") + split := strings.SplitN(requestPath, "/", 2) + namespace := split[0] + + requestPath = "" + if len(split) > 1 { + requestPath = split[1] + } + + switch { + case strings.HasPrefix(requestPath, "secrets"): + return transport.proxySecretRequest(request, namespace, requestPath) + case requestPath == "" && request.Method == "DELETE": + return transport.proxyNamespaceDeleteOperation(request, namespace) + default: + return transport.executeKubernetesRequest(request) + } +} + +func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) { + + resp, err := transport.httpTransport.RoundTrip(request) + + return resp, err +} + +// #endregion + +// #region ROUND TRIP + +func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) { + token, err := getRoundTripToken(request, transport.tokenManager) if err != nil { - return nil, err + return "", err } request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - return transport.httpTransport.RoundTrip(request) -} - -// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent -func NewAgentTransport(datastore portainer.DataStore, signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport { - transport := &agentTransport{ - dataStore: datastore, - httpTransport: &http.Transport{ - TLSClientConfig: tlsConfig, - }, - tokenManager: tokenManager, - signatureService: signatureService, - } - - return transport + return token, nil } // RoundTrip is the implementation of the the http.RoundTripper interface -func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) - if err != nil { - return nil, err - } - - 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 - } - - request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey()) - request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) - - return transport.httpTransport.RoundTrip(request) +func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.proxyKubernetesRequest(request) } -// 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, - endpointIdentifier: endpointIdentifier, - } - - return transport -} - -// RoundTrip is the implementation of the the http.RoundTripper interface -func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) - if err != nil { - return nil, err - } - - 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 { - transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpointIdentifier) - } else { - transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpointIdentifier) - } - - return response, err -} - -func getRoundTripToken( - request *http.Request, - tokenManager *tokenManager, - endpointIdentifier portainer.EndpointID, -) (string, error) { +func getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) { tokenData, err := security.RetrieveTokenData(request) if err != nil { return "", err @@ -168,32 +122,56 @@ func getRoundTripToken( return token, nil } +// #endregion + +// #region DECORATE FUNCTIONS + 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 decorateAgentDockerHubRequest(r, dataStore) } return nil } func decorateAgentDockerHubRequest(r *http.Request, dataStore portainer.DataStore) error { - dockerhub, err := dataStore.DockerHub().DockerHub() + requestPath, registryIdString := path.Split(r.URL.Path) + + registryID, err := strconv.Atoi(registryIdString) if err != nil { - return err + return fmt.Errorf("missing registry id: %w", err) } - newBody, err := json.Marshal(dockerhub) + r.URL.Path = strings.TrimSuffix(requestPath, "/") + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + } + + if registryID != 0 { + registry, err = dataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err != nil { + return fmt.Errorf("failed fetching registry: %w", err) + } + } + + if registry.Type != portainer.DockerHubRegistry { + return errors.New("invalid registry type") + } + + newBody, err := json.Marshal(registry) if err != nil { - return err + return fmt.Errorf("failed marshaling registry: %w", err) } r.Method = http.MethodPost - r.Body = ioutil.NopCloser(bytes.NewReader(newBody)) r.ContentLength = int64(len(newBody)) return nil } + +// #endregion diff --git a/api/http/proxy/factory/responseutils/json.go b/api/http/proxy/factory/responseutils/json.go deleted file mode 100644 index 15af94c60..000000000 --- a/api/http/proxy/factory/responseutils/json.go +++ /dev/null @@ -1,11 +0,0 @@ -package responseutils - -// GetJSONObject will extract an object from a specific property of another JSON object. -// Returns nil if nothing is associated to the specified key. -func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} { - object := jsonObject[property] - if object != nil { - return object.(map[string]interface{}) - } - return nil -} diff --git a/api/http/proxy/factory/utils/json.go b/api/http/proxy/factory/utils/json.go new file mode 100644 index 000000000..2d44e17f4 --- /dev/null +++ b/api/http/proxy/factory/utils/json.go @@ -0,0 +1,91 @@ +package utils + +import ( + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + + "gopkg.in/yaml.v3" +) + +// GetJSONObject will extract an object from a specific property of another JSON object. +// Returns nil if nothing is associated to the specified key. +func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} { + object := jsonObject[property] + if object != nil { + return object.(map[string]interface{}) + } + return nil +} + +// GetArrayObject will extract an array from a specific property of another JSON object. +// Returns nil if nothing is associated to the specified key. +func GetArrayObject(jsonObject map[string]interface{}, property string) []interface{} { + object := jsonObject[property] + if object != nil { + return object.([]interface{}) + } + return nil +} + +func getBody(body io.ReadCloser, contentType string, isGzip bool) (interface{}, error) { + if body == nil { + return nil, errors.New("unable to parse response: empty response body") + } + + reader := body + + if isGzip { + gzipReader, err := gzip.NewReader(reader) + if err != nil { + return nil, err + } + + reader = gzipReader + } + + defer reader.Close() + + bodyBytes, err := ioutil.ReadAll(reader) + if err != nil { + return nil, err + } + + err = body.Close() + if err != nil { + return nil, err + } + + var data interface{} + err = unmarshal(contentType, bodyBytes, &data) + if err != nil { + return nil, err + } + + return data, nil +} + +func marshal(contentType string, data interface{}) ([]byte, error) { + switch contentType { + case "application/yaml": + return yaml.Marshal(data) + case "application/json", "": + return json.Marshal(data) + } + + return nil, fmt.Errorf("content type is not supported for marshaling: %s", contentType) +} + +func unmarshal(contentType string, body []byte, returnBody interface{}) error { + switch contentType { + case "application/yaml": + return yaml.Unmarshal(body, returnBody) + case "application/json", "": + return json.Unmarshal(body, returnBody) + } + + return fmt.Errorf("content type is not supported for unmarshaling: %s", contentType) +} diff --git a/api/http/proxy/factory/utils/request.go b/api/http/proxy/factory/utils/request.go new file mode 100644 index 000000000..92724b7fa --- /dev/null +++ b/api/http/proxy/factory/utils/request.go @@ -0,0 +1,45 @@ +package utils + +import ( + "bytes" + "io/ioutil" + "net/http" + "strconv" +) + +// GetRequestAsMap returns the response content as a generic JSON object +func GetRequestAsMap(request *http.Request) (map[string]interface{}, error) { + data, err := getRequestBody(request) + if err != nil { + return nil, err + } + + return data.(map[string]interface{}), nil +} + +// RewriteRequest will replace the existing request body with the one specified +// in parameters +func RewriteRequest(request *http.Request, newData interface{}) error { + data, err := marshal(getContentType(request.Header), newData) + if err != nil { + return err + } + + body := ioutil.NopCloser(bytes.NewReader(data)) + + request.Body = body + request.ContentLength = int64(len(data)) + + if request.Header == nil { + request.Header = make(http.Header) + } + request.Header.Set("Content-Length", strconv.Itoa(len(data))) + + return nil +} + +func getRequestBody(request *http.Request) (interface{}, error) { + isGzip := request.Header.Get("Content-Encoding") == "gzip" + + return getBody(request.Body, getContentType(request.Header), isGzip) +} diff --git a/api/http/proxy/factory/responseutils/response.go b/api/http/proxy/factory/utils/response.go similarity index 62% rename from api/http/proxy/factory/responseutils/response.go rename to api/http/proxy/factory/utils/response.go index a32cd3252..dc73e5618 100644 --- a/api/http/proxy/factory/responseutils/response.go +++ b/api/http/proxy/factory/utils/response.go @@ -1,9 +1,7 @@ -package responseutils +package utils import ( "bytes" - "compress/gzip" - "encoding/json" "errors" "io/ioutil" "log" @@ -13,7 +11,7 @@ import ( // GetResponseAsJSONObject returns the response content as a generic JSON object func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, error) { - responseData, err := getResponseBodyAsGenericJSON(response) + responseData, err := getResponseBody(response) if err != nil { return nil, err } @@ -24,7 +22,7 @@ func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, e // GetResponseAsJSONArray returns the response content as an array of generic JSON object func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) { - responseData, err := getResponseBodyAsGenericJSON(response) + responseData, err := getResponseBody(response) if err != nil { return nil, err } @@ -44,72 +42,54 @@ func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) { } } -func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) { - if response.Body == nil { - return nil, errors.New("unable to parse response: empty response body") - } - - reader := response.Body - - if response.Header.Get("Content-Encoding") == "gzip" { - response.Header.Del("Content-Encoding") - gzipReader, err := gzip.NewReader(response.Body) - if err != nil { - return nil, err - } - reader = gzipReader - } - - defer reader.Close() - - var data interface{} - body, err := ioutil.ReadAll(reader) - if err != nil { - return nil, err - } - - err = json.Unmarshal(body, &data) - if err != nil { - return nil, err - } - - return data, nil -} - -type dockerErrorResponse struct { +type errorResponse struct { Message string `json:"message,omitempty"` } // WriteAccessDeniedResponse will create a new access denied response func WriteAccessDeniedResponse() (*http.Response, error) { response := &http.Response{} - err := RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden) + err := RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden) return response, err } // RewriteAccessDeniedResponse will overwrite the existing response with an access denied response func RewriteAccessDeniedResponse(response *http.Response) error { - return RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden) + return RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden) } // RewriteResponse will replace the existing response body and status code with the one specified // in parameters func RewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error { - jsonData, err := json.Marshal(newResponseData) + data, err := marshal(getContentType(response.Header), newResponseData) if err != nil { return err } - body := ioutil.NopCloser(bytes.NewReader(jsonData)) + body := ioutil.NopCloser(bytes.NewReader(data)) response.StatusCode = statusCode response.Body = body - response.ContentLength = int64(len(jsonData)) + response.ContentLength = int64(len(data)) if response.Header == nil { response.Header = make(http.Header) } - response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) + response.Header.Set("Content-Length", strconv.Itoa(len(data))) return nil } + +func getResponseBody(response *http.Response) (interface{}, error) { + isGzip := response.Header.Get("Content-Encoding") == "gzip" + + if isGzip { + response.Header.Del("Content-Encoding") + } + + return getBody(response.Body, getContentType(response.Header), isGzip) +} + +func getContentType(headers http.Header) string { + return headers.Get("Content-type") +} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 2ba3e8743..f205a6493 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -1,9 +1,21 @@ package security import ( - "github.com/portainer/portainer/api" + "net/http" + + portainer "github.com/portainer/portainer/api" ) +// IsAdmin returns true if the logged-in user is an admin +func IsAdmin(request *http.Request) (bool, error) { + tokenData, err := RetrieveTokenData(request) + if err != nil { + return false, err + } + + return tokenData.Role == portainer.AdministratorRole, nil +} + // AuthorizedResourceControlAccess checks whether the user can alter an existing resource control. func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { if context.IsAdmin || resourceControl.Public { @@ -95,9 +107,9 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams of the endpoint and the associated group. func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - groupAccess := authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) + groupAccess := AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) if !groupAccess { - return authorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies) + return AuthorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies) } return true } @@ -106,17 +118,24 @@ func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *porta // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams. func authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - return authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) + return AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) } // AuthorizedRegistryAccess ensure that the user can access the specified registry. // It will check if the user is part of the authorized users or part of a team that is -// listed in the authorized teams. -func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - return authorizedAccess(userID, memberships, registry.UserAccessPolicies, registry.TeamAccessPolicies) +// listed in the authorized teams for a specified endpoint, +func AuthorizedRegistryAccess(registry *portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) bool { + if user.Role == portainer.AdministratorRole { + return true + } + + registryEndpointAccesses := registry.RegistryAccesses[endpointID] + + return AuthorizedAccess(user.ID, teamMemberships, registryEndpointAccesses.UserAccessPolicies, registryEndpointAccesses.TeamAccessPolicies) } -func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool { +// AuthorizedAccess verifies the userID or memberships are authorized to use an object per the supplied access policies +func AuthorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool { _, userAccess := userAccessPolicies[userID] if userAccess { return true diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 5c57314af..dee955e50 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -128,31 +128,6 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, return nil } -// RegistryAccess retrieves the JWT token from the request context and verifies -// that the user can access the specified registry. -// An error is returned when access is denied. -func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portainer.Registry) error { - tokenData, err := RetrieveTokenData(r) - if err != nil { - return err - } - - if tokenData.Role == portainer.AdministratorRole { - return nil - } - - memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) - if err != nil { - return err - } - - if !AuthorizedRegistryAccess(registry, tokenData.ID, memberships) { - return httperrors.ErrEndpointAccessDenied - } - - return nil -} - // handlers are applied backwards to the incoming request: // - add secure handlers to the response // - parse the JWT token and put it into the http context. @@ -213,7 +188,7 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h return } - ctx := storeRestrictedRequestContext(r, requestContext) + ctx := StoreRestrictedRequestContext(r, requestContext) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/api/http/security/context.go b/api/http/security/context.go index 8350fa56f..1601f61ac 100644 --- a/api/http/security/context.go +++ b/api/http/security/context.go @@ -5,7 +5,7 @@ import ( "errors" "net/http" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) type ( @@ -33,9 +33,9 @@ func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) { return tokenData, nil } -// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context +// StoreRestrictedRequestContext stores a RestrictedRequestContext object inside the request context // and returns the enhanced context. -func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context { +func StoreRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context { return context.WithValue(request.Context(), contextRestrictedRequest, requestContext) } diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 1716b043e..be0106447 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -1,7 +1,7 @@ package security import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) // FilterUserTeams filters teams based on user role. @@ -64,15 +64,16 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po // FilterRegistries filters registries based on user role and team memberships. // Non administrator users only have access to authorized registries. -func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) []portainer.Registry { - filteredRegistries := registries - if !context.IsAdmin { - filteredRegistries = make([]portainer.Registry, 0) +func FilterRegistries(registries []portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) []portainer.Registry { + if user.Role == portainer.AdministratorRole { + return registries + } - for _, registry := range registries { - if AuthorizedRegistryAccess(®istry, context.UserID, context.UserMemberships) { - filteredRegistries = append(filteredRegistries, registry) - } + filteredRegistries := []portainer.Registry{} + + for _, registry := range registries { + if AuthorizedRegistryAccess(®istry, user, teamMemberships, endpointID) { + filteredRegistries = append(filteredRegistries, registry) } } diff --git a/api/http/server.go b/api/http/server.go index 8f399e7a9..ac5fe0e1b 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -16,7 +16,6 @@ import ( "github.com/portainer/portainer/api/http/handler/auth" "github.com/portainer/portainer/api/http/handler/backup" "github.com/portainer/portainer/api/http/handler/customtemplates" - "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" @@ -111,9 +110,6 @@ func (server *Server) Start() error { customTemplatesHandler.FileService = server.FileService customTemplatesHandler.GitService = server.GitService - var dockerHubHandler = dockerhub.NewHandler(requestBouncer) - dockerHubHandler.DataStore = server.DataStore - var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer) edgeGroupsHandler.DataStore = server.DataStore @@ -135,6 +131,7 @@ func (server *Server) Start() error { endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = server.ProxyManager endpointHandler.SnapshotService = server.SnapshotService + endpointHandler.K8sClientFactory = server.KubernetesClientFactory endpointHandler.ReverseTunnelService = server.ReverseTunnelService endpointHandler.ComposeStackManager = server.ComposeStackManager endpointHandler.AuthorizationService = server.AuthorizationService @@ -161,6 +158,7 @@ func (server *Server) Start() error { registryHandler.DataStore = server.DataStore registryHandler.FileService = server.FileService registryHandler.ProxyManager = server.ProxyManager + registryHandler.K8sClientFactory = server.KubernetesClientFactory var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) resourceControlHandler.DataStore = server.DataStore @@ -219,7 +217,6 @@ func (server *Server) Start() error { AuthHandler: authHandler, BackupHandler: backupHandler, CustomTemplatesHandler: customTemplatesHandler, - DockerHubHandler: dockerHubHandler, EdgeGroupsHandler: edgeGroupsHandler, EdgeJobsHandler: edgeJobsHandler, EdgeStacksHandler: edgeStacksHandler, diff --git a/api/internal/authorization/authorizations.go b/api/internal/authorization/authorizations.go index 805c2e38f..92eae802d 100644 --- a/api/internal/authorization/authorizations.go +++ b/api/internal/authorization/authorizations.go @@ -1,15 +1,15 @@ package authorization import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/kubernetes/cli" ) // Service represents a service used to // update authorizations associated to a user or team. type Service struct { - dataStore portainer.DataStore - K8sClientFactory *cli.ClientFactory + dataStore portainer.DataStore + K8sClientFactory *cli.ClientFactory } // NewService returns a point to a new Service instance. @@ -140,6 +140,7 @@ func DefaultEndpointAuthorizationsForEndpointAdministratorRole() portainer.Autho portainer.OperationDockerAgentUndefined: true, portainer.OperationPortainerResourceControlCreate: true, portainer.OperationPortainerResourceControlUpdate: true, + portainer.OperationPortainerRegistryUpdateAccess: true, portainer.OperationPortainerStackList: true, portainer.OperationPortainerStackInspect: true, portainer.OperationPortainerStackFile: true, diff --git a/api/internal/endpoint/endpoint.go b/api/internal/endpoint/endpoint.go index 378ca70e5..079474d11 100644 --- a/api/internal/endpoint/endpoint.go +++ b/api/internal/endpoint/endpoint.go @@ -9,8 +9,8 @@ func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool { endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment } -// IsDocketEndpoint returns true if this is a docker endpoint -func IsDocketEndpoint(endpoint *portainer.Endpoint) bool { +// IsDockerEndpoint returns true if this is a docker endpoint +func IsDockerEndpoint(endpoint *portainer.Endpoint) bool { return endpoint.Type == portainer.DockerEnvironment || endpoint.Type == portainer.AgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnDockerEnvironment diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index a58da5c7c..970184d86 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -7,7 +7,6 @@ import ( ) type datastore struct { - dockerHub portainer.DockerHubService customTemplate portainer.CustomTemplateService edgeGroup portainer.EdgeGroupService edgeJob portainer.EdgeJobService @@ -37,7 +36,6 @@ func (d *datastore) CheckCurrentEdition() error { retur func (d *datastore) IsNew() bool { return false } func (d *datastore) MigrateData(force bool) error { return nil } func (d *datastore) RollbackToCE() error { return nil } -func (d *datastore) DockerHub() portainer.DockerHubService { return d.dockerHub } func (d *datastore) CustomTemplate() portainer.CustomTemplateService { return d.customTemplate } func (d *datastore) EdgeGroup() portainer.EdgeGroupService { return d.edgeGroup } func (d *datastore) EdgeJob() portainer.EdgeJobService { return d.edgeJob } diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index e4b4729c6..af5fa887a 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -12,6 +12,28 @@ type ( namespaceAccessPolicies map[string]portainer.K8sNamespaceAccessPolicy ) +// GetNamespaceAccessPolicies gets the namespace access policies +// from config maps in the portainer namespace +func (kcl *KubeClient) GetNamespaceAccessPolicies() ( + map[string]portainer.K8sNamespaceAccessPolicy, error, +) { + configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return nil, nil + } else if err != nil { + return nil, err + } + + accessData := configMap.Data[portainerConfigMapAccessPoliciesKey] + + var policies map[string]portainer.K8sNamespaceAccessPolicy + err = json.Unmarshal([]byte(accessData), &policies) + if err != nil { + return nil, err + } + return policies, nil +} + func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string) error { configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{}) if k8serrors.IsNotFound(err) { @@ -80,28 +102,6 @@ func hasUserAccessToNamespace(userID int, teamIDs []int, policies portainer.K8sN return false } -// GetNamespaceAccessPolicies gets the namespace access policies -// from config maps in the portainer namespace -func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) { - configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{}) - if k8serrors.IsNotFound(err) { - return nil, nil - } - - if err != nil { - return nil, err - } - - accessData := configMap.Data[portainerConfigMapAccessPoliciesKey] - - var policies map[string]portainer.K8sNamespaceAccessPolicy - err = json.Unmarshal([]byte(accessData), &policies) - if err != nil { - return nil, err - } - return policies, nil -} - // UpdateNamespaceAccessPolicies updates the namespace access policies func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]portainer.K8sNamespaceAccessPolicy) error { data, err := json.Marshal(accessPolicies) diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index a268150c9..c3e687fd9 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strconv" + "time" cmap "github.com/orcaman/concurrent-map" @@ -17,6 +18,7 @@ import ( type ( // ClientFactory is used to create Kubernetes clients ClientFactory struct { + dataStore portainer.DataStore reverseTunnelService portainer.ReverseTunnelService signatureService portainer.DigitalSignatureService instanceID string @@ -31,8 +33,9 @@ type ( ) // NewClientFactory returns a new instance of a ClientFactory -func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *ClientFactory { +func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *ClientFactory { return &ClientFactory{ + dataStore: dataStore, signatureService: signatureService, reverseTunnelService: reverseTunnelService, instanceID: instanceID, @@ -133,7 +136,29 @@ func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*k func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) - endpointURL := fmt.Sprintf("http://localhost:%d/kubernetes", tunnel.Port) + + if tunnel.Status == portainer.EdgeAgentIdle { + err := factory.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID) + if err != nil { + return nil, fmt.Errorf("failed opening tunnel to endpoint: %w", err) + } + + if endpoint.EdgeCheckinInterval == 0 { + settings, err := factory.dataStore.Settings().Settings() + if err != nil { + return nil, fmt.Errorf("failed fetching settings from db: %w", err) + } + + endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval + } + + waitForAgentToConnect := time.Duration(endpoint.EdgeCheckinInterval) * time.Second + time.Sleep(waitForAgentToConnect * 2) + + tunnel = factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) + } + + endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port) config, err := clientcmd.BuildConfigFromFlags(endpointURL, "") if err != nil { diff --git a/api/kubernetes/cli/registries.go b/api/kubernetes/cli/registries.go new file mode 100644 index 000000000..122741a9b --- /dev/null +++ b/api/kubernetes/cli/registries.go @@ -0,0 +1,96 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + secretDockerConfigKey = ".dockerconfigjson" +) + +type ( + dockerConfig struct { + Auths map[string]registryDockerConfig `json:"auths"` + } + + registryDockerConfig struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + } +) + +func (kcl *KubeClient) DeleteRegistrySecret(registry *portainer.Registry, namespace string) error { + err := kcl.cli.CoreV1().Secrets(namespace).Delete(registrySecretName(registry), &metav1.DeleteOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return errors.Wrap(err, "failed removing secret") + } + + return nil +} + +func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namespace string) error { + config := dockerConfig{ + Auths: map[string]registryDockerConfig{ + registry.URL: { + Username: registry.Username, + Password: registry.Password, + }, + }, + } + + configByte, err := json.Marshal(config) + if err != nil { + return errors.Wrap(err, "failed marshal config") + } + + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: registrySecretName(registry), + Annotations: map[string]string{ + "portainer.io/registry.id": strconv.Itoa(int(registry.ID)), + }, + }, + Data: map[string][]byte{ + secretDockerConfigKey: configByte, + }, + Type: v1.SecretTypeDockerConfigJson, + } + + _, err = kcl.cli.CoreV1().Secrets(namespace).Create(secret) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return errors.Wrap(err, "failed saving secret") + } + + return nil + +} + +func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, error) { + secret, err := cli.cli.CoreV1().Secrets(namespace).Get(secretName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, nil + } + + return false, err + } + + isSecret := secret.Type == v1.SecretTypeDockerConfigJson + + return isSecret, nil + +} + +func registrySecretName(registry *portainer.Registry) string { + return fmt.Sprintf("registry-%d", registry.ID) +} diff --git a/api/kubernetes/privateregistries/labels.go b/api/kubernetes/privateregistries/labels.go new file mode 100644 index 000000000..dc780814c --- /dev/null +++ b/api/kubernetes/privateregistries/labels.go @@ -0,0 +1,5 @@ +package privateregistries + +const ( + RegistryIDLabel = "portainer.io/registry.id" +) diff --git a/api/portainer.go b/api/portainer.go index ccfeeea7a..5458fca4b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -511,8 +511,8 @@ type ( Registry struct { // Registry Identifier ID RegistryID `json:"Id" example:"1"` - // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet) - Type RegistryType `json:"Type" enums:"1,2,3,4,5"` + // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet, 6 - DockerHub) + Type RegistryType `json:"Type" enums:"1,2,3,4,5,6"` // Registry Name Name string `json:"Name" example:"my-registry"` // URL or IP address of the Docker registry @@ -528,15 +528,28 @@ type ( ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` Gitlab GitlabRegistryData `json:"Gitlab"` Quay QuayRegistryData `json:"Quay"` - UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` - TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` // Deprecated fields + // Deprecated in DBVersion == 31 + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + // Deprecated in DBVersion == 31 + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + // Deprecated in DBVersion == 18 AuthorizedUsers []UserID `json:"AuthorizedUsers"` + // Deprecated in DBVersion == 18 AuthorizedTeams []TeamID `json:"AuthorizedTeams"` } + RegistryAccesses map[EndpointID]RegistryAccessPolicies + + RegistryAccessPolicies struct { + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + Namespaces []string `json:"Namespaces"` + } + // RegistryID represents a registry identifier RegistryID int @@ -1019,7 +1032,6 @@ type ( CheckCurrentEdition() error BackupTo(w io.Writer) error - DockerHub() DockerHubService CustomTemplate() CustomTemplateService EdgeGroup() EdgeGroupService EdgeJob() EdgeJobService @@ -1050,12 +1062,6 @@ type ( CreateSignature(message string) (string, error) } - // DockerHubService represents a service for managing the DockerHub object - DockerHubService interface { - DockerHub() (*DockerHub, error) - UpdateDockerHub(registry *DockerHub) error - } - // DockerSnapshotter represents a service used to create Docker endpoint snapshots DockerSnapshotter interface { CreateSnapshot(endpoint *Endpoint) (*DockerSnapshot, error) @@ -1169,6 +1175,9 @@ type ( StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error + DeleteRegistrySecret(registry *Registry, namespace string) error + CreateRegistrySecret(registry *Registry, namespace string) error + IsRegistrySecret(namespace, secretName string) (bool, error) } // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint @@ -1266,7 +1275,7 @@ type ( // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { - Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) + Login(registries []Registry, endpoint *Endpoint) Logout(endpoint *Endpoint) error Deploy(stack *Stack, prune bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error @@ -1345,7 +1354,7 @@ const ( // APIVersion is the version number of the Portainer API APIVersion = "2.6.0" // DBVersion is the version number of the Portainer database - DBVersion = 30 + DBVersion = 32 // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax ComposeSyntaxMaxVersion = "3.9" // AssetsServerURL represents the URL of the Portainer asset server @@ -1493,6 +1502,8 @@ const ( GitlabRegistry // ProGetRegistry represents a proget registry ProGetRegistry + // DockerHubRegistry represents a dockerhub registry + DockerHubRegistry ) const ( diff --git a/app/agent/rest/dockerhub.js b/app/agent/rest/dockerhub.js index b48481e8a..eb901b494 100644 --- a/app/agent/rest/dockerhub.js +++ b/app/agent/rest/dockerhub.js @@ -4,10 +4,10 @@ angular.module('portainer.agent').factory('AgentDockerhub', AgentDockerhub); function AgentDockerhub($resource, API_ENDPOINT_ENDPOINTS) { return $resource( - `${API_ENDPOINT_ENDPOINTS}/:endpointId/:endpointType/v2/dockerhub`, + `${API_ENDPOINT_ENDPOINTS}/:endpointId/:endpointType/v2/dockerhub/:registryId`, {}, { - limits: { method: 'GET' }, + limits: { method: 'GET', params: { registryId: '@registryId' } }, } ); } diff --git a/app/constants.js b/app/constants.js index febc848e8..57d1c7c41 100644 --- a/app/constants.js +++ b/app/constants.js @@ -1,7 +1,6 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') - .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') .constant('API_ENDPOINT_CUSTOM_TEMPLATES', 'api/custom_templates') .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') .constant('API_ENDPOINT_EDGE_JOBS', 'api/edge_jobs') diff --git a/app/docker/__module.js b/app/docker/__module.js index 40cf82c24..94e7e969c 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -591,6 +591,26 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; + const registries = { + name: 'docker.registries', + url: '/registries', + views: { + 'content@': { + component: 'endpointRegistriesView', + }, + }, + }; + + const registryAccess = { + name: 'docker.registries.access', + url: '/:id/access', + views: { + 'content@': { + component: 'dockerRegistryAccessView', + }, + }, + }; + $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); $stateRegistryProvider.register(configCreation); @@ -641,5 +661,7 @@ angular.module('portainer.docker', ['portainer.app']).config([ $stateRegistryProvider.register(volumeBrowse); $stateRegistryProvider.register(volumeCreation); $stateRegistryProvider.register(dockerFeaturesConfiguration); + $stateRegistryProvider.register(registries); + $stateRegistryProvider.register(registryAccess); }, ]); diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index 484451142..086047756 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -35,17 +35,19 @@ <li class="sidebar-list" ng-if="$ctrl.standaloneManagement && $ctrl.adminAccess && !$ctrl.offlineMode"> <a ui-sref="docker.events({endpointId: $ctrl.endpointId})" ui-sref-active="active">Events <span class="menu-icon fa fa-history fa-fw"></span></a> </li> -<li class="sidebar-list" ng-if="$ctrl.swarmManagement"> - <a ui-sref="docker.swarm({endpointId: $ctrl.endpointId})" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group fa-fw"></span></a> +<li class="sidebar-list"> + <a ng-if="$ctrl.standaloneManagement" ui-sref="docker.host({endpointId: $ctrl.endpointId})" ui-sref-active="active">Host <span class="menu-icon fa fa-th fa-fw"></span></a> + <a ng-if="$ctrl.swarmManagement" ui-sref="docker.swarm({endpointId: $ctrl.endpointId})" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group fa-fw"></span></a> - <div class="sidebar-sublist" ng-if="$ctrl.adminAccess && ['docker.featuresConfiguration', 'docker.swarm'].includes($ctrl.currentRouteName)"> - <a ui-sref="docker.featuresConfiguration({endpointId: $ctrl.endpointId})" ui-sref-active="active">Setup</a> - </div> -</li> -<li class="sidebar-list" ng-if="$ctrl.standaloneManagement"> - <a ui-sref="docker.host({endpointId: $ctrl.endpointId})" ui-sref-active="active">Host <span class="menu-icon fa fa-th fa-fw"></span></a> - - <div class="sidebar-sublist" ng-if="$ctrl.adminAccess && ['docker.featuresConfiguration', 'docker.host'].includes($ctrl.currentRouteName)"> - <a ui-sref="docker.featuresConfiguration({endpointId: $ctrl.endpointId})" ui-sref-active="active">Setup</a> + <div + ng-if="$ctrl.adminAccess && ['docker.swarm', 'docker.host', 'docker.registries', 'docker.registries.access', 'docker.featuresConfiguration'].includes($ctrl.currentRouteName)" + > + <div class="sidebar-sublist"> + <a ui-sref="docker.featuresConfiguration({endpointId: $ctrl.endpointId})" ui-sref-active="active">Setup</a> + </div> + + <div class="sidebar-sublist"> + <a ui-sref="docker.registries({endpointId: $ctrl.endpointId})" ui-sref-active="active">Registries</a> + </div> </div> </li> diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js index d823c848d..0c23eba56 100644 --- a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js @@ -1,31 +1,41 @@ +import EndpointHelper from '@/portainer/helpers/endpointHelper'; + export default class porImageRegistryContainerController { /* @ngInject */ - constructor(EndpointHelper, DockerHubService, Notifications) { - this.EndpointHelper = EndpointHelper; + constructor(DockerHubService, Notifications) { this.DockerHubService = DockerHubService; this.Notifications = Notifications; this.pullRateLimits = null; } - $onChanges({ isDockerHubRegistry }) { - if (isDockerHubRegistry && isDockerHubRegistry.currentValue) { + $onChanges({ registryId }) { + if (registryId) { this.fetchRateLimits(); } } + $onInit() { + this.setValidity = + this.setValidity || + (() => { + /* noop */ + }); + } + 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); - this.setValidity(true); - } - } else { + if (!EndpointHelper.isAgentEndpoint(this.endpoint) && !EndpointHelper.isLocalEndpoint(this.endpoint)) { + this.setValidity(true); + return; + } + + try { + this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint, this.registryId || 0); + this.setValidity(this.pullRateLimits.remaining >= 0); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed loading DockerHub pull rate limits', e); this.setValidity(true); } } diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.html b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html index 31d7eaa82..7097fa66c 100644 --- a/app/docker/components/imageRegistry/por-image-registry-rate-limits.html +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html @@ -1,4 +1,4 @@ -<div class="form-group" ng-if="$ctrl.isDockerHubRegistry && $ctrl.pullRateLimits"> +<div class="form-group" ng-if="$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> diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js index 3418054f6..b87835a9d 100644 --- a/app/docker/components/imageRegistry/por-image-registry-rate-limits.js +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js @@ -5,10 +5,12 @@ import controller from './por-image-registry-rate-limits.controller'; angular.module('portainer.docker').component('porImageRegistryRateLimits', { bindings: { endpoint: '<', + registry: '<', setValidity: '<', isAdmin: '<', isDockerHubRegistry: '<', isAuthenticated: '<', + registryId: '<', }, controller, transclude: { diff --git a/app/docker/components/imageRegistry/por-image-registry.controller.js b/app/docker/components/imageRegistry/por-image-registry.controller.js index edf570f8b..5cf3fc709 100644 --- a/app/docker/components/imageRegistry/por-image-registry.controller.js +++ b/app/docker/components/imageRegistry/por-image-registry.controller.js @@ -5,18 +5,21 @@ import { RegistryTypes } from '@/portainer/models/registryTypes'; class porImageRegistryController { /* @ngInject */ - constructor($async, $scope, ImageHelper, RegistryService, DockerHubService, ImageService, Notifications) { + constructor($async, $scope, ImageHelper, RegistryService, EndpointService, ImageService, Notifications) { this.$async = $async; this.$scope = $scope; this.ImageHelper = ImageHelper; this.RegistryService = RegistryService; - this.DockerHubService = DockerHubService; + this.EndpointService = EndpointService; this.ImageService = ImageService; this.Notifications = Notifications; - this.onInit = this.onInit.bind(this); this.onRegistryChange = this.onRegistryChange.bind(this); + this.registries = []; + this.images = []; + this.defaultRegistry = new DockerHubViewModel(); + this.$scope.$watch(() => this.model.Registry, this.onRegistryChange); } @@ -40,7 +43,7 @@ class porImageRegistryController { const registryImages = _.filter(this.images, (image) => _.includes(image, url)); images = _.map(registryImages, (image) => _.replace(image, new RegExp(url + '/?'), '')); } else { - const registries = _.filter(this.availableRegistries, (reg) => this.isKnownRegistry(reg)); + const registries = _.filter(this.registries, (reg) => this.isKnownRegistry(reg)); const registryImages = _.flatMap(registries, (registry) => _.filter(this.images, (image) => _.includes(image, registry.URL))); const imagesWithoutKnown = _.difference(this.images, registryImages); images = _.filter(imagesWithoutKnown, (image) => !this.ImageHelper.imageContainsURL(image)); @@ -49,7 +52,7 @@ class porImageRegistryController { } isDockerHubRegistry() { - return this.model.UseRegistry && this.model.Registry.Name === 'DockerHub'; + return this.model.UseRegistry && (this.model.Registry.Type === RegistryTypes.DOCKERHUB || this.model.Registry.Type === RegistryTypes.ANONYMOUS); } async onRegistryChange() { @@ -63,29 +66,49 @@ class porImageRegistryController { return this.getRegistryURL(this.model.Registry) || 'docker.io'; } - async onInit() { - try { - const [registries, dockerhub, images] = await Promise.all([ - this.RegistryService.registries(), - this.DockerHubService.dockerhub(), - this.autoComplete ? this.ImageService.images() : [], - ]); - this.images = this.ImageService.getUniqueTagListFromImages(images); - this.availableRegistries = _.concat(dockerhub, registries); + async reloadRegistries() { + return this.$async(async () => { + try { + const registries = await this.EndpointService.registries(this.endpoint.Id, this.namespace); + this.registries = _.concat(this.defaultRegistry, registries); - const id = this.model.Registry.Id; - if (!id) { - this.model.Registry = dockerhub; - } else { - this.model.Registry = _.find(this.availableRegistries, { Id: id }); + const id = this.model.Registry.Id; + const registry = _.find(this.registries, { Id: id }); + if (!registry) { + this.model.Registry = this.defaultRegistry; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + }); + } + + async loadImages() { + return this.$async(async () => { + try { + if (!this.autoComplete) { + this.images = []; + return; + } + + const images = await this.ImageService.images(); + this.images = this.ImageService.getUniqueTagListFromImages(images); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve images'); + } + }); + } + + $onChanges({ namespace, endpoint }) { + if ((namespace || endpoint) && this.endpoint.Id) { + this.reloadRegistries(); } } $onInit() { - return this.$async(this.onInit); + return this.$async(async () => { + await this.loadImages(); + }); } } diff --git a/app/docker/components/imageRegistry/por-image-registry.html b/app/docker/components/imageRegistry/por-image-registry.html index 97b5c07e4..e332a9f4d 100644 --- a/app/docker/components/imageRegistry/por-image-registry.html +++ b/app/docker/components/imageRegistry/por-image-registry.html @@ -6,10 +6,9 @@ </label> <div ng-class="$ctrl.inputClass"> <select - ng-options="registry as registry.Name for registry in $ctrl.availableRegistries track by registry.Name" + ng-options="registry as registry.Name for registry in $ctrl.registries track by registry.Name" ng-model="$ctrl.model.Registry" id="image_registry" - selected-item-id="ctrl.selectedItemId" class="form-control" ></select> </div> @@ -86,12 +85,13 @@ <div ng-transclude></div> <por-image-registry-rate-limits - ng-show="$ctrl.checkRateLimits" - is-docker-hub-registry="$ctrl.isDockerHubRegistry()" + ng-if="$ctrl.checkRateLimits && $ctrl.isDockerHubRegistry()" endpoint="$ctrl.endpoint" + registry="$ctrl.model.Registry" set-validity="$ctrl.setValidity" is-authenticated="$ctrl.model.Registry.Authentication" is-admin="$ctrl.isAdmin" + registry-id="$ctrl.model.Registry.Id" > </por-image-registry-rate-limits> </div> diff --git a/app/docker/components/imageRegistry/por-image-registry.js b/app/docker/components/imageRegistry/por-image-registry.js index 52b001db0..5c397045a 100644 --- a/app/docker/components/imageRegistry/por-image-registry.js +++ b/app/docker/components/imageRegistry/por-image-registry.js @@ -3,7 +3,6 @@ angular.module('portainer.docker').component('porImageRegistry', { controller: 'porImageRegistryController', bindings: { model: '=', // must be of type PorImageRegistryModel - pullWarning: '<', autoComplete: '<', labelClass: '@', inputClass: '@', @@ -12,6 +11,7 @@ angular.module('portainer.docker').component('porImageRegistry', { checkRateLimits: '<', onImageChange: '&', setValidity: '<', + namespace: '<', }, require: { form: '^form', diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js index af50cea4b..67529a10e 100644 --- a/app/docker/helpers/imageHelper.js +++ b/app/docker/helpers/imageHelper.js @@ -1,77 +1,85 @@ import _ from 'lodash-es'; -import { RegistryTypes } from '@/portainer/models/registryTypes'; +import { RegistryTypes } from 'Portainer/models/registryTypes'; -angular.module('portainer.docker').factory('ImageHelper', [ - function ImageHelperFactory() { - 'use strict'; +angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory); +function ImageHelperFactory() { + return { + isValidTag, + createImageConfigForContainer, + getImagesNamesForDownload, + removeDigestFromRepository, + imageContainsURL, + }; - var helper = {}; + function isValidTag(tag) { + return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); + } - helper.isValidTag = isValidTag; - helper.createImageConfigForContainer = createImageConfigForContainer; - helper.getImagesNamesForDownload = getImagesNamesForDownload; - helper.removeDigestFromRepository = removeDigestFromRepository; - helper.imageContainsURL = imageContainsURL; + function getImagesNamesForDownload(images) { + var names = images.map(function (image) { + return image.RepoTags[0] !== '<none>:<none>' ? image.RepoTags[0] : image.Id; + }); + return { + names: names, + }; + } - function isValidTag(tag) { - return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); + /** + * + * @param {PorImageRegistryModel} registry + */ + function createImageConfigForContainer(imageModel) { + return { + fromImage: buildImageFullURI(imageModel), + }; + } + + function imageContainsURL(image) { + const split = _.split(image, '/'); + const url = split[0]; + if (split.length > 1) { + return _.includes(url, '.') || _.includes(url, ':'); } + return false; + } - function getImagesNamesForDownload(images) { - var names = images.map(function (image) { - return image.RepoTags[0] !== '<none>:<none>' ? image.RepoTags[0] : image.Id; - }); - return { - names: names, - }; - } + function removeDigestFromRepository(repository) { + return repository.split('@sha')[0]; + } +} +/** + * builds the complete uri for an image based on its registry + * @param {PorImageRegistryModel} imageModel + */ +export function buildImageFullURI(imageModel) { + if (!imageModel.UseRegistry) { + return imageModel.Image; + } - /** - * - * @param {PorImageRegistryModel} registry - */ - function createImageConfigForContainer(registry) { - const data = { - fromImage: '', - }; - let fullImageName = ''; + let fullImageName = ''; - if (registry.UseRegistry) { - if (registry.Registry.Type === RegistryTypes.GITLAB) { - const slash = _.startsWith(registry.Image, ':') ? '' : '/'; - fullImageName = registry.Registry.URL + '/' + registry.Registry.Gitlab.ProjectPath + slash + registry.Image; - } else if (registry.Registry.Type === RegistryTypes.QUAY) { - const name = registry.Registry.Quay.UseOrganisation ? registry.Registry.Quay.OrganisationName : registry.Registry.Username; - const url = registry.Registry.URL ? registry.Registry.URL + '/' : ''; - fullImageName = url + name + '/' + registry.Image; - } else { - const url = registry.Registry.URL ? registry.Registry.URL + '/' : ''; - fullImageName = url + registry.Image; - } - if (!_.includes(registry.Image, ':')) { - fullImageName += ':latest'; - } - } else { - fullImageName = registry.Image; - } + switch (imageModel.Registry.Type) { + case RegistryTypes.GITLAB: + fullImageName = imageModel.Registry.URL + '/' + imageModel.Registry.Gitlab.ProjectPath + (imageModel.Image.startsWith(':') ? '' : '/') + imageModel.Image; + break; + case RegistryTypes.ANONYMOUS: + fullImageName = imageModel.Image; + break; + case RegistryTypes.QUAY: + fullImageName = + (imageModel.Registry.URL ? imageModel.Registry.URL + '/' : '') + + (imageModel.Registry.Quay.UseOrganisation ? imageModel.Registry.Quay.OrganisationName : imageModel.Registry.Username) + + '/' + + imageModel.Image; + break; + default: + fullImageName = imageModel.Registry.URL + '/' + imageModel.Image; + break; + } - data.fromImage = fullImageName; - return data; - } + if (!imageModel.Image.includes(':')) { + fullImageName += ':latest'; + } - function imageContainsURL(image) { - const split = _.split(image, '/'); - const url = split[0]; - if (split.length > 1) { - return _.includes(url, '.') || _.includes(url, ':'); - } - return false; - } - - function removeDigestFromRepository(repository) { - return repository.split('@sha')[0]; - } - - return helper; - }, -]); + return fullImageName; +} diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index f75b000ae..8b7c062d6 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -575,7 +575,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ } function loadFromContainerImageConfig() { - RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image) + RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id) .then((model) => { $scope.formValues.RegistryModel = model; }) diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index 6f2aaa9cd..0ce6d50b1 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -40,15 +40,14 @@ <!-- image-and-registry --> <por-image-registry model="formValues.RegistryModel" - pull-warning="formValues.alwaysPull" ng-if="formValues.RegistryModel.Registry" auto-complete="true" label-class="col-sm-1" input-class="col-sm-11" + on-image-change="onImageNameChange()" endpoint="endpoint" is-admin="isAdmin" check-rate-limits="formValues.alwaysPull" - on-image-change="onImageNameChange()" set-validity="setPullImageValidity" > <!-- always-pull --> diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index 097162ce0..52076b7e6 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -190,7 +190,7 @@ </div> <!-- !tag-description --> <!-- image-and-registry --> - <por-image-registry model="config.RegistryModel" auto-complete="true" label-class="col-sm-1" input-class="col-sm-11"></por-image-registry> + <por-image-registry model="config.RegistryModel" auto-complete="true" label-class="col-sm-1" input-class="col-sm-11" endpoint="endpoint"></por-image-registry> <!-- !image-and-registry --> <!-- tag-note --> <div class="form-group"> diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index 83db4e944..c828853f0 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -21,7 +21,6 @@ angular.module('portainer.docker').controller('ContainerController', [ 'ImageService', 'HttpRequestHelper', 'Authentication', - 'StateManager', 'endpoint', function ( $q, @@ -42,9 +41,9 @@ angular.module('portainer.docker').controller('ContainerController', [ ImageService, HttpRequestHelper, Authentication, - StateManager, endpoint ) { + $scope.endpoint = endpoint; $scope.activityTime = 0; $scope.portBindings = []; $scope.displayRecreateButton = false; @@ -295,7 +294,7 @@ angular.module('portainer.docker').controller('ContainerController', [ if (!pullImage) { return $q.when(); } - return RegistryService.retrievePorRegistryModelFromRepository(container.Config.Image).then(function pullImage(registryModel) { + return RegistryService.retrievePorRegistryModelFromRepository(container.Config.Image, endpoint.Id).then((registryModel) => { return ImageService.pullImage(registryModel, true); }); } diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html index b8a686edb..e117000c5 100644 --- a/app/docker/views/images/edit/image.html +++ b/app/docker/views/images/edit/image.html @@ -63,7 +63,7 @@ <rd-widget-body> <form class="form-horizontal"> <!-- image-and-registry --> - <por-image-registry model="formValues.RegistryModel" label-class="col-sm-1" input-class="col-sm-11"></por-image-registry> + <por-image-registry model="formValues.RegistryModel" endpoint="endpoint" label-class="col-sm-1" input-class="col-sm-11"></por-image-registry> <!-- !image-and-registry --> <!-- tag-note --> <div class="form-group"> diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js index 597cdb7a0..e7408de7d 100644 --- a/app/docker/views/images/edit/imageController.js +++ b/app/docker/views/images/edit/imageController.js @@ -2,11 +2,12 @@ import _ from 'lodash-es'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker').controller('ImageController', [ + '$async', '$q', '$scope', '$transition$', '$state', - '$timeout', + 'endpoint', 'ImageService', 'ImageHelper', 'RegistryService', @@ -15,7 +16,8 @@ angular.module('portainer.docker').controller('ImageController', [ 'ModalService', 'FileSaver', 'Blob', - function ($q, $scope, $transition$, $state, $timeout, ImageService, ImageHelper, RegistryService, Notifications, HttpRequestHelper, ModalService, FileSaver, Blob) { + function ($async, $q, $scope, $transition$, $state, endpoint, ImageService, ImageHelper, RegistryService, Notifications, HttpRequestHelper, ModalService, FileSaver, Blob) { + $scope.endpoint = endpoint; $scope.formValues = { RegistryModel: new PorImageRegistryModel(), }; @@ -53,39 +55,38 @@ angular.module('portainer.docker').controller('ImageController', [ }); }; - $scope.pushTag = function (repository) { - $('#uploadResourceHint').show(); - RegistryService.retrievePorRegistryModelFromRepository(repository) - .then(function success(registryModel) { - return ImageService.pushImage(registryModel); - }) - .then(function success() { - Notifications.success('Image successfully pushed', repository); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to push image to repository'); - }) - .finally(function final() { - $('#uploadResourceHint').hide(); - }); - }; + $scope.pushTag = pushTag; - $scope.pullTag = function (repository) { - $('#downloadResourceHint').show(); - RegistryService.retrievePorRegistryModelFromRepository(repository) - .then(function success(registryModel) { - return ImageService.pullImage(registryModel, false); - }) - .then(function success() { - Notifications.success('Image successfully pulled', repository); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to pull image'); - }) - .finally(function final() { + async function pushTag(repository) { + return $async(async () => { + $('#uploadResourceHint').show(); + try { + const registryModel = await RegistryService.retrievePorRegistryModelFromRepository(repository, endpoint.Id); + await ImageService.pushImage(registryModel); + Notifications.success('Image successfully pushed', repository); + } catch (err) { + Notifications.error('Failure', err, 'Unable to push image to repository'); + } finally { + $('#uploadResourceHint').hide(); + } + }); + } + + $scope.pullTag = pullTag; + async function pullTag(repository) { + return $async(async () => { + $('#downloadResourceHint').show(); + try { + const registryModel = await RegistryService.retrievePorRegistryModelFromRepository(repository, endpoint.Id); + await ImageService.pullImage(registryModel); + Notifications.success('Image successfully pushed', repository); + } catch (err) { + Notifications.error('Failure', err, 'Unable to push image to repository'); + } finally { $('#downloadResourceHint').hide(); - }); - }; + } + }); + } $scope.removeTag = function (repository) { ImageService.deleteImage(repository, false) diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index a2f3034c3..7130a8bd1 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -17,7 +17,6 @@ <por-image-registry model="formValues.RegistryModel" auto-complete="true" - pull-warning="true" label-class="col-sm-1" input-class="col-sm-11" endpoint="endpoint" diff --git a/app/docker/views/registries/access/registryAccess.html b/app/docker/views/registries/access/registryAccess.html new file mode 100644 index 000000000..b139ba1d3 --- /dev/null +++ b/app/docker/views/registries/access/registryAccess.html @@ -0,0 +1,16 @@ +<rd-header> + <rd-header-title title-text="Registry access"></rd-header-title> + <rd-header-content> <a ui-sref="docker.registries">Registries</a> > {{ $ctrl.registry.Name }} > Access management </rd-header-content> +</rd-header> + +<registry-details registry="$ctrl.registry" ng-if="$ctrl.registry"></registry-details> + +<por-access-management + ng-if="$ctrl.registry && $ctrl.endpointGroup" + access-controlled-entity="$ctrl.registryEndpointAccesses" + entity-type="registry" + action-in-progress="$ctrl.state.actionInProgress" + update-access="$ctrl.updateAccess" + filter-users="$ctrl.filterUsers" +> +</por-access-management> diff --git a/app/docker/views/registries/access/registryAccess.js b/app/docker/views/registries/access/registryAccess.js new file mode 100644 index 000000000..7ee0814dd --- /dev/null +++ b/app/docker/views/registries/access/registryAccess.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('dockerRegistryAccessView', { + templateUrl: './registryAccess.html', + controller: 'DockerRegistryAccessController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/registries/access/registryAccessController.js b/app/docker/views/registries/access/registryAccessController.js new file mode 100644 index 000000000..c30d25dd4 --- /dev/null +++ b/app/docker/views/registries/access/registryAccessController.js @@ -0,0 +1,67 @@ +import { TeamAccessViewModel, UserAccessViewModel } from 'Portainer/models/access'; + +class DockerRegistryAccessController { + /* @ngInject */ + constructor($async, $state, Notifications, EndpointService, GroupService) { + this.$async = $async; + this.$state = $state; + this.Notifications = Notifications; + this.EndpointService = EndpointService; + this.GroupService = GroupService; + + this.updateAccess = this.updateAccess.bind(this); + this.filterUsers = this.filterUsers.bind(this); + } + + updateAccess() { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + await this.EndpointService.updateRegistryAccess(this.state.endpointId, this.state.registryId, this.registryEndpointAccesses); + this.Notifications.success('Access successfully updated'); + this.$state.reload(); + } catch (err) { + this.state.actionInProgress = false; + this.Notifications.error('Failure', err, 'Unable to update accesses'); + } + }); + } + + filterUsers(users) { + const endpointUsers = this.endpoint.UserAccessPolicies; + const endpointTeams = this.endpoint.TeamAccessPolicies; + + const endpointGroupUsers = this.endpointGroup.UserAccessPolicies; + const endpointGroupTeams = this.endpointGroup.TeamAccessPolicies; + + return users.filter((userOrTeam) => { + const userRole = userOrTeam instanceof UserAccessViewModel && (endpointUsers[userOrTeam.Id] || endpointGroupUsers[userOrTeam.Id]); + const teamRole = userOrTeam instanceof TeamAccessViewModel && (endpointTeams[userOrTeam.Id] || endpointGroupTeams[userOrTeam.Id]); + + return userRole || teamRole; + }); + } + + $onInit() { + return this.$async(async () => { + try { + this.state = { + viewReady: false, + actionInProgress: false, + endpointId: this.$state.params.endpointId, + registryId: this.$state.params.id, + }; + this.registry = await this.EndpointService.registry(this.state.endpointId, this.state.registryId); + this.registryEndpointAccesses = this.registry.RegistryAccesses[this.state.endpointId] || {}; + this.endpointGroup = await this.GroupService.group(this.endpoint.GroupId); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry details'); + } finally { + this.state.viewReady = true; + } + }); + } +} + +export default DockerRegistryAccessController; +angular.module('portainer.docker').controller('DockerRegistryAccessController', DockerRegistryAccessController); diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index f1aef75a4..73a4f70be 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -50,7 +50,6 @@ angular.module('portainer.docker').controller('ServiceController', [ 'VolumeService', 'ImageHelper', 'WebhookService', - 'EndpointProvider', 'clipboard', 'WebhookHelper', 'NetworkService', @@ -82,7 +81,6 @@ angular.module('portainer.docker').controller('ServiceController', [ VolumeService, ImageHelper, WebhookService, - EndpointProvider, clipboard, WebhookHelper, NetworkService, @@ -337,7 +335,7 @@ angular.module('portainer.docker').controller('ServiceController', [ Notifications.error('Failure', err, 'Unable to delete webhook'); }); } else { - WebhookService.createServiceWebhook(service.Id, EndpointProvider.endpointID()) + WebhookService.createServiceWebhook(service.Id, endpoint.Id) .then(function success(data) { $scope.WebhookExists = true; $scope.webhookID = data.Id; @@ -688,7 +686,7 @@ angular.module('portainer.docker').controller('ServiceController', [ availableImages: ImageService.images(), availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25), availableNetworks: NetworkService.networks(true, true, apiVersion >= 1.25), - webhooks: WebhookService.webhooks(service.Id, EndpointProvider.endpointID()), + webhooks: WebhookService.webhooks(service.Id, endpoint.Id), }); }) .then(async function success(data) { diff --git a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js index c3c6bc3f4..196993d66 100644 --- a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js +++ b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js @@ -1,6 +1,6 @@ export class EditEdgeGroupController { /* @ngInject */ - constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async, EndpointService, EndpointHelper) { + constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async, EndpointService) { this.EdgeGroupService = EdgeGroupService; this.GroupService = GroupService; this.TagService = TagService; @@ -8,7 +8,6 @@ export class EditEdgeGroupController { this.$state = $state; this.$async = $async; this.EndpointService = EndpointService; - this.EndpointHelper = EndpointHelper; this.state = { actionInProgress: false, diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index d867a288f..acdde4334 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -1,4 +1,6 @@ -angular.module('portainer.kubernetes', ['portainer.app']).config([ +import registriesModule from './registries'; + +angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; @@ -272,6 +274,26 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([ }, }; + const registries = { + name: 'kubernetes.registries', + url: '/registries', + views: { + 'content@': { + component: 'endpointRegistriesView', + }, + }, + }; + + const registriesAccess = { + name: 'kubernetes.registries.access', + url: '/:id/access', + views: { + 'content@': { + component: 'kubernetesRegistryAccessView', + }, + }, + }; + $stateRegistryProvider.register(kubernetes); $stateRegistryProvider.register(applications); $stateRegistryProvider.register(applicationCreation); @@ -297,5 +319,7 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([ $stateRegistryProvider.register(resourcePoolAccess); $stateRegistryProvider.register(volumes); $stateRegistryProvider.register(volume); + $stateRegistryProvider.register(registries); + $stateRegistryProvider.register(registriesAccess); }, ]); diff --git a/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatableController.js b/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatableController.js index 18417d951..c9459ffba 100644 --- a/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatableController.js +++ b/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatableController.js @@ -28,7 +28,7 @@ angular.module('portainer.docker').controller('KubernetesConfigurationsDatatable }; this.isSystemConfig = function (item) { - return ctrl.isSystemNamespace(item) || ctrl.isSystemToken(item); + return ctrl.isSystemNamespace(item) || ctrl.isSystemToken(item) || item.IsRegistrySecret; }; this.isExternalConfiguration = function (item) { diff --git a/app/kubernetes/components/kubernetes-sidebar-content/kubernetesSidebarContent.html b/app/kubernetes/components/kubernetes-sidebar-content/kubernetesSidebarContent.html index f640ce220..f7439c9a6 100644 --- a/app/kubernetes/components/kubernetes-sidebar-content/kubernetesSidebarContent.html +++ b/app/kubernetes/components/kubernetes-sidebar-content/kubernetesSidebarContent.html @@ -15,7 +15,17 @@ </li> <li class="sidebar-list"> <a ui-sref="kubernetes.cluster({endpointId: $ctrl.endpointId})" ui-sref-active="active">Cluster <span class="menu-icon fa fa-server fa-fw"></span></a> - <div class="sidebar-sublist" ng-if="$ctrl.adminAccess && ($ctrl.currentState === 'kubernetes.cluster' || $ctrl.currentState === 'portainer.endpoints.endpoint.kubernetesConfig')"> - <a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: $ctrl.endpointId})" ui-sref-active="active">Setup</a> + <div + ng-if=" + $ctrl.adminAccess && + ['kubernetes.cluster', 'portainer.endpoints.endpoint.kubernetesConfig', 'kubernetes.registries', 'kubernetes.registries.access'].includes($ctrl.currentState) + " + > + <div class="sidebar-sublist"> + <a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: $ctrl.endpointId})" ui-sref-active="active">Setup</a> + </div> + <div class="sidebar-sublist"> + <a ui-sref="kubernetes.registries({endpointId: $ctrl.endpointId})" ui-sref-active="active">Registries</a> + </div> </div> </li> diff --git a/app/kubernetes/converters/application.js b/app/kubernetes/converters/application.js index a4b3406cf..881e98ece 100644 --- a/app/kubernetes/converters/application.js +++ b/app/kubernetes/converters/application.js @@ -62,6 +62,9 @@ class KubernetesApplicationConverter { if (containers.length) { res.Image = containers[0].image; } + if (data.spec.template && data.spec.template.spec && data.spec.template.spec.imagePullSecrets && data.spec.template.spec.imagePullSecrets.length) { + res.RegistryId = parseInt(data.spec.template.spec.imagePullSecrets[0].name.replace('registry-', ''), 10); + } res.CreationDate = data.metadata.creationTimestamp; res.Env = _.without(_.flatMap(_.map(containers, 'env')), undefined); res.Pods = data.spec.selector ? KubernetesApplicationHelper.associatePodsAndApplication(pods, data.spec.selector) : [data]; @@ -268,7 +271,8 @@ class KubernetesApplicationConverter { res.Name = app.Name; res.StackName = app.StackName; res.ApplicationOwner = app.ApplicationOwner; - res.Image = app.Image; + res.ImageModel.Image = app.Image; + res.ImageModel.Registry.Id = app.RegistryId; res.ReplicaCount = app.TotalPodsCount; res.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(app.Limits.Memory); res.CpuLimit = app.Limits.Cpu; @@ -292,7 +296,10 @@ class KubernetesApplicationConverter { res.PublishingType = KubernetesApplicationPublishingTypes.INTERNAL; } - KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity, nodesLabels); + if (app.Pods && app.Pods.length) { + KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity, nodesLabels); + } + return res; } diff --git a/app/kubernetes/converters/configuration.js b/app/kubernetes/converters/configuration.js index e034fc2c6..c5e4c32fb 100644 --- a/app/kubernetes/converters/configuration.js +++ b/app/kubernetes/converters/configuration.js @@ -14,6 +14,7 @@ class KubernetesConfigurationConverter { res.Data[entry.Key] = entry.Value; }); res.ConfigurationOwner = secret.ConfigurationOwner; + res.IsRegistrySecret = secret.IsRegistrySecret; return res; } diff --git a/app/kubernetes/converters/daemonSet.js b/app/kubernetes/converters/daemonSet.js index 063005593..8f95eba1c 100644 --- a/app/kubernetes/converters/daemonSet.js +++ b/app/kubernetes/converters/daemonSet.js @@ -10,10 +10,11 @@ import { import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper'; +import { buildImageFullURI } from 'Docker/helpers/imageHelper'; class KubernetesDaemonSetConverter { /** - * Generate KubernetesDaemonSet from KubenetesApplicationFormValues + * Generate KubernetesDaemonSet from KubernetesApplicationFormValues * @param {KubernetesApplicationFormValues} formValues */ static applicationFormValuesToDaemonSet(formValues, volumeClaims) { @@ -23,7 +24,7 @@ class KubernetesDaemonSetConverter { res.StackName = formValues.StackName ? formValues.StackName : formValues.Name; res.ApplicationOwner = formValues.ApplicationOwner; res.ApplicationName = formValues.Name; - res.Image = formValues.Image; + res.ImageModel = formValues.ImageModel; res.CpuLimit = formValues.CpuLimit; res.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); res.Env = KubernetesApplicationHelper.generateEnvFromEnvVariables(formValues.EnvironmentVariables); @@ -35,7 +36,7 @@ class KubernetesDaemonSetConverter { /** * Generate CREATE payload from DaemonSet - * @param {KubernetesDaemonSetPayload} model DaemonSet to genereate payload from + * @param {KubernetesDaemonSetPayload} model DaemonSet to generate payload from */ static createPayload(daemonSet) { const payload = new KubernetesDaemonSetCreatePayload(); @@ -50,7 +51,10 @@ class KubernetesDaemonSetConverter { payload.spec.template.metadata.labels.app = daemonSet.Name; payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = daemonSet.ApplicationName; payload.spec.template.spec.containers[0].name = daemonSet.Name; - payload.spec.template.spec.containers[0].image = daemonSet.Image; + payload.spec.template.spec.containers[0].image = buildImageFullURI(daemonSet.ImageModel); + if (daemonSet.ImageModel.Registry && daemonSet.ImageModel.Registry.Authentication) { + payload.spec.template.spec.imagePullSecrets = [{ name: `registry-${daemonSet.ImageModel.Registry.Id}` }]; + } payload.spec.template.spec.affinity = daemonSet.Affinity; KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', daemonSet.Env); KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', daemonSet.VolumeMounts); diff --git a/app/kubernetes/converters/deployment.js b/app/kubernetes/converters/deployment.js index 471faa179..080644641 100644 --- a/app/kubernetes/converters/deployment.js +++ b/app/kubernetes/converters/deployment.js @@ -11,6 +11,7 @@ import { import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper'; +import { buildImageFullURI } from 'Docker/helpers/imageHelper'; class KubernetesDeploymentConverter { /** @@ -25,7 +26,7 @@ class KubernetesDeploymentConverter { res.ApplicationOwner = formValues.ApplicationOwner; res.ApplicationName = formValues.Name; res.ReplicaCount = formValues.ReplicaCount; - res.Image = formValues.Image; + res.ImageModel = formValues.ImageModel; res.CpuLimit = formValues.CpuLimit; res.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); res.Env = KubernetesApplicationHelper.generateEnvFromEnvVariables(formValues.EnvironmentVariables); @@ -53,7 +54,10 @@ class KubernetesDeploymentConverter { payload.spec.template.metadata.labels.app = deployment.Name; payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = deployment.ApplicationName; payload.spec.template.spec.containers[0].name = deployment.Name; - payload.spec.template.spec.containers[0].image = deployment.Image; + payload.spec.template.spec.containers[0].image = buildImageFullURI(deployment.ImageModel); + if (deployment.ImageModel.Registry && deployment.ImageModel.Registry.Authentication) { + payload.spec.template.spec.imagePullSecrets = [{ name: `registry-${deployment.ImageModel.Registry.Id}` }]; + } payload.spec.template.spec.affinity = deployment.Affinity; KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', deployment.Env); KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', deployment.VolumeMounts); diff --git a/app/kubernetes/converters/resourcePool.js b/app/kubernetes/converters/resourcePool.js index 234d13d27..eb20ce5e4 100644 --- a/app/kubernetes/converters/resourcePool.js +++ b/app/kubernetes/converters/resourcePool.js @@ -28,7 +28,16 @@ class KubernetesResourcePoolConverter { } }); const ingresses = _.without(ingMap, undefined); - return [namespace, quota, ingresses]; + const registries = _.map(formValues.Registries, (r) => { + if (!r.RegistryAccesses[formValues.EndpointId]) { + r.RegistryAccesses[formValues.EndpointId] = { Namespaces: [] }; + } + if (!_.includes(r.RegistryAccesses[formValues.EndpointId].Namespaces, formValues.Name)) { + r.RegistryAccesses[formValues.EndpointId].Namespaces = [...r.RegistryAccesses[formValues.EndpointId].Namespaces, formValues.Name]; + } + return r; + }); + return [namespace, quota, ingresses, registries]; } } diff --git a/app/kubernetes/converters/secret.js b/app/kubernetes/converters/secret.js index 72d37f070..16920f784 100644 --- a/app/kubernetes/converters/secret.js +++ b/app/kubernetes/converters/secret.js @@ -56,6 +56,9 @@ class KubernetesSecretConverter { res.Namespace = payload.metadata.namespace; res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : ''; res.CreationDate = payload.metadata.creationTimestamp; + + res.IsRegistrySecret = payload.metadata.annotations && !!payload.metadata.annotations['portainer.io/registry.id']; + res.Yaml = yaml ? yaml.data : ''; res.Data = _.map(payload.data, (value, key) => { diff --git a/app/kubernetes/converters/statefulSet.js b/app/kubernetes/converters/statefulSet.js index 26cffd4ac..a7693513b 100644 --- a/app/kubernetes/converters/statefulSet.js +++ b/app/kubernetes/converters/statefulSet.js @@ -12,6 +12,7 @@ import { import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper'; +import { buildImageFullURI } from 'Docker/helpers/imageHelper'; import KubernetesPersistentVolumeClaimConverter from './persistentVolumeClaim'; class KubernetesStatefulSetConverter { @@ -27,7 +28,7 @@ class KubernetesStatefulSetConverter { res.ApplicationOwner = formValues.ApplicationOwner; res.ApplicationName = formValues.Name; res.ReplicaCount = formValues.ReplicaCount; - res.Image = formValues.Image; + res.ImageModel = formValues.ImageModel; res.CpuLimit = formValues.CpuLimit; res.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); res.Env = KubernetesApplicationHelper.generateEnvFromEnvVariables(formValues.EnvironmentVariables); @@ -56,7 +57,12 @@ class KubernetesStatefulSetConverter { payload.spec.template.metadata.labels.app = statefulSet.Name; payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = statefulSet.ApplicationName; payload.spec.template.spec.containers[0].name = statefulSet.Name; - payload.spec.template.spec.containers[0].image = statefulSet.Image; + if (statefulSet.ImageModel.Image) { + payload.spec.template.spec.containers[0].image = buildImageFullURI(statefulSet.ImageModel); + if (statefulSet.ImageModel.Registry && statefulSet.ImageModel.Registry.Authentication) { + payload.spec.template.spec.imagePullSecrets = [{ name: `registry-${statefulSet.ImageModel.Registry.Id}` }]; + } + } payload.spec.template.spec.affinity = statefulSet.Affinity; KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', statefulSet.Env); KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', statefulSet.VolumeMounts); diff --git a/app/kubernetes/models/application/formValues.js b/app/kubernetes/models/application/formValues.js index 6faffaeb6..83dd05499 100644 --- a/app/kubernetes/models/application/formValues.js +++ b/app/kubernetes/models/application/formValues.js @@ -1,37 +1,34 @@ +import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationPublishingTypes, KubernetesApplicationPlacementTypes } from './models'; /** * KubernetesApplicationFormValues Model */ -const _KubernetesApplicationFormValues = Object.freeze({ - ApplicationType: undefined, // will only exist for formValues generated from Application (app edit situation) - ResourcePool: {}, - Name: '', - StackName: '', - ApplicationOwner: '', - Image: '', - Note: '', - MemoryLimit: 0, - CpuLimit: 0, - DeploymentType: KubernetesApplicationDeploymentTypes.REPLICATED, - ReplicaCount: 1, - AutoScaler: {}, - Containers: [], - EnvironmentVariables: [], // KubernetesApplicationEnvironmentVariableFormValue list - DataAccessPolicy: KubernetesApplicationDataAccessPolicies.ISOLATED, - PersistedFolders: [], // KubernetesApplicationPersistedFolderFormValue list - Configurations: [], // KubernetesApplicationConfigurationFormValue list - PublishingType: KubernetesApplicationPublishingTypes.INTERNAL, - PublishedPorts: [], // KubernetesApplicationPublishedPortFormValue list - PlacementType: KubernetesApplicationPlacementTypes.MANDATORY, - Placements: [], // KubernetesApplicationPlacementFormValue list - OriginalIngresses: undefined, -}); - -export class KubernetesApplicationFormValues { - constructor() { - Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationFormValues))); - } +export function KubernetesApplicationFormValues() { + return { + ApplicationType: undefined, // will only exist for formValues generated from Application (app edit situation) + ResourcePool: {}, + Name: '', + StackName: '', + ApplicationOwner: '', + ImageModel: new PorImageRegistryModel(), + Note: '', + MemoryLimit: 0, + CpuLimit: 0, + DeploymentType: KubernetesApplicationDeploymentTypes.REPLICATED, + ReplicaCount: 1, + AutoScaler: {}, + Containers: [], + EnvironmentVariables: [], // KubernetesApplicationEnvironmentVariableFormValue list + DataAccessPolicy: KubernetesApplicationDataAccessPolicies.SHARED, + PersistedFolders: [], // KubernetesApplicationPersistedFolderFormValue list + Configurations: [], // KubernetesApplicationConfigurationFormValue list + PublishingType: KubernetesApplicationPublishingTypes.INTERNAL, + PublishedPorts: [], // KubernetesApplicationPublishedPortFormValue list + PlacementType: KubernetesApplicationPlacementTypes.PREFERRED, + Placements: [], // KubernetesApplicationPlacementFormValue list + OriginalIngresses: undefined, + }; } export const KubernetesApplicationConfigurationFormValueOverridenKeyTypes = Object.freeze({ diff --git a/app/kubernetes/models/daemon-set/models.js b/app/kubernetes/models/daemon-set/models.js index a4c4fc411..4f2f82ed1 100644 --- a/app/kubernetes/models/daemon-set/models.js +++ b/app/kubernetes/models/daemon-set/models.js @@ -5,7 +5,7 @@ const _KubernetesDaemonSet = Object.freeze({ Namespace: '', Name: '', StackName: '', - Image: '', + ImageModel: null, Env: [], CpuLimit: 0, MemoryLimit: 0, diff --git a/app/kubernetes/models/deployment/models.js b/app/kubernetes/models/deployment/models.js index df8a8d79c..05a6963df 100644 --- a/app/kubernetes/models/deployment/models.js +++ b/app/kubernetes/models/deployment/models.js @@ -6,7 +6,7 @@ const _KubernetesDeployment = Object.freeze({ Name: '', StackName: '', ReplicaCount: 0, - Image: '', + ImageModel: null, Env: [], CpuLimit: 0, MemoryLimit: 0, diff --git a/app/kubernetes/models/resource-pool/formValues.js b/app/kubernetes/models/resource-pool/formValues.js index 55efbfbba..e4e9f95f1 100644 --- a/app/kubernetes/models/resource-pool/formValues.js +++ b/app/kubernetes/models/resource-pool/formValues.js @@ -1,9 +1,13 @@ export function KubernetesResourcePoolFormValues(defaults) { - this.Name = ''; - this.MemoryLimit = defaults.MemoryLimit; - this.CpuLimit = defaults.CpuLimit; - this.HasQuota = false; - this.IngressClasses = []; // KubernetesResourcePoolIngressClassFormValue + return { + Name: '', + MemoryLimit: defaults.MemoryLimit, + CpuLimit: defaults.CpuLimit, + HasQuota: false, + IngressClasses: [], // KubernetesResourcePoolIngressClassFormValue + Registries: [], // RegistryViewModel + EndpointId: 0, + }; } /** diff --git a/app/kubernetes/models/stateful-set/models.js b/app/kubernetes/models/stateful-set/models.js index 28609416c..fa445799f 100644 --- a/app/kubernetes/models/stateful-set/models.js +++ b/app/kubernetes/models/stateful-set/models.js @@ -6,7 +6,7 @@ const _KubernetesStatefulSet = Object.freeze({ Name: '', StackName: '', ReplicaCount: 0, - Image: '', + ImageModel: null, Env: [], CpuLimit: '', MemoryLimit: '', diff --git a/app/kubernetes/registries/index.js b/app/kubernetes/registries/index.js new file mode 100644 index 000000000..54373821c --- /dev/null +++ b/app/kubernetes/registries/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import { kubernetesRegistryAccessView } from './kube-registry-access-view'; + +export default angular.module('portainer.kubernetes.registries', []).component('kubernetesRegistryAccessView', kubernetesRegistryAccessView).name; diff --git a/app/kubernetes/registries/kube-registry-access-view/index.js b/app/kubernetes/registries/kube-registry-access-view/index.js new file mode 100644 index 000000000..bf51890e0 --- /dev/null +++ b/app/kubernetes/registries/kube-registry-access-view/index.js @@ -0,0 +1,9 @@ +import controller from './kube-registry-access-view.controller'; + +export const kubernetesRegistryAccessView = { + templateUrl: './kube-registry-access-view.html', + controller, + bindings: { + endpoint: '<', + }, +}; diff --git a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.controller.js b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.controller.js new file mode 100644 index 000000000..e2703bd2c --- /dev/null +++ b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.controller.js @@ -0,0 +1,70 @@ +export default class KubernetesRegistryAccessController { + /* @ngInject */ + constructor($async, $state, EndpointService, Notifications, KubernetesResourcePoolService, KubernetesNamespaceHelper) { + this.$async = $async; + this.$state = $state; + this.Notifications = Notifications; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.KubernetesNamespaceHelper = KubernetesNamespaceHelper; + this.EndpointService = EndpointService; + + this.state = { + actionInProgress: false, + }; + + this.selectedResourcePools = []; + this.resourcePools = []; + this.savedResourcePools = []; + + this.handleRemove = this.handleRemove.bind(this); + } + + async submit() { + return this.updateNamespaces([...this.savedResourcePools.map(({ value }) => value), ...this.selectedResourcePools.map((pool) => pool.name)]); + } + + handleRemove(namespaces) { + const removeNamespaces = namespaces.map(({ value }) => value); + + return this.updateNamespaces(this.savedResourcePools.map(({ value }) => value).filter((value) => !removeNamespaces.includes(value))); + } + + updateNamespaces(namespaces) { + return this.$async(async () => { + try { + await this.EndpointService.updateRegistryAccess(this.endpoint.Id, this.registry.Id, { + namespaces, + }); + this.$state.reload(); + } catch (err) { + this.Notifications.error('Failure', err, 'Failed saving registry access'); + } + }); + } + + $onInit() { + return this.$async(async () => { + try { + this.state = { + registryId: this.$state.params.id, + }; + this.registry = await this.EndpointService.registry(this.endpoint.Id, this.state.registryId); + if (this.registry.RegistryAccesses && this.registry.RegistryAccesses[this.endpoint.Id]) { + this.savedResourcePools = this.registry.RegistryAccesses[this.endpoint.Id].Namespaces.map((value) => ({ value })); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry details'); + } + + try { + const resourcePools = await this.KubernetesResourcePoolService.get(); + + this.resourcePools = resourcePools + .filter((pool) => !this.KubernetesNamespaceHelper.isSystemNamespace(pool.Namespace.Name) && !this.savedResourcePools.find(({ value }) => value === pool.Namespace.Name)) + .map((pool) => ({ name: pool.Namespace.Name, id: pool.Namespace.Id })); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve namespaces'); + } + }); + } +} diff --git a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html new file mode 100644 index 000000000..969d3d3a1 --- /dev/null +++ b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html @@ -0,0 +1,72 @@ +<rd-header> + <rd-header-title title-text="Registry access"></rd-header-title> + <rd-header-content> <a ui-sref="kubernetes.registries">Registries</a> > {{ $ctrl.registry.Name }} > Access management </rd-header-content> +</rd-header> + +<registry-details registry="$ctrl.registry" ng-if="$ctrl.registry"></registry-details> + +<div class="row"> + <div class="col-sm-12"> + <rd-widget> + <rd-widget-header icon="fa-user-lock" title-text="Create access"></rd-widget-header> + <rd-widget-body> + <form class="form-horizontal"> + <div class="form-group"> + <label class="col-sm-3 col-lg-2 control-label text-left" style="padding-top: 0;"> + Select namespaces + </label> + <div class="col-sm-9 col-lg-4"> + <span class="small text-muted" ng-if="!$ctrl.resourcePools.length"> + No namespaces available. + </span> + <span + isteven-multi-select + ng-if="$ctrl.resourcePools.length" + input-model="$ctrl.resourcePools" + output-model="$ctrl.selectedResourcePools" + button-label="name" + item-label="name" + tick-property="ticked" + helper-elements="filter" + search-property="name" + translation="{nothingSelected: 'Select one or more namespaces', search: 'Search...'}" + > + </span> + </div> + </div> + + <!-- actions --> + <div class="form-group"> + <div class="col-sm-12"> + <button + type="submit" + class="btn btn-primary btn-sm" + ng-disabled="$ctrl.selectedResourcePools.length === 0 || $ctrl.state.actionInProgress" + ng-click="$ctrl.submit()" + button-spinner="$ctrl.state.actionInProgress" + > + <span ng-hide="$ctrl.state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Create access</span> + <span ng-show="$ctrl.state.actionInProgress">Creating access...</span> + </button> + </div> + </div> + <!-- !actions --> + </form> + </rd-widget-body> + </rd-widget> + </div> +</div> +<div class="row"> + <div class="col-sm-12"> + <strings-datatable + title-text="Access" + title-icon="fa-user-lock" + table-key="access_registry_resourcepools" + dataset="$ctrl.savedResourcePools" + empty-dataset-message="No namespace has been authorized yet." + on-remove="($ctrl.handleRemove)" + column-header="Namespace" + > + </strings-datatable> + </div> +</div> diff --git a/app/kubernetes/services/resourcePoolService.js b/app/kubernetes/services/resourcePoolService.js index ac38e2d4f..e89069191 100644 --- a/app/kubernetes/services/resourcePoolService.js +++ b/app/kubernetes/services/resourcePoolService.js @@ -5,7 +5,7 @@ import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool' import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper'; /* @ngInject */ -export function KubernetesResourcePoolService($async, KubernetesNamespaceService, KubernetesResourceQuotaService, KubernetesIngressService) { +export function KubernetesResourcePoolService($async, EndpointService, KubernetesNamespaceService, KubernetesResourceQuotaService, KubernetesIngressService) { return { get, create, @@ -59,7 +59,7 @@ export function KubernetesResourcePoolService($async, KubernetesNamespaceService function create(formValues) { return $async(async () => { try { - const [namespace, quota, ingresses] = KubernetesResourcePoolConverter.formValuesToResourcePool(formValues); + const [namespace, quota, ingresses, registries] = KubernetesResourcePoolConverter.formValuesToResourcePool(formValues); await KubernetesNamespaceService.create(namespace); if (quota) { @@ -67,6 +67,10 @@ export function KubernetesResourcePoolService($async, KubernetesNamespaceService } const ingressPromises = _.map(ingresses, (i) => KubernetesIngressService.create(i)); await Promise.all(ingressPromises); + + const endpointId = formValues.EndpointId; + const registriesPromises = _.map(registries, (r) => EndpointService.updateRegistryAccess(endpointId, r.Id, r.RegistryAccesses[endpointId])); + await Promise.all(registriesPromises); } catch (err) { throw err; } @@ -76,8 +80,8 @@ export function KubernetesResourcePoolService($async, KubernetesNamespaceService function patch(oldFormValues, newFormValues) { return $async(async () => { try { - const [oldNamespace, oldQuota, oldIngresses] = KubernetesResourcePoolConverter.formValuesToResourcePool(oldFormValues); - const [newNamespace, newQuota, newIngresses] = KubernetesResourcePoolConverter.formValuesToResourcePool(newFormValues); + const [oldNamespace, oldQuota, oldIngresses, oldRegistries] = KubernetesResourcePoolConverter.formValuesToResourcePool(oldFormValues); + const [newNamespace, newQuota, newIngresses, newRegistries] = KubernetesResourcePoolConverter.formValuesToResourcePool(newFormValues); void oldNamespace, newNamespace; if (oldQuota && newQuota) { @@ -103,6 +107,18 @@ export function KubernetesResourcePoolService($async, KubernetesNamespaceService const promises = _.flatten([createPromises, delPromises, patchPromises]); await Promise.all(promises); + + const endpointId = newFormValues.EndpointId; + const keptRegistries = _.intersectionBy(oldRegistries, newRegistries, 'Id'); + const removedRegistries = _.without(oldRegistries, ...keptRegistries); + + const newRegistriesPromises = _.map(newRegistries, (r) => EndpointService.updateRegistryAccess(endpointId, r.Id, r.RegistryAccesses[endpointId])); + const removedRegistriesPromises = _.map(removedRegistries, (r) => { + _.pull(r.RegistryAccesses[endpointId].Namespaces, newFormValues.Name); + return EndpointService.updateRegistryAccess(endpointId, r.Id, r.RegistryAccesses[endpointId]); + }); + + await Promise.all(_.concat(newRegistriesPromises, removedRegistriesPromises)); } catch (err) { throw err; } diff --git a/app/kubernetes/services/statefulSetService.js b/app/kubernetes/services/statefulSetService.js index 60cdea44c..5a14c4190 100644 --- a/app/kubernetes/services/statefulSetService.js +++ b/app/kubernetes/services/statefulSetService.js @@ -93,6 +93,7 @@ class KubernetesStatefulSetService { if (!payload.length) { return; } + const data = await this.KubernetesStatefulSets(namespace).patch(params, payload).$promise; return data; } catch (err) { diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 152bbc87a..c256ec3e0 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -16,6 +16,40 @@ <rd-widget> <rd-widget-body> <form class="form-horizontal" name="kubernetesApplicationCreationForm" autocomplete="off"> + <div class="col-sm-12 form-section-title"> + Namespace + </div> + <!-- #region NAMESPACE --> + <div class="form-group" ng-if="ctrl.formValues.ResourcePool"> + <label for="resource-pool-selector" class="col-sm-1 control-label text-left">Namespace</label> + <div class="col-sm-11"> + <select + class="form-control" + id="resource-pool-selector" + ng-model="ctrl.formValues.ResourcePool" + ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools" + ng-change="ctrl.onResourcePoolSelectionChange()" + ng-disabled="ctrl.state.isEdit" + ></select> + </div> + </div> + <div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool"> + <div class="col-sm-12 small text-danger"> + <i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i> + This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the + namespace. + </div> + </div> + <div class="form-group" ng-if="!ctrl.formValues.ResourcePool"> + <div class="col-sm-12 small text-muted"> + <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> + You do not have access to any namespace. Contact your administrator to get access to a namespace. + </div> + </div> + <!-- #endregion --> + <div class="col-sm-12 form-section-title"> + Application + </div> <!-- #region NAME FIELD --> <div class="form-group"> <label for="application_name" class="col-sm-1 control-label text-left">Name</label> @@ -53,61 +87,20 @@ <!-- #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"> - <input - type="text" - class="form-control" - name="container_image" - ng-model="ctrl.formValues.Image" - placeholder="nginx:latest" - required - ng-disabled="ctrl.formValues.Containers.length > 1" - /> + <div class="col-sm-12"> + <por-image-registry + model="ctrl.formValues.ImageModel" + auto-complete="false" + label-class="col-sm-1" + input-class="col-sm-11" + namespace="ctrl.formValues.ResourcePool.Namespace.Name" + endpoint="ctrl.endpoint" + is-admin="ctrl.isAdmin" + check-rate-limits="true" + set-validity="ctrl.setPullImageValidity" + ></por-image-registry> </div> </div> - <div class="form-group" ng-show="kubernetesApplicationCreationForm.container_image.$invalid"> - <div class="col-sm-12 small text-warning"> - <div ng-messages="kubernetesApplicationCreationForm.container_image.$error"> - <p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p> - </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"> - Namespace - </div> - <!-- #region NAMESPACE --> - <div class="form-group"> - <label for="resource-pool-selector" class="col-sm-1 control-label text-left">Namespace</label> - <div class="col-sm-11"> - <select - class="form-control" - id="resource-pool-selector" - ng-model="ctrl.formValues.ResourcePool" - ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools" - ng-change="ctrl.onResourcePoolSelectionChange()" - ng-disabled="ctrl.state.isEdit" - ></select> - </div> - </div> - <div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded()"> - <div class="col-sm-12 small text-danger"> - <i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i> - This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the - namespace. - </div> - </div> - <!-- #endregion --> <div class="col-sm-12 form-section-title"> Stack diff --git a/app/kubernetes/views/applications/create/createApplication.js b/app/kubernetes/views/applications/create/createApplication.js index 9feaabc76..c5c8df16b 100644 --- a/app/kubernetes/views/applications/create/createApplication.js +++ b/app/kubernetes/views/applications/create/createApplication.js @@ -3,7 +3,6 @@ angular.module('portainer.kubernetes').component('kubernetesCreateApplicationVie controller: 'KubernetesCreateApplicationController', controllerAs: 'ctrl', bindings: { - $transition$: '<', endpoint: '<', }, }); diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 09b25384f..aca7d8dcc 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -38,9 +38,7 @@ class KubernetesCreateApplicationController { $async, $state, Notifications, - EndpointProvider, Authentication, - DockerHubService, ModalService, KubernetesResourcePoolService, KubernetesApplicationService, @@ -50,14 +48,13 @@ class KubernetesCreateApplicationController { KubernetesIngressService, KubernetesPersistentVolumeClaimService, KubernetesNamespaceHelper, - KubernetesVolumeService + KubernetesVolumeService, + RegistryService ) { this.$async = $async; this.$state = $state; this.Notifications = Notifications; - this.EndpointProvider = EndpointProvider; this.Authentication = Authentication; - this.DockerHubService = DockerHubService; this.ModalService = ModalService; this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.KubernetesApplicationService = KubernetesApplicationService; @@ -68,6 +65,7 @@ class KubernetesCreateApplicationController { this.KubernetesIngressService = KubernetesIngressService; this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; this.KubernetesNamespaceHelper = KubernetesNamespaceHelper; + this.RegistryService = RegistryService; this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies; @@ -77,6 +75,56 @@ class KubernetesCreateApplicationController { this.ApplicationConfigurationFormValueOverridenKeyTypes = KubernetesApplicationConfigurationFormValueOverridenKeyTypes; this.ServiceTypes = KubernetesServiceTypes; + this.state = { + actionInProgress: false, + useLoadBalancer: false, + useServerMetrics: false, + sliders: { + cpu: { + min: 0, + max: 0, + }, + memory: { + min: 0, + max: 0, + }, + }, + nodes: { + memory: 0, + cpu: 0, + }, + resourcePoolHasQuota: false, + viewReady: false, + availableSizeUnits: ['MB', 'GB', 'TB'], + alreadyExists: false, + duplicates: { + environmentVariables: new KubernetesFormValidationReferences(), + persistedFolders: new KubernetesFormValidationReferences(), + configurationPaths: new KubernetesFormValidationReferences(), + existingVolumes: new KubernetesFormValidationReferences(), + publishedPorts: { + containerPorts: new KubernetesFormValidationReferences(), + nodePorts: new KubernetesFormValidationReferences(), + ingressRoutes: new KubernetesFormValidationReferences(), + loadBalancerPorts: new KubernetesFormValidationReferences(), + }, + placements: new KubernetesFormValidationReferences(), + }, + isEdit: this.$state.params.namespace && this.$state.params.name, + persistedFoldersUseExistingVolumes: false, + pullImageValidity: false, + }; + + this.isAdmin = this.Authentication.isAdmin(); + + this.editChanges = []; + + this.storageClasses = []; + this.state.useLoadBalancer = false; + this.state.useServerMetrics = false; + + this.formValues = new KubernetesApplicationFormValues(); + this.updateApplicationAsync = this.updateApplicationAsync.bind(this); this.deployApplicationAsync = this.deployApplicationAsync.bind(this); this.setPullImageValidity = this.setPullImageValidity.bind(this); @@ -869,9 +917,9 @@ class KubernetesCreateApplicationController { getApplication() { return this.$async(async () => { try { - const namespace = this.state.params.namespace; + const namespace = this.$state.params.namespace; [this.application, this.persistentVolumeClaims] = await Promise.all([ - this.KubernetesApplicationService.get(namespace, this.state.params.name), + this.KubernetesApplicationService.get(namespace, this.$state.params.name), this.KubernetesPersistentVolumeClaimService.get(namespace), ]); } catch (err) { @@ -879,71 +927,26 @@ class KubernetesCreateApplicationController { } }); } + + async parseImageConfiguration(imageModel) { + return this.$async(async () => { + try { + return await this.RegistryService.retrievePorRegistryModelFromRepository(imageModel.Image, this.endpoint.Id, imageModel.Registry.Id, this.$state.params.namespace); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry'); + return imageModel; + } + }); + } /* #endregion */ /* #region ON INIT */ $onInit() { return this.$async(async () => { try { - this.state = { - actionInProgress: false, - useLoadBalancer: false, - useServerMetrics: false, - sliders: { - cpu: { - min: 0, - max: 0, - }, - memory: { - min: 0, - max: 0, - }, - }, - nodes: { - memory: 0, - cpu: 0, - }, - resourcePoolHasQuota: false, - viewReady: false, - availableSizeUnits: ['MB', 'GB', 'TB'], - alreadyExists: false, - duplicates: { - environmentVariables: new KubernetesFormValidationReferences(), - persistedFolders: new KubernetesFormValidationReferences(), - configurationPaths: new KubernetesFormValidationReferences(), - existingVolumes: new KubernetesFormValidationReferences(), - publishedPorts: { - containerPorts: new KubernetesFormValidationReferences(), - nodePorts: new KubernetesFormValidationReferences(), - ingressRoutes: new KubernetesFormValidationReferences(), - loadBalancerPorts: new KubernetesFormValidationReferences(), - }, - placements: new KubernetesFormValidationReferences(), - }, - isEdit: false, - params: { - namespace: this.$transition$.params().namespace, - name: this.$transition$.params().name, - }, - persistedFoldersUseExistingVolumes: false, - pullImageValidity: false, - }; - - this.isAdmin = this.Authentication.isAdmin(); - - this.editChanges = []; - - if (this.state.params.namespace && this.state.params.name) { - this.state.isEdit = true; - } - - const endpoint = this.EndpointProvider.currentEndpoint(); - this.endpoint = endpoint; - this.storageClasses = endpoint.Kubernetes.Configuration.StorageClasses; - this.state.useLoadBalancer = endpoint.Kubernetes.Configuration.UseLoadBalancer; - this.state.useServerMetrics = endpoint.Kubernetes.Configuration.UseServerMetrics; - - this.formValues = new KubernetesApplicationFormValues(); + this.storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; + this.state.useLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; + this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; const [resourcePools, nodes, ingresses] = await Promise.all([ this.KubernetesResourcePoolService.get(), @@ -964,7 +967,7 @@ class KubernetesCreateApplicationController { }); this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes); - const namespace = this.state.isEdit ? this.state.params.namespace : this.formValues.ResourcePool.Namespace.Name; + const namespace = this.state.isEdit ? this.$state.params.namespace : this.formValues.ResourcePool.Namespace.Name; await this.refreshNamespaceData(namespace); if (this.state.isEdit) { @@ -978,6 +981,7 @@ class KubernetesCreateApplicationController { this.filteredIngresses ); this.formValues.OriginalIngresses = this.filteredIngresses; + this.formValues.ImageModel = await this.parseImageConfiguration(this.formValues.ImageModel); this.savedFormValues = angular.copy(this.formValues); delete this.formValues.ApplicationType; @@ -995,11 +999,7 @@ class KubernetesCreateApplicationController { this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(null, this.formValues.ReplicaCount); this.formValues.OriginalIngressClasses = angular.copy(this.ingresses); } - 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 { diff --git a/app/kubernetes/views/configurations/edit/configuration.html b/app/kubernetes/views/configurations/edit/configuration.html index 09b07e13e..1ccc77ed3 100644 --- a/app/kubernetes/views/configurations/edit/configuration.html +++ b/app/kubernetes/views/configurations/edit/configuration.html @@ -21,6 +21,7 @@ <td>Name</td> <td> {{ ctrl.configuration.Name }} + <span style="margin-left: 5px;" class="label label-info image-tag" ng-if="ctrl.configuration.IsRegistrySecret">system</span> </td> </tr> <tr> @@ -76,7 +77,7 @@ <div class="col-sm-12"> <rd-widget> <rd-widget-body> - <form ng-if="!ctrl.isSystemNamespace()" class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off"> + <form ng-if="!ctrl.isSystemConfig()" class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off"> <kubernetes-configuration-data ng-if="ctrl.formValues" form-values="ctrl.formValues" @@ -111,7 +112,7 @@ </div> <!-- !actions --> </form> - <div ng-if="ctrl.isSystemNamespace()"> + <div ng-if="ctrl.isSystemConfig()"> <div class="col-sm-12 form-section-title" style="margin-top: 10px;"> Data </div> diff --git a/app/kubernetes/views/configurations/edit/configurationController.js b/app/kubernetes/views/configurations/edit/configurationController.js index 57928dbb1..0bf9f2366 100644 --- a/app/kubernetes/views/configurations/edit/configurationController.js +++ b/app/kubernetes/views/configurations/edit/configurationController.js @@ -55,6 +55,10 @@ class KubernetesConfigurationController { return this.KubernetesNamespaceHelper.isSystemNamespace(this.configuration.Namespace); } + isSystemConfig() { + return this.isSystemNamespace() || this.configuration.IsRegistrySecret; + } + selectTab(index) { this.LocalStorage.storeActiveTab('configuration', index); } @@ -134,6 +138,10 @@ class KubernetesConfigurationController { const name = this.$transition$.params().name; const namespace = this.$transition$.params().namespace; const [configMap, secret] = await Promise.allSettled([this.KubernetesConfigMapService.get(namespace, name), this.KubernetesSecretService.get(namespace, name)]); + if (secret.status === 'rejected' && secret.reason.err.status === 403) { + this.$state.go('kubernetes.configurations'); + throw new Error('Not authorized to edit secret'); + } if (secret.status === 'fulfilled') { this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret.value); this.formValues.Data = secret.value.Data; @@ -146,6 +154,8 @@ class KubernetesConfigurationController { this.formValues.Name = this.configuration.Name; this.formValues.Type = this.configuration.Type; this.oldDataYaml = this.formValues.DataYaml; + + return this.configuration; } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve configuration'); } finally { @@ -251,11 +261,12 @@ class KubernetesConfigurationController { this.formValues = new KubernetesConfigurationFormValues(); this.resourcePools = await this.KubernetesResourcePoolService.get(); - await this.getConfiguration(); - await this.getApplications(this.configuration.Namespace); - await this.getEvents(this.configuration.Namespace); - await this.getConfigurations(); - + const configuration = await this.getConfiguration(); + if (configuration) { + await this.getApplications(this.configuration.Namespace); + await this.getEvents(this.configuration.Namespace); + await this.getConfigurations(); + } this.tagUsedDataKeys(); } catch (err) { this.Notifications.error('Failure', err, 'Unable to load view data'); diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index a1c5db285..10184786c 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -9,18 +9,10 @@ import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; class KubernetesConfigureController { /* #region CONSTRUCTOR */ - // TODO: technical debt - // $transition$ cannot be injected as bindings: { $transition$: '<' } inside app/portainer/__module.js - // because this view is not using a component (https://ui-router.github.io/guide/ng1/route-to-component#accessing-transition) - // and will cause - // >> Error: Cannot combine: component|bindings|componentProvider - // >> with: templateProvider|templateUrl|template|notify|async|controller|controllerProvider|controllerAs|resolveAs - // >> in stateview: 'content@@portainer.endpoints.endpoint.kubernetesConfig' /* @ngInject */ constructor( $async, $state, - $transition$, Notifications, KubernetesStorageService, EndpointService, @@ -33,7 +25,6 @@ class KubernetesConfigureController { ) { this.$async = $async; this.$state = $state; - this.$transition$ = $transition$; this.Notifications = Notifications; this.KubernetesStorageService = KubernetesStorageService; this.EndpointService = EndpointService; @@ -253,7 +244,7 @@ class KubernetesConfigureController { actionInProgress: false, displayConfigureClassPanel: {}, viewReady: false, - endpointId: this.$transition$.params().id, + endpointId: this.$state.params.id, duplicates: { ingressClasses: new KubernetesFormValidationReferences(), }, diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html index 1b2bb4555..6cf535d1d 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.html +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html @@ -2,9 +2,9 @@ <a ui-sref="kubernetes.resourcePools">Namespaces</a> > Create a namespace </kubernetes-view-header> -<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading> +<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading> -<div ng-if="ctrl.state.viewReady"> +<div ng-if="$ctrl.state.viewReady"> <div class="row"> <div class="col-lg-12 col-md-12 col-xs-12"> <rd-widget> @@ -18,16 +18,16 @@ type="text" class="form-control" name="pool_name" - ng-model="ctrl.formValues.Name" + ng-model="$ctrl.formValues.Name" ng-pattern="/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/" - ng-change="ctrl.onChangeName()" + ng-change="$ctrl.onChangeName()" placeholder="my-project" required auto-focus /> </div> </div> - <div class="form-group" ng-show="resourcePoolCreationForm.pool_name.$invalid || ctrl.state.isAlreadyExist"> + <div class="form-group" ng-show="resourcePoolCreationForm.pool_name.$invalid || $ctrl.state.isAlreadyExist"> <div class="col-sm-12 small text-warning"> <div ng-messages="resourcePoolCreationForm.pool_name.$error"> <p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p> @@ -36,7 +36,7 @@ with an alphanumeric character.</p > </div> - <p ng-if="ctrl.state.isAlreadyExist"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A namespace with the same name already exists.</p> + <p ng-if="$ctrl.state.isAlreadyExist"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A namespace with the same name already exists.</p> </div> </div> <!-- #endregion --> @@ -58,16 +58,16 @@ <label class="control-label text-left"> Resource assignment </label> - <label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.HasQuota" /><i></i> </label> + <label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.formValues.HasQuota" /><i></i> </label> </div> </div> - <div class="form-group" ng-if="ctrl.formValues.HasQuota && !ctrl.isQuotaValid()"> + <div class="form-group" ng-if="$ctrl.formValues.HasQuota && !$ctrl.isQuotaValid()"> <span class="col-sm-12 text-warning small"> <p> <i class="fa fa-exclamation-triangle" aria-hidden="true" style="margin-right: 2px;"></i> At least a single limit must be set for the quota to be valid. </p> </span> </div> <!-- !quotas-switch --> - <div ng-if="ctrl.formValues.HasQuota"> + <div ng-if="$ctrl.formValues.HasQuota"> <div class="col-sm-12 form-section-title"> Resource limits </div> @@ -78,17 +78,23 @@ Memory </label> <div class="col-sm-3"> - <slider model="ctrl.formValues.MemoryLimit" floor="ctrl.defaults.MemoryLimit" ceil="ctrl.state.sliderMaxMemory" step="128" ng-if="ctrl.state.sliderMaxMemory"> + <slider + model="$ctrl.formValues.MemoryLimit" + floor="$ctrl.defaults.MemoryLimit" + ceil="$ctrl.state.sliderMaxMemory" + step="128" + ng-if="$ctrl.state.sliderMaxMemory" + > </slider> </div> <div class="col-sm-2"> <input name="memory_limit" type="number" - min="{{ ctrl.defaults.MemoryLimit }}" - max="{{ ctrl.state.sliderMaxMemory }}" + min="{{ $ctrl.defaults.MemoryLimit }}" + max="{{ $ctrl.state.sliderMaxMemory }}" class="form-control" - ng-model="ctrl.formValues.MemoryLimit" + ng-model="$ctrl.formValues.MemoryLimit" id="memory-limit" required /> @@ -103,7 +109,7 @@ <div class="col-sm-12 small text-warning"> <div ng-messages="resourcePoolCreationForm.pool_name.$error"> <p - ><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Value must be between {{ ctrl.defaults.MemoryLimit }} and {{ ctrl.state.sliderMaxMemory }} + ><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Value must be between {{ $ctrl.defaults.MemoryLimit }} and {{ $ctrl.state.sliderMaxMemory }} </p> </div> </div> @@ -115,7 +121,14 @@ CPU </label> <div class="col-sm-5"> - <slider model="ctrl.formValues.CpuLimit" floor="ctrl.defaults.CpuLimit" ceil="ctrl.state.sliderMaxCpu" step="0.1" precision="2" ng-if="ctrl.state.sliderMaxCpu"> + <slider + model="$ctrl.formValues.CpuLimit" + floor="$ctrl.defaults.CpuLimit" + ceil="$ctrl.state.sliderMaxCpu" + step="0.1" + precision="2" + ng-if="$ctrl.state.sliderMaxCpu" + > </slider> </div> <div class="col-sm-4" style="margin-top: 20px;"> @@ -186,20 +199,20 @@ </div> <!-- #endregion --> - <div ng-if="ctrl.state.canUseIngress"> + <div ng-if="$ctrl.state.canUseIngress"> <div class="col-sm-12 form-section-title"> Ingresses </div> <!-- #region INGRESSES --> - <div class="form-group" ng-if="ctrl.formValues.IngressClasses.length === 0"> + <div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length === 0"> <div class="col-sm-12 small text-muted"> The ingress feature must be enabled in the - <a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: ctrl.endpoint.Id})">endpoint configuration view</a> to be able to register ingresses inside this + <a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: $ctrl.endpoint.Id})">endpoint configuration view</a> to be able to register ingresses inside this namespace. </div> </div> - <div class="form-group" ng-if="ctrl.formValues.IngressClasses.length > 0"> + <div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length > 0"> <div class="col-sm-12 small text-muted"> <p> <i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i> @@ -208,7 +221,7 @@ </div> </div> - <div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses track by ic.IngressClass.Name"> + <div class="form-group" ng-repeat-start="ic in $ctrl.formValues.IngressClasses track by ic.IngressClass.Name"> <div class="text-muted col-sm-12" style="width: 100%;"> <div style="border-bottom: 1px solid #cdcdcd; padding-bottom: 5px;"> <i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i> {{ ic.IngressClass.Name }} @@ -234,7 +247,7 @@ > </portainer-tooltip> </label> - <span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addHostname(ic)"> + <span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addHostname(ic)"> <i class="fa fa-plus-circle" aria-hidden="true"></i> add hostname </span> </div> @@ -248,13 +261,13 @@ class="form-control" name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}" ng-model="item.Host" - ng-change="ctrl.onChangeIngressHostname()" + ng-change="$ctrl.onChangeIngressHostname()" placeholder="foo" required /> </div> <div class="col-sm-1 input-group input-group-sm" ng-if="$index > 0"> - <button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeHostname(ic, $index)"> + <button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeHostname(ic, $index)"> <i class="fa fa-trash-alt" aria-hidden="true"></i> </button> </div> @@ -264,20 +277,20 @@ style="margin-top: 5px;" ng-show=" resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid || - ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined + $ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined " > <ng-messages for="resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error"> <p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Hostname is required.</p> </ng-messages> - <p ng-if="ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined"> + <p ng-if="$ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This hostname is already used. </p> </div> </div> </div> </div> - <div class="form-group" ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"> + <div class="form-group" ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"> <div class="col-sm-12"> <label class="control-label text-left"> Redirect published routes to / in application @@ -312,7 +325,7 @@ <div class="col-sm-12" ng-if="ic.AdvancedConfig"> <label class="control-label text-left">Annotations</label> - <span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addAnnotation(ic)"> + <span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addAnnotation(ic)"> <i class="fa fa-plus-circle" aria-hidden="true"></i> add annotation </span> </div> @@ -328,7 +341,7 @@ <input type="text" class="form-control" ng-model="annotation.Value" placeholder="/$1" required /> </div> <div class="col-sm-1 input-group input-group-sm"> - <button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeAnnotation(ic, $index)"> + <button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeAnnotation(ic, $index)"> <i class="fa fa-trash-alt" aria-hidden="true"></i> </button> </div> @@ -338,8 +351,49 @@ <!-- #endregion --> </div> + <!-- #region REGISTRIES --> + <div class="col-sm-12 form-section-title"> + Registries + </div> + <div class="form-group"> + <div class="col-sm-12 small text-muted"> + <p> + <i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i> + Define which registry can be used by users who have access to this namespace. + </p> + </div> + </div> + + <div class="form-group"> + <label class="col-sm-3 col-lg-2 control-label text-left" style="padding-top: 0;"> + Select registries + </label> + <div class="col-sm-9 col-lg-4"> + <span class="small text-muted" ng-if="!$ctrl.registries.length && $ctrl.state.isAdmin"> + No registries available. Head over <a ui-sref="portainer.registries">registry view</a> to define container registry. + </span> + <span class="small text-muted" ng-if="!$ctrl.registries.length && !$ctrl.state.isAdmin"> + No registries available. Contact your administrator to create a container registry. + </span> + <span + isteven-multi-select + ng-if="$ctrl.registries.length" + input-model="$ctrl.registries" + output-model="$ctrl.formValues.Registries" + button-label="Name" + item-label="Name" + tick-property="Checked" + helper-elements="filter" + search-property="Name" + translation="{nothingSelected: 'Select one or more registry', search: 'Search...'}" + > + </span> + </div> + </div> + <!-- #endregion --> + <!-- summary --> - <kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !ctrl.isCreateButtonDisabled()" form-values="ctrl.formValues"></kubernetes-summary-view> + <kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !$ctrl.isCreateButtonDisabled()" form-values="$ctrl.formValues"></kubernetes-summary-view> <!-- !summary --> <div class="col-sm-12 form-section-title"> @@ -351,12 +405,12 @@ <button type="button" class="btn btn-primary btn-sm" - ng-disabled="!resourcePoolCreationForm.$valid || ctrl.isCreateButtonDisabled()" - ng-click="ctrl.createResourcePool()" - button-spinner="ctrl.state.actionInProgress" + ng-disabled="!resourcePoolCreationForm.$valid || $ctrl.isCreateButtonDisabled()" + ng-click="$ctrl.createResourcePool()" + button-spinner="$ctrl.state.actionInProgress" > - <span ng-hide="ctrl.state.actionInProgress">Create namespace</span> - <span ng-show="ctrl.state.actionInProgress">Creation in progress...</span> + <span ng-hide="$ctrl.state.actionInProgress">Create namespace</span> + <span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span> </button> </div> </div> diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.js b/app/kubernetes/views/resource-pools/create/createResourcePool.js index daf67bd9c..a192da090 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.js @@ -1,5 +1,10 @@ +import angular from 'angular'; +import KubernetesCreateResourcePoolController from './createResourcePoolController'; + angular.module('portainer.kubernetes').component('kubernetesCreateResourcePoolView', { templateUrl: './createResourcePool.html', - controller: 'KubernetesCreateResourcePoolController', - controllerAs: 'ctrl', + controller: KubernetesCreateResourcePoolController, + bindings: { + endpoint: '<', + }, }); diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js index 21d070bcb..5cdc8accc 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js @@ -1,4 +1,3 @@ -import angular from 'angular'; import _ from 'lodash-es'; import filesizeParser from 'filesize-parser'; import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models'; @@ -16,23 +15,19 @@ import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; class KubernetesCreateResourcePoolController { /* #region CONSTRUCTOR */ /* @ngInject */ - constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointProvider) { - this.$async = $async; - this.$state = $state; - this.Notifications = Notifications; - this.Authentication = Authentication; - this.EndpointProvider = EndpointProvider; - - this.KubernetesNodeService = KubernetesNodeService; - this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.KubernetesIngressService = KubernetesIngressService; + constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) { + Object.assign(this, { + $async, + $state, + Notifications, + KubernetesNodeService, + KubernetesResourcePoolService, + KubernetesIngressService, + Authentication, + EndpointService, + }); this.IngressClassTypes = KubernetesIngressClassTypes; - - this.onInit = this.onInit.bind(this); - this.createResourcePoolAsync = this.createResourcePoolAsync.bind(this); - this.getResourcePoolsAsync = this.getResourcePoolsAsync.bind(this); - this.getIngressesAsync = this.getIngressesAsync.bind(this); } /* #endregion */ @@ -116,106 +111,111 @@ class KubernetesCreateResourcePoolController { } /* #region CREATE NAMESPACE */ - async createResourcePoolAsync() { - this.state.actionInProgress = true; - try { - this.checkDefaults(); - const owner = this.Authentication.getUserDetails().username; - this.formValues.Owner = owner; - await this.KubernetesResourcePoolService.create(this.formValues); - this.Notifications.success('Namespace successfully created', this.formValues.Name); - this.$state.go('kubernetes.resourcePools'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to create namespace'); - } finally { - this.state.actionInProgress = false; - } - } - createResourcePool() { - return this.$async(this.createResourcePoolAsync); + return this.$async(async () => { + this.state.actionInProgress = true; + try { + this.checkDefaults(); + this.formValues.Owner = this.Authentication.getUserDetails().username; + await this.KubernetesResourcePoolService.create(this.formValues); + this.Notifications.success('Namespace successfully created', this.formValues.Name); + this.$state.go('kubernetes.resourcePools'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create namespace'); + } finally { + this.state.actionInProgress = false; + } + }); } /* #endregion */ /* #region GET INGRESSES */ - async getIngressesAsync() { - try { - this.allIngresses = await this.KubernetesIngressService.get(); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve ingresses.'); - } - } - getIngresses() { - return this.$async(this.getIngressesAsync); + return this.$async(async () => { + try { + this.allIngresses = await this.KubernetesIngressService.get(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve ingresses.'); + } + }); } /* #endregion */ /* #region GET NAMESPACES */ - async getResourcePoolsAsync() { - try { - this.resourcePools = await this.KubernetesResourcePoolService.get(); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve namespaces'); - } - } - getResourcePools() { - return this.$async(this.getResourcePoolsAsync); + return this.$async(async () => { + try { + this.resourcePools = await this.KubernetesResourcePoolService.get(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve namespaces'); + } + }); + } + /* #endregion */ + + /* #region GET REGISTRIES */ + getRegistries() { + return this.$async(async () => { + try { + this.registries = await this.EndpointService.registries(this.endpoint.Id); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + } + }); } /* #endregion */ /* #region ON INIT */ - async onInit() { - try { - const endpoint = this.EndpointProvider.currentEndpoint(); - this.endpoint = endpoint; - this.defaults = KubernetesResourceQuotaDefaults; - this.formValues = new KubernetesResourcePoolFormValues(this.defaults); - this.formValues.HasQuota = true; - - this.state = { - actionInProgress: false, - sliderMaxMemory: 0, - sliderMaxCpu: 0, - viewReady: false, - isAlreadyExist: false, - canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length, - duplicates: { - ingressHosts: new KubernetesFormValidationReferences(), - }, - }; - - const nodes = await this.KubernetesNodeService.get(); - - _.forEach(nodes, (item) => { - this.state.sliderMaxMemory += filesizeParser(item.Memory); - this.state.sliderMaxCpu += item.CPU; - }); - this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory); - await this.getResourcePools(); - if (this.state.canUseIngress) { - await this.getIngresses(); - const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses; - this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses); - } - _.forEach(this.formValues.IngressClasses, (ic) => { - if (ic.Hosts.length === 0) { - ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue()); - } - }); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to load view data'); - } finally { - this.state.viewReady = true; - } - } - $onInit() { - return this.$async(this.onInit); + return this.$async(async () => { + try { + const endpoint = this.endpoint; + this.defaults = KubernetesResourceQuotaDefaults; + this.formValues = new KubernetesResourcePoolFormValues(this.defaults); + this.formValues.EndpointId = this.endpoint.Id; + this.formValues.HasQuota = true; + + this.state = { + actionInProgress: false, + sliderMaxMemory: 0, + sliderMaxCpu: 0, + viewReady: false, + isAlreadyExist: false, + canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length, + duplicates: { + ingressHosts: new KubernetesFormValidationReferences(), + }, + isAdmin: this.Authentication.isAdmin(), + }; + + const nodes = await this.KubernetesNodeService.get(); + + _.forEach(nodes, (item) => { + this.state.sliderMaxMemory += filesizeParser(item.Memory); + this.state.sliderMaxCpu += item.CPU; + }); + this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory); + await this.getResourcePools(); + if (this.state.canUseIngress) { + await this.getIngresses(); + const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses; + this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses); + } + _.forEach(this.formValues.IngressClasses, (ic) => { + if (ic.Hosts.length === 0) { + ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue()); + } + }); + + await this.getRegistries(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + }); } /* #endregion */ } export default KubernetesCreateResourcePoolController; -angular.module('portainer.kubernetes').controller('KubernetesCreateResourcePoolController', KubernetesCreateResourcePoolController); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index 78c180980..2a7ece853 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -298,6 +298,61 @@ </div> <!-- #endregion --> </div> + + <!-- #region REGISTRIES --> + <div> + <div class="col-sm-12 form-section-title"> + Registries + </div> + + <div class="form-group" ng-if="!ctrl.isAdmin"> + <label class="col-sm-3 col-lg-2 control-label text-left" style="padding-top: 0;"> + Selected registries + </label> + <div class="col-sm-9 col-lg-4"> + {{ ctrl.selectedRegistries }} + </div> + </div> + + <div ng-if="ctrl.isAdmin"> + <div class="form-group"> + <div class="col-sm-12 small text-muted"> + <p> + <i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i> + Define which registry can be used by users who have access to this namespace. + </p> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 col-lg-2 control-label text-left" style="padding-top: 0;"> + Select registries + </label> + <div class="col-sm-9 col-lg-4"> + <span class="small text-muted" ng-if="!ctrl.registries.length && ctrl.state.isAdmin"> + No registries available. Head over <a ui-sref="portainer.registries">registry view</a> to define container registry. + </span> + <span class="small text-muted" ng-if="!ctrl.registries.length && !ctrl.state.isAdmin"> + No registries available. Contact your administrator to create a container registry. + </span> + <span + isteven-multi-select + ng-if="ctrl.registries.length" + input-model="ctrl.registries" + output-model="ctrl.formValues.Registries" + button-label="Name" + item-label="Name" + tick-property="Checked" + helper-elements="filter" + search-property="Name" + translation="{nothingSelected: 'Select one or more registry', search: 'Search...'}" + > + </span> + </div> + </div> + </div> + </div> + <!-- #endregion --> + <!-- #region STORAGES --> <div class="col-sm-12 form-section-title"> Storages diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.js b/app/kubernetes/views/resource-pools/edit/resourcePool.js index 3b6012a2d..d612e9f6a 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.js @@ -3,6 +3,6 @@ angular.module('portainer.kubernetes').component('kubernetesResourcePoolView', { controller: 'KubernetesResourcePoolController', controllerAs: 'ctrl', bindings: { - $transition$: '<', + endpoint: '<', }, }); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index ee12da091..31c5ff6f3 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -1,7 +1,7 @@ import angular from 'angular'; import _ from 'lodash-es'; import filesizeParser from 'filesize-parser'; -import { KubernetesResourceQuota, KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models'; +import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import { @@ -24,7 +24,7 @@ class KubernetesResourcePoolController { Authentication, Notifications, LocalStorage, - EndpointProvider, + EndpointService, ModalService, KubernetesNodeService, KubernetesResourceQuotaService, @@ -36,33 +36,30 @@ class KubernetesResourcePoolController { KubernetesIngressService, KubernetesVolumeService ) { - this.$async = $async; - this.$state = $state; - this.Notifications = Notifications; - this.Authentication = Authentication; - this.LocalStorage = LocalStorage; - this.EndpointProvider = EndpointProvider; - this.ModalService = ModalService; - - this.KubernetesNodeService = KubernetesNodeService; - this.KubernetesResourceQuotaService = KubernetesResourceQuotaService; - this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.KubernetesEventService = KubernetesEventService; - this.KubernetesPodService = KubernetesPodService; - this.KubernetesApplicationService = KubernetesApplicationService; - this.KubernetesNamespaceHelper = KubernetesNamespaceHelper; - this.KubernetesIngressService = KubernetesIngressService; - this.KubernetesVolumeService = KubernetesVolumeService; + Object.assign(this, { + $async, + $state, + Authentication, + Notifications, + LocalStorage, + EndpointService, + ModalService, + KubernetesNodeService, + KubernetesResourceQuotaService, + KubernetesResourcePoolService, + KubernetesEventService, + KubernetesPodService, + KubernetesApplicationService, + KubernetesNamespaceHelper, + KubernetesIngressService, + KubernetesVolumeService, + }); this.IngressClassTypes = KubernetesIngressClassTypes; this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults; - this.onInit = this.onInit.bind(this); - this.createResourceQuotaAsync = this.createResourceQuotaAsync.bind(this); this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this); this.getEvents = this.getEvents.bind(this); - this.getApplications = this.getApplications.bind(this); - this.getIngresses = this.getIngresses.bind(this); } /* #endregion */ @@ -159,15 +156,6 @@ class KubernetesResourcePoolController { this.selectTab(2); } - async createResourceQuotaAsync(namespace, owner, cpuLimit, memoryLimit) { - const quota = new KubernetesResourceQuota(namespace); - quota.CpuLimit = cpuLimit; - quota.MemoryLimit = memoryLimit; - quota.ResourcePoolName = namespace; - quota.ResourcePoolOwner = owner; - await this.KubernetesResourceQuotaService.create(quota); - } - hasResourceQuotaBeenReduced() { if (this.formValues.HasQuota && this.oldQuota) { const cpuLimit = this.formValues.CpuLimit; @@ -285,88 +273,119 @@ class KubernetesResourcePoolController { } /* #endregion */ + /* #region GET REGISTRIES */ + getRegistries() { + return this.$async(async () => { + try { + const namespace = this.$state.params.id; + + if (this.isAdmin) { + this.registries = await this.EndpointService.registries(this.endpoint.Id); + this.registries.forEach((reg) => { + if (reg.RegistryAccesses && reg.RegistryAccesses[this.endpoint.Id] && reg.RegistryAccesses[this.endpoint.Id].Namespaces.includes(namespace)) { + reg.Checked = true; + this.formValues.Registries.push(reg); + } + }); + + return; + } + + const registries = await this.EndpointService.registries(this.endpoint.Id, namespace); + this.selectedRegistries = registries.map((r) => r.Name).join(', '); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + } + }); + } + /* #endregion */ + /* #region ON INIT */ - async onInit() { - try { - const endpoint = this.EndpointProvider.currentEndpoint(); - this.endpoint = endpoint; - this.isAdmin = this.Authentication.isAdmin(); - - this.state = { - actionInProgress: false, - sliderMaxMemory: 0, - sliderMaxCpu: 0, - cpuUsage: 0, - cpuUsed: 0, - memoryUsage: 0, - memoryUsed: 0, - activeTab: 0, - currentName: this.$state.$current.name, - showEditorTab: false, - eventsLoading: true, - applicationsLoading: true, - ingressesLoading: true, - viewReady: false, - eventWarningCount: 0, - canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length, - duplicates: { - ingressHosts: new KubernetesFormValidationReferences(), - }, - }; - - this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool'); - - const name = this.$transition$.params().id; - - const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get()]); - - this.pool = _.find(pools, { Namespace: { Name: name } }); - this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults); - this.formValues.Name = this.pool.Namespace.Name; - - _.forEach(nodes, (item) => { - this.state.sliderMaxMemory += filesizeParser(item.Memory); - this.state.sliderMaxCpu += item.CPU; - }); - this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory); - - const quota = this.pool.Quota; - if (quota) { - this.oldQuota = angular.copy(quota); - this.formValues = KubernetesResourceQuotaConverter.quotaToResourcePoolFormValues(quota); - this.state.cpuUsed = quota.CpuLimitUsed; - this.state.memoryUsed = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed); - } - - this.isEditable = !this.KubernetesNamespaceHelper.isSystemNamespace(this.pool.Namespace.Name); - if (this.pool.Namespace.Name === 'default') { - this.isEditable = false; - } - - await this.getEvents(); - await this.getApplications(); - - if (this.state.canUseIngress) { - await this.getIngresses(); - const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses; - this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses); - _.forEach(this.formValues.IngressClasses, (ic) => { - if (ic.Hosts.length === 0) { - ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue()); - } - }); - } - this.savedFormValues = angular.copy(this.formValues); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to load view data'); - } finally { - this.state.viewReady = true; - } - } - $onInit() { - return this.$async(this.onInit); + return this.$async(async () => { + try { + const endpoint = this.endpoint; + this.isAdmin = this.Authentication.isAdmin(); + + this.state = { + actionInProgress: false, + sliderMaxMemory: 0, + sliderMaxCpu: 0, + cpuUsage: 0, + cpuUsed: 0, + memoryUsage: 0, + memoryUsed: 0, + activeTab: 0, + currentName: this.$state.$current.name, + showEditorTab: false, + eventsLoading: true, + applicationsLoading: true, + ingressesLoading: true, + viewReady: false, + eventWarningCount: 0, + canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length, + duplicates: { + ingressHosts: new KubernetesFormValidationReferences(), + }, + }; + + this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool'); + + const name = this.$state.params.id; + + const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get()]); + + this.pool = _.find(pools, { Namespace: { Name: name } }); + this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults); + this.formValues.Name = this.pool.Namespace.Name; + this.formValues.EndpointId = this.endpoint.Id; + + _.forEach(nodes, (item) => { + this.state.sliderMaxMemory += filesizeParser(item.Memory); + this.state.sliderMaxCpu += item.CPU; + }); + this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory); + + const quota = this.pool.Quota; + if (quota) { + this.oldQuota = angular.copy(quota); + this.formValues = KubernetesResourceQuotaConverter.quotaToResourcePoolFormValues(quota); + this.formValues.EndpointId = this.endpoint.Id; + + this.state.cpuUsed = quota.CpuLimitUsed; + this.state.memoryUsed = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed); + } + + this.isEditable = !this.KubernetesNamespaceHelper.isSystemNamespace(this.pool.Namespace.Name); + if (this.pool.Namespace.Name === 'default') { + this.isEditable = false; + } + + await this.getEvents(); + await this.getApplications(); + + if (this.state.canUseIngress) { + await this.getIngresses(); + const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses; + this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses); + _.forEach(this.formValues.IngressClasses, (ic) => { + if (ic.Hosts.length === 0) { + ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue()); + } + }); + } + + await this.getRegistries(); + + this.savedFormValues = angular.copy(this.formValues); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + }); } + /* #endregion */ $onDestroy() { diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 21e14120b..2724c8d6d 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -295,24 +295,12 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([ }, }; - var registryCreation = { + const registryCreation = { name: 'portainer.registries.new', url: '/new', views: { 'content@': { - templateUrl: './views/registries/create/createregistry.html', - controller: 'CreateRegistryController', - }, - }, - }; - - var registryAccess = { - name: 'portainer.registries.registry.access', - url: '/access', - views: { - 'content@': { - templateUrl: './views/registries/access/registryAccess.html', - controller: 'RegistryAccessController', + component: 'createRegistry', }, }, }; @@ -425,7 +413,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([ $stateRegistryProvider.register(initAdmin); $stateRegistryProvider.register(registries); $stateRegistryProvider.register(registry); - $stateRegistryProvider.register(registryAccess); $stateRegistryProvider.register(registryCreation); $stateRegistryProvider.register(settings); $stateRegistryProvider.register(settingsAuthentication); diff --git a/app/portainer/components/accessManagement/index.js b/app/portainer/components/accessManagement/index.js new file mode 100644 index 000000000..bfbb96001 --- /dev/null +++ b/app/portainer/components/accessManagement/index.js @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { porAccessManagement } from './por-access-management'; +import { porAccessManagementUsersSelector } from './por-access-management-users-selector'; + +export default angular + .module('portainer.app.component.access-management', []) + .component('porAccessManagement', porAccessManagement) + .component('porAccessManagementUsersSelector', porAccessManagementUsersSelector).name; diff --git a/app/portainer/components/accessManagement/por-access-management-users-selector/index.js b/app/portainer/components/accessManagement/por-access-management-users-selector/index.js new file mode 100644 index 000000000..51c4de45c --- /dev/null +++ b/app/portainer/components/accessManagement/por-access-management-users-selector/index.js @@ -0,0 +1,7 @@ +export const porAccessManagementUsersSelector = { + templateUrl: './por-access-management-users-selector.html', + bindings: { + options: '<', + value: '=', + }, +}; diff --git a/app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html b/app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html new file mode 100644 index 000000000..e73eb3bc9 --- /dev/null +++ b/app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html @@ -0,0 +1,23 @@ +<div class="form-group"> + <label class="col-sm-3 col-lg-2 control-label text-left"> + Select user(s) and/or team(s) + </label> + <div class="col-sm-9 col-lg-4"> + <span class="small text-muted" ng-if="$ctrl.options.length === 0"> + No users or teams available. + </span> + <span + isteven-multi-select + ng-if="$ctrl.options.length > 0" + input-model="$ctrl.options" + output-model="$ctrl.value" + button-label="icon '-' Name" + item-label="icon '-' Name" + tick-property="ticked" + helper-elements="filter" + search-property="Name" + translation="{nothingSelected: 'Select one or more users and/or teams', search: 'Search...'}" + > + </span> + </div> +</div> diff --git a/app/portainer/components/accessManagement/por-access-management.js b/app/portainer/components/accessManagement/por-access-management.js index 491f795c0..7c8cf34ab 100644 --- a/app/portainer/components/accessManagement/por-access-management.js +++ b/app/portainer/components/accessManagement/por-access-management.js @@ -1,4 +1,4 @@ -angular.module('portainer.app').component('porAccessManagement', { +export const porAccessManagement = { templateUrl: './porAccessManagement.html', controller: 'porAccessManagementController', controllerAs: 'ctrl', @@ -8,5 +8,6 @@ angular.module('portainer.app').component('porAccessManagement', { entityType: '@', updateAccess: '<', actionInProgress: '<', + filterUsers: '<', }, -}); +}; diff --git a/app/portainer/components/accessManagement/porAccessManagement.html b/app/portainer/components/accessManagement/porAccessManagement.html index 011394d5f..182bc6097 100644 --- a/app/portainer/components/accessManagement/porAccessManagement.html +++ b/app/portainer/components/accessManagement/porAccessManagement.html @@ -4,31 +4,9 @@ <rd-widget-header icon="fa-user-lock" title-text="Create access"></rd-widget-header> <rd-widget-body> <form class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 col-lg-2 control-label text-left"> - Select user(s) and/or team(s) - </label> - <div class="col-sm-9 col-lg-4"> - <span class="small text-muted" ng-if="ctrl.availableUsersAndTeams.length === 0"> - No users or teams available. - </span> - <span - isteven-multi-select - ng-if="ctrl.availableUsersAndTeams.length > 0" - input-model="ctrl.availableUsersAndTeams" - output-model="ctrl.formValues.multiselectOutput" - button-label="icon '-' Name" - item-label="icon '-' Name" - tick-property="ticked" - helper-elements="filter" - search-property="Name" - translation="{nothingSelected: 'Select one or more users and/or teams', search: 'Search...'}" - > - </span> - </div> - </div> + <por-access-management-users-selector options="ctrl.availableUsersAndTeams" value="ctrl.formValues.multiselectOutput"></por-access-management-users-selector> - <div class="form-group"> + <div class="form-group" ng-if="ctrl.entityType != 'registry'"> <label class="col-sm-3 col-lg-2 control-label text-left"> Role </label> diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js index 460c302d9..8c1e5d524 100644 --- a/app/portainer/components/accessManagement/porAccessManagementController.js +++ b/app/portainer/components/accessManagement/porAccessManagementController.js @@ -44,6 +44,7 @@ class PorAccessManagementController { const teamAccessPolicies = entity.TeamAccessPolicies; const selectedUserAccesses = _.filter(selectedAccesses, (access) => access.Type === 'user'); const selectedTeamAccesses = _.filter(selectedAccesses, (access) => access.Type === 'team'); + _.forEach(selectedUserAccesses, (access) => delete userAccessPolicies[access.Id]); _.forEach(selectedTeamAccesses, (access) => delete teamAccessPolicies[access.Id]); this.updateAccess(); @@ -55,6 +56,11 @@ class PorAccessManagementController { const parent = this.inheritFrom; const data = await this.AccessService.accesses(entity, parent, this.roles); + + if (this.filterUsers) { + data.availableUsersAndTeams = this.filterUsers(data.availableUsersAndTeams); + } + this.availableUsersAndTeams = _.orderBy(data.availableUsersAndTeams, 'Name', 'asc'); this.authorizedUsersAndTeams = data.authorizedUsersAndTeams; } catch (err) { diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html index 697e7fd47..16d09365a 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html @@ -4,8 +4,14 @@ <div class="toolBar"> <div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div> </div> - <div class="actionBar" ng-if="$ctrl.accessManagement"> - <button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"> + <div class="actionBar" ng-if="$ctrl.isAdmin"> + <button + ng-if="!$ctrl.endpointType" + type="button" + class="btn btn-sm btn-danger" + ng-disabled="$ctrl.state.selectedItemCount === 0" + ng-click="$ctrl.removeAction($ctrl.state.selectedItems)" + > <i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove </button> <button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.registries.new"> <i class="fa fa-plus space-right" aria-hidden="true"></i>Add registry </button> @@ -27,7 +33,7 @@ <thead> <tr> <th> - <span class="md-checkbox" ng-if="$ctrl.accessManagement"> + <span class="md-checkbox" ng-if="$ctrl.isAdmin && !$ctrl.endpointType"> <input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" /> <label for="select_all"></label> </span> @@ -53,22 +59,29 @@ ng-class="{ active: item.Checked }" > <td> - <span class="md-checkbox" ng-if="$ctrl.accessManagement"> - <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" /> + <span class="md-checkbox" ng-if="$ctrl.isAdmin && !$ctrl.endpointType"> + <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="!$ctrl.allowSelection(item)" /> <label for="select_{{ $index }}"></label> </span> - <a ui-sref="portainer.registries.registry({id: item.Id})" ng-if="$ctrl.accessManagement">{{ item.Name }}</a> - <span ng-if="!$ctrl.accessManagement">{{ item.Name }}</span> + <a ui-sref="portainer.registries.registry({id: item.Id})" ng-if="$ctrl.enableGoToLink(item)">{{ item.Name }}</a> + <span ng-if="!$ctrl.enableGoToLink(item)">{{ item.Name }}</span> <span ng-if="item.Authentication" style="margin-left: 5px;" class="label label-info image-tag">authentication-enabled</span> </td> <td> {{ item.URL }} </td> <td> - <a ui-sref="portainer.registries.registry.access({id: item.Id})" ng-if="$ctrl.accessManagement"> <i class="fa fa-users" aria-hidden="true"></i> Manage access </a> - <span class="text-muted space-left" style="cursor: pointer;" data-toggle="tooltip" title="This feature is available in Portainer Business Edition"> + <a ng-if="$ctrl.canManageAccess(item)" ng-click="$ctrl.redirectToManageAccess(item)"> <i class="fa fa-users" aria-hidden="true"></i> Manage access </a> + <span + ng-if="$ctrl.canBrowse(item)" + class="text-muted space-left" + style="cursor: pointer;" + data-toggle="tooltip" + title="This feature is available in Portainer Business Edition" + > <i class="fa fa-search" aria-hidden="true"></i> Browse</span > + <span ng-if="!$ctrl.canBrowse(item) && !$ctrl.canManageAccess(item)"> - </span> </td> </tr> <tr ng-if="!$ctrl.dataset"> diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.js b/app/portainer/components/datatables/registries-datatable/registriesDatatable.js index f4df3c5a8..e5acb6907 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.js +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.js @@ -1,6 +1,6 @@ angular.module('portainer.app').component('registriesDatatable', { templateUrl: './registriesDatatable.html', - controller: 'GenericDatatableController', + controller: 'RegistriesDatatableController', bindings: { titleText: '@', titleIcon: '@', @@ -8,8 +8,9 @@ angular.module('portainer.app').component('registriesDatatable', { tableKey: '@', orderBy: '@', reverseOrder: '<', - accessManagement: '<', removeAction: '<', canBrowse: '<', + endpointType: '<', + canManageAccess: '<', }, }); diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js b/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js new file mode 100644 index 000000000..1b2f86e35 --- /dev/null +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js @@ -0,0 +1,85 @@ +import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; + +angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController); + +/* @ngInject */ +function RegistriesDatatableController($scope, $controller, $state, Authentication, DatatableService) { + angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); + + this.allowSelection = function (item) { + return item.Id; + }; + + this.enableGoToLink = (item) => { + return this.isAdmin && item.Id && !this.endpointType; + }; + + this.goToRegistry = function (item) { + if ( + this.endpointType === PortainerEndpointTypes.KubernetesLocalEnvironment || + this.endpointType === PortainerEndpointTypes.AgentOnKubernetesEnvironment || + this.endpointType === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment + ) { + $state.go('kubernetes.registries.registry', { id: item.Id }); + } else if ( + this.endpointType === PortainerEndpointTypes.DockerEnvironment || + this.endpointType === PortainerEndpointTypes.AgentOnDockerEnvironment || + this.endpointType === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment + ) { + $state.go('docker.registries.registry', { id: item.Id }); + } else { + $state.go('portainer.registries.registry', { id: item.Id }); + } + }; + + this.redirectToManageAccess = function (item) { + if ( + this.endpointType === PortainerEndpointTypes.KubernetesLocalEnvironment || + this.endpointType === PortainerEndpointTypes.AgentOnKubernetesEnvironment || + this.endpointType === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment + ) { + $state.go('kubernetes.registries.access', { id: item.Id }); + } else { + $state.go('docker.registries.access', { id: item.Id }); + } + }; + + this.$onInit = function () { + this.isAdmin = Authentication.isAdmin(); + this.setDefaults(); + this.prepareTableFromDataset(); + + this.state.orderBy = this.orderBy; + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } + + var storedFilters = DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.filters = storedFilters; + } + if (this.filters && this.filters.state) { + this.filters.state.open = false; + } + + var storedSettings = DatatableService.getDataTableSettings(this.tableKey); + if (storedSettings !== null) { + this.settings = storedSettings; + this.settings.open = false; + } + this.onSettingsRepeaterChange(); + + var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey); + if (storedColumnVisibility !== null) { + this.columnVisibility = storedColumnVisibility; + } + }; +} diff --git a/app/portainer/components/datatables/strings-datatable/index.js b/app/portainer/components/datatables/strings-datatable/index.js new file mode 100644 index 000000000..dda9fa5fd --- /dev/null +++ b/app/portainer/components/datatables/strings-datatable/index.js @@ -0,0 +1,20 @@ +import angular from 'angular'; +// import controller from './strings-datatable.controller.js'; + +export const stringsDatatable = { + templateUrl: './strings-datatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + emptyDatasetMessage: '@', + + columnHeader: '@', + tableKey: '@', + + onRemove: '<', + }, +}; + +angular.module('portainer.app').component('stringsDatatable', stringsDatatable); diff --git a/app/portainer/components/datatables/strings-datatable/strings-datatable.html b/app/portainer/components/datatables/strings-datatable/strings-datatable.html new file mode 100644 index 000000000..0b6faf0ec --- /dev/null +++ b/app/portainer/components/datatables/strings-datatable/strings-datatable.html @@ -0,0 +1,65 @@ +<div class="datatable"> + <rd-widget> + <rd-widget-header icon="{{ $ctrl.titleIcon }}" title-text="{{ $ctrl.titleText }}"> </rd-widget-header> + <rd-widget-body classes="no-padding"> + <div class="actionBar"> + <button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.onRemove($ctrl.state.selectedItems)"> + <i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove + </button> + </div> + <div class="searchBar"> + <i class="fa fa-search searchIcon" aria-hidden="true"></i> + <input + type="text" + class="searchInput" + ng-model="$ctrl.state.textFilter" + ng-change="$ctrl.onTextFilterChange()" + placeholder="Search..." + ng-model-options="{ debounce: 300 }" + /> + </div> + <div class="table-responsive"> + <table class="table table-hover nowrap-cells"> + <thead> + <tr> + <th> + <span class="md-checkbox"> + <input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" /> + <label for="select_all"></label> + </span> + <a ng-click="$ctrl.changeOrderBy('Name')"> + {{ $ctrl.columnHeader }} + <i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i> + <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i> + </a> + </th> + </tr> + </thead> + <tbody> + <tr + dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit)) track by $index" + ng-class="{ active: $ctrl.state.selectedItems.includes(item) }" + > + <td> + <span class="md-checkbox"> + <input + id="select_{{ $index }}" + type="checkbox" + ng-checked="$ctrl.state.selectedItems.includes(item)" + ng-disabled="$ctrl.disableRemove(item)" + ng-click="$ctrl.selectItem(item, $event)" + /> + <label for="select_{{ $index }}"></label> + </span> + {{ item.value }} + </td> + </tr> + <tr ng-if="$ctrl.state.filteredDataSet.length === 0"> + <td class="text-center text-muted">{{ $ctrl.emptyDatasetMessage }}</td> + </tr> + </tbody> + </table> + </div> + </rd-widget-body> + </rd-widget> +</div> diff --git a/app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.html b/app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.html new file mode 100644 index 000000000..ddf26592e --- /dev/null +++ b/app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.html @@ -0,0 +1,64 @@ +<form class="form-horizontal" name="registryFormDockerhub" ng-submit="$ctrl.formAction()"> + <div class="col-sm-12 form-section-title"> + DockerHub account 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="dockerhub-prod-us" required /> + </div> + </div> + <div class="form-group" ng-show="registryFormDockerhub.registry_name.$invalid"> + <div class="col-sm-12 small text-warning"> + <div ng-messages="registryFormDockerhub.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 --> + <!-- credentials-user --> + <div class="form-group"> + <label for="registry_username" class="col-sm-3 col-lg-2 control-label text-left">DockerHub 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="registryFormDockerhub.registry_username.$invalid"> + <div class="col-sm-12 small text-warning"> + <div ng-messages="registryFormDockerhub.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">DockerHub 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="registryFormDockerhub.registry_password.$invalid"> + <div class="col-sm-12 small text-warning"> + <div ng-messages="registryFormDockerhub.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 --> + + <!-- 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 || !registryFormDockerhub.$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> diff --git a/app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.js b/app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.js new file mode 100644 index 000000000..adf60fcd5 --- /dev/null +++ b/app/portainer/components/forms/registry-form-dockerhub/registry-form-dockerhub.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormDockerhub', { + templateUrl: './registry-form-dockerhub.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + }, +}); diff --git a/app/portainer/components/forms/template-form/template-form.js b/app/portainer/components/forms/template-form/template-form.js deleted file mode 100644 index 8aacd5839..000000000 --- a/app/portainer/components/forms/template-form/template-form.js +++ /dev/null @@ -1,13 +0,0 @@ -angular.module('portainer.app').component('templateForm', { - templateUrl: './templateForm.html', - controller: 'TemplateFormController', - bindings: { - model: '=', - categories: '<', - networks: '<', - formAction: '<', - formActionLabel: '@', - actionInProgress: '<', - showTypeSelector: '<', - }, -}); diff --git a/app/portainer/components/forms/template-form/templateForm.html b/app/portainer/components/forms/template-form/templateForm.html deleted file mode 100644 index 12bff3e7b..000000000 --- a/app/portainer/components/forms/template-form/templateForm.html +++ /dev/null @@ -1,580 +0,0 @@ -<form class="form-horizontal" name="templateForm"> - <!-- title-input --> - <div class="form-group" ng-class="{ 'has-error': templateForm.template_title.$invalid }"> - <label for="template_title" class="col-sm-3 col-lg-2 control-label text-left">Title</label> - <div class="col-sm-9 col-lg-10"> - <input type="text" class="form-control" name="template_title" ng-model="$ctrl.model.Title" placeholder="e.g. my-template" required auto-focus /> - </div> - </div> - <div class="form-group" ng-show="templateForm.template_title.$invalid"> - <div class="col-sm-12 small text-warning"> - <div ng-messages="templateForm.template_title.$error"> - <p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p> - </div> - </div> - </div> - <!-- !title-input --> - <!-- description-input --> - <div class="form-group" ng-class="{ 'has-error': templateForm.template_description.$invalid }"> - <label for="template_description" class="col-sm-3 col-lg-2 control-label text-left">Description</label> - <div class="col-sm-9 col-lg-10"> - <input type="text" class="form-control" name="template_description" ng-model="$ctrl.model.Description" placeholder="e.g. template description..." required /> - </div> - </div> - <div class="form-group" ng-show="templateForm.template_description.$invalid"> - <div class="col-sm-12 small text-warning"> - <div ng-messages="templateForm.template_description.$error"> - <p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p> - </div> - </div> - </div> - <!-- !description-input --> - <div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseTemplate = !$ctrl.state.collapseTemplate"> - Template - <span class="small space-left"> - <a ng-if="$ctrl.state.collapseTemplate"><i class="fa fa-plus" aria-hidden="true"></i> expand</a> - <a ng-if="!$ctrl.state.collapseTemplate"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a> - </span> - </div> - <!-- template-details --> - <div uib-collapse="$ctrl.state.collapseTemplate"> - <div ng-if="$ctrl.showTypeSelector"> - <div class="form-group"></div> - <div class="form-group" style="margin-bottom: 0;"> - <div class="boxselector_wrapper"> - <div> - <input type="radio" id="template_container" ng-model="$ctrl.model.Type" ng-value="1" /> - <label for="template_container"> - <div class="boxselector_header"> - <i class="fa fa-cubes" aria-hidden="true" style="margin-right: 2px;"></i> - Container - </div> - <p>Container template</p> - </label> - </div> - <div> - <input type="radio" id="template_swarm_stack" ng-model="$ctrl.model.Type" ng-value="2" /> - <label for="template_swarm_stack"> - <div class="boxselector_header"> - <i class="fa fa-th-list" aria-hidden="true" style="margin-right: 2px;"></i> - Swarm stack - </div> - <p>Stack template (Swarm)</p> - </label> - </div> - <div> - <input type="radio" id="template_compose_stack" ng-model="$ctrl.model.Type" ng-value="3" /> - <label for="template_compose_stack"> - <div class="boxselector_header"> - <i class="fa fa-th-list" aria-hidden="true" style="margin-right: 2px;"></i> - Compose stack - </div> - <p>Stack template (Compose)</p> - </label> - </div> - </div> - </div> - </div> - - <!-- name --> - <div class="form-group"> - <label for="template_name" class="col-sm-3 col-lg-2 control-label text-left"> - Name - <portainer-tooltip position="bottom" message="Default name that will be associated to the template"></portainer-tooltip> - </label> - <div class="col-sm-9 col-lg-10"> - <input type="text" class="form-control" name="template_name" ng-model="$ctrl.model.Name" placeholder="e.g. myApp" /> - </div> - </div> - <!-- !name --> - <!-- logo --> - <div class="form-group"> - <label for="template_logo" class="col-sm-3 col-lg-2 control-label text-left"> - Logo URL - <portainer-tooltip position="bottom" message="Recommended size: 60x60"></portainer-tooltip> - </label> - <div class="col-sm-9 col-lg-10"> - <input type="text" class="form-control" name="template_logo" ng-model="$ctrl.model.Logo" placeholder="e.g. https://portainer.io/images/logos/nginx.png" /> - </div> - </div> - <!-- !logo --> - <!-- note --> - <div class="form-group"> - <label for="template_note" class="col-sm-3 col-lg-2 control-label text-left"> - Note - <portainer-tooltip position="bottom" message="Usage/extra information about the template. Supports HTML."></portainer-tooltip> - </label> - <div class="col-sm-9 col-lg-10"> - <textarea - class="form-control" - name="template_note" - ng-model="$ctrl.model.Note" - placeholder="You can use this field to specify extra information. <br/> It supports <b>HTML</b>." - ></textarea> - </div> - </div> - <!-- !note --> - <!-- platform --> - <div class="form-group"> - <label for="template_platform" class="col-sm-3 col-lg-2 control-label text-left"> - Platform - </label> - <div class="col-sm-9 col-lg-10"> - <select class="form-control" name="template_platform" ng-model="$ctrl.model.Platform"> - <option value="">Multi-platform</option> - <option value="linux">Linux</option> - <option value="windows">Windows</option> - </select> - </div> - </div> - <!-- !platform --> - <!-- categories --> - <div class="form-group"> - <label for="template_categories" class="col-sm-3 col-lg-2 control-label text-left"> - Categories - </label> - <div class="col-sm-9 col-lg-10"> - <ui-select multiple tagging tagging-label="(new category)" ng-model="$ctrl.model.Categories" sortable="true" style="width: 300px;" title="Choose a category"> - <ui-select-match placeholder="Select categories...">{{ $item }}</ui-select-match> - <ui-select-choices repeat="category in $ctrl.categories | filter:$select.search"> - {{ category }} - </ui-select-choices> - </ui-select> - </div> - </div> - <!-- !categories --> - <!-- administrator-only --> - <div class="form-group"> - <div class="col-sm-12"> - <label for="tls" class="control-label text-left"> - Administrator template - <portainer-tooltip position="bottom" message="This template will only be available to administrator users."></portainer-tooltip> - </label> - <label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.model.AdministratorOnly" /><i></i> </label> - </div> - </div> - <!-- administrator-only --> - </div> - <!-- !template-details --> - <div ng-if="$ctrl.model.Type === 2 || $ctrl.model.Type === 3"> - <div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseStack = !$ctrl.state.collapseStack"> - Stack - <span class="small space-left"> - <a ng-if="$ctrl.state.collapseStack"><i class="fa fa-plus" aria-hidden="true"></i> expand</a> - <a ng-if="!$ctrl.state.collapseStack"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a> - </span> - </div> - <!-- stack-details --> - <div uib-collapse="$ctrl.state.collapseStack"> - <!-- repository-url --> - <div class="form-group" ng-class="{ 'has-error': templateForm.template_repository_url.$invalid }"> - <label for="template_repository_url" class="col-sm-3 col-lg-2 control-label text-left">Repository URL</label> - <div class="col-sm-9 col-lg-10"> - <input - type="text" - class="form-control" - name="template_repository_url" - ng-model="$ctrl.model.Repository.url" - placeholder="https://github.com/portainer/portainer-compose" - required - /> - </div> - </div> - <div class="form-group" ng-show="templateForm.template_repository_url.$invalid"> - <div class="col-sm-12 small text-warning"> - <div ng-messages="templateForm.template_repository_url.$error"> - <p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p> - </div> - </div> - </div> - <!-- !repository-url --> - <!-- composefile-path --> - <div class="form-group"> - <label for="template_repository_path" class="col-sm-3 col-lg-2 control-label text-left"> - Compose file path - </label> - <div class="col-sm-9 col-lg-10"> - <input type="text" class="form-control" name="template_repository_path" ng-model="$ctrl.model.Repository.stackfile" placeholder="docker-compose.yml" /> - </div> - </div> - <!-- !composefile-path --> - </div> - <!-- !stack-details --> - </div> - <div ng-if="$ctrl.model.Type === 1"> - <div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseContainer = !$ctrl.state.collapseContainer"> - Container - <span class="small space-left"> - <a ng-if="$ctrl.state.collapseContainer"><i class="fa fa-plus" aria-hidden="true"></i> expand</a> - <a ng-if="!$ctrl.state.collapseContainer"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a> - </span> - </div> - <!-- container-details --> - <div uib-collapse="$ctrl.state.collapseContainer"> - <por-image-registry model="$ctrl.model.RegistryModel" auto-complete="true" label-class="col-sm-1" input-class="col-sm-11"></por-image-registry> - <!-- command --> - <div class="form-group"> - <label for="template_command" class="col-sm-3 col-lg-2 control-label text-left"> - Command - <portainer-tooltip - position="bottom" - message="The command to run in the container. If not specified, the container will use the default command specified in its Dockerfile." - ></portainer-tooltip> - </label> - <div class="col-sm-9 col-lg-10"> - <input type="text" class="form-control" name="template_command" ng-model="$ctrl.model.Command" placeholder='/bin/bash -c \"echo hello\" && exit 777' /> - </div> - </div> - <!-- !command --> - <!-- hostname --> - <div class="form-group"> - <label for="template_hostname" class="col-sm-3 col-lg-2 control-label text-left"> - Hostname - <portainer-tooltip position="bottom" message="Set the hostname of the container. Will use Docker default if not specified."></portainer-tooltip> - </label> - <div class="col-sm-9 col-lg-10"> - <input type="text" class="form-control" name="template_hostname" ng-model="$ctrl.model.Hostname" placeholder="mycontainername" /> - </div> - </div> - <!-- !hostname --> - <!-- network --> - <div class="form-group"> - <label for="template_network" class="col-sm-3 col-lg-2 control-label text-left"> - Network - </label> - <div class="col-sm-10"> - <select class="form-control" ng-options="net.Name for net in $ctrl.networks" ng-model="$ctrl.model.Network"> - <option disabled hidden value="">Select a network</option> - </select> - </div> - </div> - <!-- !network --> - <!-- port-mapping --> - <div class="form-group"> - <div class="col-sm-12" style="margin-top: 5px;"> - <label class="control-label text-left">Port mapping</label> - <span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addPortBinding()"> - <i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port - </span> - </div> - <div class="col-sm-12" style="margin-top: 10px;" ng-if="$ctrl.model.Ports.length > 0"> - <span class="small text-muted">Portainer will automatically assign a port if you leave the host port empty.</span> - </div> - <!-- port-mapping-input-list --> - <div class="col-sm-12"> - <div class="col-sm-12 form-inline" style="margin-top: 10px;"> - <div ng-repeat="portBinding in $ctrl.model.Ports" style="margin-top: 2px;"> - <!-- host-port --> - <div class="input-group col-sm-4 input-group-sm"> - <span class="input-group-addon">host</span> - <input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)" /> - </div> - <!-- !host-port --> - <span style="margin: 0 10px 0 10px;"> - <i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i> - </span> - <!-- container-port --> - <div class="input-group col-sm-4 input-group-sm"> - <span class="input-group-addon">container</span> - <input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80" /> - </div> - <!-- !container-port --> - <!-- protocol-actions --> - <div class="input-group col-sm-3 input-group-sm"> - <div class="btn-group btn-group-sm"> - <label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label> - <label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label> - </div> - <button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removePortBinding($index)"> - <i class="fa fa-trash" aria-hidden="true"></i> - </button> - </div> - <!-- !protocol-actions --> - </div> - </div> - </div> - <!-- !port-mapping-input-list --> - </div> - <!-- !port-mapping --> - <!-- volumes --> - <div class="form-group"> - <div class="col-sm-12" style="margin-top: 5px;"> - <label class="control-label text-left">Volume mapping</label> - <span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addVolume()"> - <i class="fa fa-plus-circle" aria-hidden="true"></i> map additional volume - </span> - </div> - <div class="col-sm-12" style="margin-top: 10px;" ng-if="$ctrl.model.Volumes.length > 0"> - <span class="small text-muted">Portainer will automatically create and map a local volume when using the <b>auto</b> option.</span> - </div> - <div ng-repeat="volume in $ctrl.model.Volumes"> - <div class="col-sm-12" style="margin-top: 10px;"> - <!-- volume-line1 --> - <div class="col-sm-12 form-inline"> - <!-- container-path --> - <div class="input-group input-group-sm col-sm-6"> - <span class="input-group-addon">container</span> - <input type="text" class="form-control" ng-model="volume.container" placeholder="e.g. /path/in/container" /> - </div> - <!-- !container-path --> - <!-- volume-type --> - <div class="input-group col-sm-5" style="margin-left: 5px;"> - <div class="btn-group btn-group-sm"> - <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.bind = ''">Auto</label> - <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.bind = ''">Bind</label> - </div> - <button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeVolume($index)"> - <i class="fa fa-trash" aria-hidden="true"></i> - </button> - </div> - <!-- !volume-type --> - </div> - <!-- !volume-line1 --> - <!-- volume-line2 --> - <div class="col-sm-12 form-inline" style="margin-top: 5px;" ng-if="volume.type !== 'auto'"> - <i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i> - <!-- bind --> - <div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'"> - <span class="input-group-addon">host</span> - <input type="text" class="form-control" ng-model="volume.bind" placeholder="e.g. /path/on/host" /> - </div> - <!-- !bind --> - <!-- read-only --> - <div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;"> - <div class="btn-group btn-group-sm"> - <label class="btn btn-primary" ng-model="volume.readonly" uib-btn-radio="false">Writable</label> - <label class="btn btn-primary" ng-model="volume.readonly" uib-btn-radio="true">Read-only</label> - </div> - </div> - <!-- !read-only --> - </div> - <!-- !volume-line2 --> - </div> - </div> - </div> - <!-- !volumes --> - <!-- labels --> - <div class="form-group"> - <div class="col-sm-12" style="margin-top: 5px;"> - <label class="control-label text-left">Labels</label> - <span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addLabel()"> - <i class="fa fa-plus-circle" aria-hidden="true"></i> add label - </span> - </div> - <!-- labels-input-list --> - <div class="col-sm-12"> - <div class="col-sm-12 form-inline" style="margin-top: 10px;"> - <div ng-repeat="label in $ctrl.model.Labels" style="margin-top: 2px;"> - <div class="input-group col-sm-5 input-group-sm"> - <span class="input-group-addon">name</span> - <input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo" /> - </div> - <div class="input-group col-sm-5 input-group-sm"> - <span class="input-group-addon">value</span> - <input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" /> - </div> - <button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLabel($index)"> - <i class="fa fa-trash" aria-hidden="true"></i> - </button> - </div> - </div> - </div> - <!-- !labels-input-list --> - </div> - <!-- !labels --> - <!-- restart_policy --> - <div class="form-group"> - <label for="template_restart_policy" class="col-sm-3 col-lg-2 control-label text-left"> - Restart policy - </label> - <div class="col-sm-9 col-lg-10"> - <select class="form-control" name="template_platform" ng-model="$ctrl.model.RestartPolicy"> - <option value="always">Always</option> - <option value="unless-stopped">Unless stopped</option> - <option value="on-failure">On failure</option> - <option value="no">None</option> - </select> - </div> - </div> - <!-- !restart_policy --> - <!-- privileged-mode --> - <div class="form-group"> - <div class="col-sm-12"> - <label for="tls" class="control-label text-left"> - Privileged mode - <portainer-tooltip position="bottom" message="Start the container in privileged mode."></portainer-tooltip> - </label> - <label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.model.Privileged" /><i></i> </label> - </div> - </div> - <!-- !privileged-mode --> - <!-- interactive-mode --> - <div class="form-group"> - <div class="col-sm-12"> - <label for="tls" class="control-label text-left"> - Interactive mode - <portainer-tooltip position="bottom" message="Start the container in foreground (equivalent of -i -t flags)."></portainer-tooltip> - </label> - <label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.model.Interactive" /><i></i> </label> - </div> - </div> - <!-- !interactive-mode --> - </div> - <!-- !container-details --> - </div> - <div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseEnv = !$ctrl.state.collapseEnv"> - Environment - <span class="small space-left"> - <a ng-if="$ctrl.state.collapseEnv"><i class="fa fa-plus" aria-hidden="true"></i> expand</a> - <a ng-if="!$ctrl.state.collapseEnv"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a> - </span> - </div> - <!-- environment-details --> - <div uib-collapse="$ctrl.state.collapseEnv"> - <!-- env --> - <div class="form-group"> - <div class="col-sm-12" style="margin-top: 5px;"> - <label class="control-label text-left">Environment variables</label> - <span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addEnvVar()"> - <i class="fa fa-plus-circle" aria-hidden="true"></i> add variable - </span> - </div> - <!-- env-var-list --> - <div style="margin-top: 10px;"> - <div class="col-sm-12 template-envvar" ng-repeat="var in $ctrl.model.Env" style="margin-top: 10px;"> - <div class="form-group"></div> - <div class="form-group" style="margin-bottom: 0;"> - <div class="boxselector_wrapper"> - <div> - <input type="radio" id="preset_var_{{ $index }}" ng-model="var.type" ng-value="1" ng-change="$ctrl.changeEnvVarType(var)" /> - <label for="preset_var_{{ $index }}"> - <div class="boxselector_header"> - <i class="fa fa-user-slash" aria-hidden="true" style="margin-right: 2px;"></i> - Preset - </div> - <p>Preset variable</p> - </label> - </div> - <div> - <input type="radio" id="text_var_{{ $index }}" ng-model="var.type" ng-value="2" ng-change="$ctrl.changeEnvVarType(var)" /> - <label for="text_var_{{ $index }}"> - <div class="boxselector_header"> - <i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i> - Text - </div> - <p>Free text value</p> - </label> - </div> - <div> - <input type="radio" id="select_var_{{ $index }}" ng-model="var.type" ng-value="3" /> - <label for="select_var_{{ $index }}"> - <div class="boxselector_header"> - <i class="fa fa-list-ol" aria-hidden="true" style="margin-right: 2px;"></i> - Select - </div> - <p>Choose value from list</p> - </label> - </div> - </div> - </div> - <div class="form-group"> - <label class="col-sm-2 control-label text-left"> - Name - </label> - <div class="col-sm-8"> - <input type="text" class="form-control" ng-model="var.name" placeholder="env_var" /> - </div> - <div class="col-sm-2"> - <button class="btn btn-sm btn-danger space-left" type="button" ng-click="$ctrl.removeEnvVar($index)"> - <i class="fa fa-trash" aria-hidden="true"></i> - </button> - </div> - </div> - <div ng-if="var.type == 2 || var.type == 3"> - <div class="form-group"> - <label class="col-sm-2 control-label text-left"> - Label - </label> - <div class="col-sm-10"> - <input type="text" class="form-control" ng-model="var.label" placeholder="Choose a label" /> - </div> - </div> - <div class="form-group"> - <label class="col-sm-2 control-label text-left" style="margin-top: 2px;"> - Description - </label> - <div class="col-sm-10" style="margin-top: 2px;"> - <input type="text" class="form-control" ng-model="var.description" placeholder="Tooltip" /> - </div> - </div> - </div> - <div class="form-group" ng-if="var.type === 1 || var.type === 2"> - <label class="col-sm-2 control-label text-left"> - Default value - </label> - <div class="col-sm-10"> - <input type="text" class="form-control" ng-model="var.default" placeholder="default_value" /> - </div> - </div> - <div ng-if="var.type === 3" style="margin-bottom: 5px;" class="form-group"> - <div class="col-sm-12" style="margin-top: 5px;"> - <label class="control-label text-left">Values</label> - <span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addEnvVarValue(var)"> - <i class="fa fa-plus-circle" aria-hidden="true"></i> add allowed value - </span> - </div> - <!-- envvar-values-list --> - <div class="col-sm-12"> - <div class="col-sm-12 form-inline" style="margin-top: 10px;"> - <div ng-repeat="val in var.select" style="margin-top: 2px;"> - <div class="input-group col-sm-5 input-group-sm"> - <span class="input-group-addon">name</span> - <input type="text" class="form-control" ng-model="val.text" placeholder="Yes, I agree" /> - </div> - <div class="input-group col-sm-5 input-group-sm"> - <span class="input-group-addon">value</span> - <input type="text" class="form-control" ng-model="val.value" placeholder="Y" /> - </div> - <div class="input-group col-sm-1 input-group-sm"> - <button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeEnvVarValue(var, $index)"> - <i class="fa fa-trash" aria-hidden="true"></i> - </button> - <input style="margin-left: 5px;" type="checkbox" ng-model="val.default" id="val_default_{{ $index }}" /><label for="val_default_{{ $index }}" class="space-left" - >Default</label - > - </div> - </div> - </div> - </div> - <!-- envvar-values-list --> - </div> - <div class="col-sm-12" ng-show="$ctrl.model.Env.length > 1"> - <div class="line-separator"></div> - </div> - </div> - </div> - <!-- !env-var-list --> - </div> - <!-- !env --> - </div> - <!-- !environment-details --> - <!-- actions --> - <div class="col-sm-12 form-section-title"> - Actions - </div> - <div class="form-group"> - <div class="col-sm-12"> - <button - type="button" - class="btn btn-primary btn-sm" - ng-click="$ctrl.formAction()" - ng-disabled="$ctrl.actionInProgress || !templateForm.$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> diff --git a/app/portainer/components/forms/template-form/templateFormController.js b/app/portainer/components/forms/template-form/templateFormController.js deleted file mode 100644 index c03c11b60..000000000 --- a/app/portainer/components/forms/template-form/templateFormController.js +++ /dev/null @@ -1,55 +0,0 @@ -angular.module('portainer.app').controller('TemplateFormController', [ - function () { - this.state = { - collapseTemplate: false, - collapseContainer: false, - collapseStack: false, - collapseEnv: false, - }; - - this.addPortBinding = function () { - this.model.Ports.push({ containerPort: '', protocol: 'tcp' }); - }; - - this.removePortBinding = function (index) { - this.model.Ports.splice(index, 1); - }; - - this.addVolume = function () { - this.model.Volumes.push({ container: '', bind: '', readonly: false, type: 'auto' }); - }; - - this.removeVolume = function (index) { - this.model.Volumes.splice(index, 1); - }; - - this.addLabel = function () { - this.model.Labels.push({ name: '', value: '' }); - }; - - this.removeLabel = function (index) { - this.model.Labels.splice(index, 1); - }; - - this.addEnvVar = function () { - this.model.Env.push({ type: 1, name: '', label: '', description: '', default: '', preset: true, select: [] }); - }; - - this.removeEnvVar = function (index) { - this.model.Env.splice(index, 1); - }; - - this.addEnvVarValue = function (env) { - env.select = env.select || []; - env.select.push({ name: '', value: '' }); - }; - - this.removeEnvVarValue = function (env, index) { - env.select.splice(index, 1); - }; - - this.changeEnvVarType = function (env) { - env.preset = env.type === 1; - }; - }, -]); diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js index 8fa3030a0..6291cb0b5 100644 --- a/app/portainer/components/index.js +++ b/app/portainer/components/index.js @@ -1,5 +1,6 @@ import angular from 'angular'; import gitFormModule from './forms/git-form'; +import porAccessManagementModule from './accessManagement'; -export default angular.module('portainer.app.components', [gitFormModule]).name; +export default angular.module('portainer.app.components', [gitFormModule, porAccessManagementModule]).name; diff --git a/app/portainer/components/registry-details/index.js b/app/portainer/components/registry-details/index.js new file mode 100644 index 000000000..418c48781 --- /dev/null +++ b/app/portainer/components/registry-details/index.js @@ -0,0 +1,10 @@ +import angular from 'angular'; + +export const registryDetails = { + templateUrl: './registry-details.html', + bindings: { + registry: '<', + }, +}; + +angular.module('portainer.app').component('registryDetails', registryDetails); diff --git a/app/portainer/components/registry-details/registry-details.html b/app/portainer/components/registry-details/registry-details.html new file mode 100644 index 000000000..b67e69b03 --- /dev/null +++ b/app/portainer/components/registry-details/registry-details.html @@ -0,0 +1,25 @@ +<div class="row" ng-if="$ctrl.registry"> + <div class="col-lg-12 col-md-12 col-xs-12"> + <rd-widget> + <rd-widget-header icon="fa-plug" title-text="Registry"></rd-widget-header> + <rd-widget-body classes="no-padding"> + <table class="table"> + <tbody> + <tr> + <td>Name</td> + <td> + {{ $ctrl.registry.Name }} + </td> + </tr> + <tr> + <td>URL</td> + <td> + {{ $ctrl.registry.URL }} + </td> + </tr> + </tbody> + </table> + </rd-widget-body> + </rd-widget> + </div> +</div> diff --git a/app/portainer/helpers/endpointHelper.js b/app/portainer/helpers/endpointHelper.js index 609d31f19..005127859 100644 --- a/app/portainer/helpers/endpointHelper.js +++ b/app/portainer/helpers/endpointHelper.js @@ -1,36 +1,33 @@ import _ from 'lodash-es'; +import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; -angular.module('portainer.app').factory('EndpointHelper', [ - function EndpointHelperFactory() { - 'use strict'; - var helper = {}; +function findAssociatedGroup(endpoint, groups) { + return _.find(groups, function (group) { + return group.Id === endpoint.GroupId; + }); +} - function findAssociatedGroup(endpoint, groups) { - return _.find(groups, function (group) { - return group.Id === endpoint.GroupId; - }); - } +export default class EndpointHelper { + static isLocalEndpoint(endpoint) { + return endpoint.URL.includes('unix://') || endpoint.URL.includes('npipe://') || endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment; + } - helper.isLocalEndpoint = isLocalEndpoint; - function isLocalEndpoint(endpoint) { - return endpoint.URL.includes('unix://') || endpoint.URL.includes('npipe://') || endpoint.Type === 5; - } + static isAgentEndpoint(endpoint) { + return [ + PortainerEndpointTypes.AgentOnDockerEnvironment, + PortainerEndpointTypes.EdgeAgentOnDockerEnvironment, + PortainerEndpointTypes.AgentOnKubernetesEnvironment, + PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment, + ].includes(endpoint.Type); + } - 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]; - var group = findAssociatedGroup(endpoint, groups); - if (group) { - endpoint.GroupName = group.Name; - } + static mapGroupNameToEndpoint(endpoints, groups) { + for (var i = 0; i < endpoints.length; i++) { + var endpoint = endpoints[i]; + var group = findAssociatedGroup(endpoint, groups); + if (group) { + endpoint.GroupName = group.Name; } - }; - - return helper; - }, -]); + } + } +} diff --git a/app/portainer/models/dockerhub.js b/app/portainer/models/dockerhub.js index 880a89f5b..27b67e130 100644 --- a/app/portainer/models/dockerhub.js +++ b/app/portainer/models/dockerhub.js @@ -1,7 +1,8 @@ -export function DockerHubViewModel(data) { - this.Name = 'DockerHub'; - this.URL = ''; - this.Authentication = data.Authentication; - this.Username = data.Username; - this.Password = data.Password; +import { RegistryTypes } from './registryTypes'; + +export function DockerHubViewModel() { + this.Id = 0; + this.Type = RegistryTypes.ANONYMOUS; + this.Name = 'DockerHub (anonymous)'; + this.URL = 'docker.io'; } diff --git a/app/portainer/models/registry.js b/app/portainer/models/registry.js index a7e2ac26e..a9effdc33 100644 --- a/app/portainer/models/registry.js +++ b/app/portainer/models/registry.js @@ -10,10 +10,7 @@ export function RegistryViewModel(data) { this.Authentication = data.Authentication; this.Username = data.Username; this.Password = data.Password; - this.AuthorizedUsers = data.AuthorizedUsers; - this.AuthorizedTeams = data.AuthorizedTeams; - this.UserAccessPolicies = data.UserAccessPolicies; - this.TeamAccessPolicies = data.TeamAccessPolicies; + this.RegistryAccesses = data.RegistryAccesses; // map[EndpointID]{UserAccessPolicies, TeamAccessPolicies, NamespaceAccessPolicies} this.Checked = false; this.Gitlab = data.Gitlab; this.Quay = data.Quay; @@ -40,7 +37,7 @@ export function RegistryManagementConfigurationDefaultModel(registry) { } } -export function RegistryDefaultModel() { +export function RegistryCreateFormValues() { this.Type = RegistryTypes.CUSTOM; this.URL = ''; this.Name = ''; diff --git a/app/portainer/models/registryTypes.js b/app/portainer/models/registryTypes.js index a427a5954..ef5ded0ae 100644 --- a/app/portainer/models/registryTypes.js +++ b/app/portainer/models/registryTypes.js @@ -1,7 +1,9 @@ export const RegistryTypes = Object.freeze({ + ANONYMOUS: 0, // not backend replicated, only for frontend QUAY: 1, AZURE: 2, CUSTOM: 3, GITLAB: 4, PROGET: 5, + DOCKERHUB: 6, }); diff --git a/app/portainer/rest/dockerhub.js b/app/portainer/rest/dockerhub.js deleted file mode 100644 index 35b891076..000000000 --- a/app/portainer/rest/dockerhub.js +++ /dev/null @@ -1,15 +0,0 @@ -angular.module('portainer.app').factory('DockerHub', [ - '$resource', - 'API_ENDPOINT_DOCKERHUB', - function DockerHubFactory($resource, API_ENDPOINT_DOCKERHUB) { - 'use strict'; - return $resource( - API_ENDPOINT_DOCKERHUB, - {}, - { - get: { method: 'GET' }, - update: { method: 'PUT' }, - } - ); - }, -]); diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js index d88e4e243..52bc57f92 100644 --- a/app/portainer/rest/endpoint.js +++ b/app/portainer/rest/endpoint.js @@ -22,7 +22,26 @@ 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' } }, + dockerhubLimits: { + method: 'GET', + url: `${API_ENDPOINT_ENDPOINTS}/:id/dockerhub/:registryId`, + }, + registries: { + method: 'GET', + url: `${API_ENDPOINT_ENDPOINTS}/:id/registries`, + params: { id: '@id', namespace: '@namespace' }, + isArray: true, + }, + registry: { + url: `${API_ENDPOINT_ENDPOINTS}/:id/registries/:registryId`, + method: 'GET', + params: { id: '@id', namespace: '@namespace', registryId: '@registryId' }, + }, + updateRegistryAccess: { + method: 'PUT', + url: `${API_ENDPOINT_ENDPOINTS}/:id/registries/:registryId`, + params: { id: '@id', registryId: '@registryId' }, + }, } ); }, diff --git a/app/portainer/rest/registry.js b/app/portainer/rest/registry.js index 345ee6d5b..706c56ae1 100644 --- a/app/portainer/rest/registry.js +++ b/app/portainer/rest/registry.js @@ -9,9 +9,8 @@ angular.module('portainer.app').factory('Registries', [ { create: { method: 'POST', ignoreLoadingBar: true }, query: { method: 'GET', isArray: true }, - get: { method: 'GET', params: { id: '@id' } }, + get: { method: 'GET', params: { id: '@id', action: '', endpointId: '@endpointId' } }, update: { method: 'PUT', params: { id: '@id' } }, - updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, remove: { method: 'DELETE', params: { id: '@id' } }, configure: { method: 'POST', params: { id: '@id', action: 'configure' } }, } diff --git a/app/portainer/services/api/accessService.js b/app/portainer/services/api/accessService.js index d09f4bdfe..40018afd3 100644 --- a/app/portainer/services/api/accessService.js +++ b/app/portainer/services/api/accessService.js @@ -9,7 +9,10 @@ angular.module('portainer.app').factory('AccessService', [ 'TeamService', function AccessServiceFactory($q, $async, UserService, TeamService) { 'use strict'; - var service = {}; + return { + accesses, + generateAccessPolicies, + }; function _mapAccessData(accesses, authorizedPolicies, inheritedPolicies) { var availableAccesses = []; @@ -76,7 +79,7 @@ angular.module('portainer.app').factory('AccessService', [ async function accessesAsync(entity, parent) { try { if (!entity) { - throw { msg: 'Unable to retrieve accesses' }; + throw new Error('Unable to retrieve accesses'); } if (!entity.UserAccessPolicies) { entity.UserAccessPolicies = {}; @@ -100,9 +103,7 @@ angular.module('portainer.app').factory('AccessService', [ return $async(accessesAsync, entity, parent); } - service.accesses = accesses; - - service.generateAccessPolicies = function (userAccessPolicies, teamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId) { + function generateAccessPolicies(userAccessPolicies, teamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId) { const newUserPolicies = _.clone(userAccessPolicies); const newTeamPolicies = _.clone(teamAccessPolicies); @@ -115,8 +116,6 @@ angular.module('portainer.app').factory('AccessService', [ }; return accessPolicies; - }; - - return service; + } }, ]); diff --git a/app/portainer/services/api/dockerhubService.js b/app/portainer/services/api/dockerhubService.js index aa079ae40..6326fa9a8 100644 --- a/app/portainer/services/api/dockerhubService.js +++ b/app/portainer/services/api/dockerhubService.js @@ -1,51 +1,27 @@ -import { DockerHubViewModel } from '../../models/dockerhub'; +import EndpointHelper from 'Portainer/helpers/endpointHelper'; +import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; -angular.module('portainer.app').factory('DockerHubService', [ - '$q', - 'DockerHub', - 'Endpoints', - 'AgentDockerhub', - 'EndpointHelper', - function DockerHubServiceFactory($q, DockerHub, Endpoints, AgentDockerhub, EndpointHelper) { - 'use strict'; - var service = {}; +angular.module('portainer.app').factory('DockerHubService', DockerHubService); - service.dockerhub = function () { - var deferred = $q.defer(); +/* @ngInject */ +function DockerHubService(Endpoints, AgentDockerhub) { + return { + checkRateLimits, + }; - DockerHub.get() - .$promise.then(function success(data) { - var dockerhub = new DockerHubViewModel(data); - deferred.resolve(dockerhub); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve DockerHub details', err: err }); - }); - - return deferred.promise; - }; - - service.update = function (dockerhub) { - 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; - } + function checkRateLimits(endpoint, registryId) { + if (EndpointHelper.isLocalEndpoint(endpoint)) { + return Endpoints.dockerhubLimits({ id: endpoint.Id, registryId }).$promise; } - return service; - }, -]); + switch (endpoint.Type) { + case PortainerEndpointTypes.AgentOnDockerEnvironment: + case PortainerEndpointTypes.EdgeAgentOnDockerEnvironment: + return AgentDockerhub.limits({ endpointId: endpoint.Id, endpointType: 'docker', registryId }).$promise; + + case PortainerEndpointTypes.AgentOnKubernetesEnvironment: + case PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment: + return AgentDockerhub.limits({ endpointId: endpoint.Id, endpointType: 'kubernetes', registryId }).$promise; + } + } +} diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 870075156..ef7291343 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -8,6 +8,9 @@ angular.module('portainer.app').factory('EndpointService', [ 'use strict'; var service = { updateSecuritySettings, + registries, + registry, + updateRegistryAccess, }; service.endpoint = function (endpointID) { @@ -157,10 +160,22 @@ angular.module('portainer.app').factory('EndpointService', [ return deferred.promise; }; + function updateRegistryAccess(id, registryId, endpointAccesses) { + return Endpoints.updateRegistryAccess({ registryId, id }, endpointAccesses).$promise; + } + + function registries(id, namespace) { + return Endpoints.registries({ namespace, id }).$promise; + } + return service; function updateSecuritySettings(id, securitySettings) { return Endpoints.updateSecuritySettings({ id }, securitySettings).$promise; } + + function registry(endpointId, registryId) { + return Endpoints.registry({ registryId, id: endpointId }).$promise; + } }, ]); diff --git a/app/portainer/services/api/registryService.js b/app/portainer/services/api/registryService.js index 36c46ad77..34b8e882f 100644 --- a/app/portainer/services/api/registryService.js +++ b/app/portainer/services/api/registryService.js @@ -1,20 +1,30 @@ import _ from 'lodash-es'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; -import { RegistryTypes } from '@/portainer/models/registryTypes'; -import { RegistryCreateRequest, RegistryViewModel } from '../../models/registry'; +import { RegistryTypes } from 'Portainer/models/registryTypes'; +import { RegistryCreateRequest, RegistryViewModel } from 'Portainer/models/registry'; +import { DockerHubViewModel } from 'Portainer/models/dockerhub'; angular.module('portainer.app').factory('RegistryService', [ '$q', '$async', + 'EndpointService', 'Registries', - 'DockerHubService', 'ImageHelper', 'FileUploadService', - function RegistryServiceFactory($q, $async, Registries, DockerHubService, ImageHelper, FileUploadService) { - 'use strict'; - var service = {}; + function RegistryServiceFactory($q, $async, EndpointService, Registries, ImageHelper, FileUploadService) { + return { + registries, + registry, + encodedCredentials, + deleteRegistry, + updateRegistry, + configureRegistry, + createRegistry, + createGitlabRegistries, + retrievePorRegistryModelFromRepository, + }; - service.registries = function () { + function registries() { var deferred = $q.defer(); Registries.query() @@ -29,12 +39,12 @@ angular.module('portainer.app').factory('RegistryService', [ }); return deferred.promise; - }; + } - service.registry = function (id) { + function registry(id, endpointId) { var deferred = $q.defer(); - Registries.get({ id: id }) + Registries.get({ id, endpointId }) .$promise.then(function success(data) { var registry = new RegistryViewModel(data); deferred.resolve(registry); @@ -44,39 +54,35 @@ angular.module('portainer.app').factory('RegistryService', [ }); return deferred.promise; - }; + } - service.encodedCredentials = function (registry) { + function encodedCredentials(registry) { var credentials = { - serveraddress: registry.URL, + registryId: registry.Id, }; return btoa(JSON.stringify(credentials)); - }; + } - service.updateAccess = function (id, userAccessPolicies, teamAccessPolicies) { - return Registries.updateAccess({ id: id }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise; - }; - - service.deleteRegistry = function (id) { + function deleteRegistry(id) { return Registries.remove({ id: id }).$promise; - }; + } - service.updateRegistry = function (registry) { + function updateRegistry(registry) { registry.URL = _.replace(registry.URL, /^https?\:\/\//i, ''); registry.URL = _.replace(registry.URL, /\/$/, ''); return Registries.update({ id: registry.Id }, registry).$promise; - }; + } - service.configureRegistry = function (id, registryManagementConfigurationModel) { + function configureRegistry(id, registryManagementConfigurationModel) { return FileUploadService.configureRegistry(id, registryManagementConfigurationModel); - }; + } - service.createRegistry = function (model) { + function createRegistry(model) { var payload = new RegistryCreateRequest(model); return Registries.create(payload).$promise; - }; + } - service.createGitlabRegistries = function (model, projects) { + function createGitlabRegistries(model, projects) { const promises = []; _.forEach(projects, (p) => { const m = model; @@ -88,9 +94,7 @@ angular.module('portainer.app').factory('RegistryService', [ promises.push(Registries.create(payload).$promise); }); return $q.all(promises); - }; - - service.retrievePorRegistryModelFromRepositoryWithRegistries = retrievePorRegistryModelFromRepositoryWithRegistries; + } function getURL(reg) { let url = reg.URL; @@ -103,14 +107,23 @@ angular.module('portainer.app').factory('RegistryService', [ return url; } - function retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, dockerhub) { + function retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, registryId) { const model = new PorImageRegistryModel(); - const registry = _.find(registries, (reg) => _.includes(repository, getURL(reg))); + const registry = registries.find((reg) => { + if (registryId) { + return reg.Id === registryId; + } + if (reg.Type === RegistryTypes.DOCKERHUB) { + return _.includes(repository, reg.Username); + } + return _.includes(repository, getURL(reg)); + }); if (registry) { const url = getURL(registry); - const lastIndex = repository.lastIndexOf(url) + url.length; + let lastIndex = repository.lastIndexOf(url); + lastIndex = lastIndex === -1 ? 0 : lastIndex + url.length; let image = repository.substring(lastIndex); - if (!_.startsWith(image, ':')) { + if (_.startsWith(image, '/')) { image = image.substring(1); } model.Registry = registry; @@ -119,25 +132,21 @@ angular.module('portainer.app').factory('RegistryService', [ if (ImageHelper.imageContainsURL(repository)) { model.UseRegistry = false; } - model.Registry = dockerhub; + model.Registry = new DockerHubViewModel(); model.Image = repository; } return model; } - async function retrievePorRegistryModelFromRepositoryAsync(repository) { - try { - let [registries, dockerhub] = await Promise.all([service.registries(), DockerHubService.dockerhub()]); - return retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, dockerhub); - } catch (err) { - throw { msg: 'Unable to retrieve the registry associated to the repository', err: err }; - } + function retrievePorRegistryModelFromRepository(repository, endpointId, registryId, namespace) { + return $async(async () => { + try { + const regs = await EndpointService.registries(endpointId, namespace); + return retrievePorRegistryModelFromRepositoryWithRegistries(repository, regs, registryId); + } catch (err) { + throw { msg: 'Unable to retrieve the registry associated to the repository', err: err }; + } + }); } - - service.retrievePorRegistryModelFromRepository = function (repository) { - return $async(retrievePorRegistryModelFromRepositoryAsync, repository); - }; - - return service; }, ]); diff --git a/app/portainer/services/api/templateService.js b/app/portainer/services/api/templateService.js index 82fb486e9..04265c85b 100644 --- a/app/portainer/services/api/templateService.js +++ b/app/portainer/services/api/templateService.js @@ -1,91 +1,85 @@ -import _ from 'lodash-es'; +import { DockerHubViewModel } from 'Portainer/models/dockerhub'; import { TemplateViewModel } from '../../models/template'; -angular.module('portainer.app').factory('TemplateService', [ - '$q', - 'Templates', - 'TemplateHelper', - 'RegistryService', - 'DockerHubService', - 'ImageHelper', - 'ContainerHelper', - function TemplateServiceFactory($q, Templates, TemplateHelper, RegistryService, DockerHubService, ImageHelper, ContainerHelper) { - 'use strict'; - var service = {}; +angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory); - service.templates = function () { - const deferred = $q.defer(); +/* @ngInject */ +function TemplateServiceFactory($q, Templates, TemplateHelper, EndpointProvider, ImageHelper, ContainerHelper, EndpointService) { + var service = {}; - $q.all({ - templates: Templates.query().$promise, - registries: RegistryService.registries(), - dockerhub: DockerHubService.dockerhub(), - }) - .then(function success(data) { - const version = data.templates.version; - const templates = _.map(data.templates.templates, (item) => { + service.templates = function () { + const deferred = $q.defer(); + const endpointId = EndpointProvider.currentEndpoint().Id; + + $q.all({ + templates: Templates.query().$promise, + registries: EndpointService.registries(endpointId), + }) + .then(function success({ templates, registries }) { + const version = templates.version; + deferred.resolve( + templates.templates.map((item) => { try { const template = new TemplateViewModel(item, version); - const registry = RegistryService.retrievePorRegistryModelFromRepositoryWithRegistries(template.RegistryModel.Registry.URL, data.registries, data.dockerhub); - registry.Image = template.RegistryModel.Image; - template.RegistryModel = registry; + const registryURL = template.RegistryModel.Registry.URL; + const registry = registryURL ? registries.find((reg) => reg.URL === registryURL) : new DockerHubViewModel(); + template.RegistryModel.Registry = registry; return template; } catch (err) { deferred.reject({ msg: 'Unable to retrieve templates', err: err }); } - }); - deferred.resolve(templates); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve templates', err: err }); - }); - - return deferred.promise; - }; - - service.templateFile = templateFile; - function templateFile(repositoryUrl, composeFilePathInRepository) { - return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise; - } - - service.createTemplateConfiguration = function (template, containerName, network) { - var imageConfiguration = ImageHelper.createImageConfigForContainer(template.RegistryModel); - var containerConfiguration = createContainerConfiguration(template, containerName, network); - containerConfiguration.Image = imageConfiguration.fromImage; - return containerConfiguration; - }; - - function createContainerConfiguration(template, containerName, network) { - var configuration = TemplateHelper.getDefaultContainerConfiguration(); - configuration.HostConfig.NetworkMode = network.Name; - configuration.HostConfig.Privileged = template.Privileged; - configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy }; - configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : []; - configuration.name = containerName; - configuration.Hostname = template.Hostname; - configuration.Env = TemplateHelper.EnvToStringArray(template.Env); - configuration.Cmd = ContainerHelper.commandStringToArray(template.Command); - var portConfiguration = TemplateHelper.portArrayToPortConfiguration(template.Ports); - configuration.HostConfig.PortBindings = portConfiguration.bindings; - configuration.ExposedPorts = portConfiguration.exposedPorts; - var consoleConfiguration = TemplateHelper.getConsoleConfiguration(template.Interactive); - configuration.OpenStdin = consoleConfiguration.openStdin; - configuration.Tty = consoleConfiguration.tty; - configuration.Labels = TemplateHelper.updateContainerConfigurationWithLabels(template.Labels); - return configuration; - } - - service.updateContainerConfigurationWithVolumes = function (configuration, template, generatedVolumesPile) { - var volumes = template.Volumes; - TemplateHelper.createVolumeBindings(volumes, generatedVolumesPile); - volumes.forEach(function (volume) { - if (volume.binding) { - configuration.Volumes[volume.container] = {}; - configuration.HostConfig.Binds.push(volume.binding); - } + }) + ); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve templates', err: err }); }); - }; - return service; - }, -]); + return deferred.promise; + }; + + service.templateFile = templateFile; + function templateFile(repositoryUrl, composeFilePathInRepository) { + return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise; + } + + service.createTemplateConfiguration = function (template, containerName, network) { + var imageConfiguration = ImageHelper.createImageConfigForContainer(template.RegistryModel); + var containerConfiguration = createContainerConfiguration(template, containerName, network); + containerConfiguration.Image = imageConfiguration.fromImage; + return containerConfiguration; + }; + + function createContainerConfiguration(template, containerName, network) { + var configuration = TemplateHelper.getDefaultContainerConfiguration(); + configuration.HostConfig.NetworkMode = network.Name; + configuration.HostConfig.Privileged = template.Privileged; + configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy }; + configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : []; + configuration.name = containerName; + configuration.Hostname = template.Hostname; + configuration.Env = TemplateHelper.EnvToStringArray(template.Env); + configuration.Cmd = ContainerHelper.commandStringToArray(template.Command); + var portConfiguration = TemplateHelper.portArrayToPortConfiguration(template.Ports); + configuration.HostConfig.PortBindings = portConfiguration.bindings; + configuration.ExposedPorts = portConfiguration.exposedPorts; + var consoleConfiguration = TemplateHelper.getConsoleConfiguration(template.Interactive); + configuration.OpenStdin = consoleConfiguration.openStdin; + configuration.Tty = consoleConfiguration.tty; + configuration.Labels = TemplateHelper.updateContainerConfigurationWithLabels(template.Labels); + return configuration; + } + + service.updateContainerConfigurationWithVolumes = function (configuration, template, generatedVolumesPile) { + var volumes = template.Volumes; + TemplateHelper.createVolumeBindings(volumes, generatedVolumesPile); + volumes.forEach(function (volume) { + if (volume.binding) { + configuration.Volumes[volume.container] = {}; + configuration.HostConfig.Binds.push(volume.binding); + } + }); + }; + + return service; +} diff --git a/app/portainer/views/endpoint-registries/registries.html b/app/portainer/views/endpoint-registries/registries.html new file mode 100644 index 000000000..5ed4f74b3 --- /dev/null +++ b/app/portainer/views/endpoint-registries/registries.html @@ -0,0 +1,21 @@ +<rd-header> + <rd-header-title title-text="Environment registries"> + <a data-toggle="tooltip" title="Refresh" ui-sref="docker.registries" ui-sref-opts="{reload: true}"> + <i class="fa fa-sync" aria-hidden="true"></i> + </a> + </rd-header-title> + <rd-header-content>Manage registry access inside this environment</rd-header-content> +</rd-header> +<div class="row"> + <div class="col-sm-12"> + <registries-datatable + title-text="Registries" + title-icon="fa-database" + dataset="$ctrl.registries" + table-key="endpointRegistries" + order-by="Name" + endpoint-type="$ctrl.endpointType" + can-manage-access="$ctrl.canManageAccess" + ></registries-datatable> + </div> +</div> diff --git a/app/portainer/views/endpoint-registries/registries.js b/app/portainer/views/endpoint-registries/registries.js new file mode 100644 index 000000000..61dfc3b42 --- /dev/null +++ b/app/portainer/views/endpoint-registries/registries.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('endpointRegistriesView', { + templateUrl: './registries.html', + controller: 'EndpointRegistriesController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/portainer/views/endpoint-registries/registriesController.js b/app/portainer/views/endpoint-registries/registriesController.js new file mode 100644 index 000000000..2805fa433 --- /dev/null +++ b/app/portainer/views/endpoint-registries/registriesController.js @@ -0,0 +1,51 @@ +import _ from 'lodash-es'; +import { DockerHubViewModel } from 'Portainer/models/dockerhub'; +import { RegistryTypes } from 'Portainer/models/registryTypes'; + +class EndpointRegistriesController { + /* @ngInject */ + constructor($async, Notifications, EndpointService) { + this.$async = $async; + this.Notifications = Notifications; + this.EndpointService = EndpointService; + + this.canManageAccess = this.canManageAccess.bind(this); + } + + canManageAccess(item) { + return item.Type !== RegistryTypes.ANONYMOUS; + } + + getRegistries() { + return this.$async(async () => { + try { + const dockerhub = new DockerHubViewModel(); + const registries = await this.EndpointService.registries(this.endpointId); + this.registries = _.concat(dockerhub, registries); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + } + }); + } + + $onInit() { + return this.$async(async () => { + this.state = { + viewReady: false, + }; + + try { + this.endpointType = this.endpoint.Type; + this.endpointId = this.endpoint.Id; + await this.getRegistries(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + } finally { + this.state.viewReady = true; + } + }); + } +} + +export default EndpointRegistriesController; +angular.module('portainer.app').controller('EndpointRegistriesController', EndpointRegistriesController); diff --git a/app/portainer/views/endpoints/endpointsController.js b/app/portainer/views/endpoints/endpointsController.js index df3c72781..83f9bfd0a 100644 --- a/app/portainer/views/endpoints/endpointsController.js +++ b/app/portainer/views/endpoints/endpointsController.js @@ -1,8 +1,9 @@ import angular from 'angular'; +import EndpointHelper from 'Portainer/helpers/endpointHelper'; angular.module('portainer.app').controller('EndpointsController', EndpointsController); -function EndpointsController($q, $scope, $state, $async, EndpointService, GroupService, EndpointHelper, Notifications) { +function EndpointsController($q, $scope, $state, $async, EndpointService, GroupService, Notifications) { $scope.removeAction = removeAction; function removeAction(endpoints) { diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index a3fe0ddcc..b629a6bea 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -1,3 +1,5 @@ +import EndpointHelper from 'Portainer/helpers/endpointHelper'; + angular .module('portainer.app') .controller('HomeController', function ( @@ -7,7 +9,6 @@ angular TagService, Authentication, EndpointService, - EndpointHelper, GroupService, Notifications, EndpointProvider, diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 8872bcfe1..1432b8ecc 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -1,5 +1,4 @@ angular.module('portainer.app').controller('InitAdminController', [ - '$async', '$scope', '$state', 'Notifications', @@ -10,7 +9,7 @@ angular.module('portainer.app').controller('InitAdminController', [ 'EndpointService', 'BackupService', 'StatusService', - function ($async, $scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService, BackupService, StatusService) { + function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService, BackupService, StatusService) { $scope.logo = StateManager.getState().application.logo; $scope.formValues = { @@ -85,7 +84,9 @@ angular.module('portainer.app').controller('InitAdminController', [ if (status && status.Version) { return; } - } catch (e) {} + } catch (e) { + // pass + } } throw 'Timeout to wait for Portainer restarting'; } diff --git a/app/portainer/views/registries/access/registryAccess.html b/app/portainer/views/registries/access/registryAccess.html deleted file mode 100644 index bab5d7391..000000000 --- a/app/portainer/views/registries/access/registryAccess.html +++ /dev/null @@ -1,35 +0,0 @@ -<rd-header> - <rd-header-title title-text="Registry access"></rd-header-title> - <rd-header-content> - <a ui-sref="portainer.registries">Registries</a> > <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> > Access management - </rd-header-content> -</rd-header> - -<div class="row" ng-if="registry"> - <div class="col-lg-12 col-md-12 col-xs-12"> - <rd-widget> - <rd-widget-header icon="fa-plug" title-text="Registry"></rd-widget-header> - <rd-widget-body classes="no-padding"> - <table class="table"> - <tbody> - <tr> - <td>Name</td> - <td> - {{ registry.Name }} - </td> - </tr> - <tr> - <td>URL</td> - <td> - {{ registry.URL }} - </td> - </tr> - </tbody> - </table> - </rd-widget-body> - </rd-widget> - </div> -</div> - -<por-access-management ng-if="registry" access-controlled-entity="registry" entity-type="registry" action-in-progress="state.actionInProgress" update-access="updateAccess"> -</por-access-management> diff --git a/app/portainer/views/registries/access/registryAccessController.js b/app/portainer/views/registries/access/registryAccessController.js deleted file mode 100644 index a31372368..000000000 --- a/app/portainer/views/registries/access/registryAccessController.js +++ /dev/null @@ -1,34 +0,0 @@ -angular.module('portainer.app').controller('RegistryAccessController', [ - '$scope', - '$state', - '$transition$', - 'RegistryService', - 'Notifications', - function ($scope, $state, $transition$, RegistryService, Notifications) { - $scope.updateAccess = function () { - $scope.state.actionInProgress = true; - RegistryService.updateRegistry($scope.registry) - .then(() => { - Notifications.success('Access successfully updated'); - $state.reload(); - }) - .catch((err) => { - $scope.state.actionInProgress = false; - Notifications.error('Failure', err, 'Unable to update accesses'); - }); - }; - - function initView() { - $scope.state = { actionInProgress: false }; - RegistryService.registry($transition$.params().id) - .then(function success(data) { - $scope.registry = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve registry details'); - }); - } - - initView(); - }, -]); diff --git a/app/portainer/views/registries/create/createregistry.html b/app/portainer/views/registries/create/createRegistry.html similarity index 52% rename from app/portainer/views/registries/create/createregistry.html rename to app/portainer/views/registries/create/createRegistry.html index cecca19d3..aab5b0bdd 100644 --- a/app/portainer/views/registries/create/createregistry.html +++ b/app/portainer/views/registries/create/createRegistry.html @@ -17,8 +17,18 @@ <div class="form-group" style="margin-bottom: 0;"> <div class="boxselector_wrapper"> <div> - <input type="radio" id="registry_quay" ng-model="model.Type" ng-value="RegistryTypes.QUAY" /> - <label for="registry_quay" ng-click="selectQuayRegistry()"> + <input type="radio" id="registry_dockerhub" ng-model="$ctrl.model.Type" ng-value="$ctrl.RegistryTypes.DOCKERHUB" /> + <label for="registry_dockerhub" ng-click="$ctrl.selectDockerHub()"> + <div class="boxselector_header"> + <i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i> + DockerHub + </div> + <p>DockerHub authenticated account</p> + </label> + </div> + <div> + <input type="radio" id="registry_quay" ng-model="$ctrl.model.Type" ng-value="$ctrl.RegistryTypes.QUAY" /> + <label for="registry_quay" ng-click="$ctrl.selectQuayRegistry()"> <div class="boxselector_header"> <i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i> Quay.io @@ -27,8 +37,8 @@ </label> </div> <div> - <input type="radio" id="registry_proget" ng-model="model.Type" ng-value="RegistryTypes.PROGET" /> - <label for="registry_proget" ng-click="selectProGetRegistry()"> + <input type="radio" id="registry_proget" ng-model="$ctrl.model.Type" ng-value="$ctrl.RegistryTypes.PROGET" /> + <label for="registry_proget" ng-click="$ctrl.selectProGetRegistry()"> <div class="boxselector_header"> <i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i> ProGet @@ -37,8 +47,8 @@ </label> </div> <div> - <input type="radio" id="registry_azure" ng-model="model.Type" ng-value="RegistryTypes.AZURE" /> - <label for="registry_azure" ng-click="selectAzureRegistry()"> + <input type="radio" id="registry_azure" ng-model="$ctrl.model.Type" ng-value="$ctrl.RegistryTypes.AZURE" /> + <label for="registry_azure" ng-click="$ctrl.selectAzureRegistry()"> <div class="boxselector_header"> <i class="fab fa-microsoft" aria-hidden="true" style="margin-right: 2px;"></i> Azure @@ -47,8 +57,8 @@ </label> </div> <div> - <input type="radio" id="registry_gitlab" ng-model="model.Type" ng-value="RegistryTypes.GITLAB" /> - <label for="registry_gitlab" ng-click="selectGitlabRegistry()"> + <input type="radio" id="registry_gitlab" ng-model="$ctrl.model.Type" ng-value="$ctrl.RegistryTypes.GITLAB" /> + <label for="registry_gitlab" ng-click="$ctrl.selectGitlabRegistry()"> <div class="boxselector_header"> <i class="fab fa-gitlab" aria-hidden="true" style="margin-right: 2px;"></i> Gitlab @@ -57,8 +67,8 @@ </label> </div> <div> - <input type="radio" id="registry_custom" ng-model="model.Type" ng-value="RegistryTypes.CUSTOM" /> - <label for="registry_custom" ng-click="selectCustomRegistry()"> + <input type="radio" id="registry_custom" ng-model="$ctrl.model.Type" ng-value="$ctrl.RegistryTypes.CUSTOM" /> + <label for="registry_custom" ng-click="$ctrl.selectCustomRegistry()"> <div class="boxselector_header"> <i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i> Custom registry @@ -70,47 +80,55 @@ </div> <registry-form-quay - ng-if="model.Type === RegistryTypes.QUAY" - model="model" - form-action="create" + ng-if="$ctrl.model.Type === $ctrl.RegistryTypes.QUAY" + model="$ctrl.model" + form-action="$ctrl.createRegistry" form-action-label="Add registry" - action-in-progress="state.actionInProgress" + action-in-progress="$ctrl.state.actionInProgress" ></registry-form-quay> <registry-form-azure - ng-if="model.Type === RegistryTypes.AZURE" - model="model" - form-action="create" + ng-if="$ctrl.model.Type === $ctrl.RegistryTypes.AZURE" + model="$ctrl.model" + form-action="$ctrl.createRegistry" form-action-label="Add registry" - action-in-progress="state.actionInProgress" + action-in-progress="$ctrl.state.actionInProgress" ></registry-form-azure> <registry-form-custom - ng-if="model.Type === RegistryTypes.CUSTOM" - model="model" - form-action="create" + ng-if="$ctrl.model.Type === $ctrl.RegistryTypes.CUSTOM" + model="$ctrl.model" + form-action="$ctrl.createRegistry" form-action-label="Add registry" - action-in-progress="state.actionInProgress" + action-in-progress="$ctrl.state.actionInProgress" ></registry-form-custom> <registry-form-proget - ng-if="model.Type === RegistryTypes.PROGET" - model="model" - form-action="create" + ng-if="$ctrl.model.Type === $ctrl.RegistryTypes.PROGET" + model="$ctrl.model" + form-action="$ctrl.createRegistry" form-action-label="Add registry" - action-in-progress="state.actionInProgress" + action-in-progress="$ctrl.state.actionInProgress" ></registry-form-proget> <registry-form-gitlab - ng-if="model.Type === RegistryTypes.GITLAB" - model="model" - retrieve-registries="retrieveGitlabRegistries" - create-registries="createGitlabRegistries" - projects="gitlabProjects" - state="state" - action-in-progress="state.actionInProgress" - reset-defaults="useDefaultGitlabConfiguration" + ng-if="$ctrl.model.Type === $ctrl.RegistryTypes.GITLAB" + model="$ctrl.model" + retrieve-registries="$ctrl.retrieveGitlabRegistries" + create-registries="$ctrl.createGitlabRegistries" + projects="$ctrl.gitlabProjects" + state="$ctrl.state" + action-in-progress="$ctrl.state.actionInProgress" + reset-defaults="$ctrl.useDefaultGitlabConfiguration" ></registry-form-gitlab> + + <registry-form-dockerhub + ng-if="$ctrl.model.Type === $ctrl.RegistryTypes.DOCKERHUB" + model="$ctrl.model" + form-action="$ctrl.createRegistry" + form-action-label="Add registry" + action-in-progress="$ctrl.state.actionInProgress" + ></registry-form-dockerhub> </form> </rd-widget-body> </rd-widget> diff --git a/app/portainer/views/registries/create/createRegistry.js b/app/portainer/views/registries/create/createRegistry.js new file mode 100644 index 000000000..e935feea6 --- /dev/null +++ b/app/portainer/views/registries/create/createRegistry.js @@ -0,0 +1,10 @@ +import angular from 'angular'; +import CreateRegistryController from './createRegistryController'; + +angular.module('portainer.app').component('createRegistry', { + templateUrl: './createRegistry.html', + controller: CreateRegistryController, + bindings: { + $transition$: '<', + }, +}); diff --git a/app/portainer/views/registries/create/createRegistryController.js b/app/portainer/views/registries/create/createRegistryController.js index 5d4b0acc2..7198ee3b5 100644 --- a/app/portainer/views/registries/create/createRegistryController.js +++ b/app/portainer/views/registries/create/createRegistryController.js @@ -1,24 +1,13 @@ -import { RegistryTypes } from '@/portainer/models/registryTypes'; -import { RegistryDefaultModel } from '@/portainer/models/registry'; +import { RegistryTypes } from 'Portainer/models/registryTypes'; +import { RegistryCreateFormValues } from 'Portainer/models/registry'; -angular.module('portainer.app').controller('CreateRegistryController', [ - '$scope', - '$state', - 'RegistryService', - 'Notifications', - 'RegistryGitlabService', - function ($scope, $state, RegistryService, Notifications, RegistryGitlabService) { - $scope.selectQuayRegistry = selectQuayRegistry; - $scope.selectAzureRegistry = selectAzureRegistry; - $scope.selectCustomRegistry = selectCustomRegistry; - $scope.selectProGetRegistry = selectProGetRegistry; - $scope.selectGitlabRegistry = selectGitlabRegistry; - $scope.create = createRegistry; - $scope.useDefaultGitlabConfiguration = useDefaultGitlabConfiguration; - $scope.retrieveGitlabRegistries = retrieveGitlabRegistries; - $scope.createGitlabRegistries = createGitlabRegistries; +class CreateRegistryController { + /* @ngInject */ + constructor($async, $state, EndpointProvider, Notifications, RegistryService, RegistryGitlabService) { + Object.assign(this, { $async, $state, EndpointProvider, Notifications, RegistryService, RegistryGitlabService }); - $scope.state = { + this.RegistryTypes = RegistryTypes; + this.state = { actionInProgress: false, overrideConfiguration: false, gitlab: { @@ -27,101 +16,113 @@ angular.module('portainer.app').controller('CreateRegistryController', [ }, selectedItems: [], }, + originViewReference: 'portainer.registries', }; - function useDefaultQuayConfiguration() { - $scope.model.Quay.useOrganisation = false; - $scope.model.Quay.organisationName = ''; - } + this.createRegistry = this.createRegistry.bind(this); + this.retrieveGitlabRegistries = this.retrieveGitlabRegistries.bind(this); + this.createGitlabRegistries = this.createGitlabRegistries.bind(this); + } - function selectQuayRegistry() { - $scope.model.Name = 'Quay'; - $scope.model.URL = 'quay.io'; - $scope.model.Authentication = true; - $scope.model.Quay = {}; - useDefaultQuayConfiguration(); - } + useDefaultQuayConfiguration() { + this.model.Quay.useOrganisation = false; + this.model.Quay.organisationName = ''; + } - function useDefaultGitlabConfiguration() { - $scope.model.URL = 'https://registry.gitlab.com'; - $scope.model.Gitlab.InstanceURL = 'https://gitlab.com'; - } + selectQuayRegistry() { + this.model.Name = 'Quay'; + this.model.URL = 'quay.io'; + this.model.Authentication = true; + this.model.Quay = {}; + this.useDefaultQuayConfiguration(); + } - function selectGitlabRegistry() { - $scope.model.Name = ''; - $scope.model.Authentication = true; - $scope.model.Gitlab = {}; - useDefaultGitlabConfiguration(); - } + useDefaultGitlabConfiguration() { + this.model.URL = 'https://registry.gitlab.com'; + this.model.Gitlab.InstanceURL = 'https://gitlab.com'; + } - function selectAzureRegistry() { - $scope.model.Name = ''; - $scope.model.URL = ''; - $scope.model.Authentication = true; - } + selectGitlabRegistry() { + this.model.Name = ''; + this.model.Authentication = true; + this.model.Gitlab = {}; + this.useDefaultGitlabConfiguration(); + } - function selectCustomRegistry() { - $scope.model.Name = ''; - $scope.model.URL = ''; - $scope.model.Authentication = false; - } + selectAzureRegistry() { + this.model.Name = ''; + this.model.URL = ''; + this.model.Authentication = true; + } - function selectProGetRegistry() { - $scope.model.Name = ''; - $scope.model.URL = ''; - $scope.model.BaseURL = ''; - $scope.model.Authentication = true; - } + selectProGetRegistry() { + this.model.Name = ''; + this.model.URL = ''; + this.model.BaseURL = ''; + this.model.Authentication = true; + } - function retrieveGitlabRegistries() { - $scope.state.actionInProgress = true; - RegistryGitlabService.projects($scope.model.Gitlab.InstanceURL, $scope.model.Token) - .then((data) => { - $scope.gitlabProjects = data; - }) - .catch((err) => { - Notifications.error('Failure', err, 'Unable to retrieve projects'); - }) - .finally(() => { - $scope.state.actionInProgress = false; - }); - } + selectCustomRegistry() { + this.model.Name = ''; + this.model.URL = ''; + this.model.Authentication = false; + } - function createGitlabRegistries() { - $scope.state.actionInProgress = true; - RegistryService.createGitlabRegistries($scope.model, $scope.state.gitlab.selectedItems) - .then(() => { - Notifications.success('Registries successfully created'); - $state.go('portainer.registries'); - }) - .catch((err) => { - Notifications.error('Failure', err, 'Unable to create registries'); - }) - .finally(() => { - $scope.state.actionInProgress = false; - }); - } + selectDockerHub() { + this.model.Name = ''; + this.model.URL = 'docker.io'; + this.model.Authentication = true; + } - function createRegistry() { - $scope.state.actionInProgress = true; - RegistryService.createRegistry($scope.model) - .then(function success() { - Notifications.success('Registry successfully created'); - $state.go('portainer.registries'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to create registry'); - }) - .finally(function final() { - $scope.state.actionInProgress = false; - }); - } + retrieveGitlabRegistries() { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + this.gitlabProjects = await this.RegistryGitlabService.projects(this.model.Gitlab.InstanceURL, this.model.Token); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve projects'); + } finally { + this.state.actionInProgress = false; + } + }); + } - function initView() { - $scope.RegistryTypes = RegistryTypes; - $scope.model = new RegistryDefaultModel(); - } + createGitlabRegistries() { + return this.$async(async () => { + try { + this.state.actionInProgress = true; + await this.RegistryService.createGitlabRegistries(this.model, this.state.gitlab.selectedItems); + this.Notifications.success('Registries successfully created'); + this.$state.go(this.state.originViewReference, { endpointId: this.EndpointProvider.endpointID() }); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create registries'); + this.state.actionInProgress = false; + } + }); + } - initView(); - }, -]); + createRegistry() { + return this.$async(async () => { + try { + this.state.actionInProgress = true; + await this.RegistryService.createRegistry(this.model); + this.Notifications.success('Registry successfully created'); + this.$state.go(this.state.originViewReference, { endpointId: this.EndpointProvider.endpointID() }); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create registry'); + this.state.actionInProgress = false; + } + }); + } + + $onInit() { + this.model = new RegistryCreateFormValues(); + + const origin = this.$transition$.originalTransition().from(); + if (origin.name && /^[a-z]+\.registries$/.test(origin.name)) { + this.state.originViewReference = origin; + } + } +} + +export default CreateRegistryController; diff --git a/app/portainer/views/registries/edit/registryController.js b/app/portainer/views/registries/edit/registryController.js index 52bb669ae..2ea6d5784 100644 --- a/app/portainer/views/registries/edit/registryController.js +++ b/app/portainer/views/registries/edit/registryController.js @@ -3,11 +3,9 @@ import { RegistryTypes } from '@/portainer/models/registryTypes'; angular.module('portainer.app').controller('RegistryController', [ '$scope', '$state', - '$transition$', - '$filter', 'RegistryService', 'Notifications', - function ($scope, $state, $transition$, $filter, RegistryService, Notifications) { + function ($scope, $state, RegistryService, Notifications) { $scope.state = { actionInProgress: false, }; @@ -36,7 +34,7 @@ angular.module('portainer.app').controller('RegistryController', [ }; function initView() { - var registryID = $transition$.params().id; + var registryID = $state.params.id; RegistryService.registry(registryID) .then(function success(data) { $scope.registry = data; diff --git a/app/portainer/views/registries/registries.html b/app/portainer/views/registries/registries.html index 420f06612..219a1886f 100644 --- a/app/portainer/views/registries/registries.html +++ b/app/portainer/views/registries/registries.html @@ -7,79 +7,6 @@ <rd-header-content>Registry management</rd-header-content> </rd-header> -<information-panel title-text="Registry usage"> - <span class="small"> - <p class="text-muted"> - <i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i> - DockerHub credentials and registries can only be used with Docker endpoints at the time. - </p> - </span> -</information-panel> - -<div class="row" ng-if="dockerhub && isAdmin"> - <div class="col-sm-12"> - <rd-widget> - <rd-widget-header icon="fa-database" title-text="DockerHub"> </rd-widget-header> - <rd-widget-body> - <form class="form-horizontal"> - <!-- note --> - <div class="form-group"> - <span class="col-sm-12 text-muted small"> - The DockerHub registry can be used by any user. You can specify the credentials that will be used to push & pull images here. - </span> - </div> - <!-- !note --> - <!-- authentication-checkbox --> - <div class="form-group"> - <div class="col-sm-12"> - <label for="registry_auth" class="control-label text-left"> - Authentication - <portainer-tooltip position="bottom" message="Enable this option if you need to specify credentials to connect to push/pull private images."></portainer-tooltip> - </label> - <label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="dockerhub.Authentication" /><i></i> </label> - </div> - </div> - <!-- !authentication-checkbox --> - <!-- authentication-credentials --> - <div ng-if="dockerhub.Authentication"> - <!-- credentials-user --> - <div class="form-group"> - <label for="hub_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="hub_username" ng-model="dockerhub.Username" /> - </div> - </div> - <!-- !credentials-user --> - <!-- credentials-password --> - <div class="form-group"> - <label for="hub_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="hub_password" ng-model="formValues.dockerHubPassword" placeholder="*******" /> - </div> - </div> - <!-- !credentials-password --> - </div> - <!-- !authentication-credentials --> - <div class="form-group"> - <div class="col-sm-12"> - <button - type="button" - class="btn btn-primary btn-sm" - ng-disabled="state.actionInProgress || dockerhub.Authentication && (!dockerhub.Username || !formValues.dockerHubPassword)" - ng-click="updateDockerHub()" - button-spinner="state.actionInProgress" - > - <span ng-hide="state.actionInProgress">Update</span> - <span ng-show="state.actionInProgress">Updating DockerHub settings...</span> - </button> - </div> - </div> - </form> - </rd-widget-body> - </rd-widget> - </div> -</div> - <div class="row"> <div class="col-sm-12"> <registries-datatable @@ -88,7 +15,6 @@ dataset="registries" table-key="registries" order-by="Name" - access-management="isAdmin" remove-action="removeAction" can-browse="canBrowse" ></registries-datatable> diff --git a/app/portainer/views/registries/registriesController.js b/app/portainer/views/registries/registriesController.js index c0e40acf8..83e3cbafc 100644 --- a/app/portainer/views/registries/registriesController.js +++ b/app/portainer/views/registries/registriesController.js @@ -1,43 +1,23 @@ import _ from 'lodash-es'; +import { RegistryTypes } from 'Portainer/models/registryTypes'; +import { DockerHubViewModel } from 'Portainer/models/dockerhub'; angular.module('portainer.app').controller('RegistriesController', [ '$q', '$scope', '$state', 'RegistryService', - 'DockerHubService', 'ModalService', 'Notifications', - 'Authentication', - function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications, Authentication) { + function ($q, $scope, $state, RegistryService, ModalService, Notifications) { $scope.state = { actionInProgress: false, }; - $scope.formValues = { - dockerHubPassword: '', - }; - - const nonBrowsableUrls = ['quay.io']; + const nonBrowsableTypes = [RegistryTypes.ANONYMOUS, RegistryTypes.DOCKERHUB, RegistryTypes.QUAY]; $scope.canBrowse = function (item) { - return !_.includes(nonBrowsableUrls, item.URL); - }; - - $scope.updateDockerHub = function () { - var dockerhub = $scope.dockerhub; - dockerhub.Password = $scope.formValues.dockerHubPassword; - $scope.state.actionInProgress = true; - DockerHubService.update(dockerhub) - .then(function success() { - Notifications.success('DockerHub registry updated'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update DockerHub details'); - }) - .finally(function final() { - $scope.state.actionInProgress = false; - }); + return !_.includes(nonBrowsableTypes, item.Type); }; $scope.removeAction = function (selectedItems) { @@ -73,12 +53,9 @@ angular.module('portainer.app').controller('RegistriesController', [ function initView() { $q.all({ registries: RegistryService.registries(), - dockerhub: DockerHubService.dockerhub(), }) .then(function success(data) { - $scope.registries = data.registries; - $scope.dockerhub = data.dockerhub; - $scope.isAdmin = Authentication.isAdmin(); + $scope.registries = _.concat(new DockerHubViewModel(), data.registries); }) .catch(function error(err) { $scope.registries = []; diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index d1d181d92..c1be6661b 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -108,7 +108,7 @@ <li class="sidebar-list" ng-if="isAdmin && applicationState.application.enableEdgeComputeFeatures"> <a ui-sref="edge.jobs" ui-sref-active="active">Edge Jobs <span class="menu-icon fa fa-clock fa-fw"></span></a> </li> - <li class="sidebar-title"> + <li class="sidebar-title" ng-if="isAdmin || isTeamLeader"> <span>Settings</span> </li> <li class="sidebar-list" ng-if="isAdmin || isTeamLeader"> @@ -181,7 +181,7 @@ <a ui-sref="portainer.tags" ui-sref-active="active">Tags</a> </div> </li> - <li class="sidebar-list"> + <li class="sidebar-list" ng-if="isAdmin"> <a ui-sref="portainer.registries" ui-sref-active="active">Registries <span class="menu-icon fa fa-database fa-fw"></span></a> </li> <li class="sidebar-list" ng-if="isAdmin"> diff --git a/app/portainer/views/sidebar/sidebarController.js b/app/portainer/views/sidebar/sidebarController.js index 6e1d8ecef..29c12a27f 100644 --- a/app/portainer/views/sidebar/sidebarController.js +++ b/app/portainer/views/sidebar/sidebarController.js @@ -19,15 +19,20 @@ angular.module('portainer.app').controller('SidebarController', [ $scope.isTeamLeader = isLeader; } + function isClusterAdmin() { + return Authentication.isAdmin(); + } + async function initView() { $scope.uiVersion = StateManager.getState().application.version; $scope.logo = StateManager.getState().application.logo; - $scope.showStacks = await shouldShowStacks(); + + $scope.endpointId = EndpointProvider.endpointID(); + $scope.showStacks = shouldShowStacks(); let userDetails = Authentication.getUserDetails(); - let isAdmin = Authentication.isAdmin(); + const isAdmin = isClusterAdmin(); $scope.isAdmin = isAdmin; - $scope.endpointId = EndpointProvider.endpointID(); $q.when(!isAdmin ? UserService.userMemberships(userDetails.ID) : []) .then(function success(data) { @@ -36,18 +41,12 @@ angular.module('portainer.app').controller('SidebarController', [ .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve user memberships'); }); - - $transitions.onEnter({}, () => { - $scope.endpointId = EndpointProvider.endpointID(); - }); } initView(); - async function shouldShowStacks() { - const isAdmin = Authentication.isAdmin(); - - if (isAdmin) { + function shouldShowStacks() { + if (isClusterAdmin()) { return true; } @@ -60,7 +59,9 @@ angular.module('portainer.app').controller('SidebarController', [ } $transitions.onEnter({}, async () => { - $scope.showStacks = await shouldShowStacks(); + $scope.endpointId = EndpointProvider.endpointID(); + $scope.showStacks = shouldShowStacks(); + $scope.isAdmin = isClusterAdmin(); if ($scope.applicationState.endpoint.name) { document.title = `${$rootScope.defaultTitle} | ${$scope.applicationState.endpoint.name}`; diff --git a/jsconfig.json b/jsconfig.json index 6e5e7a1af..a09392634 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -9,7 +9,8 @@ "Azure/*": ["azure/*"], "Docker/*": ["docker/*"], "Kubernetes/*": ["kubernetes/*"], - "Portainer/*": ["portainer/*"] + "Portainer/*": ["portainer/*"], + "@/*": ["../app/*"], } }, "exclude": ["api", "build", "dist", "distribution", "node_modules", "test", "webpack"] diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 585692e93..3b8dcaac5 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -27,8 +27,8 @@ module.exports = { loader: 'source-map-loader', options: { filterSourceMappingUrl: (_, resourcePath) => { - // ignores `chardet` missing sourcemaps - return !/node_modules\/chardet/i.test(resourcePath); + // ignores pkgs missing sourcemaps + return ['chardet', 'tokenize-ansi'].every((pkg) => !resourcePath.includes(pkg)); }, }, },