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