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/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/go.mod b/api/go.mod index 4a7d993be..5d5d984d5 100644 --- a/api/go.mod +++ b/api/go.mod @@ -38,7 +38,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/portainer/docker-compose-wrapper v0.0.0-20210906052132-ef24824f7548 github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a - github.com/portainer/libhelm v0.0.0-20210827003723-50899dcffbec + github.com/portainer/libhelm v0.0.0-20210903050903-43c3353fd37c 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 95ebec969..20d5c4c35 100644 --- a/api/go.sum +++ b/api/go.sum @@ -210,8 +210,8 @@ github.com/portainer/docker-compose-wrapper v0.0.0-20210906052132-ef24824f7548 h github.com/portainer/docker-compose-wrapper v0.0.0-20210906052132-ef24824f7548/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-20210827003723-50899dcffbec h1:L3o94L7VYLjAt4gKGKpgKFIkzpUYPh/UiEVSyjNGYGE= -github.com/portainer/libhelm v0.0.0-20210827003723-50899dcffbec/go.mod h1:dL39owjMIeGKuSsSbbTrLCzZQfoB1zORhq0TAet7D5E= +github.com/portainer/libhelm v0.0.0-20210903050903-43c3353fd37c h1:KALKyW7pC75SBMpLgB0zL129OwHxYwYxVc6x0HjPFD0= +github.com/portainer/libhelm v0.0.0-20210903050903-43c3353fd37c/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/helm/handler.go b/api/http/handler/helm/handler.go index 748f89d1f..106992e20 100644 --- a/api/http/handler/helm/handler.go +++ b/api/http/handler/helm/handler.go @@ -54,23 +54,27 @@ func NewHandler(bouncer requestBouncer, dataStore portainer.DataStore, helmPacka 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, dataStore portainer.DataStore, helmPackageManager libhelm.HelmPackageManager) *Handler { +func NewTemplateHandler(bouncer requestBouncer, 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}", + // 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 diff --git a/api/http/handler/helm/helm_delete.go b/api/http/handler/helm/helm_delete.go index 7fef51642..24a517221 100644 --- a/api/http/handler/helm/helm_delete.go +++ b/api/http/handler/helm/helm_delete.go @@ -17,7 +17,8 @@ import ( // @security jwt // @accept json // @produce json -// @param namespace +// @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" diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go index 0cdb398b5..94f31dfb2 100644 --- a/api/http/handler/helm/helm_install.go +++ b/api/http/handler/helm/helm_install.go @@ -12,16 +12,14 @@ import ( 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/middlewares" - "github.com/portainer/portainer/api/http/security" - validation "github.com/portainer/portainer/api/kubernetes/validation" + "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"` } @@ -30,8 +28,49 @@ var errChartNameInvalid = errors.New("invalid chart name. " + " and must start and end with an alphanumeric character", ) +// @id HelmInstall +// @summary Install Helm Chart +// @description +// @description **Access policy**: authorized +// @tags helm_chart +// @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/{release} [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") } @@ -52,75 +91,16 @@ func (p *installChartPayload) Validate(_ *http.Request) error { return nil } -func readPayload(r *http.Request) (*installChartPayload, error) { - p := new(installChartPayload) - err := request.DecodeAndValidateJSONPayload(r, p) - if err != nil { - return nil, err +func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) { + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return nil, httperr.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, err := middlewares.FetchEndpoint(r) - if err != nil { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint on request context", err} - } - - 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, + Repo: p.Repo, KubernetesClusterAccess: &options.KubernetesClusterAccess{ ClusterServerURL: clusterAccess.ClusterServerURL, CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile, diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go index 1abc1a50e..f671a067f 100644 --- a/api/http/handler/helm/helm_install_test.go +++ b/api/http/handler/helm/helm_install_test.go @@ -38,11 +38,11 @@ func Test_helmInstall(t *testing.T) { 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"} + 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", func(t *testing.T) { + 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) diff --git a/api/http/handler/helm/helm_list.go b/api/http/handler/helm/helm_list.go index 2486e5d66..c69f3ff56 100644 --- a/api/http/handler/helm/helm_list.go +++ b/api/http/handler/helm/helm_list.go @@ -17,10 +17,10 @@ import ( // @security jwt // @accept json // @produce json -// @param selector specify an optional selector -// @param namespace specify an optional namespace -// @param filter specify an optional filter -// @success 200 {object} portainer.Helm "Success" +// @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" diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go index 8322484c1..caeddb92d 100644 --- a/api/http/handler/helm/helm_repo_search.go +++ b/api/http/handler/helm/helm_repo_search.go @@ -1,8 +1,11 @@ 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" @@ -13,22 +16,28 @@ import ( // @description // @description **Access policy**: authorized // @tags helm_chart +// @param repo query string true "Helm repository URL" // @security jwt -// @accept json // @produce json // @success 200 {object} string "Success" +// @failure 400 "Bad request" // @failure 401 "Unauthorized" -// @failure 404 "Endpoint or ServiceAccount not found" +// @failure 404 "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() + 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.StatusInternalServerError, Message: "Unable to retrieve settings", Err: err} + 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: settings.HelmRepositoryURL, + Repo: repo, } result, err := libhelm.SearchRepo(searchOpts) diff --git a/api/http/handler/helm/helm_repo_search_test.go b/api/http/handler/helm/helm_repo_search_test.go index 7cd584406..bbb5bd388 100644 --- a/api/http/handler/helm/helm_repo_search_test.go +++ b/api/http/handler/helm/helm_repo_search_test.go @@ -1,13 +1,14 @@ package helm import ( + "fmt" "io" "net/http" "net/http/httptest" + "net/url" "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" @@ -16,23 +17,34 @@ import ( 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) + h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager) assert.NotNil(t, h, "Handler should not fail") - t.Run("helmRepoSearch", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/templates/helm", nil) + 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(rr.Code, http.StatusOK, "Status should be 200 OK") - - _, err := io.ReadAll(rr.Body) - is.NoError(err, "ReadAll should not return error") + 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 index 8923fb88e..c10aa42d7 100644 --- a/api/http/handler/helm/helm_show.go +++ b/api/http/handler/helm/helm_show.go @@ -1,19 +1,25 @@ 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 HelmList -// @summary List Helm Chart(s) +// @id HelmShow +// @summary Show Helm Chart(s) // @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 @@ -21,20 +27,20 @@ import ( // @failure 401 "Unauthorized" // @failure 404 "Endpoint or ServiceAccount not found" // @failure 500 "Server error" -// @router /templates/helm/{chart}/{command} [get] +// @router /templates/helm/{command} [get] func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.dataStore.Settings().Settings() + 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.StatusInternalServerError, Message: "Unable to retrieve settings", Err: err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))} } - chart, err := request.RetrieveRouteVariableValue(r, "chart") - if err != nil { - return &httperror.HandlerError{ - StatusCode: http.StatusInternalServerError, - Message: "Missing chart name for show", - Err: err, - } + 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") @@ -46,7 +52,7 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper showOptions := options.ShowOptions{ OutputFormat: options.ShowOutputFormat(cmd), Chart: chart, - Repo: settings.HelmRepositoryURL, + Repo: repo, } result, err := handler.helmPackageManager.Show(showOptions) if err != nil { diff --git a/api/http/handler/helm/helm_show_test.go b/api/http/handler/helm/helm_show_test.go index 1c637f281..c619dd01e 100644 --- a/api/http/handler/helm/helm_show_test.go +++ b/api/http/handler/helm/helm_show_test.go @@ -5,10 +5,10 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "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" ) @@ -16,12 +16,8 @@ import ( 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) + h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager) is.NotNil(h, "Handler should not fail") @@ -31,12 +27,13 @@ func Test_helmShow(t *testing.T) { "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) + 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) 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..c66e7c35d --- /dev/null +++ b/api/http/handler/helm/user_helm_repos.go @@ -0,0 +1,124 @@ +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 { + GlobalRepo string `json:"GlobalRepo"` + UserRepos []portainer.HelmUserRepository `json:"UserRepos"` +} + +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 users +// @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, + } + } + + 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, suffix trimmed comparison + for _, record := range records { + if strings.EqualFold(strings.TrimSuffix(record.URL, "/"), strings.TrimSuffix(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 users +// @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{ + GlobalRepo: settings.HelmRepositoryURL, + UserRepos: userRepos, + } + + return response.JSON(w, resp) +} diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 369aa91bf..c3ba83c94 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" @@ -50,8 +52,11 @@ 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.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) @@ -107,7 +112,7 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * } if payload.HelmRepositoryURL != nil { - settings.HelmRepositoryURL = *payload.HelmRepositoryURL + settings.HelmRepositoryURL = strings.TrimSuffix(*payload.HelmRepositoryURL, "/") } if payload.BlackListedLabels != nil { 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/server.go b/api/http/server.go index c0228c67e..fe46fdc9a 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -27,7 +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" + "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" @@ -170,9 +170,9 @@ 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 endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.HelmPackageManager, server.KubeConfigService) - var helmTemplatesHandler = helmhandler.NewTemplateHandler(requestBouncer, server.DataStore, server.HelmPackageManager) + var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager) var motdHandler = motd.NewHandler(requestBouncer) diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 4c2f581c2..faca977d3 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -7,26 +7,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 } @@ -44,19 +45,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) diff --git a/api/portainer.go b/api/portainer.go index 69217fc0f..8a84b6c5c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -389,6 +389,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"` @@ -1093,6 +1105,7 @@ type ( Endpoint() EndpointService EndpointGroup() EndpointGroupService EndpointRelation() EndpointRelationService + HelmUserRepository() HelmUserRepositoryService Registry() RegistryService ResourceControl() ResourceControlService Role() RoleService @@ -1220,6 +1233,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) diff --git a/app/assets/css/app.css b/app/assets/css/app.css index d88bfc110..19e03a12a 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -289,6 +289,10 @@ a[ng-click] { padding-top: 15px !important; } +.nomargin { + margin: 0 !important; +} + .terminal-container { width: 100%; padding: 10px 0; diff --git a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.controller.js b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.controller.js new file mode 100644 index 000000000..a108f3462 --- /dev/null +++ b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.controller.js @@ -0,0 +1,33 @@ +export default class HelmAddRepositoryController { + /* @ngInject */ + constructor($async, $window, $analytics, HelmService, Notifications, EndpointProvider) { + this.$async = $async; + this.$window = $window; + this.$analytics = $analytics; + this.HelmService = HelmService; + this.Notifications = Notifications; + this.EndpointProvider = EndpointProvider; + } + + async addRepository() { + this.state.isAddingRepo = true; + try { + const { URL } = await this.HelmService.addHelmRepository(this.EndpointProvider.currentEndpoint().Id, { url: this.state.repository }); + this.Notifications.success('Helm repository added successfully'); + this.refreshCharts([URL], true); + } catch (err) { + this.Notifications.error('Installation error', err); + } finally { + this.state.isAddingRepo = false; + } + } + + $onInit() { + return this.$async(async () => { + this.state = { + isAddingRepo: false, + repository: '', + }; + }); + } +} diff --git a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.html b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.html new file mode 100644 index 000000000..adf1c2a1e --- /dev/null +++ b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.html @@ -0,0 +1,36 @@ + + + +
+
+
+ + Add a Helm repository. All Helm charts in the repository will be added to the list. + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
diff --git a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.js b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.js new file mode 100644 index 000000000..395401fb5 --- /dev/null +++ b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.js @@ -0,0 +1,12 @@ +import angular from 'angular'; +import controller from './helm-add-repository.controller'; + +angular.module('portainer.kubernetes').component('helmAddRepository', { + templateUrl: './helm-add-repository.html', + controller, + bindings: { + charts: '@', + refreshCharts: '<', + endpoint: '<', + }, +}); diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js index 2aab01be7..3cc36456b 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js +++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js @@ -11,7 +11,7 @@ export default class HelmTemplatesListController { async updateCategories() { try { - const annotationCategories = this.templates + const annotationCategories = this.charts .map((t) => t.annotations) // get annotations .filter((a) => a) // filter out undefined/nulls .map((c) => c.category); // get annotation category @@ -31,7 +31,7 @@ export default class HelmTemplatesListController { } $onChanges() { - if (this.templates.length > 0) { + if (this.charts.length > 0) { this.updateCategories(); } } diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html index 73912ff6c..7993acfb7 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html +++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html @@ -36,8 +36,8 @@
@@ -48,7 +48,7 @@ Initial download of Helm Charts can take a few minutes
-
+
No helm charts available.
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js index 63c28220f..a4038c1c7 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js +++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js @@ -8,7 +8,7 @@ angular.module('portainer.kubernetes').component('helmTemplatesList', { loading: '<', titleText: '@', titleIcon: '@', - templates: '<', + charts: '<', tableKey: '@', selectAction: '<', }, diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js index f1acf6c05..71629a4eb 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js +++ b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js @@ -2,12 +2,13 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; export default class HelmTemplatesController { /* @ngInject */ - constructor($analytics, $window, $async, $state, $anchorScroll, HelmService, KubernetesResourcePoolService, Notifications, ModalService) { + constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications, ModalService) { this.$analytics = $analytics; - this.$window = $window; this.$async = $async; + this.$window = $window; this.$state = $state; this.$anchorScroll = $anchorScroll; + this.Authentication = Authentication; this.HelmService = HelmService; this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.Notifications = Notifications; @@ -16,9 +17,11 @@ export default class HelmTemplatesController { 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); + this.getHelmRepoURLs = this.getHelmRepoURLs.bind(this); + this.getLatestCharts = this.getLatestCharts.bind(this); + this.getResourcePools = this.getResourcePools.bind(this); $window.onbeforeunload = () => { if (this.state.isEditorDirty) { @@ -46,9 +49,16 @@ export default class HelmTemplatesController { 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); + const payload = { + Name: this.state.appName, + Repo: this.state.chart.repo, + Chart: this.state.chart.name, + Values: this.state.values, + Namespace: this.state.resourcePool.Namespace.Name, + }; + await this.HelmService.install(this.endpoint.Id, payload); this.Notifications.success('Helm Chart successfully installed'); - this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.template.name } }); + this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.chart.name } }); this.state.isEditorDirty = false; this.$state.go('kubernetes.applications'); } catch (err) { @@ -61,7 +71,7 @@ export default class HelmTemplatesController { async getHelmValues() { this.state.loadingValues = true; try { - const { values } = await this.HelmService.values(this.state.template.name); + const { values } = await this.HelmService.values(this.state.chart.repo, this.state.chart.name); this.state.values = values; this.state.originalvalues = values; } catch (err) { @@ -71,28 +81,73 @@ export default class HelmTemplatesController { } } - async selectHelmChart(template) { + async selectHelmChart(chart) { this.$anchorScroll('view-top'); this.state.showCustomValues = false; - this.state.template = template; + this.state.chart = chart; await this.getHelmValues(); } - async loadInitialData() { - this.state.templatesLoading = true; + /** + * @description This function is used to get the helm repo urls for the endpoint and user + * @returns {Promise} list of helm repo urls + */ + + async getHelmRepoURLs() { + this.state.reposLoading = true; try { - const [resourcePools, templates] = await Promise.all([this.KubernetesResourcePoolService.get(), this.HelmService.search()]); + // fetch globally set helm repo and user helm repos (parallel) + const { GlobalRepo, UserRepos } = await this.HelmService.getHelmRepositories(this.endpoint.Id); + const userHelmReposUrls = UserRepos.map((repo) => repo.URL); + const uniqueHelmRepos = [...new Set([GlobalRepo, ...userHelmReposUrls])]; // remove duplicates + return uniqueHelmRepos; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve helm repo urls.'); + } finally { + this.state.reposLoading = false; + } + } + + /** + * @description This function is used to fetch the respective index.yaml files for the provided helm repo urls + * @param {string[]} helmRepos list of helm repositories + * @param {bool} append append charts returned from repo to existing list of helm charts + */ + + async getLatestCharts(helmRepos, append = false) { + this.state.chartsLoading = true; + try { + const promiseList = helmRepos.map((repo) => this.HelmService.search(repo)); + // fetch helm charts from all the provided helm repositories (parallel) + // Promise.allSettled is used to account for promise failure(s) - in cases the user has provided invalid helm repo + const chartPromises = await Promise.allSettled(promiseList); + const latestCharts = chartPromises + .filter((tp) => tp.status === 'fulfilled') // remove failed promises + .map((tp) => ({ entries: tp.value.entries, repo: helmRepos[chartPromises.indexOf(tp)] })) // extract chart entries with respective repo data + .flatMap( + ({ entries, repo }) => Object.values(entries).map((charts) => ({ ...charts[0], repo })) // flatten chart entries to single array with respective repo + ); + + this.state.charts = append ? this.state.charts.concat(latestCharts) : latestCharts; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve helm repo charts.'); + } finally { + this.state.chartsLoading = false; + } + } + + async getResourcePools() { + this.state.resourcePoolsLoading = true; + try { + const resourcePools = await this.KubernetesResourcePoolService.get(); const nonSystemNamespaces = resourcePools.filter((resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); this.state.resourcePools = nonSystemNamespaces; this.state.resourcePool = nonSystemNamespaces[0]; - - 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; + this.state.resourcePoolsLoading = false; } } @@ -100,21 +155,23 @@ export default class HelmTemplatesController { return this.$async(async () => { this.state = { appName: '', - template: null, + chart: null, showCustomValues: false, actionInProgress: false, resourcePools: [], resourcePool: '', values: null, originalvalues: null, - templates: [], + charts: [], loadingValues: false, isEditorDirty: false, - + chartsLoading: false, + resourcePoolsLoading: false, viewReady: false, }; - await this.loadInitialData(); + const helmRepos = await this.getHelmRepoURLs(); + await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]); this.state.viewReady = true; }); diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.html b/app/kubernetes/components/helm/helm-templates/helm-templates.html index 6629fc441..efb52c68f 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.html +++ b/app/kubernetes/components/helm/helm-templates/helm-templates.html @@ -7,7 +7,7 @@ Charts - +

@@ -18,9 +18,9 @@

-
+
- +
@@ -30,7 +30,7 @@
-
+
@@ -148,7 +148,7 @@ Install Helm installing in progress - +
@@ -159,16 +159,22 @@ +
+
+ +
+
+
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.js b/app/kubernetes/components/helm/helm-templates/helm-templates.js index 0a39f8e75..51d5765d1 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.js +++ b/app/kubernetes/components/helm/helm-templates/helm-templates.js @@ -4,4 +4,7 @@ import controller from './helm-templates.controller'; angular.module('portainer.kubernetes').component('helmTemplatesView', { templateUrl: './helm-templates.html', controller, + bindings: { + endpoint: '<', + }, }); diff --git a/app/kubernetes/helm/rest.js b/app/kubernetes/helm/rest.js index f145413a2..5cd64475a 100644 --- a/app/kubernetes/helm/rest.js +++ b/app/kubernetes/helm/rest.js @@ -2,31 +2,41 @@ import angular from 'angular'; angular.module('portainer.kubernetes').factory('HelmFactory', HelmFactory); -function HelmFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +/* @ngInject */ +function HelmFactory($resource, API_ENDPOINT_ENDPOINTS) { const helmUrl = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/helm'; const templatesUrl = '/api/templates/helm'; return $resource( helmUrl, - { - endpointId: EndpointProvider.endpointID, - }, + {}, { templates: { url: templatesUrl, method: 'GET', + params: { repo: '@repo' }, cache: true, }, show: { - url: `${templatesUrl}/:chart/:type`, + url: `${templatesUrl}/:type`, method: 'GET', + params: { repo: '@repo', chart: '@chart' }, transformResponse: function (data) { return { values: data }; }, }, + getHelmRepositories: { + method: 'GET', + url: `${helmUrl}/repositories`, + }, + addHelmRepository: { + method: 'POST', + url: `${helmUrl}/repositories`, + }, list: { method: 'GET', isArray: true, + params: { namespace: '@namespace', selector: '@selector', filter: '@filter', output: '@output' }, }, install: { method: 'POST' }, uninstall: { diff --git a/app/kubernetes/helm/service.js b/app/kubernetes/helm/service.js index 0cba8f1df..e3313b821 100644 --- a/app/kubernetes/helm/service.js +++ b/app/kubernetes/helm/service.js @@ -4,10 +4,12 @@ import PortainerError from 'Portainer/error'; angular.module('portainer.kubernetes').factory('HelmService', HelmService); /* @ngInject */ -export function HelmService(HelmFactory, EndpointProvider) { +export function HelmService(HelmFactory) { return { search, values, + getHelmRepositories, + addHelmRepository, install, uninstall, listReleases, @@ -15,12 +17,13 @@ export function HelmService(HelmFactory, EndpointProvider) { /** * @description: Searches for all helm charts in a helm repo + * @param {string} repo - repo url to search charts for * @returns {Promise} - Resolves with `index.yaml` of helm charts for a repo * @throws {PortainerError} - Rejects with error if searching for the `index.yaml` fails */ - async function search() { + async function search(repo) { try { - return await HelmFactory.templates().$promise; + return await HelmFactory.templates({ repo }).$promise; } catch (err) { throw new PortainerError('Unable to retrieve helm charts', err); } @@ -28,36 +31,55 @@ export function HelmService(HelmFactory, EndpointProvider) { /** * @description: Show values helm of a helm chart, this basically runs `helm show values` + * @param {string} repo - repo url to search charts values for + * @param {string} chart - chart within the repo to retrieve default values * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo * @throws {PortainerError} - Rejects with error if helm show fails */ - async function values(chart) { + async function values(repo, chart) { try { - return await HelmFactory.show({ chart, type: 'values' }).$promise; + return await HelmFactory.show({ repo, chart, type: 'values' }).$promise; } catch (err) { throw new PortainerError('Unable to retrieve values from chart', err); } } + /** + * @description: Show values helm of a helm chart, this basically runs `helm show values` + * @returns {Promise} - Resolves with an object containing list of user helm repos and default/global settings helm repo + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function getHelmRepositories(endpointId) { + return await HelmFactory.getHelmRepositories({ endpointId }).$promise; + } + + /** + * @description: Adds a helm repo for the calling user + * @param {Object} payload - helm repo url to add for the user + * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function addHelmRepository(endpointId, payload) { + return await HelmFactory.addHelmRepository({ endpointId }, payload).$promise; + } + /** * @description: Installs a helm chart, this basically runs `helm install` * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo * @throws {PortainerError} - Rejects with error if helm show fails */ - async function install(appname, namespace, chart, values) { - const endpointId = EndpointProvider.currentEndpoint().Id; - const payload = { - Name: appname, - Namespace: namespace, - Chart: chart, - Values: values, - }; + async function install(endpointId, payload) { return await HelmFactory.install({ endpointId }, payload).$promise; } - async function uninstall({ Name }) { + /** + * @description: Uninstall a helm chart, this basically runs `helm uninstall` + * @param {Object} options - Options object, release `Name` is the only required option + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function uninstall(endpointId, { Name }) { try { - await HelmFactory.uninstall({ release: Name }).$promise; + await HelmFactory.uninstall({ endpointId, release: Name }).$promise; } catch (err) { throw new PortainerError('Unable to delete release', err); } @@ -65,13 +87,13 @@ export function HelmService(HelmFactory, EndpointProvider) { /** * @description: List all helm releases based on passed in options, this basically runs `helm list` - * @param {object} options - Supported CLI flags to pass to Helm (binary) - flags to `helm list` + * @param {Object} options - Supported CLI flags to pass to Helm (binary) - flags to `helm list` * @returns {Promise} - Resolves with list of helm releases * @throws {PortainerError} - Rejects with error if helm list fails */ - async function listReleases({ namespace, selector, filter, output }) { + async function listReleases(endpointId, { namespace, selector, filter, output }) { try { - const releases = await HelmFactory.list({ selector, namespace, filter, output }).$promise; + const releases = await HelmFactory.list({ endpointId, selector, namespace, filter, output }).$promise; return releases; } catch (err) { throw new PortainerError('Unable to retrieve release list', err); diff --git a/app/kubernetes/views/applications/applications.js b/app/kubernetes/views/applications/applications.js index 7994db0eb..da0a7ff07 100644 --- a/app/kubernetes/views/applications/applications.js +++ b/app/kubernetes/views/applications/applications.js @@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsView', { controllerAs: 'ctrl', bindings: { $transition$: '<', + endpoint: '<', }, }); diff --git a/app/kubernetes/views/applications/applicationsController.js b/app/kubernetes/views/applications/applicationsController.js index ea55eec21..eb27efc94 100644 --- a/app/kubernetes/views/applications/applicationsController.js +++ b/app/kubernetes/views/applications/applicationsController.js @@ -67,7 +67,7 @@ class KubernetesApplicationsController { for (const application of selectedItems) { try { if (application.ApplicationType === KubernetesApplicationTypes.HELM) { - await this.HelmService.uninstall(application); + await this.HelmService.uninstall(this.endpoint.Id, application); } else { await this.KubernetesApplicationService.delete(application); } diff --git a/app/kubernetes/views/applications/helm/helm.controller.js b/app/kubernetes/views/applications/helm/helm.controller.js index 096b96317..83996f2c3 100644 --- a/app/kubernetes/views/applications/helm/helm.controller.js +++ b/app/kubernetes/views/applications/helm/helm.controller.js @@ -16,7 +16,7 @@ export default class KubernetesHelmApplicationController { async getHelmApplication() { try { this.state.dataLoading = true; - const releases = await this.HelmService.listReleases({ selector: `name=${this.state.params.name}`, namespace: 'default' }); + const releases = await this.HelmService.listReleases(this.endpoint.Id, { selector: `name=${this.state.params.name}`, namespace: 'default' }); if (releases.length > 0) { this.state.release = releases[0]; } else { diff --git a/app/kubernetes/views/applications/helm/index.js b/app/kubernetes/views/applications/helm/index.js index 4ce7f7976..b99f41d4b 100644 --- a/app/kubernetes/views/applications/helm/index.js +++ b/app/kubernetes/views/applications/helm/index.js @@ -5,4 +5,7 @@ import './helm.css'; angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', { templateUrl: './helm.html', controller, + bindings: { + endpoint: '<', + }, });