1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 12:25:22 +02:00

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 <hookenz@gmail.com>

* 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 <dev.786zshan@gmail.com>

* 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 <dev.786zshan@gmail.com>

* 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 <hookenz@gmail.com>

* 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 <dev.786zshan@gmail.com>

* 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 <hookenz@gmail.com>

* 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 <dev.786zshan@gmail.com>

* 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 <hookenz@gmail.com>

* 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 <hookenz@gmail.com>

* 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 <hookenz@gmail.com>

* 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 <simon.meng@portainer.io>

* 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 <hookenz@gmail.com>

* 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 <hookenz@gmail.com>
Co-authored-by: cong meng <mcpacino@gmail.com>
Co-authored-by: Simon Meng <simon.meng@portainer.io>
This commit is contained in:
zees-dev 2021-09-10 14:06:57 +12:00 committed by GitHub
parent e86a586651
commit 2a60b8fcdf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 3055 additions and 139 deletions

View file

@ -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 {

View file

@ -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)
})
}

View file

@ -44,6 +44,7 @@ func (store *Store) Init() error {
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
TemplatesURL: portainer.DefaultTemplatesURL,
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
}

View file

@ -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)
}
}
func (m *Migrator) helmRepositoryURLToDB32() error {
settings, err := m.settingsService.Settings()
if err != nil {
return err
}
settings.HelmRepositoryURL = portainer.DefaultHelmRepositoryURL
return m.settingsService.UpdateSettings(settings)
}

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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=

View file

@ -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"):

View file

@ -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
}

View file

@ -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)
}

View file

@ -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")
})
}

View file

@ -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
}

View file

@ -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")
})
}

View file

@ -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)
}

View file

@ -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")
}
})
}

View file

@ -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
}

View file

@ -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")
})
}

View file

@ -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
}

View file

@ -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")
})
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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"

View file

@ -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
}

View file

@ -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"

View file

@ -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
}

View file

@ -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) {

View file

@ -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)
}

View file

@ -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,

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
})
}
}
}

View file

@ -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,
}
}

View file

@ -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)
})
}

View file

@ -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 + ")"
}

View file

@ -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