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"
@ -44,33 +46,34 @@ const (
// Store defines the implementation of portainer.DataStore using // Store defines the implementation of portainer.DataStore using
// BoltDB as the storage system. // BoltDB as the storage system.
type Store struct { type Store struct {
path string path string
connection *internal.DbConnection connection *internal.DbConnection
isNew bool isNew bool
fileService portainer.FileService fileService portainer.FileService
CustomTemplateService *customtemplate.Service CustomTemplateService *customtemplate.Service
DockerHubService *dockerhub.Service DockerHubService *dockerhub.Service
EdgeGroupService *edgegroup.Service EdgeGroupService *edgegroup.Service
EdgeJobService *edgejob.Service EdgeJobService *edgejob.Service
EdgeStackService *edgestack.Service EdgeStackService *edgestack.Service
EndpointGroupService *endpointgroup.Service EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service EndpointService *endpoint.Service
EndpointRelationService *endpointrelation.Service EndpointRelationService *endpointrelation.Service
ExtensionService *extension.Service ExtensionService *extension.Service
RegistryService *registry.Service HelmUserRepositoryService *helmuserrepository.Service
ResourceControlService *resourcecontrol.Service RegistryService *registry.Service
RoleService *role.Service ResourceControlService *resourcecontrol.Service
ScheduleService *schedule.Service RoleService *role.Service
SettingsService *settings.Service ScheduleService *schedule.Service
SSLSettingsService *ssl.Service SettingsService *settings.Service
StackService *stack.Service SSLSettingsService *ssl.Service
TagService *tag.Service StackService *stack.Service
TeamMembershipService *teammembership.Service TagService *tag.Service
TeamService *team.Service TeamMembershipService *teammembership.Service
TunnelServerService *tunnelserver.Service TeamService *team.Service
UserService *user.Service TunnelServerService *tunnelserver.Service
VersionService *version.Service UserService *user.Service
WebhookService *webhook.Service VersionService *version.Service
WebhookService *webhook.Service
} }
func (store *Store) edition() portainer.SoftwareEdition { func (store *Store) edition() portainer.SoftwareEdition {

View File

@ -0,0 +1,73 @@
package helmuserrepository
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/boltdb/bolt"
)
const (
// BucketName represents the name of the bucket where this service stores data.
BucketName = "helm_user_repository"
)
// Service represents a service for managing endpoint data.
type Service struct {
connection *internal.DbConnection
}
// NewService creates a new instance of a service.
func NewService(connection *internal.DbConnection) (*Service, error) {
err := internal.CreateBucket(connection, BucketName)
if err != nil {
return nil, err
}
return &Service{
connection: connection,
}, nil
}
// HelmUserRepositoryByUserID return an array containing all the HelmUserRepository objects where the specified userID is present.
func (service *Service) HelmUserRepositoryByUserID(userID portainer.UserID) ([]portainer.HelmUserRepository, error) {
var result = make([]portainer.HelmUserRepository, 0)
err := service.connection.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(BucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var record portainer.HelmUserRepository
err := internal.UnmarshalObject(v, &record)
if err != nil {
return err
}
if record.UserID == userID {
result = append(result, record)
}
}
return nil
})
return result, err
}
// CreateHelmUserRepository creates a new HelmUserRepository object.
func (service *Service) CreateHelmUserRepository(record *portainer.HelmUserRepository) error {
return service.connection.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(BucketName))
id, _ := bucket.NextSequence()
record.ID = portainer.HelmUserRepositoryID(id)
data, err := internal.MarshalObject(record)
if err != nil {
return err
}
return bucket.Put(internal.Itob(int(record.ID)), data)
})
}

View File

@ -44,6 +44,7 @@ func (store *Store) Init() error {
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, 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
} }
@ -223,4 +227,13 @@ 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

@ -8,26 +8,27 @@ import (
) )
type datastore struct { type datastore struct {
customTemplate portainer.CustomTemplateService customTemplate portainer.CustomTemplateService
edgeGroup portainer.EdgeGroupService edgeGroup portainer.EdgeGroupService
edgeJob portainer.EdgeJobService edgeJob portainer.EdgeJobService
edgeStack portainer.EdgeStackService edgeStack portainer.EdgeStackService
endpoint portainer.EndpointService endpoint portainer.EndpointService
endpointGroup portainer.EndpointGroupService endpointGroup portainer.EndpointGroupService
endpointRelation portainer.EndpointRelationService endpointRelation portainer.EndpointRelationService
registry portainer.RegistryService helmUserRepository portainer.HelmUserRepositoryService
resourceControl portainer.ResourceControlService registry portainer.RegistryService
role portainer.RoleService resourceControl portainer.ResourceControlService
sslSettings portainer.SSLSettingsService role portainer.RoleService
settings portainer.SettingsService sslSettings portainer.SSLSettingsService
stack portainer.StackService settings portainer.SettingsService
tag portainer.TagService stack portainer.StackService
teamMembership portainer.TeamMembershipService tag portainer.TagService
team portainer.TeamService teamMembership portainer.TeamMembershipService
tunnelServer portainer.TunnelServerService team portainer.TeamService
user portainer.UserService tunnelServer portainer.TunnelServerService
version portainer.VersionService user portainer.UserService
webhook portainer.WebhookService version portainer.VersionService
webhook portainer.WebhookService
} }
func (d *datastore) BackupTo(io.Writer) error { return nil } func (d *datastore) BackupTo(io.Writer) error { return nil }
@ -45,19 +46,22 @@ func (d *datastore) EdgeStack() portainer.EdgeStackService { retur
func (d *datastore) Endpoint() portainer.EndpointService { return d.endpoint } func (d *datastore) 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) Registry() portainer.RegistryService { return d.registry } func (d *datastore) HelmUserRepository() portainer.HelmUserRepositoryService {
func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } return d.helmUserRepository
func (d *datastore) Role() portainer.RoleService { return d.role } }
func (d *datastore) Settings() portainer.SettingsService { return d.settings } func (d *datastore) Registry() portainer.RegistryService { return d.registry }
func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings } func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl }
func (d *datastore) Stack() portainer.StackService { return d.stack } func (d *datastore) Role() portainer.RoleService { return d.role }
func (d *datastore) Tag() portainer.TagService { return d.tag } func (d *datastore) Settings() portainer.SettingsService { return d.settings }
func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings }
func (d *datastore) Team() portainer.TeamService { return d.team } func (d *datastore) Stack() portainer.StackService { return d.stack }
func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } func (d *datastore) Tag() portainer.TagService { return d.tag }
func (d *datastore) User() portainer.UserService { return d.user } func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership }
func (d *datastore) Version() portainer.VersionService { return d.version } func (d *datastore) Team() portainer.TeamService { return d.team }
func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer }
func (d *datastore) User() portainer.UserService { return d.user }
func (d *datastore) Version() portainer.VersionService { return d.version }
func (d *datastore) Webhook() portainer.WebhookService { return d.webhook }
type datastoreOption = func(d *datastore) type datastoreOption = func(d *datastore)
@ -71,21 +75,25 @@ 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{
@ -78,4 +79,4 @@ func TestService_GenerateTokenForKubeconfig(t *testing.T) {
assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt) assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt)
}) })
} }
} }

View File

@ -0,0 +1,104 @@
package kubernetes
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
)
// KubeConfigService represents a service that is responsible for handling kubeconfig operations
type KubeConfigService interface {
IsSecure() bool
GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess
}
// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data
type kubernetesClusterAccess struct {
ClusterServerURL string `example:"https://mycompany.k8s.com"`
CertificateAuthorityFile string `example:"/data/tls/localhost.crt"`
CertificateAuthorityData string `example:"MIIC5TCCAc2gAwIBAgIJAJ+...+xuhOaFXwQ=="`
AuthToken string `example:"ey..."`
}
type kubeConfigCAService struct {
httpsBindAddr string
certificateAuthorityFile string
certificateAuthorityData string
}
var (
errTLSCertNotProvided = errors.New("tls cert path not provided")
errTLSCertFileMissing = errors.New("missing tls cert file")
errTLSCertIncorrectType = errors.New("incorrect tls cert type")
errTLSCertValidation = errors.New("failed to parse tls certificate")
)
// NewKubeConfigCAService encapsulates generation of core KubeConfig data
func NewKubeConfigCAService(httpsBindAddr string, tlsCertPath string) KubeConfigService {
certificateAuthorityData, err := getCertificateAuthorityData(tlsCertPath)
if err != nil {
log.Printf("[DEBUG] [internal,kubeconfig] [message: %s, generated KubeConfig will be insecure]", err.Error())
}
return &kubeConfigCAService{
httpsBindAddr: httpsBindAddr,
certificateAuthorityFile: tlsCertPath,
certificateAuthorityData: certificateAuthorityData,
}
}
// getCertificateAuthorityData reads tls certificate from supplied path and verifies the tls certificate
// then returns content (string) of the certificate within `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`
func getCertificateAuthorityData(tlsCertPath string) (string, error) {
if tlsCertPath == "" {
return "", errTLSCertNotProvided
}
data, err := ioutil.ReadFile(tlsCertPath)
if err != nil {
return "", errors.Wrap(errTLSCertFileMissing, err.Error())
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return "", errTLSCertIncorrectType
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", errors.Wrap(errTLSCertValidation, err.Error())
}
return base64.StdEncoding.EncodeToString(certificate.Raw), nil
}
// IsSecure specifies whether generated KubeConfig structs from the service will not have `insecure-skip-tls-verify: true`
// this is based on the fact that we can successfully extract `certificateAuthorityData` from
// certificate file at `tlsCertPath`. If we can successfully extract `certificateAuthorityData`,
// then this will be used as `certificate-authority-data` attribute in a generated KubeConfig.
func (kccas *kubeConfigCAService) IsSecure() bool {
return kccas.certificateAuthorityData != ""
}
// GetKubeConfigInternal returns K8s cluster access details for the specified endpoint.
// On startup, portainer generates a certificate against localhost at specified `httpsBindAddr` port, hence
// the kubeconfig generated should only be utilised by internal portainer binaries as the `ClusterServerURL`
// points to the internally accessible `https` based `localhost` address.
// The struct can be used to:
// - generate a kubeconfig file
// - pass down params to binaries
func (kccas *kubeConfigCAService) GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess {
clusterServerUrl := fmt.Sprintf("https://localhost%s/api/endpoints/%s/kubernetes", kccas.httpsBindAddr, fmt.Sprint(endpointId))
return kubernetesClusterAccess{
ClusterServerURL: clusterServerUrl,
CertificateAuthorityFile: kccas.certificateAuthorityFile,
CertificateAuthorityData: kccas.certificateAuthorityData,
AuthToken: authToken,
}
}

View File

@ -0,0 +1,149 @@
package kubernetes
import (
"fmt"
"io/ioutil"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// TLS certificate can be generated using:
// openssl req -x509 -out localhost.crt -keyout localhost.key -newkey rsa:2048 -nodes -sha25 -subj '/CN=localhost' -extensions EXT -config <( \
// printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
const certData = `-----BEGIN CERTIFICATE-----
MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0
B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95Ocv
X+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3Xsew
B3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXi
oa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubT
oA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo
b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B
AQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/
S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUS
Q5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/
JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMo
iuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi
4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ==
-----END CERTIFICATE-----
`
// string within the `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` without linebreaks
const certDataString = "MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95OcvX+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3XsewB3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXioa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubToA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUSQ5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMoiuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ=="
func createTempFile(filename, content string) (string, func()) {
tempPath, _ := ioutil.TempDir("", "temp")
filePath := fmt.Sprintf("%s/%s", tempPath, filename)
ioutil.WriteFile(filePath, []byte(content), 0644)
teardown := func() { os.RemoveAll(tempPath) }
return filePath, teardown
}
func Test_getCertificateAuthorityData(t *testing.T) {
is := assert.New(t)
t.Run("getCertificateAuthorityData fails on tls cert not provided", func(t *testing.T) {
_, err := getCertificateAuthorityData("")
is.ErrorIs(err, errTLSCertNotProvided, "getCertificateAuthorityData should fail with %w", errTLSCertNotProvided)
})
t.Run("getCertificateAuthorityData fails on tls cert provided but missing file", func(t *testing.T) {
_, err := getCertificateAuthorityData("/tmp/non-existent.crt")
is.ErrorIs(err, errTLSCertFileMissing, "getCertificateAuthorityData should fail with %w", errTLSCertFileMissing)
})
t.Run("getCertificateAuthorityData fails on tls cert provided but invalid file data", func(t *testing.T) {
filePath, teardown := createTempFile("invalid-cert.crt", "hello\ngo\n")
defer teardown()
_, err := getCertificateAuthorityData(filePath)
is.ErrorIs(err, errTLSCertIncorrectType, "getCertificateAuthorityData should fail with %w", errTLSCertIncorrectType)
})
t.Run("getCertificateAuthorityData succeeds on valid tls cert provided", func(t *testing.T) {
filePath, teardown := createTempFile("valid-cert.crt", certData)
defer teardown()
certificateAuthorityData, err := getCertificateAuthorityData(filePath)
is.NoError(err, "getCertificateAuthorityData succeed with valid cert; err=%w", errTLSCertIncorrectType)
is.Equal(certificateAuthorityData, certDataString, "returned certificateAuthorityData should be %s", certDataString)
})
}
func TestKubeConfigService_IsSecure(t *testing.T) {
is := assert.New(t)
t.Run("IsSecure should be false", func(t *testing.T) {
kcs := NewKubeConfigCAService("", "")
is.False(kcs.IsSecure(), "should be false if TLS cert not provided")
})
t.Run("IsSecure should be false", func(t *testing.T) {
filePath, teardown := createTempFile("valid-cert.crt", certData)
defer teardown()
kcs := NewKubeConfigCAService("", filePath)
is.True(kcs.IsSecure(), "should be true if valid TLS cert (path and content) provided")
})
}
func TestKubeConfigService_GetKubeConfigInternal(t *testing.T) {
is := assert.New(t)
t.Run("GetKubeConfigInternal returns localhost address", func(t *testing.T) {
kcs := NewKubeConfigCAService("", "")
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "https://localhost"), "should contain localhost address")
})
t.Run("GetKubeConfigInternal contains https bind address port", func(t *testing.T) {
kcs := NewKubeConfigCAService(":1010", "")
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, ":1010"), "should contain bind address port")
})
t.Run("GetKubeConfigInternal contains endpoint proxy url", func(t *testing.T) {
kcs := NewKubeConfigCAService("", "")
clusterAccessDetails := kcs.GetKubeConfigInternal(100, "some-token")
is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "api/endpoints/100/kubernetes"), "should contain endpoint proxy url")
})
t.Run("GetKubeConfigInternal returns insecure cluster access config", func(t *testing.T) {
kcs := NewKubeConfigCAService("", "")
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
wantClusterAccessDetails := kubernetesClusterAccess{
ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes",
AuthToken: "some-token",
CertificateAuthorityFile: "",
CertificateAuthorityData: "",
}
is.Equal(clusterAccessDetails, wantClusterAccessDetails)
})
t.Run("GetKubeConfigInternal returns secure cluster access config", func(t *testing.T) {
filePath, teardown := createTempFile("valid-cert.crt", certData)
defer teardown()
kcs := NewKubeConfigCAService("", filePath)
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
wantClusterAccessDetails := kubernetesClusterAccess{
ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes",
AuthToken: "some-token",
CertificateAuthorityFile: filePath,
CertificateAuthorityData: certDataString,
}
is.Equal(clusterAccessDetails, wantClusterAccessDetails)
})
}

View File

@ -0,0 +1,48 @@
package validation
// borrowed from apimachinery@v0.17.2/pkg/util/validation/validation.go
// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go
import (
"fmt"
"regexp"
)
const dns1123LabelFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"
const dns1123SubdomainFmt string = dns1123LabelFmt + "(\\." + dns1123LabelFmt + ")*"
const DNS1123SubdomainMaxLength int = 253
var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123).
func IsDNS1123Subdomain(value string) []string {
var errs []string
if len(value) > DNS1123SubdomainMaxLength {
errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength))
}
if !dns1123SubdomainRegexp.MatchString(value) {
errs = append(errs, RegexError(dns1123SubdomainFmt, "example.com"))
}
return errs
}
// MaxLenError returns a string explanation of a "string too long" validation failure.
func MaxLenError(length int) string {
return fmt.Sprintf("must be no more than %d characters", length)
}
// RegexError returns a string explanation of a regex validation failure.
func RegexError(fmt string, examples ...string) string {
s := "must match the regex " + fmt
if len(examples) == 0 {
return s
}
s += " (e.g. "
for i := range examples {
if i > 0 {
s += " or "
}
s += "'" + examples[i] + "'"
}
return s + ")"
}

View File

@ -395,6 +395,18 @@ type (
ProjectPath string `json:"ProjectPath"` 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 class="md-checkbox"> <span ng-if="$ctrl.isPrimary">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" data-cy="k8sApp-selectAllCheckbox" /> <span class="md-checkbox">
<label for="select_all"></label> <input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" data-cy="k8sApp-selectAllCheckbox" />
<label for="select_all"></label>
</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> </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>
<code>{{ item.RunningPodsCount }}</code> / <code>{{ item.TotalPodsCount }}</code> <span ng-if="item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0">
<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 {
await this.KubernetesApplicationService.delete(application); if (application.ApplicationType === KubernetesApplicationTypes.HELM) {
await this.HelmService.uninstall(this.endpoint.Id, application);
} else {
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 %>';