feat(helm/templates-add): helm templates add repo for user support EE-1278 (#5514)

* feat(helm): add helm chart backport to ce EE-1409 (#5425)

* EE-1311 Helm Chart Backport from EE

* backport to ce

Co-authored-by: Matt Hook <hookenz@gmail.com>

* feat(helm) helm chart backport from ee EE-1311 (#5436)

* Add missing defaultHelmRepoUrl and mock testing

* Backport EE-1477

* Backport updates to helm tests from EE

* add https by default changes and ssl to tls renaming from EE

* Port install integration test. Disabled by default to pass CI checks

* merged changes from EE for the integration test

* kube proxy whitelist updated to support internal helm install command

Co-authored-by: zees-dev <dev.786zshan@gmail.com>

* Pull in all changes from tech review in EE-943

* feat(helm): add helm chart backport to ce EE-1409 (#5425)

* EE-1311 Helm Chart Backport from EE

* backport to ce

Co-authored-by: Matt Hook <hookenz@gmail.com>

* Pull in all changes from tech review in EE-943

* added helm to sidebar after rebase, sync CE with EE

* backport EE-1278, squashed, diffed, updated

* helm install openapi spec update

* resolved conflicts, updated code

* - matching ee codebase at 0afe57034449ee0e9f333d92c252a13995a93019
- helm install using endpoint middleware
- remove trailing slash from added/persisted helm repo urls

* feat(helm) use libhelm url validator and improved path assembly EE-1554 (#5561)

* feat(helm/userrepos) fix getting global repo for ordinary users EE-1562 (#5567)

* feat(helm/userrepos) fix getting global repo for ordinary users EE-1562

* post review changes and further backported changes from EE

* resolved conflicts, updated code

* fixed helm_install handler unit test

* user cannot add existing repo if suffix is '/' (#5571)

* feat(helm/docs) fix broken swagger docs EE-1278 (#5572)

* Fix swagger docs

* minor correction

* fix(helm): migrating code from user handler to helm handler (#5573)

* - migrated user_helm_repos to helm endpoint handler
- migrated api operations from user factory/service to helm factory/service
- passing endpointId into helm service/factory as endpoint provider is deprecated

* upgrade libhelm to hide secrets

Co-authored-by: Matt Hook <hookenz@gmail.com>

* removed duplicate file - due to merge conflict

* dependency injection in helm factory

Co-authored-by: Richard Wei <54336863+WaysonWei@users.noreply.github.com>
Co-authored-by: Matt Hook <hookenz@gmail.com>
This commit is contained in:
zees-dev
2021-09-06 11:36:04 +12:00
committed by zees-dev
parent aa9fe5d2d7
commit c2f079b4e3
36 changed files with 683 additions and 247 deletions
+30 -27
View File
@@ -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 {
@@ -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)
})
}
+12
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+9 -5
View File
@@ -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
+2 -1
View File
@@ -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"
+48 -68
View File
@@ -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,
+2 -2
View File
@@ -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)
+4 -4
View File
@@ -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"
+14 -5
View File
@@ -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)
+24 -12
View File
@@ -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")
})
}
+19 -13
View File
@@ -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 {
+5 -8
View File
@@ -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)
+124
View File
@@ -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)
}
+9 -4
View File
@@ -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 {
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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)
+37 -33
View File
@@ -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)
+19
View File
@@ -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)
+4
View File
@@ -289,6 +289,10 @@ a[ng-click] {
padding-top: 15px !important;
}
.nomargin {
margin: 0 !important;
}
.terminal-container {
width: 100%;
padding: 10px 0;
@@ -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: '',
};
});
}
}
@@ -0,0 +1,36 @@
<rd-widget>
<rd-widget-header icon="fa-dharmachakra" title-text="Additional repositories"></rd-widget-header>
<rd-widget-body>
<div class="actionBar">
<form class="form-horizontal">
<div class="form-group">
<span class="col-sm-12 text-muted small">
Add a Helm repository. All Helm charts in the repository will be added to the list.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<input type="text" name="repo" class="form-control" ng-model="$ctrl.state.repository" placeholder="https://charts.bitnami.com/bitnami" required />
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-sm btn-default nomargin"
ng-click="$ctrl.addRepository()"
ng-disabled="$ctrl.state.isAddingRepo"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-helm-add-repository"
>
Add repository
</button>
</div>
</div>
</form>
</div>
</rd-widget-body>
</rd-widget>
@@ -0,0 +1,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: '<',
},
});
@@ -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();
}
}
@@ -36,8 +36,8 @@
<div class="blocklist">
<helm-templates-list-item
ng-repeat="template in $ctrl.templates | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory "
model="template"
ng-repeat="chart in $ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory "
model="chart"
type-label="helm"
on-select="($ctrl.selectAction)"
>
@@ -48,7 +48,7 @@
Initial download of Helm Charts can take a few minutes
</div>
</div>
<div ng-if="!$ctrl.loading && $ctrl.templates.length === 0" class="text-center text-muted">
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-center text-muted">
No helm charts available.
</div>
</div>
@@ -8,7 +8,7 @@ angular.module('portainer.kubernetes').component('helmTemplatesList', {
loading: '<',
titleText: '@',
titleIcon: '@',
templates: '<',
charts: '<',
tableKey: '@',
selectAction: '<',
},
@@ -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<string[]>} 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;
});
@@ -7,7 +7,7 @@
<rd-header-content>Charts</rd-header-content>
</rd-header>
<information-panel title-text="Information" ng-if="!$ctrl.state.template">
<information-panel title-text="Information" ng-if="!$ctrl.state.chart">
<span class="small text-muted">
<p>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
@@ -18,9 +18,9 @@
<div class="row">
<!-- helmchart-form -->
<div class="col-sm-12" ng-if="$ctrl.state.template">
<div class="col-sm-12" ng-if="$ctrl.state.chart">
<rd-widget>
<rd-widget-custom-header icon="$ctrl.state.template.icon" title-text="$ctrl.state.template.name"></rd-widget-custom-header>
<rd-widget-custom-header icon="$ctrl.state.chart.icon" title-text="$ctrl.state.chart.name"></rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal" name="$ctrl.helmTemplateCreationForm">
<!-- description -->
@@ -30,7 +30,7 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-bind-html="$ctrl.state.template.description"></div>
<div class="template-note" ng-bind-html="$ctrl.state.chart.description"></div>
</div>
</div>
</div>
@@ -148,7 +148,7 @@
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
<span ng-hide="!$ctrl.state.actionInProgress">Helm installing in progress</span>
</button>
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.state.template = null">Hide</button>
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.state.chart = null">Hide</button>
</div>
</div>
<!-- !helm actions -->
@@ -159,16 +159,22 @@
<!-- helmchart-form -->
</div>
<div class="row">
<div class="col-sm-12">
<helm-add-repository charts="$ctrl.state.charts" refresh-charts="$ctrl.getLatestCharts"></helm-add-repository>
</div>
</div>
<!-- Helm Charts Component -->
<div class="row">
<div class="col-sm-12">
<helm-templates-list
title-text="Charts"
title-icon="fa-rocket"
templates="$ctrl.state.templates"
table-key="$ctrl.state.templates"
charts="$ctrl.state.charts"
table-key="$ctrl.state.charts"
select-action="$ctrl.selectHelmChart"
loading="$ctrl.state.templatesLoading"
loading="$ctrl.state.chartsLoading || $ctrl.state.resourcePoolsLoading"
>
</helm-templates-list>
</div>
@@ -4,4 +4,7 @@ import controller from './helm-templates.controller';
angular.module('portainer.kubernetes').component('helmTemplatesView', {
templateUrl: './helm-templates.html',
controller,
bindings: {
endpoint: '<',
},
});
+15 -5
View File
@@ -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: {
+40 -18
View File
@@ -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);
@@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsView', {
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
endpoint: '<',
},
});
@@ -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);
}
@@ -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 {
@@ -5,4 +5,7 @@ import './helm.css';
angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
templateUrl: './helm.html',
controller,
bindings: {
endpoint: '<',
},
});