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 @@
+
@@ -18,9 +18,9 @@