mirror of https://github.com/portainer/portainer
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
parent
e86a586651
commit
2a60b8fcdf
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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=
|
||||||
|
|
|
@ -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"):
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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 + ")"
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
|
@ -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: '@',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -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: '<',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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%;
|
||||||
|
}
|
|
@ -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>
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 -->
|
|
@ -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: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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 }"
|
||||||
|
|
|
@ -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 '-';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsView', {
|
||||||
controllerAs: 'ctrl',
|
controllerAs: 'ctrl',
|
||||||
bindings: {
|
bindings: {
|
||||||
$transition$: '<',
|
$transition$: '<',
|
||||||
|
endpoint: '<',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
.release-table tr {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-template-columns: 1fr 4fr;
|
||||||
|
}
|
|
@ -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> > {{ $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>
|
|
@ -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: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
|
@ -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: '@',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
angular.module('portainer.app').component('showHide', {
|
||||||
|
templateUrl: './show-hide.html',
|
||||||
|
bindings: {
|
||||||
|
value: '<',
|
||||||
|
useAsterisk: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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) {
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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
|
17
gruntfile.js
17
gruntfile.js
|
@ -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 %>';
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue