From 2a60b8fcdf4afbb1e58f6af3a90eb39ae413f78e Mon Sep 17 00:00:00 2001 From: zees-dev <63374656+zees-dev@users.noreply.github.com> Date: Fri, 10 Sep 2021 14:06:57 +1200 Subject: [PATCH] feat(helm/templates): helm app templates EE-943 (#5449) * feat(helm): add helm chart backport to ce EE-1409 (#5425) * EE-1311 Helm Chart Backport from EE * backport to ce Co-authored-by: Matt Hook * feat(helm): list and configure helm chart (#5431) * backport and tidyup code * --amend * using rocket icon for charts * helm chart bugfix - clear category button * added matomo analytics for helm chart install * fix web editor exit warning without changes * editor modified exit bugfix * fixed notifications typo * updated helm template text * helper text to convey slow helm templates load Co-authored-by: zees-dev * removing redundant time-consuming api call by using prop attribute * feat(helm) helm chart backport from ee EE-1311 (#5436) * Add missing defaultHelmRepoUrl and mock testing * Backport EE-1477 * Backport updates to helm tests from EE * add https by default changes and ssl to tls renaming from EE * Port install integration test. Disabled by default to pass CI checks * merged changes from EE for the integration test * kube proxy whitelist updated to support internal helm install command Co-authored-by: zees-dev * Pull in all changes from tech review in EE-943 * added helm to sidebar after rebase, sync CE with EE * bugfix: kubectl shell not opening - bearer token bug * tidy go modules & remove yarn-error.log * removed redundant handler (not used) - to match EE * resolved merge conflicts, updated code * feat(helm/views): helm release and application views EE-1236 (#5529) * feat(helm): add helm chart backport to ce EE-1409 (#5425) * EE-1311 Helm Chart Backport from EE * backport to ce Co-authored-by: Matt Hook * Pull in all changes from tech review in EE-943 * added helm to sidebar after rebase, sync CE with EE * removed redundant handler (not used) - to match EE * feat(helm) display helm charts - backend EE-1236 * copy over components for new applications view EE-1236 * Add new applications datatable component * Add more migrated files * removed test not applicable to CE * baclkported EE app data table code to CE * removed redundant helm repo url * resolved conflicts, updated code * using endpoint middleware * PR review fixes * using constants, openapi updated Co-authored-by: Richard Wei <54336863+WaysonWei@users.noreply.github.com> Co-authored-by: zees-dev * fixed test conflicts, go linted * feat(helm/templates-add): helm templates add repo for user support EE-1278 (#5514) * feat(helm): add helm chart backport to ce EE-1409 (#5425) * EE-1311 Helm Chart Backport from EE * backport to ce Co-authored-by: Matt Hook * feat(helm) helm chart backport from ee EE-1311 (#5436) * Add missing defaultHelmRepoUrl and mock testing * Backport EE-1477 * Backport updates to helm tests from EE * add https by default changes and ssl to tls renaming from EE * Port install integration test. Disabled by default to pass CI checks * merged changes from EE for the integration test * kube proxy whitelist updated to support internal helm install command Co-authored-by: zees-dev * Pull in all changes from tech review in EE-943 * feat(helm): add helm chart backport to ce EE-1409 (#5425) * EE-1311 Helm Chart Backport from EE * backport to ce Co-authored-by: Matt Hook * Pull in all changes from tech review in EE-943 * added helm to sidebar after rebase, sync CE with EE * backport EE-1278, squashed, diffed, updated * helm install openapi spec update * resolved conflicts, updated code * - matching ee codebase at 0afe57034449ee0e9f333d92c252a13995a93019 - helm install using endpoint middleware - remove trailing slash from added/persisted helm repo urls * feat(helm) use libhelm url validator and improved path assembly EE-1554 (#5561) * feat(helm/userrepos) fix getting global repo for ordinary users EE-1562 (#5567) * feat(helm/userrepos) fix getting global repo for ordinary users EE-1562 * post review changes and further backported changes from EE * resolved conflicts, updated code * fixed helm_install handler unit test * user cannot add existing repo if suffix is '/' (#5571) * feat(helm/docs) fix broken swagger docs EE-1278 (#5572) * Fix swagger docs * minor correction * fix(helm): migrating code from user handler to helm handler (#5573) * - migrated user_helm_repos to helm endpoint handler - migrated api operations from user factory/service to helm factory/service - passing endpointId into helm service/factory as endpoint provider is deprecated * upgrade libhelm to hide secrets Co-authored-by: Matt Hook * removed duplicate file - due to merge conflict * dependency injection in helm factory Co-authored-by: Richard Wei <54336863+WaysonWei@users.noreply.github.com> Co-authored-by: Matt Hook * kubernetes.templates -> kubernetes.templates.helm name conflict fix * Validate the URL added as a public helm repo (#5579) * fix(helm): helm app deletion fix EE-1581 (#5582) * updated helm lib to show correct error on uninstall failure * passing down helm app namespace on deletion * fix(k8s): EE-1591 non-admin users cannot deploy charts containing secrets (#5590) Co-authored-by: Simon Meng * fix(helm): helm epic bugfixes EE-1582 EE-1593 (#5585) * - trim trailing slash and lowercase before persisting helm repo - browser helm templates url /kubernetes/templates/templates -> /kubernetes/templates/helm - fix publish url - fix helm repo add refresh - semi-fix k8s app expansion * Tidy up swagger documentation related to helm. Make json consistent * fixed helm release page for non-default namespaces * k8s app view table expansion bugfix * EE-1593: publish url load balancer fallback Co-authored-by: Matt Hook * k8s app list fix for charts with deployments containing multiple pods - which use the same label (#5599) * fix(kubernetes): app list view fix for secrets with long keys or values EE-1600 (#5600) * k8s app secrets key value text overflow ellipses * wrapping key value pairs instead of ellipses * fix(helm): helm apps bundling issue across different namespaces EE-1619 (#5602) * helm apps bundling issue across different namespaces * - code comments and indentation to ease reading - moved namespace calc out of loop * feat(helm/test) disable slow helm search test by default EE-1599 (#5598) * skip helm_repo_search as it's an integration test * switch to portainer built in integration test checker * make module order match EE * don't print test struct out when skipping integration test Co-authored-by: Richard Wei <54336863+WaysonWei@users.noreply.github.com> Co-authored-by: Matt Hook Co-authored-by: cong meng Co-authored-by: Simon Meng --- api/bolt/datastore.go | 57 +++--- .../helmuserrepository/helmuserrepository.go | 73 +++++++ api/bolt/init.go | 1 + api/bolt/migrator/migrate_dbversion31.go | 15 +- api/bolt/services.go | 12 ++ api/cmd/portainer/main.go | 24 ++- api/go.mod | 1 + api/go.sum | 2 + api/http/handler/handler.go | 10 + api/http/handler/helm/handler.go | 103 ++++++++++ api/http/handler/helm/helm_delete.go | 55 ++++++ api/http/handler/helm/helm_delete_test.go | 53 +++++ api/http/handler/helm/helm_install.go | 134 +++++++++++++ api/http/handler/helm/helm_install_test.go | 65 +++++++ api/http/handler/helm/helm_list.go | 63 ++++++ api/http/handler/helm/helm_list_test.go | 60 ++++++ api/http/handler/helm/helm_repo_search.go | 56 ++++++ .../handler/helm/helm_repo_search_test.go | 51 +++++ api/http/handler/helm/helm_show.go | 70 +++++++ api/http/handler/helm/helm_show_test.go | 47 +++++ api/http/handler/helm/user_helm_repos.go | 126 ++++++++++++ .../handler/kubernetes/kubernetes_config.go | 4 +- .../kubernetes/kubernetes_nodes_limits.go | 5 +- api/http/handler/settings/settings_update.go | 16 +- api/http/handler/users/handler.go | 2 +- api/http/proxy/factory/kubernetes/secrets.go | 2 +- api/http/security/bouncer.go | 39 ++-- api/http/security/context.go | 4 +- api/http/server.go | 13 +- api/internal/testhelpers/datastore.go | 88 +++++---- api/internal/testhelpers/integration.go | 22 +++ api/internal/testhelpers/request_bouncer.go | 15 ++ api/jwt/jwt_kubeconfig_test.go | 7 +- api/kubernetes/kubeconfig_service.go | 104 ++++++++++ api/kubernetes/kubeconfig_service_test.go | 149 ++++++++++++++ api/kubernetes/validation/validation.go | 48 +++++ api/portainer.go | 23 +++ app/assets/css/app.css | 9 + app/kubernetes/__module.js | 22 +++ ...plications-datatable-details.controller.js | 12 ++ .../applications-datatable-details.html | 12 ++ .../applications-datatable-details.js | 10 + .../applications-datatable-url.css | 10 + .../applications-datatable-url.html | 6 + .../applications-datatable-url.js | 9 + .../applicationsDatatable.css | 16 ++ .../applicationsDatatable.html | 91 +++++++-- .../applicationsDatatable.js | 3 + .../applicationsDatatableController.js | 90 ++++++++- .../helm-add-repository.controller.js | 40 ++++ .../helm-add-repository.html | 58 ++++++ .../helm-add-repository.js | 11 ++ .../helm-templates-list-item.css | 9 + .../helm-templates-list-item.html | 46 +++++ .../helm-templates-list-item.js | 13 ++ .../helm-templates-list.controller.js | 53 +++++ .../helm-templates-list.html | 57 ++++++ .../helm-templates-list.js | 15 ++ .../helm-templates.controller.js | 183 ++++++++++++++++++ .../helm/helm-templates/helm-templates.html | 182 +++++++++++++++++ .../helm/helm-templates/helm-templates.js | 10 + .../kubernetes-sidebar.html | 4 + app/kubernetes/filters/applicationFilters.js | 2 + app/kubernetes/helm/rest.js | 49 +++++ app/kubernetes/helm/service.js | 102 ++++++++++ app/kubernetes/helpers/application/index.js | 77 +++++++- app/kubernetes/helpers/configurationHelper.js | 14 ++ .../models/application/models/constants.js | 2 + .../models/application/models/index.js | 19 ++ .../views/applications/applications.html | 7 +- .../views/applications/applications.js | 1 + .../applications/applicationsController.js | 47 +++-- .../applications/helm/helm.controller.js | 52 +++++ .../views/applications/helm/helm.css | 5 + .../views/applications/helm/helm.html | 49 +++++ .../views/applications/helm/index.js | 11 ++ .../copy-button/copy-button.controller.js | 13 ++ .../components/copy-button/copy-button.css | 39 ++++ .../components/copy-button/copy-button.html | 4 + .../components/copy-button/copy-button.js | 11 ++ .../sensitive-details/sensitive-details.css | 11 ++ .../sensitive-details/sensitive-details.html | 5 + .../sensitive-details/sensitive-details.js | 10 + .../components/show-hide/show-hide.html | 9 + .../components/show-hide/show-hide.js | 9 + app/portainer/models/settings.js | 1 + app/portainer/views/settings/settings.html | 21 ++ build/download_helm_binary.sh | 17 ++ gruntfile.js | 17 ++ 89 files changed, 3055 insertions(+), 139 deletions(-) create mode 100644 api/bolt/helmuserrepository/helmuserrepository.go create mode 100644 api/http/handler/helm/handler.go create mode 100644 api/http/handler/helm/helm_delete.go create mode 100644 api/http/handler/helm/helm_delete_test.go create mode 100644 api/http/handler/helm/helm_install.go create mode 100644 api/http/handler/helm/helm_install_test.go create mode 100644 api/http/handler/helm/helm_list.go create mode 100644 api/http/handler/helm/helm_list_test.go create mode 100644 api/http/handler/helm/helm_repo_search.go create mode 100644 api/http/handler/helm/helm_repo_search_test.go create mode 100644 api/http/handler/helm/helm_show.go create mode 100644 api/http/handler/helm/helm_show_test.go create mode 100644 api/http/handler/helm/user_helm_repos.go create mode 100644 api/internal/testhelpers/integration.go create mode 100644 api/internal/testhelpers/request_bouncer.go create mode 100644 api/kubernetes/kubeconfig_service.go create mode 100644 api/kubernetes/kubeconfig_service_test.go create mode 100644 api/kubernetes/validation/validation.go create mode 100644 app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js create mode 100644 app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.html create mode 100644 app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.js create mode 100644 app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css create mode 100644 app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html create mode 100644 app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js create mode 100644 app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css create mode 100644 app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.controller.js create mode 100644 app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.html create mode 100644 app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.js create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates.controller.js create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates.html create mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates.js create mode 100644 app/kubernetes/helm/rest.js create mode 100644 app/kubernetes/helm/service.js create mode 100644 app/kubernetes/views/applications/helm/helm.controller.js create mode 100644 app/kubernetes/views/applications/helm/helm.css create mode 100644 app/kubernetes/views/applications/helm/helm.html create mode 100644 app/kubernetes/views/applications/helm/index.js create mode 100644 app/portainer/components/copy-button/copy-button.controller.js create mode 100644 app/portainer/components/copy-button/copy-button.css create mode 100644 app/portainer/components/copy-button/copy-button.html create mode 100644 app/portainer/components/copy-button/copy-button.js create mode 100644 app/portainer/components/sensitive-details/sensitive-details.css create mode 100644 app/portainer/components/sensitive-details/sensitive-details.html create mode 100644 app/portainer/components/sensitive-details/sensitive-details.js create mode 100644 app/portainer/components/show-hide/show-hide.html create mode 100644 app/portainer/components/show-hide/show-hide.js create mode 100755 build/download_helm_binary.sh diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index c739aa9e6..da8197cd9 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -6,6 +6,8 @@ import ( "path" "time" + "github.com/portainer/portainer/api/bolt/helmuserrepository" + "github.com/boltdb/bolt" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/customtemplate" @@ -44,33 +46,34 @@ const ( // Store defines the implementation of portainer.DataStore using // BoltDB as the storage system. type Store struct { - path string - connection *internal.DbConnection - isNew bool - fileService portainer.FileService - CustomTemplateService *customtemplate.Service - DockerHubService *dockerhub.Service - EdgeGroupService *edgegroup.Service - EdgeJobService *edgejob.Service - EdgeStackService *edgestack.Service - EndpointGroupService *endpointgroup.Service - EndpointService *endpoint.Service - EndpointRelationService *endpointrelation.Service - ExtensionService *extension.Service - RegistryService *registry.Service - ResourceControlService *resourcecontrol.Service - RoleService *role.Service - ScheduleService *schedule.Service - SettingsService *settings.Service - SSLSettingsService *ssl.Service - StackService *stack.Service - TagService *tag.Service - TeamMembershipService *teammembership.Service - TeamService *team.Service - TunnelServerService *tunnelserver.Service - UserService *user.Service - VersionService *version.Service - WebhookService *webhook.Service + path string + connection *internal.DbConnection + isNew bool + fileService portainer.FileService + CustomTemplateService *customtemplate.Service + DockerHubService *dockerhub.Service + EdgeGroupService *edgegroup.Service + EdgeJobService *edgejob.Service + EdgeStackService *edgestack.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + HelmUserRepositoryService *helmuserrepository.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + RoleService *role.Service + ScheduleService *schedule.Service + SettingsService *settings.Service + SSLSettingsService *ssl.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TeamService *team.Service + TunnelServerService *tunnelserver.Service + UserService *user.Service + VersionService *version.Service + WebhookService *webhook.Service } func (store *Store) edition() portainer.SoftwareEdition { diff --git a/api/bolt/helmuserrepository/helmuserrepository.go b/api/bolt/helmuserrepository/helmuserrepository.go new file mode 100644 index 000000000..9d5aadb95 --- /dev/null +++ b/api/bolt/helmuserrepository/helmuserrepository.go @@ -0,0 +1,73 @@ +package helmuserrepository + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "helm_user_repository" +) + +// Service represents a service for managing endpoint data. +type Service struct { + connection *internal.DbConnection +} + +// NewService creates a new instance of a service. +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// HelmUserRepositoryByUserID return an array containing all the HelmUserRepository objects where the specified userID is present. +func (service *Service) HelmUserRepositoryByUserID(userID portainer.UserID) ([]portainer.HelmUserRepository, error) { + var result = make([]portainer.HelmUserRepository, 0) + + err := service.connection.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var record portainer.HelmUserRepository + err := internal.UnmarshalObject(v, &record) + if err != nil { + return err + } + + if record.UserID == userID { + result = append(result, record) + } + } + + return nil + }) + + return result, err +} + +// CreateHelmUserRepository creates a new HelmUserRepository object. +func (service *Service) CreateHelmUserRepository(record *portainer.HelmUserRepository) error { + return service.connection.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + record.ID = portainer.HelmUserRepositoryID(id) + + data, err := internal.MarshalObject(record) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(record.ID)), data) + }) +} diff --git a/api/bolt/init.go b/api/bolt/init.go index 4df6b327b..4b9e4559f 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -44,6 +44,7 @@ func (store *Store) Init() error { EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, TemplatesURL: portainer.DefaultTemplatesURL, + HelmRepositoryURL: portainer.DefaultHelmRepositoryURL, UserSessionTimeout: portainer.DefaultUserSessionTimeout, KubeconfigExpiry: portainer.DefaultKubeconfigExpiry, } diff --git a/api/bolt/migrator/migrate_dbversion31.go b/api/bolt/migrator/migrate_dbversion31.go index e9a242f70..c2c17eca5 100644 --- a/api/bolt/migrator/migrate_dbversion31.go +++ b/api/bolt/migrator/migrate_dbversion31.go @@ -28,6 +28,10 @@ func (m *Migrator) migrateDBVersionToDB32() error { return err } + if err := m.helmRepositoryURLToDB32(); err != nil { + return err + } + return nil } @@ -223,4 +227,13 @@ func (m *Migrator) kubeconfigExpiryToDB32() error { } settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry return m.settingsService.UpdateSettings(settings) -} \ No newline at end of file +} + +func (m *Migrator) helmRepositoryURLToDB32() error { + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + settings.HelmRepositoryURL = portainer.DefaultHelmRepositoryURL + return m.settingsService.UpdateSettings(settings) +} diff --git a/api/bolt/services.go b/api/bolt/services.go index 54b1bd9ce..2ee5264bc 100644 --- a/api/bolt/services.go +++ b/api/bolt/services.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/portainer/api/bolt/endpointgroup" "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/extension" + "github.com/portainer/portainer/api/bolt/helmuserrepository" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" "github.com/portainer/portainer/api/bolt/role" @@ -88,6 +89,12 @@ func (store *Store) initServices() error { } store.ExtensionService = extensionService + helmUserRepositoryService, err := helmuserrepository.NewService(store.connection) + if err != nil { + return err + } + store.HelmUserRepositoryService = helmUserRepositoryService + registryService, err := registry.NewService(store.connection) if err != nil { return err @@ -204,6 +211,11 @@ func (store *Store) EndpointRelation() portainer.EndpointRelationService { return store.EndpointRelationService } +// HelmUserRepository access the helm user repository settings +func (store *Store) HelmUserRepository() portainer.HelmUserRepositoryService { + return store.HelmUserRepositoryService +} + // Registry gives access to the Registry data management layer func (store *Store) Registry() portainer.RegistryService { return store.RegistryService diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 00080dade..9eeb62e61 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -13,6 +13,7 @@ import ( "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/docker" + "github.com/portainer/libhelm" "github.com/portainer/portainer/api/exec" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git" @@ -102,6 +103,10 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath) } +func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) { + return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath}) +} + func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) { settings, err := dataStore.Settings().Settings() if err != nil { @@ -420,6 +425,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatal(err) } + sslSettings, err := sslService.GetSSLSettings() + if err != nil { + log.Fatalf("failed to get ssl settings: %s", err) + } + err = initKeyPair(fileService, digitalSignatureService) if err != nil { log.Fatalf("failed initializing key pai: %v", err) @@ -445,6 +455,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { authorizationService.K8sClientFactory = kubernetesClientFactory kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager() + + kubeConfigService := kubernetes.NewKubeConfigCAService(*flags.AddrHTTPS, sslSettings.CertPath) + proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager) dockerConfigPath := fileService.GetDockerConfigPath() @@ -458,6 +471,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) + helmPackageManager, err := initHelmPackageManager(*flags.Assets) + if err != nil { + log.Fatalf("failed initializing helm package manager: %s", err) + } + if dataStore.IsNew() { err = updateSettingsFromFlags(dataStore, flags) if err != nil { @@ -518,7 +536,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatalf("failed starting tunnel server: %s", err) } - sslSettings, err := dataStore.SSLSettings().Settings() + sslDBSettings, err := dataStore.SSLSettings().Settings() if err != nil { log.Fatalf("failed to fetch ssl settings from DB") } @@ -533,12 +551,13 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { Status: applicationStatus, BindAddress: *flags.Addr, BindAddressHTTPS: *flags.AddrHTTPS, - HTTPEnabled: sslSettings.HTTPEnabled, + HTTPEnabled: sslDBSettings.HTTPEnabled, AssetsPath: *flags.Assets, DataStore: dataStore, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, KubernetesDeployer: kubernetesDeployer, + HelmPackageManager: helmPackageManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, @@ -547,6 +566,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { GitService: gitService, ProxyManager: proxyManager, KubernetesTokenCacheManager: kubernetesTokenCacheManager, + KubeConfigService: kubeConfigService, SignatureService: digitalSignatureService, SnapshotService: snapshotService, SSLService: sslService, diff --git a/api/go.mod b/api/go.mod index 0cf2de039..39f55db29 100644 --- a/api/go.mod +++ b/api/go.mod @@ -38,6 +38,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1 github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a + github.com/portainer/libhelm v0.0.0-20210906035629-b5635edd5d97 github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.8.1 diff --git a/api/go.sum b/api/go.sum index 18d77a7fd..cffccad43 100644 --- a/api/go.sum +++ b/api/go.sum @@ -212,6 +212,8 @@ github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1 h github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c= github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM= github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE= +github.com/portainer/libhelm v0.0.0-20210906035629-b5635edd5d97 h1:ZcRVgWHTac8V7WU9TUBr73H3e5ajVFYTPjPl9TWULDA= +github.com/portainer/libhelm v0.0.0-20210906035629-b5635edd5d97/go.mod h1:YvYAk7krKTzB+rFwDr0jQ3sQu2BtiXK1AR0sZH7nhJA= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index e1a80581c..dbd2c6c20 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -16,6 +16,7 @@ import ( "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" + "github.com/portainer/portainer/api/http/handler/helm" "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" @@ -47,7 +48,9 @@ type Handler struct { EndpointEdgeHandler *endpointedge.Handler EndpointGroupHandler *endpointgroups.Handler EndpointHandler *endpoints.Handler + EndpointHelmHandler *helm.Handler EndpointProxyHandler *endpointproxy.Handler + HelmTemplatesHandler *helm.Handler KubernetesHandler *kubernetes.Handler FileHandler *file.Handler MOTDHandler *motd.Handler @@ -166,6 +169,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/kubernetes"): http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r) + + // Helm subpath under kubernetes -> /api/endpoints/{id}/kubernetes/helm + case strings.HasPrefix(r.URL.Path, "/api/endpoints/") && strings.Contains(r.URL.Path, "/kubernetes/helm"): + http.StripPrefix("/api/endpoints", h.EndpointHelmHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): @@ -199,6 +207,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/tags"): http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/templates/helm"): + http.StripPrefix("/api", h.HelmTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/upload"): diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go new file mode 100644 index 000000000..106992e20 --- /dev/null +++ b/api/http/handler/helm/handler.go @@ -0,0 +1,103 @@ +package helm + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/libhelm" + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes" +) + +const ( + handlerActivityContext = "Kubernetes" +) + +type requestBouncer interface { + AuthenticatedAccess(h http.Handler) http.Handler +} + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + requestBouncer requestBouncer + dataStore portainer.DataStore + kubeConfigService kubernetes.KubeConfigService + helmPackageManager libhelm.HelmPackageManager +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer requestBouncer, dataStore portainer.DataStore, helmPackageManager libhelm.HelmPackageManager, kubeConfigService kubernetes.KubeConfigService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + dataStore: dataStore, + helmPackageManager: helmPackageManager, + kubeConfigService: kubeConfigService, + } + + h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) + + // `helm list -o json` + h.Handle("/{id}/kubernetes/helm", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmList))).Methods(http.MethodGet) + + // `helm delete RELEASE_NAME` + h.Handle("/{id}/kubernetes/helm/{release}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmDelete))).Methods(http.MethodDelete) + + // `helm install [NAME] [CHART] flags` + h.Handle("/{id}/kubernetes/helm", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost) + + h.Handle("/{id}/kubernetes/helm/repositories", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userGetHelmRepos))).Methods(http.MethodGet) + h.Handle("/{id}/kubernetes/helm/repositories", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userCreateHelmRepo))).Methods(http.MethodPost) + + return h +} + +// NewTemplateHandler creates a template handler to manage endpoint group operations. +func NewTemplateHandler(bouncer requestBouncer, helmPackageManager libhelm.HelmPackageManager) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + helmPackageManager: helmPackageManager, + requestBouncer: bouncer, + } + + h.Handle("/templates/helm", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmRepoSearch))).Methods(http.MethodGet) + + // helm show [COMMAND] [CHART] [REPO] flags + h.Handle("/templates/helm/{command:chart|values|readme}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmShow))).Methods(http.MethodGet) + + return h +} + +// getHelmClusterAccess obtains the core k8s cluster access details from request. +// The cluster access includes the cluster server url, the user's bearer token and the tls certificate. +// The cluster access is passed in as kube config CLI params to helm binary. +func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint on request context", err} + } + + bearerToken, err := security.ExtractBearerToken(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err} + } + + kubeConfigInternal := handler.kubeConfigService.GetKubeConfigInternal(endpoint.ID, bearerToken) + return &options.KubernetesClusterAccess{ + ClusterServerURL: kubeConfigInternal.ClusterServerURL, + CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile, + AuthToken: kubeConfigInternal.AuthToken, + }, nil +} diff --git a/api/http/handler/helm/helm_delete.go b/api/http/handler/helm/helm_delete.go new file mode 100644 index 000000000..7939c253f --- /dev/null +++ b/api/http/handler/helm/helm_delete.go @@ -0,0 +1,55 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +// @id HelmDelete +// @summary Delete Helm Release +// @description +// @description **Access policy**: authorized +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param release query string true "The name of the release/application to uninstall" +// @param namespace query string true "An optional namespace" +// @success 204 "Success" +// @failure 400 "Invalid endpoint id or bad request" +// @failure 401 "Unauthorized" +// @failure 404 "Endpoint or ServiceAccount not found" +// @failure 500 "Server error or helm error" +// @router /endpoints/:id/kubernetes/helm/{release} [delete] +func (handler *Handler) helmDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + release, err := request.RetrieveRouteVariableValue(r, "release") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "No release specified", err} + } + + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + uninstallOpts := options.UninstallOptions{ + Name: release, + KubernetesClusterAccess: clusterAccess, + } + + q := r.URL.Query() + if namespace := q.Get("namespace"); namespace != "" { + uninstallOpts.Namespace = namespace + } + + err = handler.helmPackageManager.Uninstall(uninstallOpts) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Helm returned an error", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go new file mode 100644 index 000000000..5b7babc61 --- /dev/null +++ b/api/http/handler/helm/helm_delete_test.go @@ -0,0 +1,53 @@ +package helm + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/portainer/libhelm/options" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes" + "github.com/stretchr/testify/assert" + + bolt "github.com/portainer/portainer/api/bolt/bolttest" + helper "github.com/portainer/portainer/api/internal/testhelpers" +) + +func Test_helmDelete(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) + is.NoError(err, "Error creating endpoint") + + err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + is.NoError(err, "Error creating a user") + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + kubeConfigService := kubernetes.NewKubeConfigCAService("", "") + h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService) + + is.NotNil(h, "Handler should not fail") + + // Install a single chart directly, to be deleted by the handler + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + h.helmPackageManager.Install(options) + + t.Run("helmDelete succeeds with admin user", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/1/kubernetes/helm/%s", options.Name), nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + req.Header.Add("Authorization", "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code, "Status should be 204") + }) +} diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go new file mode 100644 index 000000000..33860ad7f --- /dev/null +++ b/api/http/handler/helm/helm_install.go @@ -0,0 +1,134 @@ +package helm + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/portainer/libhelm/options" + "github.com/portainer/libhelm/release" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/kubernetes/validation" +) + +type installChartPayload struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Chart string `json:"chart"` + Repo string `json:"repo"` + Values string `json:"values"` +} + +var errChartNameInvalid = errors.New("invalid chart name. " + + "Chart name must consist of lower case alphanumeric characters, '-' or '.'," + + " and must start and end with an alphanumeric character", +) + +// @id HelmInstall +// @summary Install Helm Chart +// @description +// @description **Access policy**: authorized +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param payload body installChartPayload true "Chart details" +// @success 201 {object} release.Release "Created" +// @failure 401 "Unauthorized" +// @failure 404 "Endpoint or ServiceAccount not found" +// @failure 500 "Server error" +// @router /endpoints/:id/kubernetes/helm [post] +func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload installChartPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid Helm install payload", + Err: err, + } + } + + release, err := handler.installChart(r, payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to install a chart", + Err: err, + } + } + + w.WriteHeader(http.StatusCreated) + return response.JSON(w, release) +} + +func (p *installChartPayload) Validate(_ *http.Request) error { + var required []string + if p.Repo == "" { + required = append(required, "repo") + } + if p.Name == "" { + required = append(required, "name") + } + if p.Namespace == "" { + required = append(required, "namespace") + } + if p.Chart == "" { + required = append(required, "chart") + } + if len(required) > 0 { + return fmt.Errorf("required field(s) missing: %s", strings.Join(required, ", ")) + } + + if errs := validation.IsDNS1123Subdomain(p.Name); len(errs) > 0 { + return errChartNameInvalid + } + + return nil +} + +func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) { + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return nil, httperr.Err + } + installOpts := options.InstallOptions{ + Name: p.Name, + Chart: p.Chart, + Namespace: p.Namespace, + Repo: p.Repo, + KubernetesClusterAccess: &options.KubernetesClusterAccess{ + ClusterServerURL: clusterAccess.ClusterServerURL, + CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile, + AuthToken: clusterAccess.AuthToken, + }, + } + + if p.Values != "" { + file, err := os.CreateTemp("", "helm-values") + if err != nil { + return nil, err + } + defer os.Remove(file.Name()) + _, err = file.WriteString(p.Values) + if err != nil { + file.Close() + return nil, err + } + err = file.Close() + if err != nil { + return nil, err + } + installOpts.ValuesFile = file.Name() + } + + release, err := handler.helmPackageManager.Install(installOpts) + if err != nil { + return nil, err + } + return release, nil +} diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go new file mode 100644 index 000000000..f671a067f --- /dev/null +++ b/api/http/handler/helm/helm_install_test.go @@ -0,0 +1,65 @@ +package helm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/portainer/libhelm/options" + "github.com/portainer/libhelm/release" + portainer "github.com/portainer/portainer/api" + bolt "github.com/portainer/portainer/api/bolt/bolttest" + "github.com/portainer/portainer/api/http/security" + helper "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/kubernetes" + "github.com/stretchr/testify/assert" +) + +func Test_helmInstall(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) + is.NoError(err, "error creating endpoint") + + err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + is.NoError(err, "error creating a user") + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + kubeConfigService := kubernetes.NewKubeConfigCAService("", "") + h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService) + + is.NotNil(h, "Handler should not fail") + + // Install a single chart. We expect to get these values back + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"} + optdata, err := json.Marshal(options) + is.NoError(err) + + t.Run("helmInstall succeeds with admin user", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/1/kubernetes/helm", bytes.NewBuffer(optdata)) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + req.Header.Add("Authorization", "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusCreated, rr.Code, "Status should be 201") + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + resp := release.Release{} + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be json") + is.EqualValues(options.Name, resp.Name, "Name doesn't match") + is.EqualValues(options.Namespace, resp.Namespace, "Namespace doesn't match") + }) +} diff --git a/api/http/handler/helm/helm_list.go b/api/http/handler/helm/helm_list.go new file mode 100644 index 000000000..f943a31ef --- /dev/null +++ b/api/http/handler/helm/helm_list.go @@ -0,0 +1,63 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +// @id HelmList +// @summary List Helm Releases +// @description +// @description **Access policy**: authorized +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param namespace query string true "specify an optional namespace" +// @param filter query string true "specify an optional filter" +// @param selector query string true "specify an optional selector" +// @success 200 {array} release.ReleaseElement "Success" +// @failure 400 "Invalid endpoint identifier" +// @failure 401 "Unauthorized" +// @failure 404 "Endpoint or ServiceAccount not found" +// @failure 500 "Server error" +// @router /endpoints/:id/kubernetes/helm [get] +func (handler *Handler) helmList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + listOpts := options.ListOptions{ + KubernetesClusterAccess: clusterAccess, + } + + params := r.URL.Query() + + // optional namespace. The library defaults to "default" + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + if namespace != "" { + listOpts.Namespace = namespace + } + + // optional filter + if filter := params.Get("filter"); filter != "" { + listOpts.Filter = filter + } + + // optional selector + if selector := params.Get("selector"); selector != "" { + listOpts.Selector = selector + } + + releases, err := handler.helmPackageManager.List(listOpts) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Helm returned an error", err} + } + + return response.JSON(w, releases) +} diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go new file mode 100644 index 000000000..9aa7d29d3 --- /dev/null +++ b/api/http/handler/helm/helm_list_test.go @@ -0,0 +1,60 @@ +package helm + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/portainer/libhelm/options" + "github.com/portainer/libhelm/release" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes" + "github.com/stretchr/testify/assert" + + bolt "github.com/portainer/portainer/api/bolt/bolttest" + helper "github.com/portainer/portainer/api/internal/testhelpers" +) + +func Test_helmList(t *testing.T) { + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) + assert.NoError(t, err, "error creating endpoint") + + err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + assert.NoError(t, err, "error creating a user") + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + kubeConfigService := kubernetes.NewKubeConfigCAService("", "") + h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService) + + // Install a single chart. We expect to get these values back + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + h.helmPackageManager.Install(options) + + t.Run("helmList", func(t *testing.T) { + is := assert.New(t) + + req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil) + req.Header.Add("Authorization", "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200 OK") + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + data := []release.ReleaseElement{} + json.Unmarshal(body, &data) + if is.Equal(1, len(data), "Expected one chart entry") { + is.EqualValues(options.Name, data[0].Name, "Name doesn't match") + is.EqualValues(options.Chart, data[0].Chart, "Chart doesn't match") + } + }) +} diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go new file mode 100644 index 000000000..499255bf9 --- /dev/null +++ b/api/http/handler/helm/helm_repo_search.go @@ -0,0 +1,56 @@ +package helm + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/pkg/errors" + "github.com/portainer/libhelm" + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" +) + +// @id HelmRepoSearch +// @summary Search Helm Charts +// @description +// @description **Access policy**: authorized +// @tags helm +// @param repo query string true "Helm repository URL" +// @security jwt +// @produce json +// @success 200 {object} string "Success" +// @failure 400 "Bad request" +// @failure 401 "Unauthorized" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /templates/helm [get] +func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + repo := r.URL.Query().Get("repo") + if repo == "" { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.New("missing `repo` query parameter")} + } + + _, err := url.ParseRequestURI(repo) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))} + } + + searchOpts := options.SearchRepoOptions{ + Repo: repo, + } + + result, err := libhelm.SearchRepo(searchOpts) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Search failed", + Err: err, + } + } + + w.Header().Set("Content-Type", "text/plain") + w.Write(result) + + return nil +} diff --git a/api/http/handler/helm/helm_repo_search_test.go b/api/http/handler/helm/helm_repo_search_test.go new file mode 100644 index 000000000..beea99f92 --- /dev/null +++ b/api/http/handler/helm/helm_repo_search_test.go @@ -0,0 +1,51 @@ +package helm + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/stretchr/testify/assert" + + helper "github.com/portainer/portainer/api/internal/testhelpers" +) + +func Test_helmRepoSearch(t *testing.T) { + helper.IntegrationTest(t) + is := assert.New(t) + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager) + + assert.NotNil(t, h, "Handler should not fail") + + repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"} + + for _, repo := range repos { + t.Run(repo, func(t *testing.T) { + repoUrlEncoded := url.QueryEscape(repo) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/templates/helm?repo=%s", repoUrlEncoded), nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200 OK") + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + is.NotEmpty(body, "Body should not be empty") + }) + } + + t.Run("fails on invalid URL", func(t *testing.T) { + repo := "abc.com" + repoUrlEncoded := url.QueryEscape(repo) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/templates/helm?repo=%s", repoUrlEncoded), nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusBadRequest, rr.Code, "Status should be 400 Bad request") + }) +} diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go new file mode 100644 index 000000000..220e33ab4 --- /dev/null +++ b/api/http/handler/helm/helm_show.go @@ -0,0 +1,70 @@ +package helm + +import ( + "fmt" + "log" + "net/http" + "net/url" + + "github.com/pkg/errors" + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" +) + +// @id HelmShow +// @summary Show Helm Chart Information +// @description +// @description **Access policy**: authorized +// @tags helm_chart +// @param repo query string true "Helm repository URL" +// @param chart query string true "Chart name" +// @param command path string false "chart/values/readme" +// @security jwt +// @accept json +// @produce text/plain +// @success 200 {object} string "Success" +// @failure 401 "Unauthorized" +// @failure 404 "Endpoint or ServiceAccount not found" +// @failure 500 "Server error" +// @router /templates/helm/{command} [get] +func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + repo := r.URL.Query().Get("repo") + if repo == "" { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.New("missing `repo` query parameter")} + } + _, err := url.ParseRequestURI(repo) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))} + } + + chart := r.URL.Query().Get("chart") + if chart == "" { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.New("missing `chart` query parameter")} + } + + cmd, err := request.RetrieveRouteVariableValue(r, "command") + if err != nil { + cmd = "all" + log.Printf("[DEBUG] [internal,helm] [message: command not provided, defaulting to %s]", cmd) + } + + showOptions := options.ShowOptions{ + OutputFormat: options.ShowOutputFormat(cmd), + Chart: chart, + Repo: repo, + } + result, err := handler.helmPackageManager.Show(showOptions) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to show chart", + Err: err, + } + } + + w.Header().Set("Content-Type", "text/plain") + w.Write(result) + + return nil +} diff --git a/api/http/handler/helm/helm_show_test.go b/api/http/handler/helm/helm_show_test.go new file mode 100644 index 000000000..c619dd01e --- /dev/null +++ b/api/http/handler/helm/helm_show_test.go @@ -0,0 +1,47 @@ +package helm + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/portainer/libhelm/binary/test" + helper "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_helmShow(t *testing.T) { + is := assert.New(t) + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager) + + is.NotNil(h, "Handler should not fail") + + commands := map[string]string{ + "values": test.MockDataValues, + "chart": test.MockDataChart, + "readme": test.MockDataReadme, + } + + for cmd, expect := range commands { + t.Run(cmd, func(t *testing.T) { + is.NotNil(h, "Handler should not fail") + + repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami") + chart := "nginx" + req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(rr.Code, http.StatusOK, "Status should be 200 OK") + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + is.EqualValues(string(body), expect, "Unexpected search response") + }) + } +} diff --git a/api/http/handler/helm/user_helm_repos.go b/api/http/handler/helm/user_helm_repos.go new file mode 100644 index 000000000..31df98d78 --- /dev/null +++ b/api/http/handler/helm/user_helm_repos.go @@ -0,0 +1,126 @@ +package helm + +import ( + "net/http" + "strings" + + "github.com/pkg/errors" + + "github.com/portainer/libhelm" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +type helmUserRepositoryResponse struct { + GlobalRepository string `json:"GlobalRepository"` + UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"` +} + +type addHelmRepoUrlPayload struct { + URL string `json:"url"` +} + +func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error { + return libhelm.ValidateHelmRepositoryURL(p.URL) +} + +// @id HelmUserRepositoryCreate +// @summary Create a user helm repository +// @description Create a user helm repository. +// @description **Access policy**: authenticated +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param payload body addHelmRepoUrlPayload true "Helm Repository" +// @success 200 {object} portainer.HelmUserRepository "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /endpoints/:id/kubernetes/helm/repositories [post] +func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + userID := portainer.UserID(tokenData.ID) + + p := new(addHelmRepoUrlPayload) + err = request.DecodeAndValidateJSONPayload(r, p) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid Helm repository URL", + Err: err, + } + } + // lowercase, remove trailing slash + p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/") + + records, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to access the DataStore", err} + } + + // check if repo already exists - by doing case insensitive comparison + for _, record := range records { + if strings.EqualFold(record.URL, p.URL) { + errMsg := "Helm repo already registered for user" + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: errMsg, Err: errors.New(errMsg)} + } + } + + record := portainer.HelmUserRepository{ + UserID: userID, + URL: p.URL, + } + + err = handler.dataStore.HelmUserRepository().CreateHelmUserRepository(&record) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to save a user Helm repository URL", err} + } + + return response.JSON(w, record) +} + +// @id HelmUserRepositoriesList +// @summary List a users helm repositories +// @description Inspect a user helm repositories. +// @description **Access policy**: authenticated +// @tags helm +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {object} helmUserRepositoryResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /endpoints/:id/kubernetes/helm/repositories [get] +func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + userID := portainer.UserID(tokenData.ID) + + settings, err := handler.dataStore.Settings().Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + userRepos, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user Helm repositories", err} + } + + resp := helmUserRepositoryResponse{ + GlobalRepository: settings.HelmRepositoryURL, + UserRepositories: userRepos, + } + + return response.JSON(w, resp) +} diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go index 898aef0a4..a3a545753 100644 --- a/api/http/handler/kubernetes/kubernetes_config.go +++ b/api/http/handler/kubernetes/kubernetes_config.go @@ -3,6 +3,8 @@ package kubernetes import ( "errors" "fmt" + "net/http" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -10,8 +12,6 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/security" kcli "github.com/portainer/portainer/api/kubernetes/cli" - - "net/http" ) // @id GetKubernetesConfig diff --git a/api/http/handler/kubernetes/kubernetes_nodes_limits.go b/api/http/handler/kubernetes/kubernetes_nodes_limits.go index 18a46d05c..09a64b116 100644 --- a/api/http/handler/kubernetes/kubernetes_nodes_limits.go +++ b/api/http/handler/kubernetes/kubernetes_nodes_limits.go @@ -1,12 +1,13 @@ package kubernetes 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" - "net/http" ) // @id getKubernetesNodesLimits @@ -18,7 +19,7 @@ import ( // @accept json // @produce json // @param id path int true "Endpoint identifier" -// @success 200 {object} K8sNodesLimits "Success" +// @success 200 {object} portainer.K8sNodesLimits "Success" // @failure 400 "Invalid request" // @failure 401 "Unauthorized" // @failure 403 "Permission denied" diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 7258170d9..aa02524ab 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -1,11 +1,13 @@ package settings import ( - "errors" "net/http" + "strings" "time" "github.com/asaskevich/govalidator" + "github.com/pkg/errors" + "github.com/portainer/libhelm" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -36,6 +38,8 @@ type settingsUpdatePayload struct { KubeconfigExpiry *string `example:"24h" default:"0"` // Whether telemetry is enabled EnableTelemetry *bool `example:"false"` + // Helm repository URL + HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"` } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { @@ -48,6 +52,12 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error { if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) { return errors.New("Invalid external templates URL. Must correspond to a valid URL format") } + if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" { + err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL) + if err != nil { + return errors.Wrap(err, "Invalid Helm repository URL. Must correspond to a valid URL format") + } + } if payload.UserSessionTimeout != nil { _, err := time.ParseDuration(*payload.UserSessionTimeout) if err != nil { @@ -101,6 +111,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.TemplatesURL = *payload.TemplatesURL } + if payload.HelmRepositoryURL != nil { + settings.HelmRepositoryURL = strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/") + } + if payload.BlackListedLabels != nil { settings.BlackListedLabels = payload.BlackListedLabels } diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 5ada9b87a..f6c399c7e 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -4,7 +4,7 @@ import ( "errors" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "net/http" diff --git a/api/http/proxy/factory/kubernetes/secrets.go b/api/http/proxy/factory/kubernetes/secrets.go index 6fc2ed7f6..dc03007d9 100644 --- a/api/http/proxy/factory/kubernetes/secrets.go +++ b/api/http/proxy/factory/kubernetes/secrets.go @@ -158,7 +158,7 @@ func (transport *baseTransport) proxySecretDeleteOperation(request *http.Request } func isSecretRepresentPrivateRegistry(secret map[string]interface{}) bool { - if secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) { + if secret["type"] == nil || secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) { return false } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 692c49544..ace0197f8 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -199,25 +199,14 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var tokenData *portainer.TokenData - var token string - // Optionally, token might be set via the "token" query parameter. - // For example, in websocket requests - token = r.URL.Query().Get("token") - - // Get token from the Authorization header - tokens, ok := r.Header["Authorization"] - if ok && len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") - } - - if token == "" { - httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized) + // get token from the Authorization header or query parameter + token, err := ExtractBearerToken(r) + if err != nil { + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", err) return } - var err error tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) if err != nil { httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) @@ -233,12 +222,28 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han return } - ctx := storeTokenData(r, tokenData) + ctx := StoreTokenData(r, tokenData) next.ServeHTTP(w, r.WithContext(ctx)) - return }) } +// ExtractBearerToken extracts the Bearer token from the request header or query parameter and returns the token. +func ExtractBearerToken(r *http.Request) (string, error) { + // Optionally, token might be set via the "token" query parameter. + // For example, in websocket requests + token := r.URL.Query().Get("token") + + tokens, ok := r.Header["Authorization"] + if ok && len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } + if token == "" { + return "", httperrors.ErrUnauthorized + } + return token, nil +} + // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/api/http/security/context.go b/api/http/security/context.go index 1601f61ac..5e96b5b88 100644 --- a/api/http/security/context.go +++ b/api/http/security/context.go @@ -17,8 +17,8 @@ const ( contextRestrictedRequest ) -// storeTokenData stores a TokenData object inside the request context and returns the enhanced context. -func storeTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context { +// StoreTokenData stores a TokenData object inside the request context and returns the enhanced context. +func StoreTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context { return context.WithValue(request.Context(), contextAuthenticationKey, tokenData) } diff --git a/api/http/server.go b/api/http/server.go index d826760f0..37df3cc78 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -9,6 +9,7 @@ import ( "path/filepath" "time" + "github.com/portainer/libhelm" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/adminmonitor" "github.com/portainer/portainer/api/crypto" @@ -26,6 +27,7 @@ import ( "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" + "github.com/portainer/portainer/api/http/handler/helm" kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" @@ -49,6 +51,7 @@ import ( "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/ssl" + k8s "github.com/portainer/portainer/api/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/scheduler" stackdeployer "github.com/portainer/portainer/api/stacks" @@ -76,11 +79,13 @@ type Server struct { SwarmStackManager portainer.SwarmStackManager ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager + KubeConfigService k8s.KubeConfigService Handler *handler.Handler SSLService *ssl.Service DockerClientFactory *docker.ClientFactory KubernetesClientFactory *cli.ClientFactory KubernetesDeployer portainer.KubernetesDeployer + HelmPackageManager libhelm.HelmPackageManager Scheduler *scheduler.Scheduler ShutdownCtx context.Context ShutdownTrigger context.CancelFunc @@ -166,6 +171,10 @@ func (server *Server) Start() error { var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) + var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.HelmPackageManager, server.KubeConfigService) + + var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager) + var motdHandler = motd.NewHandler(requestBouncer) var registryHandler = registries.NewHandler(requestBouncer) @@ -242,10 +251,12 @@ func (server *Server) Start() error { EdgeTemplatesHandler: edgeTemplatesHandler, EndpointGroupHandler: endpointGroupHandler, EndpointHandler: endpointHandler, + EndpointHelmHandler: endpointHelmHandler, EndpointEdgeHandler: endpointEdgeHandler, EndpointProxyHandler: endpointProxyHandler, - KubernetesHandler: kubernetesHandler, FileHandler: fileHandler, + HelmTemplatesHandler: helmTemplatesHandler, + KubernetesHandler: kubernetesHandler, MOTDHandler: motdHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 89e3b0329..c934691c2 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -8,26 +8,27 @@ import ( ) type datastore struct { - customTemplate portainer.CustomTemplateService - edgeGroup portainer.EdgeGroupService - edgeJob portainer.EdgeJobService - edgeStack portainer.EdgeStackService - endpoint portainer.EndpointService - endpointGroup portainer.EndpointGroupService - endpointRelation portainer.EndpointRelationService - registry portainer.RegistryService - resourceControl portainer.ResourceControlService - role portainer.RoleService - sslSettings portainer.SSLSettingsService - settings portainer.SettingsService - stack portainer.StackService - tag portainer.TagService - teamMembership portainer.TeamMembershipService - team portainer.TeamService - tunnelServer portainer.TunnelServerService - user portainer.UserService - version portainer.VersionService - webhook portainer.WebhookService + customTemplate portainer.CustomTemplateService + edgeGroup portainer.EdgeGroupService + edgeJob portainer.EdgeJobService + edgeStack portainer.EdgeStackService + endpoint portainer.EndpointService + endpointGroup portainer.EndpointGroupService + endpointRelation portainer.EndpointRelationService + helmUserRepository portainer.HelmUserRepositoryService + registry portainer.RegistryService + resourceControl portainer.ResourceControlService + role portainer.RoleService + sslSettings portainer.SSLSettingsService + settings portainer.SettingsService + stack portainer.StackService + tag portainer.TagService + teamMembership portainer.TeamMembershipService + team portainer.TeamService + tunnelServer portainer.TunnelServerService + user portainer.UserService + version portainer.VersionService + webhook portainer.WebhookService } func (d *datastore) BackupTo(io.Writer) error { return nil } @@ -45,19 +46,22 @@ func (d *datastore) EdgeStack() portainer.EdgeStackService { retur func (d *datastore) Endpoint() portainer.EndpointService { return d.endpoint } func (d *datastore) EndpointGroup() portainer.EndpointGroupService { return d.endpointGroup } func (d *datastore) EndpointRelation() portainer.EndpointRelationService { return d.endpointRelation } -func (d *datastore) Registry() portainer.RegistryService { return d.registry } -func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } -func (d *datastore) Role() portainer.RoleService { return d.role } -func (d *datastore) Settings() portainer.SettingsService { return d.settings } -func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings } -func (d *datastore) Stack() portainer.StackService { return d.stack } -func (d *datastore) Tag() portainer.TagService { return d.tag } -func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } -func (d *datastore) Team() portainer.TeamService { return d.team } -func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } -func (d *datastore) User() portainer.UserService { return d.user } -func (d *datastore) Version() portainer.VersionService { return d.version } -func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } +func (d *datastore) HelmUserRepository() portainer.HelmUserRepositoryService { + return d.helmUserRepository +} +func (d *datastore) Registry() portainer.RegistryService { return d.registry } +func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } +func (d *datastore) Role() portainer.RoleService { return d.role } +func (d *datastore) Settings() portainer.SettingsService { return d.settings } +func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings } +func (d *datastore) Stack() portainer.StackService { return d.stack } +func (d *datastore) Tag() portainer.TagService { return d.tag } +func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } +func (d *datastore) Team() portainer.TeamService { return d.team } +func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } +func (d *datastore) User() portainer.UserService { return d.user } +func (d *datastore) Version() portainer.VersionService { return d.version } +func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } type datastoreOption = func(d *datastore) @@ -71,21 +75,25 @@ func NewDatastore(options ...datastoreOption) *datastore { return &d } - type stubSettingsService struct { settings *portainer.Settings } -func (s *stubSettingsService) Settings() (*portainer.Settings, error) { return s.settings, nil } -func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { return nil } - -func WithSettings(settings *portainer.Settings) datastoreOption { +func (s *stubSettingsService) Settings() (*portainer.Settings, error) { + return s.settings, nil +} +func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { + s.settings = settings + return nil +} +func WithSettingsService(settings *portainer.Settings) datastoreOption { return func(d *datastore) { - d.settings = &stubSettingsService{settings: settings} + d.settings = &stubSettingsService{ + settings: settings, + } } } - type stubUserService struct { users []portainer.User } diff --git a/api/internal/testhelpers/integration.go b/api/internal/testhelpers/integration.go new file mode 100644 index 000000000..f7ad19be4 --- /dev/null +++ b/api/internal/testhelpers/integration.go @@ -0,0 +1,22 @@ +package testhelpers + +import ( + "flag" + "os" + "testing" +) + +var integration bool + +func init() { + flag.BoolVar(&integration, "integration", false, "enable integration tests") +} + +// IntegrationTest marks the current test as an integration test +func IntegrationTest(t *testing.T) { + _, enabled := os.LookupEnv("INTEGRATION_TEST") + + if !(integration || enabled) { + t.Skip("Skipping integration test") + } +} diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go new file mode 100644 index 000000000..39d7bab39 --- /dev/null +++ b/api/internal/testhelpers/request_bouncer.go @@ -0,0 +1,15 @@ +package testhelpers + +import "net/http" + +type testRequestBouncer struct { +} + +// NewTestRequestBouncer creates new mock for requestBouncer +func NewTestRequestBouncer() *testRequestBouncer { + return &testRequestBouncer{} +} + +func (testRequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { + return h +} diff --git a/api/jwt/jwt_kubeconfig_test.go b/api/jwt/jwt_kubeconfig_test.go index b8269b4ca..32289d0f3 100644 --- a/api/jwt/jwt_kubeconfig_test.go +++ b/api/jwt/jwt_kubeconfig_test.go @@ -1,11 +1,12 @@ package jwt import ( + "testing" + "github.com/dgrijalva/jwt-go" portainer "github.com/portainer/portainer/api" i "github.com/portainer/portainer/api/internal/testhelpers" "github.com/stretchr/testify/assert" - "testing" ) func TestService_GenerateTokenForKubeconfig(t *testing.T) { @@ -24,7 +25,7 @@ func TestService_GenerateTokenForKubeconfig(t *testing.T) { myFields := fields{ userSessionTimeout: "24h", - dataStore: i.NewDatastore(i.WithSettings(mySettings)), + dataStore: i.NewDatastore(i.WithSettingsService(mySettings)), } myTokenData := &portainer.TokenData{ @@ -78,4 +79,4 @@ func TestService_GenerateTokenForKubeconfig(t *testing.T) { assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt) }) } -} \ No newline at end of file +} diff --git a/api/kubernetes/kubeconfig_service.go b/api/kubernetes/kubeconfig_service.go new file mode 100644 index 000000000..49547b274 --- /dev/null +++ b/api/kubernetes/kubeconfig_service.go @@ -0,0 +1,104 @@ +package kubernetes + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io/ioutil" + "log" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" +) + +// KubeConfigService represents a service that is responsible for handling kubeconfig operations +type KubeConfigService interface { + IsSecure() bool + GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess +} + +// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data +type kubernetesClusterAccess struct { + ClusterServerURL string `example:"https://mycompany.k8s.com"` + CertificateAuthorityFile string `example:"/data/tls/localhost.crt"` + CertificateAuthorityData string `example:"MIIC5TCCAc2gAwIBAgIJAJ+...+xuhOaFXwQ=="` + AuthToken string `example:"ey..."` +} + +type kubeConfigCAService struct { + httpsBindAddr string + certificateAuthorityFile string + certificateAuthorityData string +} + +var ( + errTLSCertNotProvided = errors.New("tls cert path not provided") + errTLSCertFileMissing = errors.New("missing tls cert file") + errTLSCertIncorrectType = errors.New("incorrect tls cert type") + errTLSCertValidation = errors.New("failed to parse tls certificate") +) + +// NewKubeConfigCAService encapsulates generation of core KubeConfig data +func NewKubeConfigCAService(httpsBindAddr string, tlsCertPath string) KubeConfigService { + certificateAuthorityData, err := getCertificateAuthorityData(tlsCertPath) + if err != nil { + log.Printf("[DEBUG] [internal,kubeconfig] [message: %s, generated KubeConfig will be insecure]", err.Error()) + } + + return &kubeConfigCAService{ + httpsBindAddr: httpsBindAddr, + certificateAuthorityFile: tlsCertPath, + certificateAuthorityData: certificateAuthorityData, + } +} + +// getCertificateAuthorityData reads tls certificate from supplied path and verifies the tls certificate +// then returns content (string) of the certificate within `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` +func getCertificateAuthorityData(tlsCertPath string) (string, error) { + if tlsCertPath == "" { + return "", errTLSCertNotProvided + } + + data, err := ioutil.ReadFile(tlsCertPath) + if err != nil { + return "", errors.Wrap(errTLSCertFileMissing, err.Error()) + } + + block, _ := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" { + return "", errTLSCertIncorrectType + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", errors.Wrap(errTLSCertValidation, err.Error()) + } + + return base64.StdEncoding.EncodeToString(certificate.Raw), nil +} + +// IsSecure specifies whether generated KubeConfig structs from the service will not have `insecure-skip-tls-verify: true` +// this is based on the fact that we can successfully extract `certificateAuthorityData` from +// certificate file at `tlsCertPath`. If we can successfully extract `certificateAuthorityData`, +// then this will be used as `certificate-authority-data` attribute in a generated KubeConfig. +func (kccas *kubeConfigCAService) IsSecure() bool { + return kccas.certificateAuthorityData != "" +} + +// GetKubeConfigInternal returns K8s cluster access details for the specified endpoint. +// On startup, portainer generates a certificate against localhost at specified `httpsBindAddr` port, hence +// the kubeconfig generated should only be utilised by internal portainer binaries as the `ClusterServerURL` +// points to the internally accessible `https` based `localhost` address. +// The struct can be used to: +// - generate a kubeconfig file +// - pass down params to binaries +func (kccas *kubeConfigCAService) GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess { + clusterServerUrl := fmt.Sprintf("https://localhost%s/api/endpoints/%s/kubernetes", kccas.httpsBindAddr, fmt.Sprint(endpointId)) + return kubernetesClusterAccess{ + ClusterServerURL: clusterServerUrl, + CertificateAuthorityFile: kccas.certificateAuthorityFile, + CertificateAuthorityData: kccas.certificateAuthorityData, + AuthToken: authToken, + } +} diff --git a/api/kubernetes/kubeconfig_service_test.go b/api/kubernetes/kubeconfig_service_test.go new file mode 100644 index 000000000..9b143f349 --- /dev/null +++ b/api/kubernetes/kubeconfig_service_test.go @@ -0,0 +1,149 @@ +package kubernetes + +import ( + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TLS certificate can be generated using: +// openssl req -x509 -out localhost.crt -keyout localhost.key -newkey rsa:2048 -nodes -sha25 -subj '/CN=localhost' -extensions EXT -config <( \ +// printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") +const certData = `-----BEGIN CERTIFICATE----- +MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0 +B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95Ocv +X+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3Xsew +B3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXi +oa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubT +oA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B +AQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/ +S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUS +Q5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/ +JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMo +iuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi +4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ== +-----END CERTIFICATE----- +` + +// string within the `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` without linebreaks +const certDataString = "MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95OcvX+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3XsewB3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXioa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubToA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUSQ5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMoiuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ==" + +func createTempFile(filename, content string) (string, func()) { + tempPath, _ := ioutil.TempDir("", "temp") + filePath := fmt.Sprintf("%s/%s", tempPath, filename) + ioutil.WriteFile(filePath, []byte(content), 0644) + + teardown := func() { os.RemoveAll(tempPath) } + + return filePath, teardown +} + +func Test_getCertificateAuthorityData(t *testing.T) { + is := assert.New(t) + + t.Run("getCertificateAuthorityData fails on tls cert not provided", func(t *testing.T) { + _, err := getCertificateAuthorityData("") + is.ErrorIs(err, errTLSCertNotProvided, "getCertificateAuthorityData should fail with %w", errTLSCertNotProvided) + }) + + t.Run("getCertificateAuthorityData fails on tls cert provided but missing file", func(t *testing.T) { + _, err := getCertificateAuthorityData("/tmp/non-existent.crt") + is.ErrorIs(err, errTLSCertFileMissing, "getCertificateAuthorityData should fail with %w", errTLSCertFileMissing) + }) + + t.Run("getCertificateAuthorityData fails on tls cert provided but invalid file data", func(t *testing.T) { + filePath, teardown := createTempFile("invalid-cert.crt", "hello\ngo\n") + defer teardown() + + _, err := getCertificateAuthorityData(filePath) + is.ErrorIs(err, errTLSCertIncorrectType, "getCertificateAuthorityData should fail with %w", errTLSCertIncorrectType) + }) + + t.Run("getCertificateAuthorityData succeeds on valid tls cert provided", func(t *testing.T) { + filePath, teardown := createTempFile("valid-cert.crt", certData) + defer teardown() + + certificateAuthorityData, err := getCertificateAuthorityData(filePath) + is.NoError(err, "getCertificateAuthorityData succeed with valid cert; err=%w", errTLSCertIncorrectType) + + is.Equal(certificateAuthorityData, certDataString, "returned certificateAuthorityData should be %s", certDataString) + }) +} + +func TestKubeConfigService_IsSecure(t *testing.T) { + is := assert.New(t) + + t.Run("IsSecure should be false", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + is.False(kcs.IsSecure(), "should be false if TLS cert not provided") + }) + + t.Run("IsSecure should be false", func(t *testing.T) { + filePath, teardown := createTempFile("valid-cert.crt", certData) + defer teardown() + + kcs := NewKubeConfigCAService("", filePath) + is.True(kcs.IsSecure(), "should be true if valid TLS cert (path and content) provided") + }) +} + +func TestKubeConfigService_GetKubeConfigInternal(t *testing.T) { + is := assert.New(t) + + t.Run("GetKubeConfigInternal returns localhost address", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "https://localhost"), "should contain localhost address") + }) + + t.Run("GetKubeConfigInternal contains https bind address port", func(t *testing.T) { + kcs := NewKubeConfigCAService(":1010", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, ":1010"), "should contain bind address port") + }) + + t.Run("GetKubeConfigInternal contains endpoint proxy url", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(100, "some-token") + is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "api/endpoints/100/kubernetes"), "should contain endpoint proxy url") + }) + + t.Run("GetKubeConfigInternal returns insecure cluster access config", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + + wantClusterAccessDetails := kubernetesClusterAccess{ + ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes", + AuthToken: "some-token", + CertificateAuthorityFile: "", + CertificateAuthorityData: "", + } + + is.Equal(clusterAccessDetails, wantClusterAccessDetails) + }) + + t.Run("GetKubeConfigInternal returns secure cluster access config", func(t *testing.T) { + filePath, teardown := createTempFile("valid-cert.crt", certData) + defer teardown() + + kcs := NewKubeConfigCAService("", filePath) + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + + wantClusterAccessDetails := kubernetesClusterAccess{ + ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes", + AuthToken: "some-token", + CertificateAuthorityFile: filePath, + CertificateAuthorityData: certDataString, + } + + is.Equal(clusterAccessDetails, wantClusterAccessDetails) + }) +} diff --git a/api/kubernetes/validation/validation.go b/api/kubernetes/validation/validation.go new file mode 100644 index 000000000..26b26eac5 --- /dev/null +++ b/api/kubernetes/validation/validation.go @@ -0,0 +1,48 @@ +package validation + +// borrowed from apimachinery@v0.17.2/pkg/util/validation/validation.go +// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go + +import ( + "fmt" + "regexp" +) + +const dns1123LabelFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?" +const dns1123SubdomainFmt string = dns1123LabelFmt + "(\\." + dns1123LabelFmt + ")*" +const DNS1123SubdomainMaxLength int = 253 + +var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$") + +// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123). +func IsDNS1123Subdomain(value string) []string { + var errs []string + if len(value) > DNS1123SubdomainMaxLength { + errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength)) + } + if !dns1123SubdomainRegexp.MatchString(value) { + errs = append(errs, RegexError(dns1123SubdomainFmt, "example.com")) + } + return errs +} + +// MaxLenError returns a string explanation of a "string too long" validation failure. +func MaxLenError(length int) string { + return fmt.Sprintf("must be no more than %d characters", length) +} + +// RegexError returns a string explanation of a regex validation failure. +func RegexError(fmt string, examples ...string) string { + s := "must match the regex " + fmt + if len(examples) == 0 { + return s + } + s += " (e.g. " + for i := range examples { + if i > 0 { + s += " or " + } + s += "'" + examples[i] + "'" + } + return s + ")" +} diff --git a/api/portainer.go b/api/portainer.go index 703b3fd36..d70880f65 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -395,6 +395,18 @@ type ( ProjectPath string `json:"ProjectPath"` } + HelmUserRepositoryID int + + // HelmUserRepositories stores a Helm repository URL for the given user + HelmUserRepository struct { + // Membership Identifier + ID HelmUserRepositoryID `json:"Id" example:"1"` + // User identifier + UserID UserID `json:"UserId" example:"1"` + // Helm repository URL + URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"` + } + // QuayRegistryData represents data required for Quay registry to work QuayRegistryData struct { UseOrganisation bool `json:"UseOrganisation"` @@ -699,6 +711,8 @@ type ( KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"` // Whether telemetry is enabled EnableTelemetry bool `json:"EnableTelemetry" example:"false"` + // Helm repository URL, defaults to "https://charts.bitnami.com/bitnami" + HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"` // Deprecated fields DisplayDonationHeader bool @@ -1099,6 +1113,7 @@ type ( Endpoint() EndpointService EndpointGroup() EndpointGroupService EndpointRelation() EndpointRelationService + HelmUserRepository() HelmUserRepositoryService Registry() RegistryService ResourceControl() ResourceControlService Role() RoleService @@ -1226,6 +1241,12 @@ type ( LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) } + // HelmUserRepositoryService represents a service to manage HelmUserRepositories + HelmUserRepositoryService interface { + HelmUserRepositoryByUserID(userID UserID) ([]HelmUserRepository, error) + CreateHelmUserRepository(record *HelmUserRepository) error + } + // JWTService represents a service for managing JWT tokens JWTService interface { GenerateToken(data *TokenData) (string, error) @@ -1466,6 +1487,8 @@ const ( DefaultEdgeAgentCheckinIntervalInSeconds = 5 // DefaultTemplatesURL represents the URL to the official templates supported by Portainer DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" + // DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami + DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami" // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared DefaultUserSessionTimeout = "8h" // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 74b1788b3..c75fcd32d 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -310,6 +310,10 @@ a[ng-click] { padding-top: 15px !important; } +.nomargin { + margin: 0 !important; +} + .terminal-container { width: 100%; padding: 10px 0; @@ -833,3 +837,8 @@ json-tree .branch-preview { text-align: center; padding-bottom: 5px; } + +.text-wrap { + word-break: break-all; + white-space: normal; +} diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 7bb3d3739..853c7a641 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -45,6 +45,26 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo }, }; + const helmApplication = { + name: 'kubernetes.helm', + url: '/helm/:namespace/:name', + views: { + 'content@': { + component: 'kubernetesHelmApplicationView', + }, + }, + }; + + const helmTemplates = { + name: 'kubernetes.templates.helm', + url: '/helm', + views: { + 'content@': { + component: 'helmTemplatesView', + }, + }, + }; + const applications = { name: 'kubernetes.applications', url: '/applications', @@ -301,6 +321,8 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo }; $stateRegistryProvider.register(kubernetes); + $stateRegistryProvider.register(helmApplication); + $stateRegistryProvider.register(helmTemplates); $stateRegistryProvider.register(applications); $stateRegistryProvider.register(applicationCreation); $stateRegistryProvider.register(application); diff --git a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js new file mode 100644 index 000000000..b20b42ec9 --- /dev/null +++ b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js @@ -0,0 +1,12 @@ +import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models'; + +export default class { + $onInit() { + const secrets = (this.configurations || []) + .filter((config) => config.Data && config.Type === KubernetesConfigurationTypes.SECRET) + .flatMap((config) => Object.entries(config.Data)) + .map(([key, value]) => ({ key, value })); + + this.state = { secrets }; + } +} diff --git a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.html b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.html new file mode 100644 index 000000000..7d9cc659e --- /dev/null +++ b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.html @@ -0,0 +1,12 @@ +
+ Secrets +
+ + + + + + +
+ +
diff --git a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.js b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.js new file mode 100644 index 000000000..410563560 --- /dev/null +++ b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.js @@ -0,0 +1,10 @@ +import angular from 'angular'; +import controller from './applications-datatable-details.controller'; + +angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatableDetails', { + templateUrl: './applications-datatable-details.html', + controller, + bindings: { + configurations: '<', + }, +}); diff --git a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css new file mode 100644 index 000000000..17c283bf7 --- /dev/null +++ b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css @@ -0,0 +1,10 @@ +.published-url-container { + display: grid; + grid-template-columns: 1fr 1fr 3fr; + padding-top: 1rem; + padding-bottom: 0.5rem; +} + +.publish-url-link { + width: min-content; +} diff --git a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html new file mode 100644 index 000000000..ea25b014c --- /dev/null +++ b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html @@ -0,0 +1,6 @@ +
+
+ Published URL +
+ {{ $ctrl.publishedUrl }} +
diff --git a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js new file mode 100644 index 000000000..7177b851b --- /dev/null +++ b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js @@ -0,0 +1,9 @@ +import angular from 'angular'; +import './applications-datatable-url.css'; + +angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatableUrl', { + templateUrl: './applications-datatable-url.html', + bindings: { + publishedUrl: '@', + }, +}); diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css new file mode 100644 index 000000000..6cf9da69a --- /dev/null +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css @@ -0,0 +1,16 @@ +.secondary-heading { + background-color: #e7f6ff !important; +} + +.secondary-body { + background-color: #f1f9fd; +} + +.datatable-wide { + width: 55px; +} + +.datatable-padding-vertical { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html index 3a2517be6..1f1d46fdd 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html @@ -1,7 +1,7 @@
-
+
{{ $ctrl.titleText }}
@@ -62,7 +62,7 @@
-
+
-