Compare commits
11 Commits
fix/EE-151
...
fix/EE-154
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53927faf23 | ||
|
|
00cbd2796a | ||
|
|
189a98c46d | ||
|
|
735dc5eb2b | ||
|
|
1e094ab77e | ||
|
|
53e02c53c1 | ||
|
|
93e4a3fdb9 | ||
|
|
6568bb44a7 | ||
|
|
f008530462 | ||
|
|
a2b9729f56 | ||
|
|
cdb0248e03 |
@@ -44,6 +44,7 @@ func (store *Store) Init() error {
|
||||
|
||||
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
|
||||
TemplatesURL: portainer.DefaultTemplatesURL,
|
||||
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
|
||||
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ func (m *Migrator) migrateDBVersionToDB32() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.helmRepositoryURLToDB32(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -211,3 +215,12 @@ func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Migrator) helmRepositoryURLToDB32() error {
|
||||
settings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.HelmRepositoryURL = portainer.DefaultHelmRepositoryURL
|
||||
return m.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -104,6 +105,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 {
|
||||
@@ -422,6 +427,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)
|
||||
@@ -451,12 +461,20 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatalf("failed initializing swarm stack manager: %v", err)
|
||||
}
|
||||
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
|
||||
|
||||
kubeConfigService := kubernetes.NewKubeConfigCAService(*flags.AddrHTTPS, sslSettings.CertPath)
|
||||
|
||||
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager)
|
||||
|
||||
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
|
||||
|
||||
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 {
|
||||
@@ -517,7 +535,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")
|
||||
}
|
||||
@@ -532,12 +550,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,
|
||||
@@ -546,6 +565,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
GitService: gitService,
|
||||
ProxyManager: proxyManager,
|
||||
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
KubeConfigService: kubeConfigService,
|
||||
SignatureService: digitalSignatureService,
|
||||
SnapshotService: snapshotService,
|
||||
SSLService: sslService,
|
||||
|
||||
@@ -30,6 +30,7 @@ require (
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210810234209-d01bc85eb481
|
||||
github.com/portainer/libcompose v0.5.3
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libhelm v0.0.0-20210825033709-0024b491ddd9
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
@@ -37,7 +38,7 @@ require (
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
|
||||
k8s.io/api v0.17.2
|
||||
k8s.io/apimachinery v0.17.2
|
||||
k8s.io/client-go v0.17.2
|
||||
|
||||
@@ -247,6 +247,8 @@ github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXs
|
||||
github.com/portainer/libcompose v0.5.3/go.mod h1:7SKd/ho69rRKHDFSDUwkbMcol2TMKU5OslDsajr8Ro8=
|
||||
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-20210825033709-0024b491ddd9 h1:dwZ2mmzaQhYJc/3DxAja4nyD7VlgWS5FQDAcawUVp08=
|
||||
github.com/portainer/libhelm v0.0.0-20210825033709-0024b491ddd9/go.mod h1:dL39owjMIeGKuSsSbbTrLCzZQfoB1zORhq0TAet7D5E=
|
||||
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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
@@ -387,8 +389,9 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -79,4 +79,4 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
|
||||
|
||||
http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"):
|
||||
|
||||
83
api/http/handler/helm/handler.go
Normal file
83
api/http/handler/helm/handler.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/portainer/libhelm"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"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,
|
||||
}
|
||||
|
||||
// `helm install [NAME] [CHART] flags`
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// NewTemplateHandler creates a template handler to manage endpoint group operations.
|
||||
func NewTemplateHandler(bouncer requestBouncer, dataStore portainer.DataStore, helmPackageManager libhelm.HelmPackageManager) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
dataStore: dataStore,
|
||||
helmPackageManager: helmPackageManager,
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
// `helm search [COMMAND] [CHART] flags`
|
||||
h.Handle("/templates/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmRepoSearch))).Methods(http.MethodGet)
|
||||
|
||||
// `helm show [COMMAND] [CHART] flags`
|
||||
h.Handle("/templates/helm/{chart}/{command:chart|values|readme}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmShow))).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// GetEndpoint returns the portainer.Endpoint for the request
|
||||
func (handler *Handler) GetEndpoint(r *http.Request) (*portainer.Endpoint, *httperror.HandlerError) {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return nil, &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
return endpoint, nil
|
||||
}
|
||||
153
api/http/handler/helm/helm_install.go
Normal file
153
api/http/handler/helm/helm_install.go
Normal file
@@ -0,0 +1,153 @@
|
||||
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"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
validation "github.com/portainer/portainer/api/kubernetes/validation"
|
||||
)
|
||||
|
||||
type installChartPayload struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Chart string `json:"chart"`
|
||||
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",
|
||||
)
|
||||
|
||||
func (p *installChartPayload) Validate(_ *http.Request) error {
|
||||
var required []string
|
||||
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 readPayload(r *http.Request) (*installChartPayload, error) {
|
||||
p := new(installChartPayload)
|
||||
err := request.DecodeAndValidateJSONPayload(r, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// @id HelmInstall
|
||||
// @summary Install Helm Chart
|
||||
// @description
|
||||
// @description **Access policy**: authorized
|
||||
// @tags helm_chart
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body installChartPayload true "EdgeGroup data when method is string"
|
||||
// @success 201 {object} helm.Release "Created"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 404 "Endpoint or ServiceAccount not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/helm/{release} [post]
|
||||
func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpoint, httperr := handler.GetEndpoint(r)
|
||||
if httperr != nil {
|
||||
return httperr
|
||||
}
|
||||
|
||||
bearerToken, err := security.ExtractBearerToken(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err}
|
||||
}
|
||||
|
||||
settings, err := handler.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve settings", Err: err}
|
||||
}
|
||||
|
||||
payload, err := readPayload(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid Helm install payload",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
release, err := handler.installChart(settings.HelmRepositoryURL, endpoint, payload, bearerToken)
|
||||
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 (handler *Handler) installChart(repo string, endpoint *portainer.Endpoint, p *installChartPayload, bearerToken string) (*release.Release, error) {
|
||||
clusterAccess := handler.kubeConfigService.GetKubeConfigInternal(endpoint.ID, bearerToken)
|
||||
installOpts := options.InstallOptions{
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
Namespace: p.Namespace,
|
||||
Repo: 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
|
||||
}
|
||||
47
api/http/handler/helm/helm_repo_search.go
Normal file
47
api/http/handler/helm/helm_repo_search.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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_chart
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @success 200 {object} string "Success"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 404 "Endpoint or ServiceAccount not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /templates/helm [get]
|
||||
func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
settings, err := handler.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve settings", Err: err}
|
||||
}
|
||||
|
||||
searchOpts := options.SearchRepoOptions{
|
||||
Repo: settings.HelmRepositoryURL,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
38
api/http/handler/helm/helm_repo_search_test.go
Normal file
38
api/http/handler/helm/helm_repo_search_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/libhelm/binary/test"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
)
|
||||
|
||||
func Test_helmRepoSearch(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
defaultSettings := &portainer.Settings{
|
||||
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
|
||||
}
|
||||
store := helper.NewDatastore(helper.WithSettingsService(defaultSettings))
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
h := NewTemplateHandler(helper.NewTestRequestBouncer(), store, helmPackageManager)
|
||||
|
||||
assert.NotNil(t, h, "Handler should not fail")
|
||||
|
||||
t.Run("helmRepoSearch", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/templates/helm", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(rr.Code, http.StatusOK, "Status should be 200 OK")
|
||||
|
||||
_, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
})
|
||||
}
|
||||
64
api/http/handler/helm/helm_show.go
Normal file
64
api/http/handler/helm/helm_show.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/libhelm/options"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
)
|
||||
|
||||
// @id HelmList
|
||||
// @summary List Helm Chart(s)
|
||||
// @description
|
||||
// @description **Access policy**: authorized
|
||||
// @tags helm_chart
|
||||
// @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/{chart}/{command} [get]
|
||||
func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
settings, err := handler.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve settings", Err: err}
|
||||
}
|
||||
|
||||
chart, err := request.RetrieveRouteVariableValue(r, "chart")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Missing chart name for show",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
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: settings.HelmRepositoryURL,
|
||||
}
|
||||
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
|
||||
}
|
||||
50
api/http/handler/helm/helm_show_test.go
Normal file
50
api/http/handler/helm/helm_show_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/libhelm/binary/test"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_helmShow(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
defaultSettings := &portainer.Settings{
|
||||
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
|
||||
}
|
||||
store := helper.NewDatastore(helper.WithSettingsService(defaultSettings))
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
h := NewTemplateHandler(helper.NewTestRequestBouncer(), store, helmPackageManager)
|
||||
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
commands := map[string]string{
|
||||
"values": test.MockDataValues,
|
||||
"chart": test.MockDataChart,
|
||||
"readme": test.MockDataReadme,
|
||||
}
|
||||
|
||||
chartName := "test-nginx"
|
||||
for cmd, expect := range commands {
|
||||
t.Run(cmd, func(t *testing.T) {
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s/%s", chartName, cmd), 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,12 @@ package kubernetes
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
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"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
kcli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
@@ -46,7 +44,7 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
bearerToken, err := extractBearerToken(r)
|
||||
bearerToken, err := security.ExtractBearerToken(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err}
|
||||
}
|
||||
@@ -84,20 +82,6 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque
|
||||
return response.JSON(w, config)
|
||||
}
|
||||
|
||||
// extractBearerToken extracts user's portainer bearer token from request auth header
|
||||
func extractBearerToken(r *http.Request) (string, error) {
|
||||
token := ""
|
||||
tokens := r.Header["Authorization"]
|
||||
if len(tokens) >= 1 {
|
||||
token = tokens[0]
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
if token == "" {
|
||||
return "", httperrors.ErrUnauthorized
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// getProxyUrl generates portainer proxy url which acts as proxy to k8s api server
|
||||
func getProxyUrl(r *http.Request, endpointID int) string {
|
||||
return fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpointID)
|
||||
|
||||
@@ -34,6 +34,8 @@ type settingsUpdatePayload struct {
|
||||
UserSessionTimeout *string `example:"5m"`
|
||||
// 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 {
|
||||
@@ -46,6 +48,9 @@ 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 != "" && !govalidator.IsURL(*payload.HelmRepositoryURL) {
|
||||
return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.UserSessionTimeout != nil {
|
||||
_, err := time.ParseDuration(*payload.UserSessionTimeout)
|
||||
if err != nil {
|
||||
@@ -93,6 +98,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
settings.TemplatesURL = *payload.TemplatesURL
|
||||
}
|
||||
|
||||
if payload.HelmRepositoryURL != nil {
|
||||
settings.HelmRepositoryURL = *payload.HelmRepositoryURL
|
||||
}
|
||||
|
||||
if payload.BlackListedLabels != nil {
|
||||
settings.BlackListedLabels = payload.BlackListedLabels
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -239,6 +228,23 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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"
|
||||
helmhandler "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
|
||||
@@ -164,6 +169,10 @@ func (server *Server) Start() error {
|
||||
|
||||
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
|
||||
|
||||
var endpointHelmHandler = helmhandler.NewHandler(requestBouncer, server.DataStore, server.HelmPackageManager, server.KubeConfigService)
|
||||
|
||||
var helmTemplatesHandler = helmhandler.NewTemplateHandler(requestBouncer, server.DataStore, server.HelmPackageManager)
|
||||
|
||||
var motdHandler = motd.NewHandler(requestBouncer)
|
||||
|
||||
var registryHandler = registries.NewHandler(requestBouncer)
|
||||
@@ -240,10 +249,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,
|
||||
|
||||
@@ -112,3 +112,24 @@ func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||
d.edgeJob = &stubEdgeJobService{jobs: js}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
s.settings = settings
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithSettingsService(settings *portainer.Settings) datastoreOption {
|
||||
return func(d *datastore) {
|
||||
d.settings = &stubSettingsService{
|
||||
settings: settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
api/internal/testhelpers/request_bouncer.go
Normal file
15
api/internal/testhelpers/request_bouncer.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package testhelpers
|
||||
|
||||
import "net/http"
|
||||
|
||||
type testRequestBouncer struct {
|
||||
}
|
||||
|
||||
// NewTestRequestBouncer creates new mock for requestBouncer
|
||||
func NewTestRequestBouncer() *testRequestBouncer {
|
||||
return &testRequestBouncer{}
|
||||
}
|
||||
|
||||
func (testRequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
|
||||
return h
|
||||
}
|
||||
104
api/kubernetes/kubeconfig_service.go
Normal file
104
api/kubernetes/kubeconfig_service.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// KubeConfigService represents a service that is responsible for handling kubeconfig operations
|
||||
type KubeConfigService interface {
|
||||
IsSecure() bool
|
||||
GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess
|
||||
}
|
||||
|
||||
// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data
|
||||
type kubernetesClusterAccess struct {
|
||||
ClusterServerURL string `example:"https://mycompany.k8s.com"`
|
||||
CertificateAuthorityFile string `example:"/data/tls/localhost.crt"`
|
||||
CertificateAuthorityData string `example:"MIIC5TCCAc2gAwIBAgIJAJ+...+xuhOaFXwQ=="`
|
||||
AuthToken string `example:"ey..."`
|
||||
}
|
||||
|
||||
type kubeConfigCAService struct {
|
||||
httpsBindAddr string
|
||||
certificateAuthorityFile string
|
||||
certificateAuthorityData string
|
||||
}
|
||||
|
||||
var (
|
||||
errTLSCertNotProvided = errors.New("tls cert path not provided")
|
||||
errTLSCertFileMissing = errors.New("missing tls cert file")
|
||||
errTLSCertIncorrectType = errors.New("incorrect tls cert type")
|
||||
errTLSCertValidation = errors.New("failed to parse tls certificate")
|
||||
)
|
||||
|
||||
// NewKubeConfigCAService encapsulates generation of core KubeConfig data
|
||||
func NewKubeConfigCAService(httpsBindAddr string, tlsCertPath string) KubeConfigService {
|
||||
certificateAuthorityData, err := getCertificateAuthorityData(tlsCertPath)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] [internal,kubeconfig] [message: %s, generated KubeConfig will be insecure]", err.Error())
|
||||
}
|
||||
|
||||
return &kubeConfigCAService{
|
||||
httpsBindAddr: httpsBindAddr,
|
||||
certificateAuthorityFile: tlsCertPath,
|
||||
certificateAuthorityData: certificateAuthorityData,
|
||||
}
|
||||
}
|
||||
|
||||
// getCertificateAuthorityData reads tls certificate from supplied path and verifies the tls certificate
|
||||
// then returns content (string) of the certificate within `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`
|
||||
func getCertificateAuthorityData(tlsCertPath string) (string, error) {
|
||||
if tlsCertPath == "" {
|
||||
return "", errTLSCertNotProvided
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(tlsCertPath)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(errTLSCertFileMissing, err.Error())
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", errTLSCertIncorrectType
|
||||
}
|
||||
|
||||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(errTLSCertValidation, err.Error())
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(certificate.Raw), nil
|
||||
}
|
||||
|
||||
// IsSecure specifies whether generated KubeConfig structs from the service will not have `insecure-skip-tls-verify: true`
|
||||
// this is based on the fact that we can successfully extract `certificateAuthorityData` from
|
||||
// certificate file at `tlsCertPath`. If we can successfully extract `certificateAuthorityData`,
|
||||
// then this will be used as `certificate-authority-data` attribute in a generated KubeConfig.
|
||||
func (kccas *kubeConfigCAService) IsSecure() bool {
|
||||
return kccas.certificateAuthorityData != ""
|
||||
}
|
||||
|
||||
// GetKubeConfigInternal returns K8s cluster access details for the specified endpoint.
|
||||
// On startup, portainer generates a certificate against localhost at specified `httpsBindAddr` port, hence
|
||||
// the kubeconfig generated should only be utilised by internal portainer binaries as the `ClusterServerURL`
|
||||
// points to the internally accessible `https` based `localhost` address.
|
||||
// The struct can be used to:
|
||||
// - generate a kubeconfig file
|
||||
// - pass down params to binaries
|
||||
func (kccas *kubeConfigCAService) GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess {
|
||||
clusterServerUrl := fmt.Sprintf("https://localhost%s/api/endpoints/%s/kubernetes", kccas.httpsBindAddr, fmt.Sprint(endpointId))
|
||||
return kubernetesClusterAccess{
|
||||
ClusterServerURL: clusterServerUrl,
|
||||
CertificateAuthorityFile: kccas.certificateAuthorityFile,
|
||||
CertificateAuthorityData: kccas.certificateAuthorityData,
|
||||
AuthToken: authToken,
|
||||
}
|
||||
}
|
||||
149
api/kubernetes/kubeconfig_service_test.go
Normal file
149
api/kubernetes/kubeconfig_service_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TLS certificate can be generated using:
|
||||
// openssl req -x509 -out localhost.crt -keyout localhost.key -newkey rsa:2048 -nodes -sha25 -subj '/CN=localhost' -extensions EXT -config <( \
|
||||
// printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
|
||||
const certData = `-----BEGIN CERTIFICATE-----
|
||||
MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||
BAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQx
|
||||
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
|
||||
ggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0
|
||||
B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95Ocv
|
||||
X+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3Xsew
|
||||
B3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXi
|
||||
oa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubT
|
||||
oA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo
|
||||
b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B
|
||||
AQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/
|
||||
S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUS
|
||||
Q5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/
|
||||
JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMo
|
||||
iuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi
|
||||
4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ==
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
// string within the `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` without linebreaks
|
||||
const certDataString = "MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95OcvX+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3XsewB3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXioa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubToA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUSQ5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMoiuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ=="
|
||||
|
||||
func createTempFile(filename, content string) (string, func()) {
|
||||
tempPath, _ := ioutil.TempDir("", "temp")
|
||||
filePath := fmt.Sprintf("%s/%s", tempPath, filename)
|
||||
ioutil.WriteFile(filePath, []byte(content), 0644)
|
||||
|
||||
teardown := func() { os.RemoveAll(tempPath) }
|
||||
|
||||
return filePath, teardown
|
||||
}
|
||||
|
||||
func Test_getCertificateAuthorityData(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
t.Run("getCertificateAuthorityData fails on tls cert not provided", func(t *testing.T) {
|
||||
_, err := getCertificateAuthorityData("")
|
||||
is.ErrorIs(err, errTLSCertNotProvided, "getCertificateAuthorityData should fail with %w", errTLSCertNotProvided)
|
||||
})
|
||||
|
||||
t.Run("getCertificateAuthorityData fails on tls cert provided but missing file", func(t *testing.T) {
|
||||
_, err := getCertificateAuthorityData("/tmp/non-existent.crt")
|
||||
is.ErrorIs(err, errTLSCertFileMissing, "getCertificateAuthorityData should fail with %w", errTLSCertFileMissing)
|
||||
})
|
||||
|
||||
t.Run("getCertificateAuthorityData fails on tls cert provided but invalid file data", func(t *testing.T) {
|
||||
filePath, teardown := createTempFile("invalid-cert.crt", "hello\ngo\n")
|
||||
defer teardown()
|
||||
|
||||
_, err := getCertificateAuthorityData(filePath)
|
||||
is.ErrorIs(err, errTLSCertIncorrectType, "getCertificateAuthorityData should fail with %w", errTLSCertIncorrectType)
|
||||
})
|
||||
|
||||
t.Run("getCertificateAuthorityData succeeds on valid tls cert provided", func(t *testing.T) {
|
||||
filePath, teardown := createTempFile("valid-cert.crt", certData)
|
||||
defer teardown()
|
||||
|
||||
certificateAuthorityData, err := getCertificateAuthorityData(filePath)
|
||||
is.NoError(err, "getCertificateAuthorityData succeed with valid cert; err=%w", errTLSCertIncorrectType)
|
||||
|
||||
is.Equal(certificateAuthorityData, certDataString, "returned certificateAuthorityData should be %s", certDataString)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKubeConfigService_IsSecure(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
t.Run("IsSecure should be false", func(t *testing.T) {
|
||||
kcs := NewKubeConfigCAService("", "")
|
||||
is.False(kcs.IsSecure(), "should be false if TLS cert not provided")
|
||||
})
|
||||
|
||||
t.Run("IsSecure should be false", func(t *testing.T) {
|
||||
filePath, teardown := createTempFile("valid-cert.crt", certData)
|
||||
defer teardown()
|
||||
|
||||
kcs := NewKubeConfigCAService("", filePath)
|
||||
is.True(kcs.IsSecure(), "should be true if valid TLS cert (path and content) provided")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKubeConfigService_GetKubeConfigInternal(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
t.Run("GetKubeConfigInternal returns localhost address", func(t *testing.T) {
|
||||
kcs := NewKubeConfigCAService("", "")
|
||||
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
|
||||
is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "https://localhost"), "should contain localhost address")
|
||||
})
|
||||
|
||||
t.Run("GetKubeConfigInternal contains https bind address port", func(t *testing.T) {
|
||||
kcs := NewKubeConfigCAService(":1010", "")
|
||||
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
|
||||
is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, ":1010"), "should contain bind address port")
|
||||
})
|
||||
|
||||
t.Run("GetKubeConfigInternal contains endpoint proxy url", func(t *testing.T) {
|
||||
kcs := NewKubeConfigCAService("", "")
|
||||
clusterAccessDetails := kcs.GetKubeConfigInternal(100, "some-token")
|
||||
is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "api/endpoints/100/kubernetes"), "should contain endpoint proxy url")
|
||||
})
|
||||
|
||||
t.Run("GetKubeConfigInternal returns insecure cluster access config", func(t *testing.T) {
|
||||
kcs := NewKubeConfigCAService("", "")
|
||||
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
|
||||
|
||||
wantClusterAccessDetails := kubernetesClusterAccess{
|
||||
ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes",
|
||||
AuthToken: "some-token",
|
||||
CertificateAuthorityFile: "",
|
||||
CertificateAuthorityData: "",
|
||||
}
|
||||
|
||||
is.Equal(clusterAccessDetails, wantClusterAccessDetails)
|
||||
})
|
||||
|
||||
t.Run("GetKubeConfigInternal returns secure cluster access config", func(t *testing.T) {
|
||||
filePath, teardown := createTempFile("valid-cert.crt", certData)
|
||||
defer teardown()
|
||||
|
||||
kcs := NewKubeConfigCAService("", filePath)
|
||||
clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token")
|
||||
|
||||
wantClusterAccessDetails := kubernetesClusterAccess{
|
||||
ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes",
|
||||
AuthToken: "some-token",
|
||||
CertificateAuthorityFile: filePath,
|
||||
CertificateAuthorityData: certDataString,
|
||||
}
|
||||
|
||||
is.Equal(clusterAccessDetails, wantClusterAccessDetails)
|
||||
})
|
||||
}
|
||||
48
api/kubernetes/validation/validation.go
Normal file
48
api/kubernetes/validation/validation.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package validation
|
||||
|
||||
// borrowed from apimachinery@v0.17.2/pkg/util/validation/validation.go
|
||||
// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const dns1123LabelFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"
|
||||
const dns1123SubdomainFmt string = dns1123LabelFmt + "(\\." + dns1123LabelFmt + ")*"
|
||||
const DNS1123SubdomainMaxLength int = 253
|
||||
|
||||
var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
|
||||
|
||||
// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123).
|
||||
func IsDNS1123Subdomain(value string) []string {
|
||||
var errs []string
|
||||
if len(value) > DNS1123SubdomainMaxLength {
|
||||
errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength))
|
||||
}
|
||||
if !dns1123SubdomainRegexp.MatchString(value) {
|
||||
errs = append(errs, RegexError(dns1123SubdomainFmt, "example.com"))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// MaxLenError returns a string explanation of a "string too long" validation failure.
|
||||
func MaxLenError(length int) string {
|
||||
return fmt.Sprintf("must be no more than %d characters", length)
|
||||
}
|
||||
|
||||
// RegexError returns a string explanation of a regex validation failure.
|
||||
func RegexError(fmt string, examples ...string) string {
|
||||
s := "must match the regex " + fmt
|
||||
if len(examples) == 0 {
|
||||
return s
|
||||
}
|
||||
s += " (e.g. "
|
||||
for i := range examples {
|
||||
if i > 0 {
|
||||
s += " or "
|
||||
}
|
||||
s += "'" + examples[i] + "'"
|
||||
}
|
||||
return s + ")"
|
||||
}
|
||||
@@ -684,6 +684,8 @@ type (
|
||||
UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"`
|
||||
// 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
|
||||
@@ -1442,6 +1444,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"
|
||||
)
|
||||
|
||||
@@ -44,6 +44,16 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).conf
|
||||
},
|
||||
};
|
||||
|
||||
const helmTemplates = {
|
||||
name: 'kubernetes.templates',
|
||||
url: '/templates/helm',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'helmTemplatesView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const applications = {
|
||||
name: 'kubernetes.applications',
|
||||
url: '/applications',
|
||||
@@ -297,6 +307,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).conf
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(kubernetes);
|
||||
$stateRegistryProvider.register(helmTemplates);
|
||||
$stateRegistryProvider.register(applications);
|
||||
$stateRegistryProvider.register(applicationCreation);
|
||||
$stateRegistryProvider.register(application);
|
||||
|
||||
@@ -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.templates
|
||||
.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.templates.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="template in $ctrl.templates | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory "
|
||||
model="template"
|
||||
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.templates.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: '@',
|
||||
templates: '<',
|
||||
tableKey: '@',
|
||||
selectAction: '<',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
|
||||
export default class HelmTemplatesController {
|
||||
/* @ngInject */
|
||||
constructor($analytics, $window, $async, $state, $anchorScroll, HelmService, KubernetesResourcePoolService, Notifications, ModalService) {
|
||||
this.$analytics = $analytics;
|
||||
this.$window = $window;
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$anchorScroll = $anchorScroll;
|
||||
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.loadInitialData = this.loadInitialData.bind(this);
|
||||
this.getHelmValues = this.getHelmValues.bind(this);
|
||||
this.selectHelmChart = this.selectHelmChart.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 {
|
||||
await this.HelmService.install(this.state.appName, this.state.resourcePool.Namespace.Name, this.state.template.name, this.state.values);
|
||||
this.Notifications.success('Helm Chart successfully installed');
|
||||
this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.template.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.template.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(template) {
|
||||
this.$anchorScroll('view-top');
|
||||
this.state.showCustomValues = false;
|
||||
this.state.template = template;
|
||||
await this.getHelmValues();
|
||||
}
|
||||
|
||||
async loadInitialData() {
|
||||
this.state.templatesLoading = true;
|
||||
try {
|
||||
const [resourcePools, templates] = await Promise.all([this.KubernetesResourcePoolService.get(), this.HelmService.search()]);
|
||||
|
||||
const nonSystemNamespaces = resourcePools.filter((resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
|
||||
this.state.resourcePools = nonSystemNamespaces;
|
||||
this.state.resourcePool = nonSystemNamespaces[0];
|
||||
|
||||
const latestTemplates = Object.values(templates.entries).map((charts) => charts[0]);
|
||||
this.state.templates = latestTemplates;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve initial helm data.');
|
||||
} finally {
|
||||
this.state.templatesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
this.state = {
|
||||
appName: '',
|
||||
template: null,
|
||||
showCustomValues: false,
|
||||
actionInProgress: false,
|
||||
resourcePools: [],
|
||||
resourcePool: '',
|
||||
values: null,
|
||||
originalvalues: null,
|
||||
templates: [],
|
||||
loadingValues: false,
|
||||
isEditorDirty: false,
|
||||
|
||||
viewReady: false,
|
||||
};
|
||||
|
||||
await this.loadInitialData();
|
||||
|
||||
this.state.viewReady = true;
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.state.isEditorDirty = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<rd-header id="view-top">
|
||||
<rd-header-title title-text="Helm">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="kubernetes.templates" 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.template">
|
||||
<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.template">
|
||||
<rd-widget>
|
||||
<rd-widget-custom-header icon="$ctrl.state.template.icon" title-text="$ctrl.state.template.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.template.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.template = null">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !helm actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<!-- helmchart-form -->
|
||||
</div>
|
||||
|
||||
<!-- Helm Charts Component -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<helm-templates-list
|
||||
title-text="Charts"
|
||||
title-icon="fa-rocket"
|
||||
templates="$ctrl.state.templates"
|
||||
table-key="$ctrl.state.templates"
|
||||
select-action="$ctrl.selectHelmChart"
|
||||
loading="$ctrl.state.templatesLoading"
|
||||
>
|
||||
</helm-templates-list>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Helm Charts Component -->
|
||||
@@ -0,0 +1,7 @@
|
||||
import angular from 'angular';
|
||||
import controller from './helm-templates.controller';
|
||||
|
||||
angular.module('portainer.kubernetes').component('helmTemplatesView', {
|
||||
templateUrl: './helm-templates.html',
|
||||
controller,
|
||||
});
|
||||
@@ -18,6 +18,15 @@
|
||||
Namespaces
|
||||
</sidebar-menu-item>
|
||||
|
||||
<sidebar-menu-item
|
||||
path="kubernetes.templates"
|
||||
path-params="{ endpointId: $ctrl.endpointId }"
|
||||
icon-class="fa-dharmachakra fa-fw"
|
||||
class-name="sidebar-list"
|
||||
>
|
||||
Helm
|
||||
</sidebar-menu-item>
|
||||
|
||||
<sidebar-menu-item
|
||||
path="kubernetes.applications"
|
||||
path-params="{ endpointId: $ctrl.endpointId }"
|
||||
|
||||
28
app/kubernetes/helm/rest.js
Normal file
28
app/kubernetes/helm/rest.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.kubernetes').factory('HelmFactory', HelmFactory);
|
||||
|
||||
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',
|
||||
cache: true,
|
||||
},
|
||||
show: {
|
||||
url: `${templatesUrl}/:chart/:type`,
|
||||
method: 'GET',
|
||||
transformResponse: function (data) {
|
||||
return { values: data };
|
||||
},
|
||||
},
|
||||
install: { method: 'POST' },
|
||||
}
|
||||
);
|
||||
}
|
||||
55
app/kubernetes/helm/service.js
Normal file
55
app/kubernetes/helm/service.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import angular from 'angular';
|
||||
import PortainerError from 'Portainer/error';
|
||||
|
||||
angular.module('portainer.kubernetes').factory('HelmService', HelmService);
|
||||
|
||||
/* @ngInject */
|
||||
export function HelmService(HelmFactory, EndpointProvider) {
|
||||
return {
|
||||
search,
|
||||
values,
|
||||
install,
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: Searches for all helm charts in a helm repo
|
||||
* @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() {
|
||||
try {
|
||||
return await HelmFactory.templates().$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`
|
||||
* @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(chart) {
|
||||
try {
|
||||
return await HelmFactory.show({ chart, type: 'values' }).$promise;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve values from chart', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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(appname, namespace, chart, values) {
|
||||
const endpointId = EndpointProvider.currentEndpoint().Id;
|
||||
const payload = {
|
||||
Name: appname,
|
||||
Namespace: namespace,
|
||||
Chart: chart,
|
||||
Values: values,
|
||||
};
|
||||
return await HelmFactory.install({ endpointId }, payload).$promise;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ export function SettingsViewModel(data) {
|
||||
this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings);
|
||||
this.SnapshotInterval = data.SnapshotInterval;
|
||||
this.TemplatesURL = data.TemplatesURL;
|
||||
this.HelmRepositoryURL = data.HelmRepositoryURL;
|
||||
this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval;
|
||||
this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures;
|
||||
this.UserSessionTimeout = data.UserSessionTimeout;
|
||||
this.EnableTelemetry = data.EnableTelemetry;
|
||||
this.HelmRepositoryURL = data.HelmRepositoryURL;
|
||||
}
|
||||
|
||||
export function PublicSettingsViewModel(settings) {
|
||||
|
||||
@@ -11,7 +11,8 @@ angular.module('portainer.app').factory('StateManager', [
|
||||
'APPLICATION_CACHE_VALIDITY',
|
||||
'AgentPingService',
|
||||
'$analytics',
|
||||
function StateManagerFactory($q, $async, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, APPLICATION_CACHE_VALIDITY, AgentPingService, $analytics) {
|
||||
'$cacheFactory',
|
||||
function StateManagerFactory($q, $async, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, APPLICATION_CACHE_VALIDITY, AgentPingService, $analytics, $cacheFactory) {
|
||||
var manager = {};
|
||||
|
||||
var state = {
|
||||
@@ -23,6 +24,7 @@ angular.module('portainer.app').factory('StateManager', [
|
||||
dismissedInfoHash: '',
|
||||
},
|
||||
extensions: [],
|
||||
helmRepoApi: '/api/templates/helm',
|
||||
};
|
||||
|
||||
manager.setVersionInfo = function (versionInfo) {
|
||||
@@ -54,6 +56,10 @@ angular.module('portainer.app').factory('StateManager', [
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
manager.refreshHelmRepo = function () {
|
||||
$cacheFactory.get('$http').remove(state.helmRepoApi);
|
||||
}
|
||||
|
||||
manager.updateSnapshotInterval = function (interval) {
|
||||
state.application.snapshotInterval = interval;
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
|
||||
@@ -83,6 +83,27 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- !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 -->
|
||||
|
||||
<!-- edge -->
|
||||
|
||||
@@ -100,6 +100,7 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
|
||||
StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures);
|
||||
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
|
||||
StateManager.refreshHelmRepo();
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
|
||||
17
build/download_helm_binary.sh
Executable file
17
build/download_helm_binary.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PLATFORM=$1
|
||||
ARCH=$2
|
||||
HELM_VERSION=$3
|
||||
|
||||
HELM_DIST="helm-$HELM_VERSION-$PLATFORM-$ARCH"
|
||||
|
||||
if [ "${PLATFORM}" == 'linux' ]; then
|
||||
wget -qO- "https://get.helm.sh/${HELM_DIST}.tar.gz" | tar -x -z --strip-components 1 "${PLATFORM}-${ARCH}/helm"
|
||||
mv "helm" "dist/helm"
|
||||
chmod +x "dist/helm"
|
||||
elif [ "${PLATFORM}" == 'windows' ]; then
|
||||
wget -q -O tmp.zip "https://get.helm.sh/${HELM_DIST}.zip" && unzip -o -j tmp.zip "${PLATFORM}-${ARCH}/helm.exe" -d dist && rm -f tmp.zip
|
||||
fi
|
||||
|
||||
exit 0
|
||||
17
gruntfile.js
17
gruntfile.js
@@ -21,6 +21,7 @@ module.exports = function (grunt) {
|
||||
dockerWindowsVersion: '19-03-12',
|
||||
dockerLinuxComposeVersion: '1.27.4',
|
||||
dockerWindowsComposeVersion: '1.28.0',
|
||||
helmVersion: 'v3.6.3',
|
||||
komposeVersion: 'v1.22.0',
|
||||
kubectlVersion: 'v1.18.0',
|
||||
},
|
||||
@@ -40,6 +41,7 @@ module.exports = function (grunt) {
|
||||
'shell:build_binary:linux:' + arch,
|
||||
'shell:download_docker_binary:linux:' + arch,
|
||||
'shell:download_docker_compose_binary:linux:' + arch,
|
||||
'shell:download_helm_binary:linux:' + arch,
|
||||
'shell:download_kompose_binary:linux:' + arch,
|
||||
'shell:download_kubectl_binary:linux:' + arch,
|
||||
]);
|
||||
@@ -67,6 +69,7 @@ module.exports = function (grunt) {
|
||||
'shell:build_binary:' + p + ':' + a,
|
||||
'shell:download_docker_binary:' + p + ':' + a,
|
||||
'shell:download_docker_compose_binary:' + p + ':' + a,
|
||||
'shell:download_helm_binary:' + p + ':' + a,
|
||||
'shell:download_kompose_binary:' + p + ':' + a,
|
||||
'shell:download_kubectl_binary:' + p + ':' + a,
|
||||
'webpack:prod',
|
||||
@@ -82,6 +85,7 @@ module.exports = function (grunt) {
|
||||
'shell:build_binary_azuredevops:' + p + ':' + a,
|
||||
'shell:download_docker_binary:' + p + ':' + a,
|
||||
'shell:download_docker_compose_binary:' + p + ':' + a,
|
||||
'shell:download_helm_binary:' + p + ':' + a,
|
||||
'shell:download_kompose_binary:' + p + ':' + a,
|
||||
'shell:download_kubectl_binary:' + p + ':' + a,
|
||||
'webpack:prod',
|
||||
@@ -146,6 +150,7 @@ gruntfile_cfg.shell = {
|
||||
download_docker_binary: { command: shell_download_docker_binary },
|
||||
download_kompose_binary: { command: shell_download_kompose_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 },
|
||||
run_container: { command: shell_run_container },
|
||||
run_localserver: { command: shell_run_localserver, options: { async: true } },
|
||||
@@ -220,6 +225,18 @@ function shell_download_docker_compose_binary(p, a) {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
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) {
|
||||
var binaryVersion = '<%= binaries.komposeVersion %>';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user