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>
pull/5605/head
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" "path"
"time" "time"
"github.com/portainer/portainer/api/bolt/helmuserrepository"
"github.com/boltdb/bolt" "github.com/boltdb/bolt"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/customtemplate" "github.com/portainer/portainer/api/bolt/customtemplate"
@ -57,6 +59,7 @@ type Store struct {
EndpointService *endpoint.Service EndpointService *endpoint.Service
EndpointRelationService *endpointrelation.Service EndpointRelationService *endpointrelation.Service
ExtensionService *extension.Service ExtensionService *extension.Service
HelmUserRepositoryService *helmuserrepository.Service
RegistryService *registry.Service RegistryService *registry.Service
ResourceControlService *resourcecontrol.Service ResourceControlService *resourcecontrol.Service
RoleService *role.Service RoleService *role.Service

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, EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
TemplatesURL: portainer.DefaultTemplatesURL, TemplatesURL: portainer.DefaultTemplatesURL,
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
UserSessionTimeout: portainer.DefaultUserSessionTimeout, UserSessionTimeout: portainer.DefaultUserSessionTimeout,
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry, KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
} }

View File

@ -28,6 +28,10 @@ func (m *Migrator) migrateDBVersionToDB32() error {
return err return err
} }
if err := m.helmRepositoryURLToDB32(); err != nil {
return err
}
return nil return nil
} }
@ -224,3 +228,12 @@ func (m *Migrator) kubeconfigExpiryToDB32() error {
settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry
return m.settingsService.UpdateSettings(settings) 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/endpointgroup"
"github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/endpointrelation"
"github.com/portainer/portainer/api/bolt/extension" "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/registry"
"github.com/portainer/portainer/api/bolt/resourcecontrol" "github.com/portainer/portainer/api/bolt/resourcecontrol"
"github.com/portainer/portainer/api/bolt/role" "github.com/portainer/portainer/api/bolt/role"
@ -88,6 +89,12 @@ func (store *Store) initServices() error {
} }
store.ExtensionService = extensionService store.ExtensionService = extensionService
helmUserRepositoryService, err := helmuserrepository.NewService(store.connection)
if err != nil {
return err
}
store.HelmUserRepositoryService = helmUserRepositoryService
registryService, err := registry.NewService(store.connection) registryService, err := registry.NewService(store.connection)
if err != nil { if err != nil {
return err return err
@ -204,6 +211,11 @@ func (store *Store) EndpointRelation() portainer.EndpointRelationService {
return store.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 // Registry gives access to the Registry data management layer
func (store *Store) Registry() portainer.RegistryService { func (store *Store) Registry() portainer.RegistryService {
return store.RegistryService return store.RegistryService

View File

@ -13,6 +13,7 @@ import (
"github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/docker"
"github.com/portainer/libhelm"
"github.com/portainer/portainer/api/exec" "github.com/portainer/portainer/api/exec"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/git"
@ -102,6 +103,10 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath) 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) { func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
settings, err := dataStore.Settings().Settings() settings, err := dataStore.Settings().Settings()
if err != nil { if err != nil {
@ -420,6 +425,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal(err) log.Fatal(err)
} }
sslSettings, err := sslService.GetSSLSettings()
if err != nil {
log.Fatalf("failed to get ssl settings: %s", err)
}
err = initKeyPair(fileService, digitalSignatureService) err = initKeyPair(fileService, digitalSignatureService)
if err != nil { if err != nil {
log.Fatalf("failed initializing key pai: %v", err) log.Fatalf("failed initializing key pai: %v", err)
@ -445,6 +455,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
authorizationService.K8sClientFactory = kubernetesClientFactory authorizationService.K8sClientFactory = kubernetesClientFactory
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager() kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
kubeConfigService := kubernetes.NewKubeConfigCAService(*flags.AddrHTTPS, sslSettings.CertPath)
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager) proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager)
dockerConfigPath := fileService.GetDockerConfigPath() dockerConfigPath := fileService.GetDockerConfigPath()
@ -458,6 +471,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) 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() { if dataStore.IsNew() {
err = updateSettingsFromFlags(dataStore, flags) err = updateSettingsFromFlags(dataStore, flags)
if err != nil { if err != nil {
@ -518,7 +536,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatalf("failed starting tunnel server: %s", err) log.Fatalf("failed starting tunnel server: %s", err)
} }
sslSettings, err := dataStore.SSLSettings().Settings() sslDBSettings, err := dataStore.SSLSettings().Settings()
if err != nil { if err != nil {
log.Fatalf("failed to fetch ssl settings from DB") log.Fatalf("failed to fetch ssl settings from DB")
} }
@ -533,12 +551,13 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
Status: applicationStatus, Status: applicationStatus,
BindAddress: *flags.Addr, BindAddress: *flags.Addr,
BindAddressHTTPS: *flags.AddrHTTPS, BindAddressHTTPS: *flags.AddrHTTPS,
HTTPEnabled: sslSettings.HTTPEnabled, HTTPEnabled: sslDBSettings.HTTPEnabled,
AssetsPath: *flags.Assets, AssetsPath: *flags.Assets,
DataStore: dataStore, DataStore: dataStore,
SwarmStackManager: swarmStackManager, SwarmStackManager: swarmStackManager,
ComposeStackManager: composeStackManager, ComposeStackManager: composeStackManager,
KubernetesDeployer: kubernetesDeployer, KubernetesDeployer: kubernetesDeployer,
HelmPackageManager: helmPackageManager,
CryptoService: cryptoService, CryptoService: cryptoService,
JWTService: jwtService, JWTService: jwtService,
FileService: fileService, FileService: fileService,
@ -547,6 +566,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
GitService: gitService, GitService: gitService,
ProxyManager: proxyManager, ProxyManager: proxyManager,
KubernetesTokenCacheManager: kubernetesTokenCacheManager, KubernetesTokenCacheManager: kubernetesTokenCacheManager,
KubeConfigService: kubeConfigService,
SignatureService: digitalSignatureService, SignatureService: digitalSignatureService,
SnapshotService: snapshotService, SnapshotService: snapshotService,
SSLService: sslService, SSLService: sslService,

View File

@ -38,6 +38,7 @@ require (
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1 github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a 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/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.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/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 h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE= 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 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II=
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0= 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= 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/endpointproxy"
"github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file" "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/kubernetes"
"github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/registries"
@ -47,7 +48,9 @@ type Handler struct {
EndpointEdgeHandler *endpointedge.Handler EndpointEdgeHandler *endpointedge.Handler
EndpointGroupHandler *endpointgroups.Handler EndpointGroupHandler *endpointgroups.Handler
EndpointHandler *endpoints.Handler EndpointHandler *endpoints.Handler
EndpointHelmHandler *helm.Handler
EndpointProxyHandler *endpointproxy.Handler EndpointProxyHandler *endpointproxy.Handler
HelmTemplatesHandler *helm.Handler
KubernetesHandler *kubernetes.Handler KubernetesHandler *kubernetes.Handler
FileHandler *file.Handler FileHandler *file.Handler
MOTDHandler *motd.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) http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"): case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r) 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"): case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
switch { switch {
case strings.Contains(r.URL.Path, "/docker/"): 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) http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/tags"): case strings.HasPrefix(r.URL.Path, "/api/tags"):
http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) 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"): case strings.HasPrefix(r.URL.Path, "/api/templates"):
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/upload"): 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 ( import (
"errors" "errors"
"fmt" "fmt"
"net/http"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
@ -10,8 +12,6 @@ import (
bolterrors "github.com/portainer/portainer/api/bolt/errors" bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
kcli "github.com/portainer/portainer/api/kubernetes/cli" kcli "github.com/portainer/portainer/api/kubernetes/cli"
"net/http"
) )
// @id GetKubernetesConfig // @id GetKubernetesConfig

View File

@ -1,12 +1,13 @@
package kubernetes package kubernetes
import ( import (
"net/http"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors" bolterrors "github.com/portainer/portainer/api/bolt/errors"
"net/http"
) )
// @id getKubernetesNodesLimits // @id getKubernetesNodesLimits
@ -18,7 +19,7 @@ import (
// @accept json // @accept json
// @produce json // @produce json
// @param id path int true "Endpoint identifier" // @param id path int true "Endpoint identifier"
// @success 200 {object} K8sNodesLimits "Success" // @success 200 {object} portainer.K8sNodesLimits "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 401 "Unauthorized" // @failure 401 "Unauthorized"
// @failure 403 "Permission denied" // @failure 403 "Permission denied"

View File

@ -1,11 +1,13 @@
package settings package settings
import ( import (
"errors"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
"github.com/portainer/libhelm"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
@ -36,6 +38,8 @@ type settingsUpdatePayload struct {
KubeconfigExpiry *string `example:"24h" default:"0"` KubeconfigExpiry *string `example:"24h" default:"0"`
// Whether telemetry is enabled // Whether telemetry is enabled
EnableTelemetry *bool `example:"false"` EnableTelemetry *bool `example:"false"`
// Helm repository URL
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
} }
func (payload *settingsUpdatePayload) Validate(r *http.Request) error { 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) { if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) {
return errors.New("Invalid external templates URL. Must correspond to a valid URL format") 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 { if payload.UserSessionTimeout != nil {
_, err := time.ParseDuration(*payload.UserSessionTimeout) _, err := time.ParseDuration(*payload.UserSessionTimeout)
if err != nil { if err != nil {
@ -101,6 +111,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
settings.TemplatesURL = *payload.TemplatesURL settings.TemplatesURL = *payload.TemplatesURL
} }
if payload.HelmRepositoryURL != nil {
settings.HelmRepositoryURL = strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/")
}
if payload.BlackListedLabels != nil { if payload.BlackListedLabels != nil {
settings.BlackListedLabels = payload.BlackListedLabels settings.BlackListedLabels = payload.BlackListedLabels
} }

View File

@ -4,7 +4,7 @@ import (
"errors" "errors"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"net/http" "net/http"

View File

@ -158,7 +158,7 @@ func (transport *baseTransport) proxySecretDeleteOperation(request *http.Request
} }
func isSecretRepresentPrivateRegistry(secret map[string]interface{}) bool { 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 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 { func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenData *portainer.TokenData var tokenData *portainer.TokenData
var token string
// Optionally, token might be set via the "token" query parameter. // get token from the Authorization header or query parameter
// For example, in websocket requests token, err := ExtractBearerToken(r)
token = r.URL.Query().Get("token") if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", err)
// 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)
return return
} }
var err error
tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil { if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err)
@ -233,12 +222,28 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han
return return
} }
ctx := storeTokenData(r, tokenData) ctx := StoreTokenData(r, tokenData)
next.ServeHTTP(w, r.WithContext(ctx)) 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. // mwSecureHeaders provides secure headers middleware for handlers.
func mwSecureHeaders(next http.Handler) http.Handler { func mwSecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -17,8 +17,8 @@ const (
contextRestrictedRequest contextRestrictedRequest
) )
// storeTokenData stores a TokenData object inside the request context and returns the enhanced 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 { func StoreTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context {
return context.WithValue(request.Context(), contextAuthenticationKey, tokenData) return context.WithValue(request.Context(), contextAuthenticationKey, tokenData)
} }

View File

@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/portainer/libhelm"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor" "github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/crypto" "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/endpointproxy"
"github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file" "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" kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries" "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/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/ssl" "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/kubernetes/cli"
"github.com/portainer/portainer/api/scheduler" "github.com/portainer/portainer/api/scheduler"
stackdeployer "github.com/portainer/portainer/api/stacks" stackdeployer "github.com/portainer/portainer/api/stacks"
@ -76,11 +79,13 @@ type Server struct {
SwarmStackManager portainer.SwarmStackManager SwarmStackManager portainer.SwarmStackManager
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager KubernetesTokenCacheManager *kubernetes.TokenCacheManager
KubeConfigService k8s.KubeConfigService
Handler *handler.Handler Handler *handler.Handler
SSLService *ssl.Service SSLService *ssl.Service
DockerClientFactory *docker.ClientFactory DockerClientFactory *docker.ClientFactory
KubernetesClientFactory *cli.ClientFactory KubernetesClientFactory *cli.ClientFactory
KubernetesDeployer portainer.KubernetesDeployer KubernetesDeployer portainer.KubernetesDeployer
HelmPackageManager libhelm.HelmPackageManager
Scheduler *scheduler.Scheduler Scheduler *scheduler.Scheduler
ShutdownCtx context.Context ShutdownCtx context.Context
ShutdownTrigger context.CancelFunc ShutdownTrigger context.CancelFunc
@ -166,6 +171,10 @@ func (server *Server) Start() error {
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) 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 motdHandler = motd.NewHandler(requestBouncer)
var registryHandler = registries.NewHandler(requestBouncer) var registryHandler = registries.NewHandler(requestBouncer)
@ -242,10 +251,12 @@ func (server *Server) Start() error {
EdgeTemplatesHandler: edgeTemplatesHandler, EdgeTemplatesHandler: edgeTemplatesHandler,
EndpointGroupHandler: endpointGroupHandler, EndpointGroupHandler: endpointGroupHandler,
EndpointHandler: endpointHandler, EndpointHandler: endpointHandler,
EndpointHelmHandler: endpointHelmHandler,
EndpointEdgeHandler: endpointEdgeHandler, EndpointEdgeHandler: endpointEdgeHandler,
EndpointProxyHandler: endpointProxyHandler, EndpointProxyHandler: endpointProxyHandler,
KubernetesHandler: kubernetesHandler,
FileHandler: fileHandler, FileHandler: fileHandler,
HelmTemplatesHandler: helmTemplatesHandler,
KubernetesHandler: kubernetesHandler,
MOTDHandler: motdHandler, MOTDHandler: motdHandler,
RegistryHandler: registryHandler, RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler, ResourceControlHandler: resourceControlHandler,

View File

@ -15,6 +15,7 @@ type datastore struct {
endpoint portainer.EndpointService endpoint portainer.EndpointService
endpointGroup portainer.EndpointGroupService endpointGroup portainer.EndpointGroupService
endpointRelation portainer.EndpointRelationService endpointRelation portainer.EndpointRelationService
helmUserRepository portainer.HelmUserRepositoryService
registry portainer.RegistryService registry portainer.RegistryService
resourceControl portainer.ResourceControlService resourceControl portainer.ResourceControlService
role portainer.RoleService role portainer.RoleService
@ -45,6 +46,9 @@ func (d *datastore) EdgeStack() portainer.EdgeStackService { retur
func (d *datastore) Endpoint() portainer.EndpointService { return d.endpoint } func (d *datastore) Endpoint() portainer.EndpointService { return d.endpoint }
func (d *datastore) EndpointGroup() portainer.EndpointGroupService { return d.endpointGroup } func (d *datastore) EndpointGroup() portainer.EndpointGroupService { return d.endpointGroup }
func (d *datastore) EndpointRelation() portainer.EndpointRelationService { return d.endpointRelation } func (d *datastore) EndpointRelation() portainer.EndpointRelationService { return d.endpointRelation }
func (d *datastore) HelmUserRepository() portainer.HelmUserRepositoryService {
return d.helmUserRepository
}
func (d *datastore) Registry() portainer.RegistryService { return d.registry } func (d *datastore) Registry() portainer.RegistryService { return d.registry }
func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl }
func (d *datastore) Role() portainer.RoleService { return d.role } func (d *datastore) Role() portainer.RoleService { return d.role }
@ -71,20 +75,24 @@ func NewDatastore(options ...datastoreOption) *datastore {
return &d return &d
} }
type stubSettingsService struct { type stubSettingsService struct {
settings *portainer.Settings settings *portainer.Settings
} }
func (s *stubSettingsService) Settings() (*portainer.Settings, error) { return s.settings, nil } func (s *stubSettingsService) Settings() (*portainer.Settings, error) {
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { return nil } return s.settings, nil
}
func WithSettings(settings *portainer.Settings) datastoreOption { func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error {
s.settings = settings
return nil
}
func WithSettingsService(settings *portainer.Settings) datastoreOption {
return func(d *datastore) { return func(d *datastore) {
d.settings = &stubSettingsService{settings: settings} d.settings = &stubSettingsService{
settings: settings,
}
} }
} }
type stubUserService struct { type stubUserService struct {
users []portainer.User 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 package jwt
import ( import (
"testing"
"github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
i "github.com/portainer/portainer/api/internal/testhelpers" i "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing"
) )
func TestService_GenerateTokenForKubeconfig(t *testing.T) { func TestService_GenerateTokenForKubeconfig(t *testing.T) {
@ -24,7 +25,7 @@ func TestService_GenerateTokenForKubeconfig(t *testing.T) {
myFields := fields{ myFields := fields{
userSessionTimeout: "24h", userSessionTimeout: "24h",
dataStore: i.NewDatastore(i.WithSettings(mySettings)), dataStore: i.NewDatastore(i.WithSettingsService(mySettings)),
} }
myTokenData := &portainer.TokenData{ myTokenData := &portainer.TokenData{

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"` 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 represents data required for Quay registry to work
QuayRegistryData struct { QuayRegistryData struct {
UseOrganisation bool `json:"UseOrganisation"` UseOrganisation bool `json:"UseOrganisation"`
@ -699,6 +711,8 @@ type (
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"` KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
// Whether telemetry is enabled // Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"false"` 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 // Deprecated fields
DisplayDonationHeader bool DisplayDonationHeader bool
@ -1099,6 +1113,7 @@ type (
Endpoint() EndpointService Endpoint() EndpointService
EndpointGroup() EndpointGroupService EndpointGroup() EndpointGroupService
EndpointRelation() EndpointRelationService EndpointRelation() EndpointRelationService
HelmUserRepository() HelmUserRepositoryService
Registry() RegistryService Registry() RegistryService
ResourceControl() ResourceControlService ResourceControl() ResourceControlService
Role() RoleService Role() RoleService
@ -1226,6 +1241,12 @@ type (
LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) 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 represents a service for managing JWT tokens
JWTService interface { JWTService interface {
GenerateToken(data *TokenData) (string, error) GenerateToken(data *TokenData) (string, error)
@ -1466,6 +1487,8 @@ const (
DefaultEdgeAgentCheckinIntervalInSeconds = 5 DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer // DefaultTemplatesURL represents the URL to the official templates supported by Portainer
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" 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 represents the default timeout after which the user session is cleared
DefaultUserSessionTimeout = "8h" DefaultUserSessionTimeout = "8h"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared

View File

@ -310,6 +310,10 @@ a[ng-click] {
padding-top: 15px !important; padding-top: 15px !important;
} }
.nomargin {
margin: 0 !important;
}
.terminal-container { .terminal-container {
width: 100%; width: 100%;
padding: 10px 0; padding: 10px 0;
@ -833,3 +837,8 @@ json-tree .branch-preview {
text-align: center; text-align: center;
padding-bottom: 5px; padding-bottom: 5px;
} }
.text-wrap {
word-break: break-all;
white-space: normal;
}

View File

@ -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 = { const applications = {
name: 'kubernetes.applications', name: 'kubernetes.applications',
url: '/applications', url: '/applications',
@ -301,6 +321,8 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
}; };
$stateRegistryProvider.register(kubernetes); $stateRegistryProvider.register(kubernetes);
$stateRegistryProvider.register(helmApplication);
$stateRegistryProvider.register(helmTemplates);
$stateRegistryProvider.register(applications); $stateRegistryProvider.register(applications);
$stateRegistryProvider.register(applicationCreation); $stateRegistryProvider.register(applicationCreation);
$stateRegistryProvider.register(application); $stateRegistryProvider.register(application);

View File

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

View File

@ -0,0 +1,12 @@
<div class="col-xs-12 form-section-title">
Secrets
</div>
<table style="width: 50%;">
<tbody>
<tr>
<td>
<sensitive-details ng-repeat="secret in $ctrl.state.secrets" key="{{ secret.key }}" value="{{ secret.value }}"> </sensitive-details>
</td>
</tr>
</tbody>
</table>

View File

@ -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: '<',
},
});

View File

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

View File

@ -0,0 +1,6 @@
<div class="published-url-container">
<div class="text-muted">
Published URL
</div>
<a ng-href="{{ $ctrl.publishedUrl }}" target="_blank" class="publish-url-link"> <i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ $ctrl.publishedUrl }} </a>
</div>

View File

@ -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: '@',
},
});

View File

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

View File

@ -1,7 +1,7 @@
<div class="datatable"> <div class="datatable">
<rd-widget> <rd-widget>
<rd-widget-body classes="no-padding"> <rd-widget-body classes="no-padding">
<div class="toolBar"> <div ng-if="$ctrl.isPrimary" class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div> <div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<span class="small text-muted" style="float: left; margin-top: 5px;" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem"> <span class="small text-muted" style="float: left; margin-top: 5px;" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i> <i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
@ -62,7 +62,7 @@
</span> </span>
</div> </div>
</div> </div>
<div class="actionBar"> <div ng-if="$ctrl.isPrimary" class="actionBar">
<button <button
type="button" type="button"
class="btn btn-sm btn-danger" class="btn btn-sm btn-danger"
@ -79,7 +79,7 @@
<i class="fa fa-plus space-right" aria-hidden="true"></i>Create from manifest <i class="fa fa-plus space-right" aria-hidden="true"></i>Create from manifest
</button> </button>
</div> </div>
<div class="searchBar"> <div ng-if="$ctrl.isPrimary" class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i> <i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input <input
type="text" type="text"
@ -94,13 +94,20 @@
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover nowrap-cells" data-cy="k8sApp-appTable"> <table class="table table-hover nowrap-cells" data-cy="k8sApp-appTable">
<thead> <thead ng-class="{ 'secondary-heading': !$ctrl.isPrimary }">
<tr> <tr>
<th> <th class="datatable-wide">
<span ng-if="$ctrl.isPrimary">
<span class="md-checkbox"> <span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" data-cy="k8sApp-selectAllCheckbox" /> <input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" data-cy="k8sApp-selectAllCheckbox" />
<label for="select_all"></label> <label for="select_all"></label>
</span> </span>
<a ng-click="$ctrl.expandAll()">
<i ng-class="{ 'fas fa-angle-down': $ctrl.state.expandAll, 'fas fa-angle-right': !$ctrl.state.expandAll }" aria-hidden="true"></i>
</a>
</span>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')"> <a ng-click="$ctrl.changeOrderBy('Name')">
Name Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
@ -152,22 +159,42 @@
</thead> </thead>
<tbody> <tbody>
<tr <tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | filter:$ctrl.isDisplayed | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit: $ctrl.tableKey))" ng-click="$ctrl.expandItem(item, !$ctrl.isItemExpanded(item))"
ng-class="{ active: item.Checked }" dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | filter:$ctrl.isDisplayed | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit: $ctrl.tableKey))"
ng-class="{ active: item.Checked, interactive: $ctrl.isExpandable(item), 'secondary-body': !$ctrl.isPrimary }"
pagination-id="$ctrl.tableKey" pagination-id="$ctrl.tableKey"
> >
<td> <td>
<span class="md-checkbox"> <span ng-if="$ctrl.isPrimary" class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="$ctrl.isSystemNamespace(item)" /> <input
id="select_{{ $index }}"
type="checkbox"
ng-model="item.Checked"
ng-click="$ctrl.selectItem(item, $event); $event.stopPropagation()"
ng-disabled="$ctrl.isSystemNamespace(item)"
/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a> <a ng-if="$ctrl.isExpandable(item)">
<i ng-class="{ 'fas fa-angle-down': $ctrl.isItemExpanded(item), 'fas fa-angle-right': !$ctrl.isItemExpanded(item) }" class="space-right" aria-hidden="true"></i>
</a>
</td>
<td>
<a ng-if="item.KubernetesApplications" ui-sref="kubernetes.helm({ name: item.Name, namespace: item.ResourcePool })" ng-click="$event.stopPropagation()"
>{{ item.Name }}
</a>
<a
ng-if="!item.KubernetesApplications"
ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })"
ng-click="$event.stopPropagation()"
>{{ item.Name }}
</a>
<span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span> <span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span>
<span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span> <span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
</td> </td>
<td>{{ item.StackName || '-' }}</td> <td>{{ item.StackName || '-' }}</td>
<td> <td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a> <a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })" ng-click="$event.stopPropagation()">{{ item.ResourcePool }}</a>
</td> </td>
<td title="{{ item.Image }}" <td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td >{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
@ -176,7 +203,10 @@
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.POD"> <td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.POD">
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.REPLICATED">Replicated</span> <span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.REPLICATED">Replicated</span>
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.GLOBAL">Global</span> <span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.GLOBAL">Global</span>
<span ng-if="item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0">
<code>{{ item.RunningPodsCount }}</code> / <code>{{ item.TotalPodsCount }}</code> <code>{{ item.RunningPodsCount }}</code> / <code>{{ item.TotalPodsCount }}</code>
</span>
<span ng-if="item.KubernetesApplications">{{ item.Status }}</span>
</td> </td>
<td ng-if="item.ApplicationType === $ctrl.KubernetesApplicationTypes.POD"> <td ng-if="item.ApplicationType === $ctrl.KubernetesApplicationTypes.POD">
{{ item.Pods[0].Status }} {{ item.Pods[0].Status }}
@ -184,7 +214,7 @@
<td> <td>
<span ng-if="item.PublishedPorts.length"> <span ng-if="item.PublishedPorts.length">
<span> <span>
<a ng-click="$ctrl.onPublishingModeClick(item)"> <a ng-click="$ctrl.onPublishingModeClick(item); $event.stopPropagation()">
<i class="fa {{ item.ServiceType | kubernetesApplicationServiceTypeIcon }}" aria-hidden="true" style="margin-right: 2px;"> </i> <i class="fa {{ item.ServiceType | kubernetesApplicationServiceTypeIcon }}" aria-hidden="true" style="margin-right: 2px;"> </i>
{{ item.ServiceType | kubernetesApplicationServiceTypeText }} {{ item.ServiceType | kubernetesApplicationServiceTypeText }}
</a> </a>
@ -194,6 +224,33 @@
</td> </td>
<td>{{ item.CreationDate | getisodate }} {{ item.ApplicationOwner ? 'by ' + item.ApplicationOwner : '' }}</td> <td>{{ item.CreationDate | getisodate }} {{ item.ApplicationOwner ? 'by ' + item.ApplicationOwner : '' }}</td>
</tr> </tr>
<tr dir-paginate-end ng-show="$ctrl.isExpandable(item) && $ctrl.isItemExpanded(item)" ng-class="{ 'secondary-body': $ctrl.isPrimary && !item.KubernetesApplications }">
<td></td>
<td colspan="8" class="datatable-padding-vertical">
<span ng-if="item.KubernetesApplications">
<kubernetes-applications-datatable
dataset="item.KubernetesApplications"
table-key="{{ item.Id }}_table"
order-by="Name"
remove-action="$ctrl.removeAction"
refresh-callback="$ctrl.refreshCallback"
on-publishing-mode-click="($ctrl.onPublishingModeClick)"
is-primary="false"
>
</kubernetes-applications-datatable>
</span>
<span ng-if="!item.KubernetesApplications">
<kubernetes-applications-datatable-url
ng-if="$ctrl.getPublishedUrl(item)"
published-url="{{ $ctrl.getPublishedUrl(item) }}"
></kubernetes-applications-datatable-url>
<kubernetes-applications-datatable-details
ng-if="$ctrl.hasConfigurationSecrets(item)"
configurations="item.Configurations"
></kubernetes-applications-datatable-details>
</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset"> <tr ng-if="!$ctrl.dataset">
<td colspan="8" class="text-center text-muted">Loading...</td> <td colspan="8" class="text-center text-muted">Loading...</td>
</tr> </tr>
@ -203,7 +260,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="footer" ng-if="$ctrl.dataset"> <div ng-if="$ctrl.isPrimary" class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div> <div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls"> <div class="paginationControls">
<form class="form-inline"> <form class="form-inline">

View File

@ -1,3 +1,5 @@
import './applicationsDatatable.css';
angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatable', { angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatable', {
templateUrl: './applicationsDatatable.html', templateUrl: './applicationsDatatable.html',
controller: 'KubernetesApplicationsDatatableController', controller: 'KubernetesApplicationsDatatableController',
@ -11,5 +13,6 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatabl
removeAction: '<', removeAction: '<',
refreshCallback: '<', refreshCallback: '<',
onPublishingModeClick: '<', onPublishingModeClick: '<',
isPrimary: '<',
}, },
}); });

View File

@ -1,6 +1,7 @@
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
angular.module('portainer.docker').controller('KubernetesApplicationsDatatableController', [ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableController', [
'$scope', '$scope',
@ -10,12 +11,55 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
function ($scope, $controller, DatatableService, Authentication) { function ($scope, $controller, DatatableService, Authentication) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
var ctrl = this; const ctrl = this;
this.settings = Object.assign(this.settings, { this.settings = Object.assign(this.settings, {
showSystem: false, showSystem: false,
}); });
this.state = Object.assign(this.state, {
expandAll: false,
expandedItems: [],
});
this.expandAll = function () {
this.state.expandAll = !this.state.expandAll;
this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll));
};
this.isItemExpanded = function (item) {
return this.state.expandedItems.includes(item.Id);
};
this.isExpandable = function (item) {
return item.KubernetesApplications || this.hasConfigurationSecrets(item) || !!this.getPublishedUrl(item).length;
};
this.expandItem = function (item, expanded) {
// collapse item
if (!expanded) {
this.state.expandedItems = this.state.expandedItems.filter((id) => id !== item.Id);
// expanded item
} else if (expanded && !this.state.expandedItems.includes(item.Id)) {
this.state.expandedItems = [...this.state.expandedItems, item.Id];
}
DatatableService.setDataTableExpandedItems(this.tableKey, this.state.expandedItems);
};
this.expandItems = function (storedExpandedItems) {
this.state.expandedItems = storedExpandedItems;
if (this.state.expandedItems.length === this.dataset.length) {
this.state.expandAll = true;
}
};
this.onDataRefresh = function () {
const storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey);
if (storedExpandedItems !== null) {
this.expandItems(storedExpandedItems);
}
};
this.onSettingsShowSystemChange = function () { this.onSettingsShowSystemChange = function () {
DatatableService.setDataTableSettings(this.tableKey, this.settings); DatatableService.setDataTableSettings(this.tableKey, this.settings);
}; };
@ -25,6 +69,10 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
}; };
this.isSystemNamespace = function (item) { this.isSystemNamespace = function (item) {
// if all charts in a helm app/release are in the system namespace
if (item.KubernetesApplications && item.KubernetesApplications.length > 0) {
return item.KubernetesApplications.some((app) => KubernetesNamespaceHelper.isSystemNamespace(app.ResourcePool));
}
return KubernetesNamespaceHelper.isSystemNamespace(item.ResourcePool); return KubernetesNamespaceHelper.isSystemNamespace(item.ResourcePool);
}; };
@ -32,6 +80,33 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem; return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem;
}; };
this.getPublishedUrl = function (item) {
// Map all ingress rules in published ports to their respective URLs
const ingressUrls = item.PublishedPorts.flatMap((pp) => pp.IngressRules)
.filter(({ Host, IP }) => Host || IP)
.map(({ Host, IP, Port, Path }) => {
let scheme = Port === 443 ? 'https' : 'http';
let urlPort = Port === 80 || Port === 443 ? '' : `:${Port}`;
return `${scheme}://${Host || IP}${urlPort}${Path}`;
});
// Map all load balancer service ports to ip address
let loadBalancerURLs = [];
if (item.LoadBalancerIPAddress) {
loadBalancerURLs = item.PublishedPorts.map((pp) => `http://${item.LoadBalancerIPAddress}:${pp.Port}`);
}
// combine ingress urls
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
// Return the first URL - priority given to ingress urls, then services (load balancers)
return publishedUrls.length > 0 ? publishedUrls[0] : '';
};
this.hasConfigurationSecrets = function (item) {
return item.Configurations && item.Configurations.some((config) => config.Data && config.Type === KubernetesConfigurationTypes.SECRET);
};
/** /**
* Do not allow applications in system namespaces to be selected * Do not allow applications in system namespaces to be selected
*/ */
@ -47,19 +122,19 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
this.prepareTableFromDataset(); this.prepareTableFromDataset();
this.state.orderBy = this.orderBy; this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); const storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) { if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse; this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy; this.state.orderBy = storedOrder.orderBy;
} }
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); const textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) { if (textFilter !== null) {
this.state.textFilter = textFilter; this.state.textFilter = textFilter;
this.onTextFilterChange(); this.onTextFilterChange();
} }
var storedFilters = DatatableService.getDataTableFilters(this.tableKey); const storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) { if (storedFilters !== null) {
this.filters = storedFilters; this.filters = storedFilters;
} }
@ -67,7 +142,12 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
this.filters.state.open = false; this.filters.state.open = false;
} }
var storedSettings = DatatableService.getDataTableSettings(this.tableKey); const storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey);
if (storedExpandedItems !== null) {
this.expandItems(storedExpandedItems);
}
const storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) { if (storedSettings !== null) {
this.settings = storedSettings; this.settings = storedSettings;
this.settings.open = false; this.settings.open = false;

View File

@ -0,0 +1,40 @@
export default class HelmAddRepositoryController {
/* @ngInject */
constructor($state, $async, HelmService, Notifications, EndpointProvider) {
this.$state = $state;
this.$async = $async;
this.HelmService = HelmService;
this.Notifications = Notifications;
this.EndpointProvider = EndpointProvider;
}
doesRepoExist() {
if (!this.state.repository) {
return false;
}
// lowercase, strip trailing slash and compare
return this.repos.includes(this.state.repository.toLowerCase().replace(/\/$/, ''));
}
async addRepository() {
this.state.isAddingRepo = true;
try {
await this.HelmService.addHelmRepository(this.EndpointProvider.currentEndpoint().Id, { url: this.state.repository });
this.Notifications.success('Helm repository added successfully');
this.$state.reload();
} catch (err) {
this.Notifications.error('Installation error', err);
} finally {
this.state.isAddingRepo = false;
}
}
$onInit() {
return this.$async(async () => {
this.state = {
isAddingRepo: false,
repository: '',
};
});
}
}

View File

@ -0,0 +1,58 @@
<rd-widget>
<rd-widget-header icon="fa-dharmachakra" title-text="Additional repositories"></rd-widget-header>
<rd-widget-body>
<div class="actionBar">
<form class="form-horizontal" name="addUserHelmRepoForm">
<div class="form-group">
<span class="col-sm-12 text-muted small">
Add a Helm repository. All Helm charts in the repository will be added to the list.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<input
type="url"
name="repo"
class="form-control"
ng-model="$ctrl.state.repository"
placeholder="https://charts.bitnami.com/bitnami"
ng-pattern="/^https?:///"
required
/>
</div>
</div>
<div class="form-group nomargin" ng-show="addUserHelmRepoForm.repo.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="addUserHelmRepoForm.repo.$error">
<p ng-message="pattern"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A valid URL beginning with http(s) is required.</p>
</div>
</div>
</div>
<div class="form-group" ng-show="$ctrl.doesRepoExist()">
<div class="col-sm-12 small text-warning">
<div> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Helm repo already exists. </div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-sm btn-default nomargin"
ng-click="$ctrl.addRepository()"
ng-disabled="$ctrl.state.isAddingRepo || addUserHelmRepoForm.repo.$invalid || $ctrl.doesRepoExist()"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-helm-add-repository"
>
Add repository
</button>
</div>
</div>
</form>
</div>
</rd-widget-body>
</rd-widget>

View File

@ -0,0 +1,11 @@
import angular from 'angular';
import controller from './helm-add-repository.controller';
angular.module('portainer.kubernetes').component('helmAddRepository', {
templateUrl: './helm-add-repository.html',
controller,
bindings: {
repos: '<',
endpoint: '<',
},
});

View File

@ -0,0 +1,9 @@
.helm-template-item-details {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.helm-template-item-details .helm-template-item-details-sub {
width: 100%;
}

View File

@ -0,0 +1,46 @@
<!-- helm chart -->
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item" ng-click="$ctrl.onSelect($ctrl.model)">
<div class="blocklist-item-box">
<!-- helmchart-image -->
<span ng-if="$ctrl.model.icon">
<img class="blocklist-item-logo" ng-src="{{ $ctrl.model.icon }}" />
</span>
<span class="blocklist-item-logo" ng-if="!$ctrl.model.icon">
<i class="fa fa-dharmachakra fa-4x blue-icon" aria-hidden="true"></i>
</span>
<!-- !helmchart-image -->
<!-- helmchart-details -->
<div class="col-sm-12 helm-template-item-details">
<!-- blocklist-item-line1 -->
<div class="blocklist-item-line">
<span>
<span class="blocklist-item-title">
{{ $ctrl.model.name }}
</span>
<span class="space-left blocklist-item-subtitle">
<span>
<i class="fa fa-dharmachakra" aria-hidden="true"></i>
</span>
<span>
Helm
</span>
</span>
</span>
</div>
<!-- !blocklist-item-line1 -->
<span class="blocklist-item-actions" ng-transclude="actions"></span>
<!-- blocklist-item-line2 -->
<div class="blocklist-item-line helm-template-item-details-sub">
<span class="blocklist-item-desc">
{{ $ctrl.model.description }}
</span>
<span class="small text-muted" ng-if="$ctrl.model.annotations.category">
{{ $ctrl.model.annotations.category }}
</span>
</div>
<!-- !blocklist-item-line2 -->
</div>
<!-- !helmchart-details -->
</div>
<!-- !helm chart -->
</div>

View File

@ -0,0 +1,13 @@
import angular from 'angular';
import './helm-templates-list-item.css';
angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
templateUrl: './helm-templates-list-item.html',
bindings: {
model: '<',
onSelect: '<',
},
transclude: {
actions: '?templateItemActions',
},
});

View File

@ -0,0 +1,53 @@
export default class HelmTemplatesListController {
/* @ngInject */
constructor($async, DatatableService, HelmService, Notifications) {
this.$async = $async;
this.DatatableService = DatatableService;
this.HelmService = HelmService;
this.Notifications = Notifications;
this.updateCategories = this.updateCategories.bind(this);
}
async updateCategories() {
try {
const annotationCategories = this.charts
.map((t) => t.annotations) // get annotations
.filter((a) => a) // filter out undefined/nulls
.map((c) => c.category); // get annotation category
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
this.state.categories = availableCategories;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm charts categories');
}
}
onTextFilterChange() {
this.DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
}
clearCategory() {
this.state.selectedCategory = '';
}
$onChanges() {
if (this.charts.length > 0) {
this.updateCategories();
}
}
$onInit() {
return this.$async(async () => {
this.state = {
textFilter: '',
selectedCategory: '',
categories: [],
};
const textFilter = this.DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
}
});
}
}

View File

@ -0,0 +1,57 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar">
<div>
<span style="width: 25%;">
<ui-select ng-model="$ctrl.state.selectedCategory" theme="bootstrap">
<ui-select-match placeholder="Select a category">
<a class="btn btn-xs btn-link pull-right" ng-click="$ctrl.clearCategory()"><i class="glyphicon glyphicon-remove"></i></a>
<span>{{ $select.selected }}</span>
</ui-select-match>
<ui-select-choices repeat="category in ($ctrl.state.categories | filter: $select.search)">
<span>{{ category }}</span>
</ui-select-choices>
</ui-select>
</span>
</div>
</div>
<div class="searchBar" style="border-top: 2px solid #f6f6f6;">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="blocklist">
<helm-templates-list-item
ng-repeat="chart in $ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory "
model="chart"
type-label="helm"
on-select="($ctrl.selectAction)"
>
</helm-templates-list-item>
<div ng-if="$ctrl.loading" class="text-center text-muted">
Loading...
<div class="text-center text-muted">
Initial download of Helm Charts can take a few minutes
</div>
</div>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-center text-muted">
No helm charts available.
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -0,0 +1,15 @@
import angular from 'angular';
import controller from './helm-templates-list.controller';
angular.module('portainer.kubernetes').component('helmTemplatesList', {
templateUrl: './helm-templates-list.html',
controller,
bindings: {
loading: '<',
titleText: '@',
titleIcon: '@',
charts: '<',
tableKey: '@',
selectAction: '<',
},
});

View File

@ -0,0 +1,183 @@
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
export default class HelmTemplatesController {
/* @ngInject */
constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications, ModalService) {
this.$analytics = $analytics;
this.$async = $async;
this.$window = $window;
this.$state = $state;
this.$anchorScroll = $anchorScroll;
this.Authentication = Authentication;
this.HelmService = HelmService;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.Notifications = Notifications;
this.ModalService = ModalService;
this.editorUpdate = this.editorUpdate.bind(this);
this.uiCanExit = this.uiCanExit.bind(this);
this.installHelmchart = this.installHelmchart.bind(this);
this.getHelmValues = this.getHelmValues.bind(this);
this.selectHelmChart = this.selectHelmChart.bind(this);
this.getHelmRepoURLs = this.getHelmRepoURLs.bind(this);
this.getLatestCharts = this.getLatestCharts.bind(this);
this.getResourcePools = this.getResourcePools.bind(this);
$window.onbeforeunload = () => {
if (this.state.isEditorDirty) {
return '';
}
};
}
editorUpdate(content) {
const contentvalues = content.getValue();
if (this.state.originalvalues === contentvalues) {
this.state.isEditorDirty = false;
} else {
this.state.values = contentvalues;
this.state.isEditorDirty = true;
}
}
async uiCanExit() {
if (this.state.isEditorDirty) {
return this.ModalService.confirmWebEditorDiscard();
}
}
async installHelmchart() {
this.state.actionInProgress = true;
try {
const payload = {
Name: this.state.appName,
Repo: this.state.chart.repo,
Chart: this.state.chart.name,
Values: this.state.values,
Namespace: this.state.resourcePool.Namespace.Name,
};
await this.HelmService.install(this.endpoint.Id, payload);
this.Notifications.success('Helm Chart successfully installed');
this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.chart.name } });
this.state.isEditorDirty = false;
this.$state.go('kubernetes.applications');
} catch (err) {
this.Notifications.error('Installation error', err);
} finally {
this.state.actionInProgress = false;
}
}
async getHelmValues() {
this.state.loadingValues = true;
try {
const { values } = await this.HelmService.values(this.state.chart.repo, this.state.chart.name);
this.state.values = values;
this.state.originalvalues = values;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm chart values.');
} finally {
this.state.loadingValues = false;
}
}
async selectHelmChart(chart) {
this.$anchorScroll('view-top');
this.state.showCustomValues = false;
this.state.chart = chart;
await this.getHelmValues();
}
/**
* @description This function is used to get the helm repo urls for the endpoint and user
* @returns {Promise<string[]>} list of helm repo urls
*/
async getHelmRepoURLs() {
this.state.reposLoading = true;
try {
// fetch globally set helm repo and user helm repos (parallel)
const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.endpoint.Id);
const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
const uniqueHelmRepos = [...new Set([GlobalRepository, ...userHelmReposUrls])].map((url) => url.toLowerCase()); // remove duplicates, to lowercase
this.state.repos = uniqueHelmRepos;
return uniqueHelmRepos;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo urls.');
} finally {
this.state.reposLoading = false;
}
}
/**
* @description This function is used to fetch the respective index.yaml files for the provided helm repo urls
* @param {string[]} helmRepos list of helm repositories
* @param {bool} append append charts returned from repo to existing list of helm charts
*/
async getLatestCharts(helmRepos) {
this.state.chartsLoading = true;
try {
const promiseList = helmRepos.map((repo) => this.HelmService.search(repo));
// fetch helm charts from all the provided helm repositories (parallel)
// Promise.allSettled is used to account for promise failure(s) - in cases the user has provided invalid helm repo
const chartPromises = await Promise.allSettled(promiseList);
const latestCharts = chartPromises
.filter((tp) => tp.status === 'fulfilled') // remove failed promises
.map((tp) => ({ entries: tp.value.entries, repo: helmRepos[chartPromises.indexOf(tp)] })) // extract chart entries with respective repo data
.flatMap(
({ entries, repo }) => Object.values(entries).map((charts) => ({ ...charts[0], repo })) // flatten chart entries to single array with respective repo
);
this.state.charts = latestCharts;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo charts.');
} finally {
this.state.chartsLoading = false;
}
}
async getResourcePools() {
this.state.resourcePoolsLoading = true;
try {
const resourcePools = await this.KubernetesResourcePoolService.get();
const nonSystemNamespaces = resourcePools.filter((resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
this.state.resourcePools = nonSystemNamespaces;
this.state.resourcePool = nonSystemNamespaces[0];
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve initial helm data.');
} finally {
this.state.resourcePoolsLoading = false;
}
}
$onInit() {
return this.$async(async () => {
this.state = {
appName: '',
chart: null,
showCustomValues: false,
actionInProgress: false,
resourcePools: [],
resourcePool: '',
values: null,
originalvalues: null,
repos: [],
charts: [],
loadingValues: false,
isEditorDirty: false,
chartsLoading: false,
resourcePoolsLoading: false,
viewReady: false,
};
const helmRepos = await this.getHelmRepoURLs();
await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]);
this.state.viewReady = true;
});
}
$onDestroy() {
this.state.isEditorDirty = false;
}
}

View File

@ -0,0 +1,182 @@
<rd-header id="view-top">
<rd-header-title title-text="Helm">
<a data-toggle="tooltip" title="Refresh" ui-sref="kubernetes.templates.helm" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Charts</rd-header-content>
</rd-header>
<information-panel title-text="Information" ng-if="!$ctrl.state.chart">
<span class="small text-muted">
<p>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This is a first version for Helm charts, for more information see this <a href="#">blog post.</a>
</p>
</span>
</information-panel>
<div class="row">
<!-- helmchart-form -->
<div class="col-sm-12" ng-if="$ctrl.state.chart">
<rd-widget>
<rd-widget-custom-header icon="$ctrl.state.chart.icon" title-text="$ctrl.state.chart.name"></rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal" name="$ctrl.helmTemplateCreationForm">
<!-- description -->
<div>
<div class="col-sm-12 form-section-title">
Description
</div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-bind-html="$ctrl.state.chart.description"></div>
</div>
</div>
</div>
<!-- !description -->
<div class="col-sm-12 form-section-title">
Configuration
</div>
<!-- namespace-input -->
<div class="form-group">
<label for="resource-pool-selector" class="col-sm-2 control-label text-left">Namespace</label>
<div class="col-sm-10">
<select
class="form-control"
id="resource-pool-selector"
ng-model="$ctrl.state.resourcePool"
ng-options="resourcePool.Namespace.Name for resourcePool in $ctrl.state.resourcePools"
ng-change=""
ng-disabled="$ctrl.state.isEdit"
></select>
</div>
</div>
<div class="form-group" ng-if="!$ctrl.state.resourcePool">
<div class="col-sm-12 small text-danger">
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
namespace.
</div>
</div>
<div class="form-group" ng-if="!$ctrl.state.resourcePool">
<div class="col-sm-12 small text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
You do not have access to any namespace. Contact your administrator to get access to a namespace.
</div>
</div>
<!-- !namespace-input -->
<!-- name-input -->
<div class="form-group">
<label for="release_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<input
type="text"
name="release_name"
class="form-control"
ng-model="$ctrl.state.appName"
placeholder="e.g. my-app"
required
ng-pattern="/^[a-z]([-a-z0-9]*[a-z0-9])?$/"
/>
</div>
</div>
<div class="form-group" ng-show="$ctrl.helmTemplateCreationForm.release_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="$ctrl.helmTemplateCreationForm.release_name.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
<p ng-message="pattern">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field must consist of lower case alphanumeric characters or '-', start with an alphabetic
character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').
</p>
</div>
</div>
</div>
<!-- !name-input -->
<div class="form-group">
<div class="col-sm-12">
<a class="small interactive" ng-if="!$ctrl.state.showCustomValues && !$ctrl.state.loadingValues" ng-click="$ctrl.state.showCustomValues = true;">
<i class="fa fa-plus space-right" aria-hidden="true"></i> Show custom values
</a>
<span class="small interactive" ng-if="$ctrl.state.loadingValues"> <i class="fa fa-sync-alt space-right" aria-hidden="true"></i> Loading values.yaml... </span>
<a class="small interactive" ng-if="$ctrl.state.showCustomValues" ng-click="$ctrl.state.showCustomValues = false;">
<i class="fa fa-minus space-right" aria-hidden="true"></i> Hide custom values
</a>
</div>
</div>
<!-- values override -->
<div ng-if="$ctrl.state.showCustomValues">
<!-- web-editor -->
<div>
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Helm values file format in the
<a href="https://helm.sh/docs/chart_template_guide/values_files/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="helm-app-creation-editor"
placeholder="# Define or paste the content of your values yaml file here"
yml="true"
on-change="($ctrl.editorUpdate)"
value="$ctrl.state.values"
></code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
</div>
<!-- !values override -->
<!-- helm actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="!($ctrl.state.appName && $ctrl.state.resourcePool && !$ctrl.state.loadingValues && !$ctrl.state.actionInProgress)"
ng-click="$ctrl.installHelmchart()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
<span ng-hide="!$ctrl.state.actionInProgress">Helm installing in progress</span>
</button>
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.state.chart = null">Hide</button>
</div>
</div>
<!-- !helm actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
<!-- helmchart-form -->
</div>
<div class="row">
<div class="col-sm-12">
<helm-add-repository repos="$ctrl.state.repos"></helm-add-repository>
</div>
</div>
<!-- Helm Charts Component -->
<div class="row">
<div class="col-sm-12">
<helm-templates-list
title-text="Charts"
title-icon="fa-rocket"
charts="$ctrl.state.charts"
table-key="$ctrl.state.charts"
select-action="$ctrl.selectHelmChart"
loading="$ctrl.state.chartsLoading || $ctrl.state.resourcePoolsLoading"
>
</helm-templates-list>
</div>
</div>
<!-- !Helm Charts Component -->

View File

@ -0,0 +1,10 @@
import angular from 'angular';
import controller from './helm-templates.controller';
angular.module('portainer.kubernetes').component('helmTemplatesView', {
templateUrl: './helm-templates.html',
controller,
bindings: {
endpoint: '<',
},
});

View File

@ -28,6 +28,10 @@
Namespaces Namespaces
</sidebar-menu-item> </sidebar-menu-item>
<sidebar-menu-item path="kubernetes.templates.helm" path-params="{ endpointId: $ctrl.endpointId }" icon-class="fa-dharmachakra fa-fw" class-name="sidebar-list">
Helm
</sidebar-menu-item>
<sidebar-menu-item <sidebar-menu-item
path="kubernetes.applications" path="kubernetes.applications"
path-params="{ endpointId: $ctrl.endpointId }" path-params="{ endpointId: $ctrl.endpointId }"

View File

@ -59,6 +59,8 @@ angular
return KubernetesApplicationTypeStrings.STATEFULSET; return KubernetesApplicationTypeStrings.STATEFULSET;
case KubernetesApplicationTypes.POD: case KubernetesApplicationTypes.POD:
return KubernetesApplicationTypeStrings.POD; return KubernetesApplicationTypeStrings.POD;
case KubernetesApplicationTypes.HELM:
return KubernetesApplicationTypeStrings.HELM;
default: default:
return '-'; return '-';
} }

View File

@ -0,0 +1,49 @@
import angular from 'angular';
angular.module('portainer.kubernetes').factory('HelmFactory', HelmFactory);
/* @ngInject */
function HelmFactory($resource, API_ENDPOINT_ENDPOINTS) {
const helmUrl = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/helm';
const templatesUrl = '/api/templates/helm';
return $resource(
helmUrl,
{},
{
templates: {
url: templatesUrl,
method: 'GET',
params: { repo: '@repo' },
cache: true,
},
show: {
url: `${templatesUrl}/:type`,
method: 'GET',
params: { repo: '@repo', chart: '@chart' },
transformResponse: function (data) {
return { values: data };
},
},
getHelmRepositories: {
method: 'GET',
url: `${helmUrl}/repositories`,
},
addHelmRepository: {
method: 'POST',
url: `${helmUrl}/repositories`,
},
list: {
method: 'GET',
isArray: true,
params: { namespace: '@namespace', selector: '@selector', filter: '@filter', output: '@output' },
},
install: { method: 'POST' },
uninstall: {
url: `${helmUrl}/:release`,
method: 'DELETE',
params: { namespace: '@namespace' },
},
}
);
}

View File

@ -0,0 +1,102 @@
import angular from 'angular';
import PortainerError from 'Portainer/error';
angular.module('portainer.kubernetes').factory('HelmService', HelmService);
/* @ngInject */
export function HelmService(HelmFactory) {
return {
search,
values,
getHelmRepositories,
addHelmRepository,
install,
uninstall,
listReleases,
};
/**
* @description: Searches for all helm charts in a helm repo
* @param {string} repo - repo url to search charts for
* @returns {Promise} - Resolves with `index.yaml` of helm charts for a repo
* @throws {PortainerError} - Rejects with error if searching for the `index.yaml` fails
*/
async function search(repo) {
try {
return await HelmFactory.templates({ repo }).$promise;
} catch (err) {
throw new PortainerError('Unable to retrieve helm charts', err);
}
}
/**
* @description: Show values helm of a helm chart, this basically runs `helm show values`
* @param {string} repo - repo url to search charts values for
* @param {string} chart - chart within the repo to retrieve default values
* @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo
* @throws {PortainerError} - Rejects with error if helm show fails
*/
async function values(repo, chart) {
try {
return await HelmFactory.show({ repo, chart, type: 'values' }).$promise;
} catch (err) {
throw new PortainerError('Unable to retrieve values from chart', err);
}
}
/**
* @description: Show values helm of a helm chart, this basically runs `helm show values`
* @returns {Promise} - Resolves with an object containing list of user helm repos and default/global settings helm repo
* @throws {PortainerError} - Rejects with error if helm show fails
*/
async function getHelmRepositories(endpointId) {
return await HelmFactory.getHelmRepositories({ endpointId }).$promise;
}
/**
* @description: Adds a helm repo for the calling user
* @param {Object} payload - helm repo url to add for the user
* @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo
* @throws {PortainerError} - Rejects with error if helm show fails
*/
async function addHelmRepository(endpointId, payload) {
return await HelmFactory.addHelmRepository({ endpointId }, payload).$promise;
}
/**
* @description: Installs a helm chart, this basically runs `helm install`
* @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo
* @throws {PortainerError} - Rejects with error if helm show fails
*/
async function install(endpointId, payload) {
return await HelmFactory.install({ endpointId }, payload).$promise;
}
/**
* @description: Uninstall a helm chart, this basically runs `helm uninstall`
* @param {Object} options - Options object, release `Name` is the only required option
* @throws {PortainerError} - Rejects with error if helm show fails
*/
async function uninstall(endpointId, { Name, ResourcePool }) {
try {
await HelmFactory.uninstall({ endpointId, release: Name, namespace: ResourcePool }).$promise;
} catch (err) {
throw new PortainerError('Unable to delete release', err);
}
}
/**
* @description: List all helm releases based on passed in options, this basically runs `helm list`
* @param {Object} options - Supported CLI flags to pass to Helm (binary) - flags to `helm list`
* @returns {Promise} - Resolves with list of helm releases
* @throws {PortainerError} - Rejects with error if helm list fails
*/
async function listReleases(endpointId, { namespace, selector, filter, output }) {
try {
const releases = await HelmFactory.list({ endpointId, selector, namespace, filter, output }).$promise;
return releases;
} catch (err) {
throw new PortainerError('Unable to retrieve release list', err);
}
}
}

View File

@ -23,7 +23,7 @@ import {
KubernetesApplicationVolumeSecretPayload, KubernetesApplicationVolumeSecretPayload,
} from 'Kubernetes/models/application/payloads'; } from 'Kubernetes/models/application/payloads';
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper'; import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationPlacementTypes } from 'Kubernetes/models/application/models'; import { KubernetesApplicationDeploymentTypes, KubernetesApplicationPlacementTypes, KubernetesApplicationTypes, HelmApplication } from 'Kubernetes/models/application/models';
import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models'; import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
import { import {
KubernetesNodeSelectorRequirementPayload, KubernetesNodeSelectorRequirementPayload,
@ -32,6 +32,9 @@ import {
KubernetesPreferredSchedulingTermPayload, KubernetesPreferredSchedulingTermPayload,
} from 'Kubernetes/pod/payloads/affinities'; } from 'Kubernetes/pod/payloads/affinities';
export const PodKubernetesInstanceLabel = 'app.kubernetes.io/instance';
export const PodManagedByLabel = 'app.kubernetes.io/managed-by';
class KubernetesApplicationHelper { class KubernetesApplicationHelper {
/* #region UTILITY FUNCTIONS */ /* #region UTILITY FUNCTIONS */
static isExternalApplication(application) { static isExternalApplication(application) {
@ -436,5 +439,77 @@ class KubernetesApplicationHelper {
} }
} }
/* #endregion */ /* #endregion */
/**
* Get Helm managed applications
* @param {KubernetesApplication[]} applications Application list
* @returns {Object} { [releaseName]: [app1, app2, ...], [releaseName2]: [app3, app4, ...] }
*/
static getHelmApplications(applications) {
// filter out all the applications that are managed by helm
// to identify the helm managed applications, we need to check if the applications pod labels include
// `app.kubernetes.io/instance` and `app.kubernetes.io/managed-by` = `helm`
const helmManagedApps = applications.filter((app) =>
app.Pods.flatMap((pod) => pod.Labels).some((label) => label && label[PodKubernetesInstanceLabel] && label[PodManagedByLabel] === 'Helm')
);
// groups the helm managed applications by helm release name
// the release name is retrieved from the `app.kubernetes.io/instance` label on the pods within the apps
// `namespacedHelmReleases` object structure:
// {
// [namespace1]: {
// [releaseName]: [app1, app2, ...],
// },
// [namespace2]: {
// [releaseName2]: [app1, app2, ...],
// }
// }
const namespacedHelmReleases = {};
helmManagedApps.forEach((app) => {
const namespace = app.ResourcePool;
const labels = app.Pods.filter((p) => p.Labels).map((p) => p.Labels[PodKubernetesInstanceLabel]);
const uniqueLabels = [...new Set(labels)];
uniqueLabels.forEach((instanceStr) => {
if (namespacedHelmReleases[namespace]) {
namespacedHelmReleases[namespace][instanceStr] = [...(namespacedHelmReleases[namespace][instanceStr] || []), app];
} else {
namespacedHelmReleases[namespace] = { [instanceStr]: [app] };
}
});
});
// `helmAppsEntriesList` object structure:
// [
// ["airflow-test", Array(5)],
// ["traefik", Array(1)],
// ["airflow-test", Array(2)],
// ...,
// ]
const helmAppsEntriesList = Object.values(namespacedHelmReleases).flatMap((r) => Object.entries(r));
const helmAppsList = helmAppsEntriesList.map(([helmInstance, applications]) => {
const helmApp = new HelmApplication();
helmApp.Name = helmInstance;
helmApp.ApplicationType = KubernetesApplicationTypes.HELM;
helmApp.KubernetesApplications = applications;
// the status of helm app is `Ready` based on whether the underlying RunningPodsCount of the k8s app
// reaches the TotalPodsCount of the app
const appsNotReady = applications.some((app) => app.RunningPodsCount < app.TotalPodsCount);
helmApp.Status = appsNotReady ? 'Not ready' : 'Ready';
// use earliest date
helmApp.CreationDate = applications.map((app) => app.CreationDate).sort((a, b) => new Date(a) - new Date(b))[0];
// use first app namespace as helm app namespace
helmApp.ResourcePool = applications[0].ResourcePool;
// required for persisting table expansion state and differenting same named helm apps across different namespaces
helmApp.Id = helmApp.ResourcePool + '-' + helmApp.Name.toLowerCase().replaceAll(' ', '-');
return helmApp;
});
return helmAppsList;
}
} }
export default KubernetesApplicationHelper; export default KubernetesApplicationHelper;

View File

@ -38,6 +38,20 @@ class KubernetesConfigurationHelper {
}); });
} }
static getApplicationConfigurations(applications, configurations) {
const configurationsUsed = configurations.filter((config) => KubernetesConfigurationHelper.getUsingApplications(config, applications).length !== 0);
// set the configurations used for each application in the list
const configuredApps = applications.map((app) => {
const configMappedByName = configurationsUsed.filter((config) => app.ApplicationName === config.Name && app.ResourcePool === config.Namespace);
const configMappedByVolume = configurationsUsed
.filter((config) => app.ConfigurationVolumes.some((cv) => cv.configurationName === config.Name))
.filter((config) => !configMappedByName.some((c) => c.Name === config.Name)); // filter out duplicates that are mapped by name
app.Configurations = [...configMappedByName, ...configMappedByVolume];
return app;
});
return configuredApps;
}
static parseYaml(formValues) { static parseYaml(formValues) {
YAML.defaultOptions.customTags = ['binary']; YAML.defaultOptions.customTags = ['binary'];
const data = _.map(YAML.parse(formValues.DataYaml), (value, key) => { const data = _.map(YAML.parse(formValues.DataYaml), (value, key) => {

View File

@ -13,9 +13,11 @@ export const KubernetesApplicationTypes = Object.freeze({
DAEMONSET: 2, DAEMONSET: 2,
STATEFULSET: 3, STATEFULSET: 3,
POD: 4, POD: 4,
HELM: 5,
}); });
export const KubernetesApplicationTypeStrings = Object.freeze({ export const KubernetesApplicationTypeStrings = Object.freeze({
HELM: 'Helm',
DEPLOYMENT: 'Deployment', DEPLOYMENT: 'Deployment',
DAEMONSET: 'DaemonSet', DAEMONSET: 'DaemonSet',
STATEFULSET: 'StatefulSet', STATEFULSET: 'StatefulSet',

View File

@ -46,6 +46,25 @@ export class KubernetesApplication {
} }
} }
/**
* HelmApplication Model (Composite)
*/
export class HelmApplication {
constructor() {
this.Id = '';
this.Name = '';
this.KubernetesApplications = [];
this.ApplicationOwner = '';
this.CreationDate = 0;
this.ApplicationType = 'Unknown';
this.Status = '';
this.StackName = '-';
this.ResourcePool = '-';
this.Image = '-';
this.PublishedPorts = [];
}
}
/** /**
* KubernetesApplicationPersistedFolder Model * KubernetesApplicationPersistedFolder Model
*/ */

View File

@ -13,24 +13,25 @@
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)"> <uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading> <i class="fa fa-laptop-code space-right" aria-hidden="true"></i> Applications </uib-tab-heading> <uib-tab-heading> <i class="fa fa-laptop-code space-right" aria-hidden="true"></i> Applications </uib-tab-heading>
<kubernetes-applications-datatable <kubernetes-applications-datatable
dataset="ctrl.applications" dataset="ctrl.state.applications"
table-key="kubernetes.applications" table-key="kubernetes.applications"
order-by="Name" order-by="Name"
remove-action="ctrl.removeAction" remove-action="ctrl.removeAction"
refresh-callback="ctrl.getApplications" refresh-callback="ctrl.getApplications"
on-publishing-mode-click="(ctrl.onPublishingModeClick)" on-publishing-mode-click="(ctrl.onPublishingModeClick)"
is-primary="true"
> >
</kubernetes-applications-datatable> </kubernetes-applications-datatable>
</uib-tab> </uib-tab>
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)"> <uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
<uib-tab-heading> <i class="fa fa-exchange-alt space-right" aria-hidden="true"></i> Port mappings </uib-tab-heading> <uib-tab-heading> <i class="fa fa-exchange-alt space-right" aria-hidden="true"></i> Port mappings </uib-tab-heading>
<kubernetes-applications-ports-datatable dataset="ctrl.ports" table-key="kubernetes.applications.ports" order-by="Name" refresh-callback="ctrl.getApplications"> <kubernetes-applications-ports-datatable dataset="ctrl.state.ports" table-key="kubernetes.applications.ports" order-by="Name" refresh-callback="ctrl.getApplications">
</kubernetes-applications-ports-datatable> </kubernetes-applications-ports-datatable>
</uib-tab> </uib-tab>
<uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)"> <uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
<uib-tab-heading> <i class="fa fa-th-list space-right" aria-hidden="true"></i> Stacks </uib-tab-heading> <uib-tab-heading> <i class="fa fa-th-list space-right" aria-hidden="true"></i> Stacks </uib-tab-heading>
<kubernetes-applications-stacks-datatable <kubernetes-applications-stacks-datatable
dataset="ctrl.stacks" dataset="ctrl.state.stacks"
table-key="kubernetes.applications.stacks" table-key="kubernetes.applications.stacks"
order-by="Name" order-by="Name"
refresh-callback="ctrl.getApplications" refresh-callback="ctrl.getApplications"

View File

@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsView', {
controllerAs: 'ctrl', controllerAs: 'ctrl',
bindings: { bindings: {
$transition$: '<', $transition$: '<',
endpoint: '<',
}, },
}); });

View File

@ -1,16 +1,19 @@
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash-es'; import _ from 'lodash-es';
import KubernetesStackHelper from 'Kubernetes/helpers/stackHelper'; import KubernetesStackHelper from 'Kubernetes/helpers/stackHelper';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesApplicationHelper, { PodKubernetesInstanceLabel } from 'Kubernetes/helpers/application';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
class KubernetesApplicationsController { class KubernetesApplicationsController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, Notifications, KubernetesApplicationService, Authentication, ModalService, LocalStorage) { constructor($async, $state, Notifications, KubernetesApplicationService, HelmService, KubernetesConfigurationService, Authentication, ModalService, LocalStorage) {
this.$async = $async; this.$async = $async;
this.$state = $state; this.$state = $state;
this.Notifications = Notifications; this.Notifications = Notifications;
this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesApplicationService = KubernetesApplicationService;
this.HelmService = HelmService;
this.KubernetesConfigurationService = KubernetesConfigurationService;
this.Authentication = Authentication; this.Authentication = Authentication;
this.ModalService = ModalService; this.ModalService = ModalService;
this.LocalStorage = LocalStorage; this.LocalStorage = LocalStorage;
@ -36,7 +39,7 @@ class KubernetesApplicationsController {
const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app)); const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app));
await Promise.all(promises); await Promise.all(promises);
this.Notifications.success('Stack successfully removed', stack.Name); this.Notifications.success('Stack successfully removed', stack.Name);
_.remove(this.stacks, { Name: stack.Name }); _.remove(this.state.stacks, { Name: stack.Name });
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove stack'); this.Notifications.error('Failure', err, 'Unable to remove stack');
} finally { } finally {
@ -63,10 +66,14 @@ class KubernetesApplicationsController {
let actionCount = selectedItems.length; let actionCount = selectedItems.length;
for (const application of selectedItems) { for (const application of selectedItems) {
try { try {
if (application.ApplicationType === KubernetesApplicationTypes.HELM) {
await this.HelmService.uninstall(this.endpoint.Id, application);
} else {
await this.KubernetesApplicationService.delete(application); await this.KubernetesApplicationService.delete(application);
}
this.Notifications.success('Application successfully removed', application.Name); this.Notifications.success('Application successfully removed', application.Name);
const index = this.applications.indexOf(application); const index = this.state.applications.indexOf(application);
this.applications.splice(index, 1); this.state.applications.splice(index, 1);
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove application'); this.Notifications.error('Failure', err, 'Unable to remove application');
} finally { } finally {
@ -88,7 +95,7 @@ class KubernetesApplicationsController {
onPublishingModeClick(application) { onPublishingModeClick(application) {
this.state.activeTab = 1; this.state.activeTab = 1;
_.forEach(this.ports, (item) => { _.forEach(this.state.ports, (item) => {
item.Expanded = false; item.Expanded = false;
item.Highlighted = false; item.Highlighted = false;
if (item.Name === application.Name && item.Ports.length > 1) { if (item.Name === application.Name && item.Ports.length > 1) {
@ -100,10 +107,22 @@ class KubernetesApplicationsController {
async getApplicationsAsync() { async getApplicationsAsync() {
try { try {
const applications = await this.KubernetesApplicationService.get(); const [applications, configurations] = await Promise.all([this.KubernetesApplicationService.get(), this.KubernetesConfigurationService.get()]);
this.applications = applications; const configuredApplications = KubernetesConfigurationHelper.getApplicationConfigurations(applications, configurations);
this.stacks = KubernetesStackHelper.stacksFromApplications(applications); const helmApplications = KubernetesApplicationHelper.getHelmApplications(configuredApplications);
this.ports = KubernetesApplicationHelper.portMappingsFromApplications(applications);
// filter out multi-chart helm managed applications
const helmAppNames = [...new Set(helmApplications.map((hma) => hma.Name))]; // distinct helm app names
const nonHelmApps = configuredApplications.filter(
(app) =>
!app.Pods.flatMap((pod) => pod.Labels) // flatten pod labels
.filter((label) => label) // filter out empty labels
.some((label) => helmAppNames.includes(label[PodKubernetesInstanceLabel])) // check if label key is in helmAppNames
);
this.state.applications = [...nonHelmApps, ...helmApplications];
this.state.stacks = KubernetesStackHelper.stacksFromApplications(applications);
this.state.ports = KubernetesApplicationHelper.portMappingsFromApplications(applications);
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve applications'); this.Notifications.error('Failure', err, 'Unable to retrieve applications');
} }
@ -119,8 +138,10 @@ class KubernetesApplicationsController {
currentName: this.$state.$current.name, currentName: this.$state.$current.name,
isAdmin: this.Authentication.isAdmin(), isAdmin: this.Authentication.isAdmin(),
viewReady: false, viewReady: false,
applications: [],
stacks: [],
ports: [],
}; };
await this.getApplications(); await this.getApplications();
this.state.viewReady = true; this.state.viewReady = true;

View File

@ -0,0 +1,52 @@
import PortainerError from 'Portainer/error';
export default class KubernetesHelmApplicationController {
/* @ngInject */
constructor($async, $state, Authentication, Notifications, HelmService) {
this.$async = $async;
this.$state = $state;
this.Authentication = Authentication;
this.Notifications = Notifications;
this.HelmService = HelmService;
}
/**
* APPLICATION
*/
async getHelmApplication() {
try {
this.state.dataLoading = true;
const releases = await this.HelmService.listReleases(this.endpoint.Id, { selector: `name=${this.state.params.name}`, namespace: this.state.params.namespace });
if (releases.length > 0) {
this.state.release = releases[0];
} else {
throw PortainerError(`Release ${this.state.params.name} not found`);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm application details');
} finally {
this.state.dataLoading = false;
}
}
$onInit() {
return this.$async(async () => {
this.state = {
dataLoading: true,
viewReady: false,
params: {
name: this.$state.params.name,
namespace: this.$state.params.namespace,
},
release: {
name: undefined,
chart: undefined,
app_version: undefined,
},
};
await this.getHelmApplication();
this.state.viewReady = true;
});
}
}

View File

@ -0,0 +1,5 @@
.release-table tr {
display: grid;
grid-auto-flow: column;
grid-template-columns: 1fr 4fr;
}

View File

@ -0,0 +1,49 @@
<kubernetes-view-header title="Helm details" state="kubernetes.helm" view-ready="$ctrl.state.viewReady">
<a ui-sref="kubernetes.applications">Applications</a> &gt; {{ $ctrl.state.params.name }}
</kubernetes-view-header>
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="$ctrl.state.viewReady">
<div class="row">
<div class="col-sm-12">
<div class="col-sm-12 form-section-title">
Information
</div>
<p class="text-muted">
<i class="fa fa-flask orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This is a first version for Helm charts, for more information see this
<a href="https://www.portainer.io/blog/portainer-now-with-helm-support" target="_blank">blog post</a>.
</p>
</div>
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-dharmachakra" title-text="Release"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody class="release-table">
<tr>
<td>Name</td>
<td>
{{ $ctrl.state.release.name }}
</td>
</tr>
<tr>
<td>Chart</td>
<td>
{{ $ctrl.state.release.chart }}
</td>
</tr>
<tr>
<td>App version</td>
<td>
{{ $ctrl.state.release.app_version }}
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
import angular from 'angular';
import controller from './helm.controller';
import './helm.css';
angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
templateUrl: './helm.html',
controller,
bindings: {
endpoint: '<',
},
});

View File

@ -0,0 +1,13 @@
export default class CopyButtonController {
/* @ngInject */
constructor(clipboard) {
this.clipboard = clipboard;
this.state = { isFading: false };
}
copyValueText() {
this.clipboard.copyText(this.value);
this.state.isFading = true;
setTimeout(() => (this.state.isFading = false), 1000);
}
}

View File

@ -0,0 +1,39 @@
@-webkit-keyframes fadeOut {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
99% {
opacity: 0.01;
}
100% {
opacity: 0;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
99% {
opacity: 0.01;
}
100% {
opacity: 0;
}
}
.copy-button-fadeout {
animation: fadeOut 2.5s;
animation-fill-mode: forwards;
}
.copy-button-copy-text {
opacity: 0;
margin-left: 7px;
color: #23ae89;
}

View File

@ -0,0 +1,4 @@
<span>
<button type="button" class="btn btn-link nopadding" ng-click="$ctrl.copyValueText()" title="Copy Value"> <i class="fa fa-copy"></i> Copy </button>
<span ng-class="{ 'copy-button-fadeout': $ctrl.state.isFading }" class="copy-button-copy-text"> <i class="fa fa-check" aria-hidden="true"></i> copied </span>
</span>

View File

@ -0,0 +1,11 @@
import angular from 'angular';
import controller from './copy-button.controller';
import './copy-button.css';
angular.module('portainer.app').component('copyButton', {
templateUrl: './copy-button.html',
controller,
bindings: {
value: '<',
},
});

View File

@ -0,0 +1,11 @@
.sensitive-details-container {
display: grid;
grid-template-columns: 25ch 25ch auto;
gap: 2rem;
align-items: start;
}
.show-hide-container {
display: flex;
justify-content: space-between;
}

View File

@ -0,0 +1,5 @@
<div class="sensitive-details-container">
<div class="text-wrap">{{ $ctrl.key }}</div>
<show-hide class="show-hide-container" value="$ctrl.value" use-asterisk="true"></show-hide>
<copy-button value="$ctrl.value"></copy-button>
</div>

View File

@ -0,0 +1,10 @@
import angular from 'angular';
import './sensitive-details.css';
angular.module('portainer.app').component('sensitiveDetails', {
templateUrl: './sensitive-details.html',
bindings: {
key: '@',
value: '@',
},
});

View File

@ -0,0 +1,9 @@
<div class="small text-muted text-wrap">
<span ng-if="!$ctrl.show && $ctrl.useAsterisk">********</span>
<span ng-if="$ctrl.show">{{ $ctrl.value }}</span>
</div>
<button type="button" class="btn btn-link nopadding" ng-click="$ctrl.show = !$ctrl.show" title="Show/Hide value">
<div ng-if="!$ctrl.show"> <i class="fa fa-eye-slash"></i> Show</div>
<div ng-if="$ctrl.show"> <i class="fa fa-eye"></i> Hide</div>
</button>

View File

@ -0,0 +1,9 @@
import angular from 'angular';
angular.module('portainer.app').component('showHide', {
templateUrl: './show-hide.html',
bindings: {
value: '<',
useAsterisk: '<',
},
});

View File

@ -11,6 +11,7 @@ export function SettingsViewModel(data) {
this.UserSessionTimeout = data.UserSessionTimeout; this.UserSessionTimeout = data.UserSessionTimeout;
this.EnableTelemetry = data.EnableTelemetry; this.EnableTelemetry = data.EnableTelemetry;
this.KubeconfigExpiry = data.KubeconfigExpiry; this.KubeconfigExpiry = data.KubeconfigExpiry;
this.HelmRepositoryURL = data.HelmRepositoryURL;
} }
export function PublicSettingsViewModel(settings) { export function PublicSettingsViewModel(settings) {

View File

@ -83,6 +83,27 @@
</div> </div>
</div> </div>
<!-- !templates --> <!-- !templates -->
<!-- helm charts -->
<div class="col-sm-12 form-section-title">
Helm Repository
</div>
<div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can specify the URL to your own helm repository here. See the
<a href="https://helm.sh/docs/topics/chart_repository/" target="_blank">official documentation</a> for more details.
</span>
</div>
<div class="form-group">
<label for="helmrepository_url" class="col-sm-1 control-label text-left">
URL
</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="settings.HelmRepositoryURL" id="helmrepository_url" placeholder="https://charts.bitnami.com/bitnami" required />
</div>
</div>
</div>
<!-- !helm charts -->
<!-- host-filesystem --> <!-- host-filesystem -->
<!-- edge --> <!-- edge -->

17
build/download_helm_binary.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
PLATFORM=$1
ARCH=$2
HELM_VERSION=$3
HELM_DIST="helm-$HELM_VERSION-$PLATFORM-$ARCH"
if [ "${PLATFORM}" == 'linux' ]; then
wget -qO- "https://get.helm.sh/${HELM_DIST}.tar.gz" | tar -x -z --strip-components 1 "${PLATFORM}-${ARCH}/helm"
mv "helm" "dist/helm"
chmod +x "dist/helm"
elif [ "${PLATFORM}" == 'windows' ]; then
wget -q -O tmp.zip "https://get.helm.sh/${HELM_DIST}.zip" && unzip -o -j tmp.zip "${PLATFORM}-${ARCH}/helm.exe" -d dist && rm -f tmp.zip
fi
exit 0

View File

@ -23,6 +23,7 @@ module.exports = function (grunt) {
dockerLinuxComposeVersion: '1.27.4', dockerLinuxComposeVersion: '1.27.4',
dockerWindowsComposeVersion: '1.28.0', dockerWindowsComposeVersion: '1.28.0',
dockerComposePluginVersion: '2.0.0-beta.6', dockerComposePluginVersion: '2.0.0-beta.6',
helmVersion: 'v3.6.3',
komposeVersion: 'v1.22.0', komposeVersion: 'v1.22.0',
kubectlVersion: 'v1.18.0', kubectlVersion: 'v1.18.0',
}, },
@ -42,6 +43,7 @@ module.exports = function (grunt) {
'shell:build_binary:linux:' + arch, 'shell:build_binary:linux:' + arch,
'shell:download_docker_binary:linux:' + arch, 'shell:download_docker_binary:linux:' + arch,
'shell:download_docker_compose_binary:linux:' + arch, 'shell:download_docker_compose_binary:linux:' + arch,
'shell:download_helm_binary:linux:' + arch,
'shell:download_kompose_binary:linux:' + arch, 'shell:download_kompose_binary:linux:' + arch,
'shell:download_kubectl_binary:linux:' + arch, 'shell:download_kubectl_binary:linux:' + arch,
]); ]);
@ -69,6 +71,7 @@ module.exports = function (grunt) {
'shell:build_binary:' + p + ':' + a, 'shell:build_binary:' + p + ':' + a,
'shell:download_docker_binary:' + p + ':' + a, 'shell:download_docker_binary:' + p + ':' + a,
'shell:download_docker_compose_binary:' + p + ':' + a, 'shell:download_docker_compose_binary:' + p + ':' + a,
'shell:download_helm_binary:' + p + ':' + a,
'shell:download_kompose_binary:' + p + ':' + a, 'shell:download_kompose_binary:' + p + ':' + a,
'shell:download_kubectl_binary:' + p + ':' + a, 'shell:download_kubectl_binary:' + p + ':' + a,
'webpack:prod', 'webpack:prod',
@ -84,6 +87,7 @@ module.exports = function (grunt) {
'shell:build_binary_azuredevops:' + p + ':' + a, 'shell:build_binary_azuredevops:' + p + ':' + a,
'shell:download_docker_binary:' + p + ':' + a, 'shell:download_docker_binary:' + p + ':' + a,
'shell:download_docker_compose_binary:' + p + ':' + a, 'shell:download_docker_compose_binary:' + p + ':' + a,
'shell:download_helm_binary:' + p + ':' + a,
'shell:download_kompose_binary:' + p + ':' + a, 'shell:download_kompose_binary:' + p + ':' + a,
'shell:download_kubectl_binary:' + p + ':' + a, 'shell:download_kubectl_binary:' + p + ':' + a,
'webpack:prod', 'webpack:prod',
@ -148,6 +152,7 @@ gruntfile_cfg.shell = {
download_docker_binary: { command: shell_download_docker_binary }, download_docker_binary: { command: shell_download_docker_binary },
download_kompose_binary: { command: shell_download_kompose_binary }, download_kompose_binary: { command: shell_download_kompose_binary },
download_kubectl_binary: { command: shell_download_kubectl_binary }, download_kubectl_binary: { command: shell_download_kubectl_binary },
download_helm_binary: { command: shell_download_helm_binary },
download_docker_compose_binary: { command: shell_download_docker_compose_binary }, download_docker_compose_binary: { command: shell_download_docker_compose_binary },
run_container: { command: shell_run_container }, run_container: { command: shell_run_container },
run_localserver: { command: shell_run_localserver, options: { async: true } }, run_localserver: { command: shell_run_localserver, options: { async: true } },
@ -235,6 +240,18 @@ function shell_download_docker_compose_binary(p, a) {
fi`; fi`;
} }
function shell_download_helm_binary(p, a) {
var binaryVersion = '<%= binaries.helmVersion %>';
return [
'if [ -f dist/helm ] || [ -f dist/helm.exe ]; then',
'echo "helm binary exists";',
'else',
'build/download_helm_binary.sh ' + p + ' ' + a + ' ' + binaryVersion + ';',
'fi',
].join(' ');
}
function shell_download_kompose_binary(p, a) { function shell_download_kompose_binary(p, a) {
var binaryVersion = '<%= binaries.komposeVersion %>'; var binaryVersion = '<%= binaries.komposeVersion %>';