mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
feat(helm): update helm view [r8s-256] (#582)
Co-authored-by: Cara Ryan <cara.ryan@portainer.io> Co-authored-by: James Player <james.player@portainer.io> Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
parent
46eddbe7b9
commit
0ca9321db1
57 changed files with 2635 additions and 222 deletions
|
@ -54,6 +54,14 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||||
h.Handle("/{id}/kubernetes/helm",
|
h.Handle("/{id}/kubernetes/helm",
|
||||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
// `helm get all [RELEASE_NAME]`
|
||||||
|
h.Handle("/{id}/kubernetes/helm/{release}",
|
||||||
|
httperror.LoggerHandler(h.helmGet)).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// `helm history [RELEASE_NAME]`
|
||||||
|
h.Handle("/{id}/kubernetes/helm/{release}/history",
|
||||||
|
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
67
api/http/handler/helm/helm_get.go
Normal file
67
api/http/handler/helm/helm_get.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package helm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
_ "github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id HelmGet
|
||||||
|
// @summary Get a helm release
|
||||||
|
// @description Get details of a helm release by release name
|
||||||
|
// @description **Access policy**: authenticated
|
||||||
|
// @tags helm
|
||||||
|
// @security ApiKeyAuth || jwt
|
||||||
|
// @produce json
|
||||||
|
// @param id path int true "Environment(Endpoint) identifier"
|
||||||
|
// @param name path string true "Helm release name"
|
||||||
|
// @param namespace query string false "specify an optional namespace"
|
||||||
|
// @param showResources query boolean false "show resources of the release"
|
||||||
|
// @param revision query int false "specify an optional revision"
|
||||||
|
// @success 200 {object} release.Release "Success"
|
||||||
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
|
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||||
|
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||||
|
// @failure 500 "Server error occurred while attempting to retrieve the release."
|
||||||
|
// @router /endpoints/{id}/kubernetes/helm/{name} [get]
|
||||||
|
func (handler *Handler) helmGet(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
release, err := request.RetrieveRouteVariableValue(r, "release")
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("No release specified", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||||
|
if httperr != nil {
|
||||||
|
return httperr
|
||||||
|
}
|
||||||
|
|
||||||
|
// build the get options
|
||||||
|
getOpts := options.GetOptions{
|
||||||
|
KubernetesClusterAccess: clusterAccess,
|
||||||
|
Name: release,
|
||||||
|
}
|
||||||
|
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||||
|
// optional namespace. The library defaults to "default"
|
||||||
|
if namespace != "" {
|
||||||
|
getOpts.Namespace = namespace
|
||||||
|
}
|
||||||
|
showResources, _ := request.RetrieveBooleanQueryParameter(r, "showResources", true)
|
||||||
|
getOpts.ShowResources = showResources
|
||||||
|
revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
|
||||||
|
// optional revision. The library defaults to the latest revision if not specified
|
||||||
|
if revision > 0 {
|
||||||
|
getOpts.Revision = revision
|
||||||
|
}
|
||||||
|
|
||||||
|
releases, err := handler.helmPackageManager.Get(getOpts)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Helm returned an error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, releases)
|
||||||
|
}
|
66
api/http/handler/helm/helm_get_test.go
Normal file
66
api/http/handler/helm/helm_get_test.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
package helm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/exec/exectest"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
"github.com/portainer/portainer/api/jwt"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||||
|
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_helmGet(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
_, store := datastore.MustNewTestStore(t, true, true)
|
||||||
|
|
||||||
|
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
|
||||||
|
is.NoError(err, "Error creating environment")
|
||||||
|
|
||||||
|
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||||
|
is.NoError(err, "Error creating a user")
|
||||||
|
|
||||||
|
jwtService, err := jwt.NewService("1h", store)
|
||||||
|
is.NoError(err, "Error initiating jwt service")
|
||||||
|
|
||||||
|
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||||
|
helmPackageManager := test.NewMockHelmPackageManager()
|
||||||
|
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||||
|
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||||
|
|
||||||
|
is.NotNil(h, "Handler should not fail")
|
||||||
|
|
||||||
|
// Install a single chart, to be retrieved by the handler
|
||||||
|
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||||
|
h.helmPackageManager.Upgrade(options)
|
||||||
|
|
||||||
|
t.Run("helmGet sucessfuly retrieves helm release", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"?namespace="+options.Namespace, nil)
|
||||||
|
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
data := release.Release{}
|
||||||
|
body, err := io.ReadAll(rr.Body)
|
||||||
|
is.NoError(err, "ReadAll should not return error")
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
|
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||||
|
is.Equal("nginx-1", data.Name)
|
||||||
|
})
|
||||||
|
}
|
58
api/http/handler/helm/helm_history.go
Normal file
58
api/http/handler/helm/helm_history.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package helm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
_ "github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id HelmGetHistory
|
||||||
|
// @summary Get a historical list of releases
|
||||||
|
// @description Get a historical list of releases by release name
|
||||||
|
// @description **Access policy**: authenticated
|
||||||
|
// @tags helm
|
||||||
|
// @security ApiKeyAuth || jwt
|
||||||
|
// @produce json
|
||||||
|
// @param id path int true "Environment(Endpoint) identifier"
|
||||||
|
// @param name path string true "Helm release name"
|
||||||
|
// @param namespace query string false "specify an optional namespace"
|
||||||
|
// @success 200 {array} release.Release "Success"
|
||||||
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
|
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||||
|
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||||
|
// @failure 500 "Server error occurred while attempting to retrieve the historical list of releases."
|
||||||
|
// @router /endpoints/{id}/kubernetes/helm/{release}/history [get]
|
||||||
|
func (handler *Handler) helmGetHistory(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
release, err := request.RetrieveRouteVariableValue(r, "release")
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("No release specified", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||||
|
if httperr != nil {
|
||||||
|
return httperr
|
||||||
|
}
|
||||||
|
|
||||||
|
historyOptions := options.HistoryOptions{
|
||||||
|
KubernetesClusterAccess: clusterAccess,
|
||||||
|
Name: release,
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional namespace. The library defaults to "default"
|
||||||
|
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||||
|
if namespace != "" {
|
||||||
|
historyOptions.Namespace = namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
releases, err := handler.helmPackageManager.GetHistory(historyOptions)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Helm returned an error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, releases)
|
||||||
|
}
|
67
api/http/handler/helm/helm_history_test.go
Normal file
67
api/http/handler/helm/helm_history_test.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package helm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/exec/exectest"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
"github.com/portainer/portainer/api/jwt"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||||
|
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_helmGetHistory(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
_, store := datastore.MustNewTestStore(t, true, true)
|
||||||
|
|
||||||
|
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
|
||||||
|
is.NoError(err, "Error creating environment")
|
||||||
|
|
||||||
|
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||||
|
is.NoError(err, "Error creating a user")
|
||||||
|
|
||||||
|
jwtService, err := jwt.NewService("1h", store)
|
||||||
|
is.NoError(err, "Error initiating jwt service")
|
||||||
|
|
||||||
|
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||||
|
helmPackageManager := test.NewMockHelmPackageManager()
|
||||||
|
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||||
|
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||||
|
|
||||||
|
is.NotNil(h, "Handler should not fail")
|
||||||
|
|
||||||
|
// Install a single chart, to be retrieved by the handler
|
||||||
|
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||||
|
h.helmPackageManager.Upgrade(options)
|
||||||
|
|
||||||
|
t.Run("helmGetHistory sucessfuly retrieves helm release history", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"/history?namespace="+options.Namespace, nil)
|
||||||
|
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
data := []release.Release{}
|
||||||
|
body, err := io.ReadAll(rr.Body)
|
||||||
|
is.NoError(err, "ReadAll should not return error")
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
|
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||||
|
is.Equal(1, len(data))
|
||||||
|
is.Equal("nginx-1", data[0].Name)
|
||||||
|
})
|
||||||
|
}
|
73
api/http/handler/kubernetes/describe.go
Normal file
73
api/http/handler/kubernetes/describe.go
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/pkg/libkubectl"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type describeResourceResponse struct {
|
||||||
|
Describe string `json:"describe"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id DescribeResource
|
||||||
|
// @summary Get a description of a kubernetes resource
|
||||||
|
// @description Get a description of a kubernetes resource.
|
||||||
|
// @description **Access policy**: Authenticated user.
|
||||||
|
// @tags kubernetes
|
||||||
|
// @security ApiKeyAuth || jwt
|
||||||
|
// @produce json
|
||||||
|
// @param id path int true "Environment identifier"
|
||||||
|
// @param name query string true "Resource name"
|
||||||
|
// @param kind query string true "Resource kind"
|
||||||
|
// @param namespace query string false "Namespace"
|
||||||
|
// @success 200 {object} describeResourceResponse "Success"
|
||||||
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
|
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||||
|
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||||
|
// @failure 500 "Server error occurred while attempting to retrieve resource description"
|
||||||
|
// @router /kubernetes/{id}/describe [get]
|
||||||
|
func (handler *Handler) describeResource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
name, err := request.RetrieveQueryParameter(r, "name", false)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter name")
|
||||||
|
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter name. Error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kind, err := request.RetrieveQueryParameter(r, "kind", false)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter kind")
|
||||||
|
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter kind. Error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace, err := request.RetrieveQueryParameter(r, "namespace", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter namespace")
|
||||||
|
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter namespace. Error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetches the token and the correct server URL for the endpoint, similar to getHelmClusterAccess
|
||||||
|
libKubectlAccess, err := handler.getLibKubectlAccess(r)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to get libKubectlAccess. Error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := libkubectl.NewClient(libKubectlAccess, namespace, "", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to create kubernetes client")
|
||||||
|
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to create kubernetes client. Error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := client.Describe(namespace, name, kind)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to describe kubernetes resource")
|
||||||
|
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to describe kubernetes resource. Error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, describeResourceResponse{Describe: out})
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
"github.com/portainer/portainer/pkg/libkubectl"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -102,6 +103,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||||
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
|
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
|
||||||
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
|
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
|
||||||
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
|
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
|
||||||
|
endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet)
|
||||||
|
|
||||||
// namespaces
|
// namespaces
|
||||||
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
|
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
|
||||||
|
@ -269,3 +271,36 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) getLibKubectlAccess(r *http.Request) (*libkubectl.ClientAccess, error) {
|
||||||
|
tokenData, err := security.RetrieveTokenData(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bearerToken, _, err := handler.JwtService.GenerateToken(tokenData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httperror.Unauthorized("Unauthorized", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := middlewares.FetchEndpoint(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httperror.InternalServerError("Unable to find the Kubernetes endpoint associated to the request.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sslSettings, err := handler.DataStore.SSLSettings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostURL := "localhost"
|
||||||
|
if !sslSettings.SelfSigned {
|
||||||
|
hostURL = r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
|
||||||
|
return &libkubectl.ClientAccess{
|
||||||
|
Token: bearerToken,
|
||||||
|
ServerUrl: kubeConfigInternal.ClusterServerURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ interface Props extends AutomationTestingProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
type?: Type;
|
type?: Type;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
value: string;
|
value: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
versions?: number[];
|
versions?: number[];
|
||||||
|
@ -136,7 +136,7 @@ function schemaValidationExtensions(schema: JSONSchema7) {
|
||||||
|
|
||||||
export function CodeEditor({
|
export function CodeEditor({
|
||||||
id,
|
id,
|
||||||
onChange,
|
onChange = () => {},
|
||||||
placeholder,
|
placeholder,
|
||||||
readonly,
|
readonly,
|
||||||
value,
|
value,
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export { NavTabs } from './NavTabs';
|
export { NavTabs } from './NavTabs';
|
||||||
|
export type { Option } from './NavTabs';
|
||||||
|
|
|
@ -3,6 +3,56 @@ import { AriaAttributes, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { Icon, IconProps } from '@@/Icon';
|
import { Icon, IconProps } from '@@/Icon';
|
||||||
|
|
||||||
|
export type StatusBadgeType =
|
||||||
|
| 'success'
|
||||||
|
| 'danger'
|
||||||
|
| 'warning'
|
||||||
|
| 'info'
|
||||||
|
| 'successLite'
|
||||||
|
| 'dangerLite'
|
||||||
|
| 'warningLite'
|
||||||
|
| 'mutedLite'
|
||||||
|
| 'infoLite'
|
||||||
|
| 'default';
|
||||||
|
|
||||||
|
const typeClasses: Record<StatusBadgeType, string> = {
|
||||||
|
success: clsx(
|
||||||
|
'text-white bg-success-7',
|
||||||
|
'th-dark:text-white th-dark:bg-success-9'
|
||||||
|
),
|
||||||
|
warning: clsx(
|
||||||
|
'text-white bg-warning-7',
|
||||||
|
'th-dark:text-white th-dark:bg-warning-9'
|
||||||
|
),
|
||||||
|
danger: clsx(
|
||||||
|
'text-white bg-error-7',
|
||||||
|
'th-dark:text-white th-dark:bg-error-9'
|
||||||
|
),
|
||||||
|
info: clsx('text-white bg-blue-7', 'th-dark:text-white th-dark:bg-blue-9'),
|
||||||
|
// the lite classes are a bit lighter in light mode and the same in dark mode
|
||||||
|
successLite: clsx(
|
||||||
|
'text-success-9 bg-success-3',
|
||||||
|
'th-dark:text-white th-dark:bg-success-9'
|
||||||
|
),
|
||||||
|
warningLite: clsx(
|
||||||
|
'text-warning-9 bg-warning-3',
|
||||||
|
'th-dark:text-white th-dark:bg-warning-9'
|
||||||
|
),
|
||||||
|
dangerLite: clsx(
|
||||||
|
'text-error-9 bg-error-3',
|
||||||
|
'th-dark:text-white th-dark:bg-error-9'
|
||||||
|
),
|
||||||
|
mutedLite: clsx(
|
||||||
|
'text-gray-9 bg-gray-3',
|
||||||
|
'th-dark:text-white th-dark:bg-gray-9'
|
||||||
|
),
|
||||||
|
infoLite: clsx(
|
||||||
|
'text-blue-9 bg-blue-3',
|
||||||
|
'th-dark:text-white th-dark:bg-blue-9'
|
||||||
|
),
|
||||||
|
default: '',
|
||||||
|
};
|
||||||
|
|
||||||
export function StatusBadge({
|
export function StatusBadge({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
@ -12,7 +62,7 @@ export function StatusBadge({
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
{
|
{
|
||||||
className?: string;
|
className?: string;
|
||||||
color?: 'success' | 'danger' | 'warning' | 'info' | 'default';
|
color?: StatusBadgeType;
|
||||||
icon?: IconProps['icon'];
|
icon?: IconProps['icon'];
|
||||||
} & AriaAttributes
|
} & AriaAttributes
|
||||||
>) {
|
>) {
|
||||||
|
@ -21,13 +71,8 @@ export function StatusBadge({
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'inline-flex items-center gap-1 rounded',
|
'inline-flex items-center gap-1 rounded',
|
||||||
'w-fit px-1.5 py-0.5',
|
'w-fit px-1.5 py-0.5',
|
||||||
'text-sm font-medium text-white',
|
'text-sm font-medium',
|
||||||
{
|
typeClasses[color],
|
||||||
'bg-success-7 th-dark:bg-success-9': color === 'success',
|
|
||||||
'bg-warning-7 th-dark:bg-warning-9': color === 'warning',
|
|
||||||
'bg-error-7 th-dark:bg-error-9': color === 'danger',
|
|
||||||
'bg-blue-9': color === 'info',
|
|
||||||
},
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
|
|
@ -70,6 +70,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||||
getRowCanExpand?(row: Row<D>): boolean;
|
getRowCanExpand?(row: Row<D>): boolean;
|
||||||
noWidget?: boolean;
|
noWidget?: boolean;
|
||||||
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
||||||
|
includeSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Datatable<D extends DefaultType>({
|
export function Datatable<D extends DefaultType>({
|
||||||
|
@ -98,6 +99,7 @@ export function Datatable<D extends DefaultType>({
|
||||||
totalCount = dataset.length,
|
totalCount = dataset.length,
|
||||||
isServerSidePagination = false,
|
isServerSidePagination = false,
|
||||||
extendTableOptions = (value) => value,
|
extendTableOptions = (value) => value,
|
||||||
|
includeSearch,
|
||||||
}: Props<D> & PaginationProps) {
|
}: Props<D> & PaginationProps) {
|
||||||
const pageCount = useMemo(
|
const pageCount = useMemo(
|
||||||
() => Math.ceil(totalCount / settings.pageSize),
|
() => Math.ceil(totalCount / settings.pageSize),
|
||||||
|
@ -192,6 +194,7 @@ export function Datatable<D extends DefaultType>({
|
||||||
renderTableActions={() => renderTableActions(selectedItems)}
|
renderTableActions={() => renderTableActions(selectedItems)}
|
||||||
renderTableSettings={() => renderTableSettings(tableInstance)}
|
renderTableSettings={() => renderTableSettings(tableInstance)}
|
||||||
data-cy={`${dataCy}-header`}
|
data-cy={`${dataCy}-header`}
|
||||||
|
includeSearch={includeSearch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DatatableContent<D>
|
<DatatableContent<D>
|
||||||
|
|
|
@ -16,6 +16,7 @@ type Props = {
|
||||||
renderTableActions?(): ReactNode;
|
renderTableActions?(): ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
titleId?: string;
|
titleId?: string;
|
||||||
|
includeSearch?: boolean;
|
||||||
} & AutomationTestingProps;
|
} & AutomationTestingProps;
|
||||||
|
|
||||||
export function DatatableHeader({
|
export function DatatableHeader({
|
||||||
|
@ -28,8 +29,9 @@ export function DatatableHeader({
|
||||||
description,
|
description,
|
||||||
titleId,
|
titleId,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
|
includeSearch = !!title,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (!title) {
|
if (!title && !includeSearch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,12 +52,12 @@ export function DatatableHeader({
|
||||||
return (
|
return (
|
||||||
<Table.Title
|
<Table.Title
|
||||||
id={titleId}
|
id={titleId}
|
||||||
label={title}
|
label={title ?? ''}
|
||||||
icon={titleIcon}
|
icon={titleIcon}
|
||||||
description={description}
|
description={description}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
>
|
>
|
||||||
{searchBar}
|
{includeSearch && searchBar}
|
||||||
{tableActions}
|
{tableActions}
|
||||||
{tableTitleSettings}
|
{tableTitleSettings}
|
||||||
</Table.Title>
|
</Table.Title>
|
||||||
|
|
|
@ -47,10 +47,14 @@ export function Modal({
|
||||||
<DialogContent
|
<DialogContent
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
className={clsx(styles.modalDialog, 'bg-transparent p-0', {
|
className={clsx(
|
||||||
'w-[450px]': size === 'md',
|
styles.modalDialog,
|
||||||
'w-[700px]': size === 'lg',
|
'max-w-[calc(100vw-2rem)] bg-transparent p-0',
|
||||||
})}
|
{
|
||||||
|
'w-[450px]': size === 'md',
|
||||||
|
'w-[700px]': size === 'lg',
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className={clsx(styles.modalContent, 'relative', className)}>
|
<div className={clsx(styles.modalContent, 'relative', className)}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { server, http } from '@/setup-tests/server';
|
||||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
|
||||||
|
|
||||||
import { HelmApplicationView } from './HelmApplicationView';
|
import { HelmApplicationView } from './HelmApplicationView';
|
||||||
|
|
||||||
|
@ -22,6 +23,41 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
useEnvironmentId: () => mockUseEnvironmentId(),
|
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mockCodeMirror();
|
||||||
|
|
||||||
|
const minimalHelmRelease = {
|
||||||
|
name: 'test-release',
|
||||||
|
version: '1',
|
||||||
|
namespace: 'default',
|
||||||
|
chart: {
|
||||||
|
metadata: {
|
||||||
|
name: 'test-chart',
|
||||||
|
// appVersion: '1.0.0', // can be missing for a minimal release
|
||||||
|
version: '2.2.2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
status: 'deployed',
|
||||||
|
// notes: 'This is a test note', // can be missing for a minimal release
|
||||||
|
},
|
||||||
|
manifest: 'This is a test manifest',
|
||||||
|
};
|
||||||
|
|
||||||
|
const helmReleaseWithAdditionalDetails = {
|
||||||
|
...minimalHelmRelease,
|
||||||
|
info: {
|
||||||
|
...minimalHelmRelease.info,
|
||||||
|
notes: 'This is a test note',
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
...minimalHelmRelease.chart,
|
||||||
|
metadata: {
|
||||||
|
...minimalHelmRelease.chart.metadata,
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function renderComponent() {
|
function renderComponent() {
|
||||||
const user = new UserViewModel({ Username: 'user' });
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
const Wrapped = withTestQueryProvider(
|
const Wrapped = withTestQueryProvider(
|
||||||
|
@ -40,47 +76,52 @@ describe('HelmApplicationView', () => {
|
||||||
namespace: 'default',
|
namespace: 'default',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up default mock API responses
|
|
||||||
server.use(
|
|
||||||
http.get('/api/endpoints/3/kubernetes/helm', () =>
|
|
||||||
HttpResponse.json([
|
|
||||||
{
|
|
||||||
name: 'test-release',
|
|
||||||
chart: 'test-chart-1.0.0',
|
|
||||||
app_version: '1.0.0',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display helm release details when data is loaded', async () => {
|
it('should display helm release details for minimal release when data is loaded', async () => {
|
||||||
renderComponent();
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||||
|
HttpResponse.json(minimalHelmRelease)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { findByText, findAllByText } = renderComponent();
|
||||||
|
|
||||||
// Check for the page header
|
// Check for the page header
|
||||||
expect(await screen.findByText('Helm details')).toBeInTheDocument();
|
expect(await findByText('Helm details')).toBeInTheDocument();
|
||||||
|
|
||||||
// Check for the release details
|
// Check for the badge content
|
||||||
expect(await screen.findByText('Release')).toBeInTheDocument();
|
expect(await findByText(/Namespace/)).toBeInTheDocument();
|
||||||
|
expect(await findByText(/Chart version:/)).toBeInTheDocument();
|
||||||
// Check for the table content
|
expect(await findByText(/Chart:/)).toBeInTheDocument();
|
||||||
expect(await screen.findByText('Name')).toBeInTheDocument();
|
expect(await findByText(/Revision/)).toBeInTheDocument();
|
||||||
expect(await screen.findByText('Chart')).toBeInTheDocument();
|
|
||||||
expect(await screen.findByText('App version')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check for the actual values
|
// Check for the actual values
|
||||||
expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent(
|
expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
|
||||||
'test-release'
|
expect(await findAllByText(/test-chart/)).toHaveLength(2);
|
||||||
);
|
|
||||||
expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument();
|
// There shouldn't be a notes tab when there are no notes
|
||||||
expect(await screen.findByText('1.0.0')).toBeInTheDocument();
|
expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// There shouldn't be an app version badge when it's missing
|
||||||
|
expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Ensure there are no console errors
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
expect(console.error).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Restore console.error
|
||||||
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display error message when API request fails', async () => {
|
it('should display error message when API request fails', async () => {
|
||||||
// Mock API failure
|
// Mock API failure
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error())
|
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||||
|
HttpResponse.error()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mock console.error to prevent test output pollution
|
// Mock console.error to prevent test output pollution
|
||||||
|
@ -97,23 +138,20 @@ describe('HelmApplicationView', () => {
|
||||||
vi.spyOn(console, 'error').mockRestore();
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display error message when release is not found', async () => {
|
it('should display additional details when available in helm release', async () => {
|
||||||
// Mock empty response (no releases found)
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([]))
|
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||||
|
HttpResponse.json(helmReleaseWithAdditionalDetails)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mock console.error to prevent test output pollution
|
const { findByText } = renderComponent();
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
renderComponent();
|
// Check for the notes tab when notes are available
|
||||||
|
expect(await findByText(/Notes/)).toBeInTheDocument();
|
||||||
|
|
||||||
// Wait for the error message to appear
|
// Check for the app version badge when it's available
|
||||||
expect(
|
expect(await findByText(/App version/)).toBeInTheDocument();
|
||||||
await screen.findByText('Failed to load Helm application details')
|
expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Restore console.error
|
|
||||||
vi.spyOn(console, 'error').mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +1,21 @@
|
||||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
|
||||||
|
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||||
import { PageHeader } from '@/react/components/PageHeader';
|
import { PageHeader } from '@/react/components/PageHeader';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { HelmDetailsWidget } from './HelmDetailsWidget';
|
import { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
|
||||||
|
import { Card } from '@@/Card';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
|
import { HelmSummary } from './HelmSummary';
|
||||||
|
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
||||||
|
import { useHelmRelease } from './queries/useHelmRelease';
|
||||||
|
|
||||||
export function HelmApplicationView() {
|
export function HelmApplicationView() {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
const { params } = useCurrentStateAndParams();
|
const { params } = useCurrentStateAndParams();
|
||||||
|
|
||||||
const { name, namespace } = params;
|
const { name, namespace } = params;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -22,9 +31,58 @@ export function HelmApplicationView() {
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<HelmDetailsWidget name={name} namespace={namespace} />
|
<Widget>
|
||||||
|
{name && <WidgetTitle icon={helm} title={name} />}
|
||||||
|
<WidgetBody className="!pt-1">
|
||||||
|
<HelmDetails
|
||||||
|
name={name}
|
||||||
|
namespace={namespace}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HelmDetailsProps = {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
|
||||||
|
const {
|
||||||
|
data: release,
|
||||||
|
isInitialLoading,
|
||||||
|
isError,
|
||||||
|
} = useHelmRelease(environmentId, name, namespace, {
|
||||||
|
showResources: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isInitialLoading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert color="error" title="Failed to load Helm application details" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
return <Alert color="error" title="No Helm application details found" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HelmSummary release={release} />
|
||||||
|
<div className="my-6 h-[1px] w-full bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-white" />
|
||||||
|
<Card className="bg-inherit">
|
||||||
|
<ReleaseTabs release={release} />
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
import {
|
|
||||||
Loading,
|
|
||||||
Widget,
|
|
||||||
WidgetBody,
|
|
||||||
WidgetTitle,
|
|
||||||
} from '@/react/components/Widget';
|
|
||||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
|
||||||
|
|
||||||
import { Alert } from '@@/Alert';
|
|
||||||
|
|
||||||
import { useHelmRelease } from './queries/useHelmRelease';
|
|
||||||
|
|
||||||
interface HelmDetailsWidgetProps {
|
|
||||||
name: string;
|
|
||||||
namespace: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HelmDetailsWidget({ name, namespace }: HelmDetailsWidgetProps) {
|
|
||||||
const environmentId = useEnvironmentId();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: release,
|
|
||||||
isInitialLoading,
|
|
||||||
isError,
|
|
||||||
} = useHelmRelease(environmentId, name, namespace);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Widget>
|
|
||||||
<WidgetTitle icon={helm} title="Release" />
|
|
||||||
<WidgetBody>
|
|
||||||
{isInitialLoading && <Loading />}
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<Alert
|
|
||||||
color="error"
|
|
||||||
title="Failed to load Helm application details"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isInitialLoading && !isError && release && (
|
|
||||||
<table className="table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td className="!border-none w-40">Name</td>
|
|
||||||
<td
|
|
||||||
className="!border-none min-w-[140px]"
|
|
||||||
data-cy="k8sAppDetail-appName"
|
|
||||||
>
|
|
||||||
{release.name}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="!border-t">Chart</td>
|
|
||||||
<td className="!border-t">{release.chart}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>App version</td>
|
|
||||||
<td>{release.app_version}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</WidgetBody>
|
|
||||||
</Widget>
|
|
||||||
);
|
|
||||||
}
|
|
111
app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
Normal file
111
app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { Badge } from '@/react/components/Badge';
|
||||||
|
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
release: HelmRelease;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DeploymentStatus {
|
||||||
|
DEPLOYED = 'deployed',
|
||||||
|
FAILED = 'failed',
|
||||||
|
PENDING = 'pending-install',
|
||||||
|
PENDINGUPGRADE = 'pending-upgrade',
|
||||||
|
PENDINGROLLBACK = 'pending-rollback',
|
||||||
|
SUPERSEDED = 'superseded',
|
||||||
|
UNINSTALLED = 'uninstalled',
|
||||||
|
UNINSTALLING = 'uninstalling',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelmSummary({ release }: Props) {
|
||||||
|
const isSuccess =
|
||||||
|
release.info?.status === DeploymentStatus.DEPLOYED ||
|
||||||
|
release.info?.status === DeploymentStatus.SUPERSEDED;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div>
|
||||||
|
<Badge type={getStatusColor(release.info?.status)}>
|
||||||
|
{getText(release.info?.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{!!release.namespace && <Badge>Namespace: {release.namespace}</Badge>}
|
||||||
|
{!!release.version && <Badge>Revision: #{release.version}</Badge>}
|
||||||
|
{!!release.chart?.metadata?.name && (
|
||||||
|
<Badge>Chart: {release.chart.metadata.name}</Badge>
|
||||||
|
)}
|
||||||
|
{!!release.chart?.metadata?.appVersion && (
|
||||||
|
<Badge>App version: {release.chart.metadata.appVersion}</Badge>
|
||||||
|
)}
|
||||||
|
{!!release.chart?.metadata?.version && (
|
||||||
|
<Badge>
|
||||||
|
Chart version: {release.chart.metadata.name}-
|
||||||
|
{release.chart.metadata.version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!!release.info?.description && !isSuccess && (
|
||||||
|
<Alert color={getAlertColor(release.info?.status)}>
|
||||||
|
{release.info?.description}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertColor(status?: string) {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case DeploymentStatus.DEPLOYED:
|
||||||
|
return 'success';
|
||||||
|
case DeploymentStatus.FAILED:
|
||||||
|
return 'error';
|
||||||
|
case DeploymentStatus.PENDING:
|
||||||
|
case DeploymentStatus.PENDINGUPGRADE:
|
||||||
|
case DeploymentStatus.PENDINGROLLBACK:
|
||||||
|
case DeploymentStatus.UNINSTALLING:
|
||||||
|
return 'warn';
|
||||||
|
case DeploymentStatus.SUPERSEDED:
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status?: string) {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case DeploymentStatus.DEPLOYED:
|
||||||
|
return 'success';
|
||||||
|
case DeploymentStatus.FAILED:
|
||||||
|
return 'danger';
|
||||||
|
case DeploymentStatus.PENDING:
|
||||||
|
case DeploymentStatus.PENDINGUPGRADE:
|
||||||
|
case DeploymentStatus.PENDINGROLLBACK:
|
||||||
|
case DeploymentStatus.UNINSTALLING:
|
||||||
|
return 'warn';
|
||||||
|
case DeploymentStatus.SUPERSEDED:
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getText(status?: string) {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case DeploymentStatus.DEPLOYED:
|
||||||
|
return 'Deployed';
|
||||||
|
case DeploymentStatus.FAILED:
|
||||||
|
return 'Failed';
|
||||||
|
case DeploymentStatus.PENDING:
|
||||||
|
case DeploymentStatus.PENDINGUPGRADE:
|
||||||
|
case DeploymentStatus.PENDINGROLLBACK:
|
||||||
|
case DeploymentStatus.UNINSTALLING:
|
||||||
|
return 'Pending';
|
||||||
|
case DeploymentStatus.SUPERSEDED:
|
||||||
|
return 'Superseded';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
manifest: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ManifestDetails({ manifest }: Props) {
|
||||||
|
return (
|
||||||
|
<CodeEditor
|
||||||
|
id="helm-manifest"
|
||||||
|
type="yaml"
|
||||||
|
data-cy="helm-manifest"
|
||||||
|
value={manifest}
|
||||||
|
height="600px"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Markdown from 'markdown-to-jsx';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotesDetails({ notes }: Props) {
|
||||||
|
return <Markdown className="list-inside mt-6">{notes}</Markdown>;
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
|
||||||
|
import { NavTabs, Option } from '@@/NavTabs';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
|
import { ManifestDetails } from './ManifestDetails';
|
||||||
|
import { NotesDetails } from './NotesDetails';
|
||||||
|
import { ValuesDetails } from './ValuesDetails';
|
||||||
|
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
release: HelmRelease;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = 'values' | 'notes' | 'manifest' | 'resources';
|
||||||
|
|
||||||
|
function helmTabs(
|
||||||
|
release: HelmRelease,
|
||||||
|
isUserSupplied: boolean,
|
||||||
|
setIsUserSupplied: (isUserSupplied: boolean) => void
|
||||||
|
): Option<Tab>[] {
|
||||||
|
return compact([
|
||||||
|
{
|
||||||
|
label: 'Resources',
|
||||||
|
id: 'resources',
|
||||||
|
children: <ResourcesTable resources={release.info?.resources ?? []} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Values',
|
||||||
|
id: 'values',
|
||||||
|
children: (
|
||||||
|
<ValuesDetails
|
||||||
|
values={release.values}
|
||||||
|
isUserSupplied={isUserSupplied}
|
||||||
|
setIsUserSupplied={setIsUserSupplied}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Manifest',
|
||||||
|
id: 'manifest',
|
||||||
|
children: <ManifestDetails manifest={release.manifest} />,
|
||||||
|
},
|
||||||
|
!!release.info?.notes && {
|
||||||
|
label: 'Notes',
|
||||||
|
id: 'notes',
|
||||||
|
children: <NotesDetails notes={release.info.notes} />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReleaseTabs({ release }: Props) {
|
||||||
|
const [tab, setTab] = useState<Tab>('resources');
|
||||||
|
// state is here so that the state isn't lost when the tab changes
|
||||||
|
const [isUserSupplied, setIsUserSupplied] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavTabs<Tab>
|
||||||
|
onSelect={setTab}
|
||||||
|
selectedId={tab}
|
||||||
|
type="pills"
|
||||||
|
justified
|
||||||
|
options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
|
||||||
|
import { DescribeModal } from './DescribeModal';
|
||||||
|
|
||||||
|
const mockUseDescribeResource = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('yaml-schema', () => ({}));
|
||||||
|
vi.mock('./queries/useDescribeResource', () => ({
|
||||||
|
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderComponent({
|
||||||
|
name = 'test-resource',
|
||||||
|
resourceType = 'Deployment',
|
||||||
|
namespace = 'default',
|
||||||
|
onDismiss = vi.fn(),
|
||||||
|
} = {}) {
|
||||||
|
const Wrapped = withTestQueryProvider(DescribeModal);
|
||||||
|
return render(
|
||||||
|
<Wrapped
|
||||||
|
name={name}
|
||||||
|
resourceType={resourceType}
|
||||||
|
namespace={namespace}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DescribeModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseDescribeResource.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display loading state initially', () => {
|
||||||
|
mockUseDescribeResource.mockReturnValue({
|
||||||
|
isLoading: true,
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display resource details when data is loaded successfully', () => {
|
||||||
|
const mockDescribeData = {
|
||||||
|
describe: 'Name: test-resource\nNamespace: default\nStatus: Running',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseDescribeResource.mockReturnValue({
|
||||||
|
isLoading: false,
|
||||||
|
data: mockDescribeData,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Check for modal title
|
||||||
|
expect(screen.getByText('Describe Deployment')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for content
|
||||||
|
const editor = screen.getByTestId('describe-resource');
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
expect(editor).toHaveTextContent('Name: test-resource');
|
||||||
|
expect(editor).toHaveTextContent('Namespace: default');
|
||||||
|
expect(editor).toHaveTextContent('Status: Running');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display error message when query fails', () => {
|
||||||
|
mockUseDescribeResource.mockReturnValue({
|
||||||
|
isLoading: false,
|
||||||
|
data: undefined,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Error loading resource details')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onDismiss when modal is closed', () => {
|
||||||
|
mockUseDescribeResource.mockReturnValue({
|
||||||
|
isLoading: false,
|
||||||
|
data: { describe: '' },
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
renderComponent({ onDismiss });
|
||||||
|
|
||||||
|
// Find and click the close button
|
||||||
|
const closeButton = screen.getByText('×');
|
||||||
|
closeButton.click();
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass correct parameters to useDescribeResource', () => {
|
||||||
|
mockUseDescribeResource.mockReturnValue({
|
||||||
|
isLoading: true,
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
name: 'my-resource',
|
||||||
|
resourceType: 'Pod',
|
||||||
|
namespace: 'kube-system',
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent(props);
|
||||||
|
|
||||||
|
expect(mockUseDescribeResource).toHaveBeenCalledWith(
|
||||||
|
props.name,
|
||||||
|
props.resourceType,
|
||||||
|
props.namespace
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
import { Modal } from '@@/modals';
|
||||||
|
import { ModalBody } from '@@/modals/Modal/ModalBody';
|
||||||
|
import { ModalHeader } from '@@/modals/Modal/ModalHeader';
|
||||||
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
|
||||||
|
import { useDescribeResource } from './queries/useDescribeResource';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
resourceType?: string;
|
||||||
|
namespace?: string;
|
||||||
|
onDismiss: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DescribeModal({
|
||||||
|
name,
|
||||||
|
resourceType,
|
||||||
|
namespace,
|
||||||
|
onDismiss,
|
||||||
|
}: Props) {
|
||||||
|
const title = `Describe ${resourceType}`;
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useDescribeResource(
|
||||||
|
name,
|
||||||
|
resourceType,
|
||||||
|
namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onDismiss={onDismiss} size="lg" aria-label={title}>
|
||||||
|
<ModalHeader title={title} />
|
||||||
|
<ModalBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<InlineLoader>Loading...</InlineLoader>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isError ? (
|
||||||
|
<Alert color="error" title="Error">
|
||||||
|
Error loading resource details
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<CodeEditor
|
||||||
|
id="describe-resource"
|
||||||
|
data-cy="describe-resource"
|
||||||
|
readonly
|
||||||
|
value={data?.describe}
|
||||||
|
type="yaml"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
|
||||||
|
import { GenericResource } from '../../../types';
|
||||||
|
|
||||||
|
import { ResourcesTable } from './ResourcesTable';
|
||||||
|
|
||||||
|
const successResources = [
|
||||||
|
{
|
||||||
|
kind: 'ValidatingWebhookConfiguration',
|
||||||
|
apiVersion: 'admissionregistration.k8s.io/v1',
|
||||||
|
metadata: {
|
||||||
|
name: 'ingress-nginx-1743063493-admission',
|
||||||
|
uid: 'e5388792-c184-479d-9133-390759c4bded',
|
||||||
|
labels: {
|
||||||
|
'app.kubernetes.io/name': 'ingress-nginx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'Exists',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Deployment',
|
||||||
|
apiVersion: 'apps/v1',
|
||||||
|
metadata: {
|
||||||
|
name: 'ingress-nginx-1743063493-controller2',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'dcfe325b-7065-47ed-91e3-47f60301cf2e',
|
||||||
|
labels: {
|
||||||
|
'app.kubernetes.io/name': 'ingress-nginx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'MinimumReplicasAvailable',
|
||||||
|
message: 'Deployment has minimum availability.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Pod',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
metadata: {
|
||||||
|
name: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-lsf9p',
|
||||||
|
generateName: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: '7176ad7c-0f83-4a65-a45e-d40076adc302',
|
||||||
|
labels: {
|
||||||
|
'app.kubernetes.io/name': 'ingress-nginx',
|
||||||
|
'pod-template-hash': '54d8f7d8c5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
phase: 'Running',
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Unknown',
|
||||||
|
reason: 'Running',
|
||||||
|
},
|
||||||
|
hostIP: '198.19.249.2',
|
||||||
|
startTime: '2025-03-27T20:39:05Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const failedResources = [
|
||||||
|
{
|
||||||
|
kind: 'PodDisruptionBudget',
|
||||||
|
metadata: {
|
||||||
|
name: 'probe-failure-nginx-bad',
|
||||||
|
namespace: 'my-namespace',
|
||||||
|
uid: 'e4e15f7a-9a68-448e-86b3-d74ef29c718c',
|
||||||
|
labels: {
|
||||||
|
'app.kubernetes.io/name': 'nginx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Unhealthy',
|
||||||
|
reason: 'InsufficientPods',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Service',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
metadata: {
|
||||||
|
name: 'probe-failure-nginx',
|
||||||
|
namespace: 'my-namespace',
|
||||||
|
uid: 'de9cdffc-6af8-43b2-9750-3ac764b25627',
|
||||||
|
labels: {
|
||||||
|
'app.kubernetes.io/name': 'nginx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'Exists',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderResourcesTable(resources: GenericResource[]) {
|
||||||
|
const Wrapped = withTestQueryProvider(withTestRouter(ResourcesTable));
|
||||||
|
return render(<Wrapped resources={resources} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResourcesTable', () => {
|
||||||
|
it('should show successful resources, including a link for the deployment and a message', () => {
|
||||||
|
renderResourcesTable(successResources);
|
||||||
|
|
||||||
|
// Check that the deployment is rendered with a link
|
||||||
|
const deploymentLink = screen.getByText(
|
||||||
|
'ingress-nginx-1743063493-controller2'
|
||||||
|
);
|
||||||
|
expect(deploymentLink).toBeInTheDocument();
|
||||||
|
expect(deploymentLink.closest('a')).toHaveTextContent(
|
||||||
|
'ingress-nginx-1743063493-controller2'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that success badge is rendered
|
||||||
|
const successBadge = screen.getByText('MinimumReplicasAvailable');
|
||||||
|
expect(successBadge).toBeInTheDocument();
|
||||||
|
expect(successBadge.className).toContain('bg-success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error badges for failed resources', () => {
|
||||||
|
renderResourcesTable(failedResources);
|
||||||
|
expect(screen.getByText('probe-failure-nginx-bad')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the unhealthy status badge and make sure it has the error styling
|
||||||
|
const errorBadge = screen.getByText('InsufficientPods');
|
||||||
|
expect(errorBadge).toBeInTheDocument();
|
||||||
|
expect(errorBadge.className).toContain('bg-error');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Datatable } from '@@/datatables';
|
||||||
|
import { createPersistedStore } from '@@/datatables/types';
|
||||||
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
|
||||||
|
import { GenericResource } from '../../../types';
|
||||||
|
|
||||||
|
import { columns } from './columns';
|
||||||
|
import { useResourceRows } from './useResourceRows';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resources: GenericResource[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const storageKey = 'helm-resources';
|
||||||
|
const settingsStore = createPersistedStore(storageKey, 'resourceType');
|
||||||
|
|
||||||
|
export function ResourcesTable({ resources }: Props) {
|
||||||
|
const tableState = useTableState(settingsStore, storageKey);
|
||||||
|
const rows = useResourceRows(resources);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget>
|
||||||
|
<Datatable
|
||||||
|
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
|
||||||
|
noWidget
|
||||||
|
dataset={rows}
|
||||||
|
columns={columns}
|
||||||
|
includeSearch
|
||||||
|
settingsManager={tableState}
|
||||||
|
emptyContentLabel="No resources found"
|
||||||
|
disableSelect
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
data-cy="helm-resources-datatable"
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CellContext } from '@tanstack/react-table';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
import { ResourceRow } from '../types';
|
||||||
|
import { DescribeModal } from '../DescribeModal';
|
||||||
|
|
||||||
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
export const actions = columnHelper.accessor((row) => row.status.label, {
|
||||||
|
header: 'Actions',
|
||||||
|
id: 'actions',
|
||||||
|
cell: Cell,
|
||||||
|
enableSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||||
|
const { describe } = row.original;
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
color="link"
|
||||||
|
data-cy="helm-resource-describe"
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="pl-0 !ml-0"
|
||||||
|
>
|
||||||
|
<Icon icon={FileText} />
|
||||||
|
Describe
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{modalOpen && (
|
||||||
|
<DescribeModal
|
||||||
|
name={describe.name}
|
||||||
|
resourceType={describe.resourceType}
|
||||||
|
namespace={describe.namespace}
|
||||||
|
onDismiss={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { ResourceRow } from '../types';
|
||||||
|
|
||||||
|
export const columnHelper = createColumnHelper<ResourceRow>();
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { name } from './name';
|
||||||
|
import { resourceType } from './resourceType';
|
||||||
|
import { status } from './status';
|
||||||
|
import { statusMessage } from './statusMessage';
|
||||||
|
import { actions } from './actions';
|
||||||
|
|
||||||
|
export const columns = [name, resourceType, status, statusMessage, actions];
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { CellContext } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { ResourceRow } from '../types';
|
||||||
|
|
||||||
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
export const name = columnHelper.accessor((row) => row.name.label, {
|
||||||
|
header: 'Name',
|
||||||
|
cell: Cell,
|
||||||
|
id: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
|
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||||
|
const { name } = row.original;
|
||||||
|
|
||||||
|
if (name.link && name.link.to) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={name.link.to}
|
||||||
|
params={name.link.params}
|
||||||
|
title={name.label}
|
||||||
|
className="w-fit max-w-xs truncate xl:max-w-sm 2xl:max-w-md"
|
||||||
|
data-cy={`helm-resource-link-${name.label}`}
|
||||||
|
>
|
||||||
|
{name.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.label;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
export const resourceType = columnHelper.accessor((row) => row.resourceType, {
|
||||||
|
header: 'Resource type',
|
||||||
|
id: 'resourceType',
|
||||||
|
});
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { CellContext } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { StatusBadge } from '@@/StatusBadge';
|
||||||
|
|
||||||
|
import { ResourceRow } from '../types';
|
||||||
|
|
||||||
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
export const status = columnHelper.accessor((row) => row.status.label, {
|
||||||
|
header: 'Status',
|
||||||
|
id: 'status',
|
||||||
|
cell: Cell,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||||
|
const { status } = row.original;
|
||||||
|
return <StatusBadge color={status.type}>{status.label}</StatusBadge>;
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
export const statusMessage = columnHelper.accessor((row) => row.statusMessage, {
|
||||||
|
header: 'Status message',
|
||||||
|
id: 'statusMessage',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="whitespace-pre-wrap">
|
||||||
|
<span>{row.original.statusMessage || '-'}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
type DescribeAPIParams = {
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
namespace?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DescribeResourceResponse = {
|
||||||
|
describe: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getDescribeResource(
|
||||||
|
environmentId: number,
|
||||||
|
name: string,
|
||||||
|
resourceType?: string,
|
||||||
|
namespace?: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// This should never happen, but to keep the linter happy...
|
||||||
|
if (!name || !resourceType) {
|
||||||
|
throw new Error('Name and kind are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: DescribeAPIParams = {
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
kind: resourceType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await axios.get<DescribeResourceResponse>(
|
||||||
|
`kubernetes/${environmentId}/describe`,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to retrieve resource details');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDescribeResource(
|
||||||
|
name: string,
|
||||||
|
resourceType?: string,
|
||||||
|
namespace?: string
|
||||||
|
) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'kubernetes', 'describe', namespace, resourceType, name],
|
||||||
|
() => getDescribeResource(environmentId, name, resourceType, namespace),
|
||||||
|
{
|
||||||
|
enabled: !!environmentId && !!name && !!resourceType,
|
||||||
|
...withGlobalError('Enable to retrieve data for resource'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { StatusBadgeType } from '@@/StatusBadge';
|
||||||
|
|
||||||
|
export type ResourceLink = {
|
||||||
|
to?: string;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceRow = {
|
||||||
|
// for the table row id
|
||||||
|
id: string;
|
||||||
|
// for the table row name (link to resource if available)
|
||||||
|
name: {
|
||||||
|
label: string;
|
||||||
|
link: ResourceLink | null;
|
||||||
|
};
|
||||||
|
resourceType: string;
|
||||||
|
describe: {
|
||||||
|
name: string;
|
||||||
|
resourceType?: string;
|
||||||
|
namespace?: string;
|
||||||
|
};
|
||||||
|
status: {
|
||||||
|
label: string;
|
||||||
|
type: StatusBadgeType;
|
||||||
|
};
|
||||||
|
statusMessage: string;
|
||||||
|
};
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { StatusBadgeType } from '@@/StatusBadge';
|
||||||
|
|
||||||
|
import { GenericResource } from '../../../types';
|
||||||
|
|
||||||
|
import { ResourceLink, ResourceRow } from './types';
|
||||||
|
|
||||||
|
// from defined routes in app/kubernetes/__module.js
|
||||||
|
const kindToUrlMap = {
|
||||||
|
Deployment: 'kubernetes.applications.application',
|
||||||
|
DaemonSet: 'kubernetes.applications.application',
|
||||||
|
StatefulSet: 'kubernetes.applications.application',
|
||||||
|
Pod: 'kubernetes.applications.application',
|
||||||
|
Ingress: 'kubernetes.ingresses',
|
||||||
|
ConfigMap: 'kubernetes.configmaps.configmap',
|
||||||
|
Secret: 'kubernetes.secrets.secret',
|
||||||
|
PersistentVolumeClaim: 'kubernetes.volumes.volume',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusToColorMap: Record<string, StatusBadgeType> = {
|
||||||
|
Healthy: 'success',
|
||||||
|
Progressing: 'warning',
|
||||||
|
Degraded: 'danger',
|
||||||
|
Failed: 'danger',
|
||||||
|
Unhealthy: 'danger',
|
||||||
|
Unknown: 'mutedLite',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useResourceRows(resources: GenericResource[]): ResourceRow[] {
|
||||||
|
return useMemo(() => getResourceRows(resources), [resources]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourceRows(resources: GenericResource[]): ResourceRow[] {
|
||||||
|
return resources.map(getResourceRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourceRow(resource: GenericResource): ResourceRow {
|
||||||
|
const {
|
||||||
|
reason = '',
|
||||||
|
status = '',
|
||||||
|
message = '',
|
||||||
|
} = resource.status.healthSummary || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${resource.kind}/${resource.metadata.name}/${resource.metadata.namespace}`,
|
||||||
|
name: {
|
||||||
|
label: resource.metadata.name,
|
||||||
|
link: getResourceLink(resource),
|
||||||
|
},
|
||||||
|
resourceType: resource.kind ?? '-',
|
||||||
|
describe: {
|
||||||
|
name: resource.metadata.name,
|
||||||
|
namespace: resource.metadata.namespace,
|
||||||
|
resourceType: resource.kind,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
label: reason ?? 'Unknown',
|
||||||
|
type: statusToColorMap[status] ?? 'default',
|
||||||
|
},
|
||||||
|
statusMessage: message ?? '-',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourceLink(resource: GenericResource): ResourceLink | null {
|
||||||
|
const { namespace, name } = resource.metadata;
|
||||||
|
|
||||||
|
const to = kindToUrlMap[resource.kind as keyof typeof kindToUrlMap];
|
||||||
|
|
||||||
|
// If the resource kind is not supported, return null
|
||||||
|
if (!to) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the resource is not namespaced, return the link to the resource with the name only
|
||||||
|
if (!namespace) {
|
||||||
|
return {
|
||||||
|
to,
|
||||||
|
params: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the resource is namespaced, return the link to the resource with the namespace and name
|
||||||
|
return {
|
||||||
|
to,
|
||||||
|
params: {
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
|
||||||
|
import { Values } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
values?: Values;
|
||||||
|
isUserSupplied: boolean;
|
||||||
|
setIsUserSupplied: (isUserSupplied: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noValuesMessage = 'No values found';
|
||||||
|
|
||||||
|
export function ValuesDetails({
|
||||||
|
values,
|
||||||
|
isUserSupplied,
|
||||||
|
setIsUserSupplied,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* bring in line with the code editor copy button */}
|
||||||
|
<div className="absolute top-1 left-0">
|
||||||
|
<Checkbox
|
||||||
|
label="User defined only"
|
||||||
|
id="values-details-user-supplied"
|
||||||
|
checked={isUserSupplied}
|
||||||
|
onChange={() => setIsUserSupplied(!isUserSupplied)}
|
||||||
|
data-cy="values-details-user-supplied"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
type="yaml"
|
||||||
|
id="values-details-code-editor"
|
||||||
|
data-cy="values-details-code-editor"
|
||||||
|
value={
|
||||||
|
isUserSupplied
|
||||||
|
? values?.userSuppliedValues ?? noValuesMessage
|
||||||
|
: values?.computedValues ?? noValuesMessage
|
||||||
|
}
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,55 +2,33 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
import PortainerError from 'Portainer/error';
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
interface HelmRelease {
|
import { HelmRelease } from '../../types';
|
||||||
name: string;
|
|
||||||
chart: string;
|
|
||||||
app_version: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* List all helm releases based on passed in options
|
|
||||||
* @param environmentId - Environment ID
|
|
||||||
* @param options - Options for filtering releases
|
|
||||||
* @returns List of helm releases
|
|
||||||
*/
|
|
||||||
export async function listReleases(
|
|
||||||
environmentId: EnvironmentId,
|
|
||||||
options: {
|
|
||||||
namespace?: string;
|
|
||||||
filter?: string;
|
|
||||||
selector?: string;
|
|
||||||
output?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<HelmRelease[]> {
|
|
||||||
try {
|
|
||||||
const { namespace, filter, selector, output } = options;
|
|
||||||
const url = `endpoints/${environmentId}/kubernetes/helm`;
|
|
||||||
const { data } = await axios.get<HelmRelease[]>(url, {
|
|
||||||
params: { namespace, filter, selector, output },
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to retrieve release list');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook to fetch a specific Helm release
|
* React hook to fetch a specific Helm release
|
||||||
*/
|
*/
|
||||||
export function useHelmRelease(
|
export function useHelmRelease<T = HelmRelease>(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
name: string,
|
name: string,
|
||||||
namespace: string
|
namespace: string,
|
||||||
|
options: {
|
||||||
|
select?: (data: HelmRelease) => T;
|
||||||
|
showResources?: boolean;
|
||||||
|
} = {}
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[environmentId, 'helm', namespace, name],
|
[environmentId, 'helm', 'releases', namespace, name, options.showResources],
|
||||||
() => getHelmRelease(environmentId, name, namespace),
|
() =>
|
||||||
|
getHelmRelease(environmentId, name, {
|
||||||
|
namespace,
|
||||||
|
showResources: options.showResources,
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
enabled: !!environmentId,
|
enabled: !!environmentId && !!name && !!namespace,
|
||||||
...withGlobalError('Unable to retrieve helm application details'),
|
...withGlobalError('Unable to retrieve helm application details'),
|
||||||
|
select: options.select,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -61,23 +39,20 @@ export function useHelmRelease(
|
||||||
async function getHelmRelease(
|
async function getHelmRelease(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
name: string,
|
name: string,
|
||||||
namespace: string
|
params: {
|
||||||
): Promise<HelmRelease> {
|
namespace: string;
|
||||||
|
showResources?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const releases = await listReleases(environmentId, {
|
const { data } = await axios.get<HelmRelease>(
|
||||||
filter: `^${name}$`,
|
`endpoints/${environmentId}/kubernetes/helm/${name}`,
|
||||||
namespace,
|
{
|
||||||
});
|
params,
|
||||||
|
}
|
||||||
if (releases.length > 0) {
|
|
||||||
return releases[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new PortainerError(`Release ${name} not found`);
|
|
||||||
} catch (err) {
|
|
||||||
throw new PortainerError(
|
|
||||||
'Unable to retrieve helm application details',
|
|
||||||
err as Error
|
|
||||||
);
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to retrieve helm application details');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,79 @@
|
||||||
|
interface ResourceStatus {
|
||||||
|
phase?: string;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
healthSummary?: {
|
||||||
|
status: string;
|
||||||
|
reason: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resource has a bunch of common fields that are shared by all kubernetes resources, so can be used when we're unsure about the resource type we have
|
||||||
|
export interface GenericResource {
|
||||||
|
apiVersion?: string;
|
||||||
|
kind?: string;
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
};
|
||||||
|
status: ResourceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmRelease {
|
||||||
|
/** The name of the release */
|
||||||
|
name: string;
|
||||||
|
/** Information about the release */
|
||||||
|
info?: {
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
description?: string;
|
||||||
|
resources?: GenericResource[];
|
||||||
|
};
|
||||||
|
/** The chart that was released */
|
||||||
|
chart: HelmChart;
|
||||||
|
/** Extra values added to the chart that override the default values */
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
/** String representation of the rendered template */
|
||||||
|
manifest: string;
|
||||||
|
/** All hooks declared for this release */
|
||||||
|
hooks?: unknown[];
|
||||||
|
/** Integer representing the revision of the release */
|
||||||
|
version?: number;
|
||||||
|
/** Kubernetes namespace of the release */
|
||||||
|
namespace?: string;
|
||||||
|
/** Values of the release */
|
||||||
|
values?: Values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
/** User supplied values */
|
||||||
|
userSuppliedValues?: string;
|
||||||
|
/** Computed values */
|
||||||
|
computedValues?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmChart {
|
||||||
|
/** Raw contents of the files originally contained in the chart archive. Only used in special cases like `helm show values` */
|
||||||
|
raw?: unknown[];
|
||||||
|
/** Contents of the Chartfile */
|
||||||
|
metadata?: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
};
|
||||||
|
/** Contents of Chart.lock */
|
||||||
|
lock?: unknown;
|
||||||
|
/** Templates for this chart */
|
||||||
|
templates?: unknown[];
|
||||||
|
/** Default config for this chart */
|
||||||
|
values?: Record<string, unknown>;
|
||||||
|
/** Optional JSON schema for imposing structure on Values */
|
||||||
|
schema?: unknown;
|
||||||
|
/** Miscellaneous files in a chart archive (e.g. README, LICENSE) */
|
||||||
|
files?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Chart extends HelmChartResponse {
|
export interface Chart extends HelmChartResponse {
|
||||||
repo: string;
|
repo: string;
|
||||||
}
|
}
|
||||||
|
|
7
go.mod
7
go.mod
|
@ -54,11 +54,14 @@ require (
|
||||||
golang.org/x/oauth2 v0.23.0
|
golang.org/x/oauth2 v0.23.0
|
||||||
golang.org/x/sync v0.11.0
|
golang.org/x/sync v0.11.0
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||||
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
helm.sh/helm/v3 v3.17.1
|
helm.sh/helm/v3 v3.17.1
|
||||||
k8s.io/api v0.32.1
|
k8s.io/api v0.32.1
|
||||||
k8s.io/apimachinery v0.32.1
|
k8s.io/apimachinery v0.32.1
|
||||||
|
k8s.io/cli-runtime v0.32.1
|
||||||
k8s.io/client-go v0.32.1
|
k8s.io/client-go v0.32.1
|
||||||
|
k8s.io/kubectl v0.32.1
|
||||||
k8s.io/metrics v0.32.1
|
k8s.io/metrics v0.32.1
|
||||||
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
|
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
|
||||||
)
|
)
|
||||||
|
@ -129,6 +132,7 @@ require (
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
|
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
|
||||||
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
|
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
|
||||||
|
github.com/fatih/camelcase v1.0.0 // indirect
|
||||||
github.com/fatih/color v1.15.0 // indirect
|
github.com/fatih/color v1.15.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsevents v0.2.0 // indirect
|
github.com/fsnotify/fsevents v0.2.0 // indirect
|
||||||
|
@ -288,14 +292,11 @@ require (
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
|
||||||
k8s.io/apiextensions-apiserver v0.32.1 // indirect
|
k8s.io/apiextensions-apiserver v0.32.1 // indirect
|
||||||
k8s.io/apiserver v0.32.1 // indirect
|
k8s.io/apiserver v0.32.1 // indirect
|
||||||
k8s.io/cli-runtime v0.32.1 // indirect
|
|
||||||
k8s.io/component-base v0.32.1 // indirect
|
k8s.io/component-base v0.32.1 // indirect
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
|
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
|
||||||
k8s.io/kubectl v0.32.1 // indirect
|
|
||||||
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||||
oras.land/oras-go v1.2.5 // indirect
|
oras.land/oras-go v1.2.5 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -229,6 +229,8 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh
|
||||||
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||||
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
|
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
|
||||||
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
|
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
|
||||||
|
github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8=
|
||||||
|
github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
|
||||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
@ -451,6 +453,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
|
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
|
||||||
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
|
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
|
||||||
|
github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
|
||||||
|
github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
|
||||||
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
|
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
|
||||||
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
|
|
@ -106,6 +106,7 @@
|
||||||
"json-schema": "^0.4.0",
|
"json-schema": "^0.4.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"markdown-to-jsx": "^7.7.4",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"moment-timezone": "^0.5.40",
|
"moment-timezone": "^0.5.40",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
|
|
@ -1,21 +1,11 @@
|
||||||
package options
|
package options
|
||||||
|
|
||||||
// releaseResource are the supported `helm get` sub-commands
|
|
||||||
// to see all available sub-commands run `helm get --help`
|
|
||||||
type releaseResource string
|
|
||||||
|
|
||||||
const (
|
|
||||||
GetAll releaseResource = "all"
|
|
||||||
GetHooks releaseResource = "hooks"
|
|
||||||
GetManifest releaseResource = "manifest"
|
|
||||||
GetNotes releaseResource = "notes"
|
|
||||||
GetValues releaseResource = "values"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GetOptions struct {
|
type GetOptions struct {
|
||||||
Name string
|
Name string
|
||||||
Namespace string
|
Namespace string
|
||||||
ReleaseResource releaseResource
|
// ShowResources indicates whether to display the resources of the named release
|
||||||
|
ShowResources bool
|
||||||
|
Revision int
|
||||||
KubernetesClusterAccess *KubernetesClusterAccess
|
KubernetesClusterAccess *KubernetesClusterAccess
|
||||||
|
|
||||||
Env []string
|
Env []string
|
||||||
|
|
9
pkg/libhelm/options/history_options.go
Normal file
9
pkg/libhelm/options/history_options.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package options
|
||||||
|
|
||||||
|
type HistoryOptions struct {
|
||||||
|
Name string
|
||||||
|
Namespace string
|
||||||
|
KubernetesClusterAccess *KubernetesClusterAccess
|
||||||
|
|
||||||
|
Env []string
|
||||||
|
}
|
|
@ -1,6 +1,9 @@
|
||||||
package release
|
package release
|
||||||
|
|
||||||
import "github.com/portainer/portainer/pkg/libhelm/time"
|
import (
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/time"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
)
|
||||||
|
|
||||||
// Release is the struct that holds the information for a helm release.
|
// Release is the struct that holds the information for a helm release.
|
||||||
// The struct definitions have been copied from the official Helm Golang client/library.
|
// The struct definitions have been copied from the official Helm Golang client/library.
|
||||||
|
@ -14,7 +17,7 @@ type ReleaseElement struct {
|
||||||
Updated string `json:"updated"`
|
Updated string `json:"updated"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Chart string `json:"chart"`
|
Chart string `json:"chart"`
|
||||||
AppVersion string `json:"app_version"`
|
AppVersion string `json:"appVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release describes a deployment of a chart, together with the chart
|
// Release describes a deployment of a chart, together with the chart
|
||||||
|
@ -23,7 +26,7 @@ type Release struct {
|
||||||
// Name is the name of the release
|
// Name is the name of the release
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
// Info provides information about a release
|
// Info provides information about a release
|
||||||
// Info *Info `json:"info,omitempty"`
|
Info *Info `json:"info,omitempty"`
|
||||||
// Chart is the chart that was released.
|
// Chart is the chart that was released.
|
||||||
Chart Chart `json:"chart,omitempty"`
|
Chart Chart `json:"chart,omitempty"`
|
||||||
// Config is the set of extra Values added to the chart.
|
// Config is the set of extra Values added to the chart.
|
||||||
|
@ -40,6 +43,13 @@ type Release struct {
|
||||||
// Labels of the release.
|
// Labels of the release.
|
||||||
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
|
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
|
||||||
Labels map[string]string `json:"-"`
|
Labels map[string]string `json:"-"`
|
||||||
|
// Values are the values used to deploy the chart.
|
||||||
|
Values Values `json:"values,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Values struct {
|
||||||
|
UserSuppliedValues string `json:"userSuppliedValues,omitempty"`
|
||||||
|
ComputedValues string `json:"computedValues,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart is a helm package that contains metadata, a default config, zero or more
|
// Chart is a helm package that contains metadata, a default config, zero or more
|
||||||
|
@ -183,6 +193,8 @@ type Info struct {
|
||||||
Status Status `json:"status,omitempty"`
|
Status Status `json:"status,omitempty"`
|
||||||
// Contains the rendered templates/NOTES.txt if available
|
// Contains the rendered templates/NOTES.txt if available
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
|
// Resources is the list of resources that are part of the release
|
||||||
|
Resources []*unstructured.Unstructured `json:"resources,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status is the status of a release
|
// Status is the status of a release
|
||||||
|
|
100
pkg/libhelm/sdk/get.go
Normal file
100
pkg/libhelm/sdk/get.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"helm.sh/helm/v3/pkg/action"
|
||||||
|
sdkrelease "helm.sh/helm/v3/pkg/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get implements the HelmPackageManager interface by using the Helm SDK to get a release.
|
||||||
|
// It returns a Release.
|
||||||
|
func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release.Release, error) {
|
||||||
|
log.Debug().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOptions.Namespace).
|
||||||
|
Str("name", getOptions.Name).
|
||||||
|
Msg("Get Helm release")
|
||||||
|
|
||||||
|
actionConfig := new(action.Configuration)
|
||||||
|
err := hspm.initActionConfig(actionConfig, getOptions.Namespace, getOptions.KubernetesClusterAccess)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOptions.Namespace).
|
||||||
|
Err(err).Msg("Failed to initialise helm configuration")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClient, err := hspm.initStatusClient(actionConfig, getOptions)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOptions.Namespace).
|
||||||
|
Err(err).Msg("Failed to initialise helm status client")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := statusClient.Run(getOptions.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOptions.Namespace).
|
||||||
|
Err(err).Msg("Failed to query helm chart")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := hspm.getValues(getOptions)
|
||||||
|
if err != nil {
|
||||||
|
// error is already logged in getValuesFromStatus
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return convert(release, values), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helm status is just an extended helm get command with resources added on (when flagged), so use the status client with the optional show resources flag
|
||||||
|
// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/get.go#L40-L47
|
||||||
|
// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/status.go#L48-L82
|
||||||
|
func (hspm *HelmSDKPackageManager) initStatusClient(actionConfig *action.Configuration, getOptions options.GetOptions) (*action.Status, error) {
|
||||||
|
statusClient := action.NewStatus(actionConfig)
|
||||||
|
statusClient.ShowResources = getOptions.ShowResources
|
||||||
|
if getOptions.Revision > 0 {
|
||||||
|
statusClient.Version = getOptions.Revision
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Release {
|
||||||
|
resources, err := parseResources(sdkRelease.Info.Resources)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", sdkRelease.Namespace).
|
||||||
|
Str("name", sdkRelease.Name).
|
||||||
|
Err(err).Msg("Failed to parse resources")
|
||||||
|
}
|
||||||
|
return &release.Release{
|
||||||
|
Name: sdkRelease.Name,
|
||||||
|
Namespace: sdkRelease.Namespace,
|
||||||
|
Version: sdkRelease.Version,
|
||||||
|
Info: &release.Info{
|
||||||
|
Status: release.Status(sdkRelease.Info.Status),
|
||||||
|
Notes: sdkRelease.Info.Notes,
|
||||||
|
Resources: resources,
|
||||||
|
Description: sdkRelease.Info.Description,
|
||||||
|
},
|
||||||
|
Manifest: sdkRelease.Manifest,
|
||||||
|
Chart: release.Chart{
|
||||||
|
Metadata: &release.Metadata{
|
||||||
|
Name: sdkRelease.Chart.Metadata.Name,
|
||||||
|
Version: sdkRelease.Chart.Metadata.Version,
|
||||||
|
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Values: values,
|
||||||
|
}
|
||||||
|
}
|
39
pkg/libhelm/sdk/get_test.go
Normal file
39
pkg/libhelm/sdk/get_test.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
libhelmrelease "github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"helm.sh/helm/v3/pkg/chart"
|
||||||
|
sdkrelease "helm.sh/helm/v3/pkg/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_Convert(t *testing.T) {
|
||||||
|
t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
release := sdkrelease.Release{
|
||||||
|
Name: "releaseName",
|
||||||
|
Version: 1,
|
||||||
|
Info: &sdkrelease.Info{
|
||||||
|
Status: "deployed",
|
||||||
|
},
|
||||||
|
Chart: &chart.Chart{
|
||||||
|
Metadata: &chart.Metadata{
|
||||||
|
Name: "chartName",
|
||||||
|
Version: "chartVersion",
|
||||||
|
AppVersion: "chartAppVersion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := libhelmrelease.Values{
|
||||||
|
UserSuppliedValues: `{"key": "value"}`,
|
||||||
|
ComputedValues: `{"key": "value"}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := convert(&release, values)
|
||||||
|
is.Equal(release.Name, result.Name)
|
||||||
|
})
|
||||||
|
}
|
68
pkg/libhelm/sdk/history.go
Normal file
68
pkg/libhelm/sdk/history.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/time"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"helm.sh/helm/v3/pkg/action"
|
||||||
|
sdkrelease "helm.sh/helm/v3/pkg/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHistory implements the HelmPackageManager interface by using the Helm SDK to get a release.
|
||||||
|
// It returns a Release.
|
||||||
|
func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOptions) ([]*release.Release, error) {
|
||||||
|
log.Debug().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", historyOptions.Namespace).
|
||||||
|
Str("name", historyOptions.Name).
|
||||||
|
Msg("Get Helm history")
|
||||||
|
|
||||||
|
actionConfig := new(action.Configuration)
|
||||||
|
err := hspm.initActionConfig(actionConfig, historyOptions.Namespace, historyOptions.KubernetesClusterAccess)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", historyOptions.Namespace).
|
||||||
|
Err(err).Msg("Failed to initialise helm configuration")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
historyClient := action.NewHistory(actionConfig)
|
||||||
|
history, err := historyClient.Run(historyOptions.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", historyOptions.Namespace).
|
||||||
|
Err(err).Msg("Failed to query helm release history")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*release.Release
|
||||||
|
for _, r := range history {
|
||||||
|
result = append(result, convertHistory(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertHistory(sdkRelease *sdkrelease.Release) *release.Release {
|
||||||
|
return &release.Release{
|
||||||
|
Name: sdkRelease.Name,
|
||||||
|
Namespace: sdkRelease.Namespace,
|
||||||
|
Version: sdkRelease.Version,
|
||||||
|
Info: &release.Info{
|
||||||
|
Status: release.Status(sdkRelease.Info.Status),
|
||||||
|
Notes: sdkRelease.Info.Notes,
|
||||||
|
LastDeployed: time.Time(sdkRelease.Info.LastDeployed),
|
||||||
|
},
|
||||||
|
Chart: release.Chart{
|
||||||
|
Metadata: &release.Metadata{
|
||||||
|
Name: sdkRelease.Chart.Metadata.Name,
|
||||||
|
Version: sdkRelease.Chart.Metadata.Version,
|
||||||
|
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
33
pkg/libhelm/sdk/history_test.go
Normal file
33
pkg/libhelm/sdk/history_test.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"helm.sh/helm/v3/pkg/chart"
|
||||||
|
sdkrelease "helm.sh/helm/v3/pkg/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_ConvertHistory(t *testing.T) {
|
||||||
|
t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
release := sdkrelease.Release{
|
||||||
|
Name: "releaseName",
|
||||||
|
Version: 1,
|
||||||
|
Info: &sdkrelease.Info{
|
||||||
|
Status: "deployed",
|
||||||
|
},
|
||||||
|
Chart: &chart.Chart{
|
||||||
|
Metadata: &chart.Metadata{
|
||||||
|
Name: "chartName",
|
||||||
|
Version: "chartVersion",
|
||||||
|
AppVersion: "chartAppVersion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := convertHistory(&release)
|
||||||
|
is.Equal(release.Name, result.Name)
|
||||||
|
})
|
||||||
|
}
|
291
pkg/libhelm/sdk/resources.go
Normal file
291
pkg/libhelm/sdk/resources.go
Normal file
|
@ -0,0 +1,291 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Unknown = "Unknown"
|
||||||
|
Healthy = "Healthy"
|
||||||
|
Unhealthy = "Unhealthy"
|
||||||
|
Progressing = "Progressing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResourceStatus represents a generic status for any Kubernetes resource.
|
||||||
|
type ResourceStatus struct {
|
||||||
|
// Phase is a simple, high-level summary of where the resource is in its lifecycle.
|
||||||
|
Phase string `json:"phase,omitempty"`
|
||||||
|
|
||||||
|
// HealthSummary represents the summarized health status of the resource
|
||||||
|
HealthSummary *HealthCondition `json:"healthSummary,omitempty"`
|
||||||
|
|
||||||
|
// Reason is a brief CamelCase string containing the reason for the resource's current status.
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
|
||||||
|
// Message is a human-readable description of the current status.
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCondition represents a summarized health condition for a resource
|
||||||
|
type HealthCondition struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResources returns a list of resources with additional status information, in a consistent format.
|
||||||
|
func parseResources(resourceTypesLists map[string][]runtime.Object) ([]*unstructured.Unstructured, error) {
|
||||||
|
flattenedResources := flattenResources(resourceTypesLists)
|
||||||
|
|
||||||
|
resourcesInfo := []*unstructured.Unstructured{}
|
||||||
|
for _, resource := range flattenedResources {
|
||||||
|
info, err := getResourceInfo(resource)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resourcesInfo = append(resourcesInfo, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourcesInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getResourceInfo(obj runtime.Object) (*unstructured.Unstructured, error) {
|
||||||
|
data, err := json.Marshal(obj)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &unstructured.Unstructured{}
|
||||||
|
err = json.Unmarshal(data, res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, conditions, err := extractStatus(res)
|
||||||
|
if err == nil {
|
||||||
|
summarizeStatus(status, conditions, res.GetName(), res.GetNamespace(), err)
|
||||||
|
applyStatusToResource(res, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// only keep metadata, kind and status (other fields are not needed)
|
||||||
|
res.Object = map[string]any{
|
||||||
|
"metadata": res.Object["metadata"],
|
||||||
|
"kind": res.Object["kind"],
|
||||||
|
"status": res.Object["status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractStatus extracts the status from an unstructured resource
|
||||||
|
func extractStatus(res *unstructured.Unstructured) (*ResourceStatus, []metav1.Condition, error) {
|
||||||
|
statusMap, found, err := unstructured.NestedMap(res.Object, "status")
|
||||||
|
if !found || err != nil {
|
||||||
|
return &ResourceStatus{}, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract basic status fields
|
||||||
|
phase, _, _ := unstructured.NestedString(statusMap, "phase")
|
||||||
|
reason, _, _ := unstructured.NestedString(statusMap, "reason")
|
||||||
|
message, _, _ := unstructured.NestedString(statusMap, "message")
|
||||||
|
|
||||||
|
// Extract conditions for analysis
|
||||||
|
conditions := []metav1.Condition{}
|
||||||
|
conditionsData, found, _ := unstructured.NestedSlice(statusMap, "conditions")
|
||||||
|
if found {
|
||||||
|
for _, condData := range conditionsData {
|
||||||
|
condMap, ok := condData.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := metav1.Condition{}
|
||||||
|
if typeStr, ok := condMap["type"].(string); ok {
|
||||||
|
cond.Type = typeStr
|
||||||
|
}
|
||||||
|
if statusStr, ok := condMap["status"].(string); ok {
|
||||||
|
cond.Status = metav1.ConditionStatus(statusStr)
|
||||||
|
}
|
||||||
|
if reasonStr, ok := condMap["reason"].(string); ok {
|
||||||
|
cond.Reason = reasonStr
|
||||||
|
}
|
||||||
|
if msgStr, ok := condMap["message"].(string); ok {
|
||||||
|
cond.Message = msgStr
|
||||||
|
}
|
||||||
|
if timeStr, ok := condMap["lastTransitionTime"].(string); ok {
|
||||||
|
t, _ := time.Parse(time.RFC3339, timeStr)
|
||||||
|
cond.LastTransitionTime = metav1.Time{Time: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions = append(conditions, cond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ResourceStatus{
|
||||||
|
Phase: phase,
|
||||||
|
Reason: reason,
|
||||||
|
Message: message,
|
||||||
|
}, conditions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeStatus creates a health summary based on resource status and conditions
|
||||||
|
func summarizeStatus(status *ResourceStatus, conditions []metav1.Condition, name string, namespace string, err error) *ResourceStatus {
|
||||||
|
healthSummary := &HealthCondition{
|
||||||
|
Status: Unknown,
|
||||||
|
Reason: status.Reason,
|
||||||
|
Message: status.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error case first
|
||||||
|
if err != nil {
|
||||||
|
healthSummary.Reason = "ErrorGettingStatus"
|
||||||
|
healthSummary.Message = err.Error()
|
||||||
|
status.HealthSummary = healthSummary
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle phase-based status
|
||||||
|
switch status.Phase {
|
||||||
|
case "Error":
|
||||||
|
healthSummary.Status = Unhealthy
|
||||||
|
healthSummary.Reason = status.Phase
|
||||||
|
case "Running":
|
||||||
|
healthSummary.Status = Healthy
|
||||||
|
healthSummary.Reason = status.Phase
|
||||||
|
case "Pending":
|
||||||
|
healthSummary.Status = Progressing
|
||||||
|
healthSummary.Reason = status.Phase
|
||||||
|
case "Failed":
|
||||||
|
healthSummary.Status = Unhealthy
|
||||||
|
healthSummary.Reason = status.Phase
|
||||||
|
case "Available", "Active", "Established", "Bound", "Ready", "Succeeded":
|
||||||
|
healthSummary.Status = Healthy
|
||||||
|
healthSummary.Reason = status.Phase
|
||||||
|
case "":
|
||||||
|
// Empty phase - check conditions or default to "Exists"
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
analyzeConditions(conditions, healthSummary)
|
||||||
|
} else {
|
||||||
|
healthSummary.Status = Healthy
|
||||||
|
healthSummary.Reason = "Exists"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Warn().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", namespace).
|
||||||
|
Str("name", name).
|
||||||
|
Str("phase", status.Phase).
|
||||||
|
Msg("Unhandled status")
|
||||||
|
healthSummary.Reason = status.Phase
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set message from first condition if available
|
||||||
|
if len(conditions) > 0 && healthSummary.Message == "" {
|
||||||
|
healthSummary.Message = conditions[0].Message
|
||||||
|
}
|
||||||
|
|
||||||
|
status.HealthSummary = healthSummary
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyzeConditions determines resource health based on standard condition types
|
||||||
|
func analyzeConditions(conditions []metav1.Condition, healthSummary *HealthCondition) {
|
||||||
|
for _, cond := range conditions {
|
||||||
|
switch cond.Type {
|
||||||
|
case "Progressing":
|
||||||
|
if cond.Status == "False" {
|
||||||
|
healthSummary.Status = Unhealthy
|
||||||
|
healthSummary.Reason = cond.Reason
|
||||||
|
} else if cond.Reason != "NewReplicaSetAvailable" {
|
||||||
|
healthSummary.Status = Unknown
|
||||||
|
healthSummary.Reason = cond.Reason
|
||||||
|
}
|
||||||
|
case "Available", "Ready", "DisruptionAllowed", "Established", "NamesAccepted":
|
||||||
|
if healthSummary.Status == Unknown ||
|
||||||
|
(cond.Type == "Established" && healthSummary.Status == Healthy) ||
|
||||||
|
(cond.Type == "NamesAccepted" && healthSummary.Status == Healthy) {
|
||||||
|
if cond.Status == "False" {
|
||||||
|
healthSummary.Status = Unhealthy
|
||||||
|
} else {
|
||||||
|
healthSummary.Status = Healthy
|
||||||
|
}
|
||||||
|
healthSummary.Reason = cond.Reason
|
||||||
|
}
|
||||||
|
case "ContainersReady":
|
||||||
|
if healthSummary.Status == Unknown && cond.Status == "False" {
|
||||||
|
healthSummary.Status = Unhealthy
|
||||||
|
healthSummary.Reason = cond.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyStatusToResource applies the typed ResourceStatus back to the unstructured resource
|
||||||
|
func applyStatusToResource(res *unstructured.Unstructured, status *ResourceStatus) {
|
||||||
|
statusMap := map[string]any{
|
||||||
|
"phase": status.Phase,
|
||||||
|
"reason": status.Reason,
|
||||||
|
"message": status.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.HealthSummary != nil {
|
||||||
|
statusMap["healthSummary"] = map[string]any{
|
||||||
|
"status": status.HealthSummary.Status,
|
||||||
|
"reason": status.HealthSummary.Reason,
|
||||||
|
"message": status.HealthSummary.Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unstructured.SetNestedMap(res.Object, statusMap, "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenResources extracts items from a list resource and convert them to runtime.Objects
|
||||||
|
func flattenResources(resourceTypesLists map[string][]runtime.Object) []runtime.Object {
|
||||||
|
flattenedResources := []runtime.Object{}
|
||||||
|
|
||||||
|
for _, resourceTypeList := range resourceTypesLists {
|
||||||
|
for _, resourceItem := range resourceTypeList {
|
||||||
|
// if the resource item is a list, we need to flatten it too e.g. PodList
|
||||||
|
items := extractItemsIfList(resourceItem)
|
||||||
|
if items != nil {
|
||||||
|
flattenedResources = append(flattenedResources, items...)
|
||||||
|
} else {
|
||||||
|
flattenedResources = append(flattenedResources, resourceItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattenedResources
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractItemsIfList extracts items if the resource is a list, or returns nil if not a list
|
||||||
|
func extractItemsIfList(resource runtime.Object) []runtime.Object {
|
||||||
|
unstructuredObj, ok := resource.(runtime.Unstructured)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !unstructuredObj.IsList() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedItems := []runtime.Object{}
|
||||||
|
err := unstructuredObj.EachListItem(func(obj runtime.Object) error {
|
||||||
|
extractedItems = append(extractedItems, obj)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractedItems
|
||||||
|
}
|
143
pkg/libhelm/sdk/resources_test.go
Normal file
143
pkg/libhelm/sdk/resources_test.go
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseResources(t *testing.T) {
|
||||||
|
t.Run("successfully parse single resource", func(t *testing.T) {
|
||||||
|
resourceTypesLists := map[string][]runtime.Object{
|
||||||
|
"v1/Pod(related)": {
|
||||||
|
&unstructured.Unstructured{
|
||||||
|
Object: map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Pod",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": "test-pod",
|
||||||
|
"namespace": "default",
|
||||||
|
},
|
||||||
|
"status": map[string]any{
|
||||||
|
"phase": "Available",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := parseResources(resourceTypesLists)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(got))
|
||||||
|
|
||||||
|
// Check resource metadata
|
||||||
|
assert.Equal(t, "test-pod", got[0].GetName())
|
||||||
|
assert.Equal(t, "default", got[0].GetNamespace())
|
||||||
|
|
||||||
|
// Check status and condition
|
||||||
|
statusMap, found, _ := unstructured.NestedMap(got[0].Object, "status")
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.Equal(t, "Available", statusMap["phase"])
|
||||||
|
|
||||||
|
healthSummary, found, _ := unstructured.NestedMap(statusMap, "healthSummary")
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.Equal(t, "Healthy", healthSummary["status"])
|
||||||
|
assert.Equal(t, "Available", healthSummary["reason"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("successfully parse multiple resources", func(t *testing.T) {
|
||||||
|
resourceTypesLists := map[string][]runtime.Object{
|
||||||
|
"v1/Pod(related)": {
|
||||||
|
&unstructured.Unstructured{
|
||||||
|
Object: map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Pod",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": "test-pod-1",
|
||||||
|
"namespace": "default",
|
||||||
|
},
|
||||||
|
"status": map[string]any{
|
||||||
|
"phase": "Pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&unstructured.Unstructured{
|
||||||
|
Object: map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Pod",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": "test-pod-2",
|
||||||
|
"namespace": "default",
|
||||||
|
},
|
||||||
|
"status": map[string]any{
|
||||||
|
"phase": "Error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := parseResources(resourceTypesLists)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(got))
|
||||||
|
|
||||||
|
// Check first resource
|
||||||
|
assert.Equal(t, "test-pod-1", got[0].GetName())
|
||||||
|
statusMap1, found, _ := unstructured.NestedMap(got[0].Object, "status")
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.Equal(t, "Pending", statusMap1["phase"])
|
||||||
|
|
||||||
|
healthSummary1, found, _ := unstructured.NestedMap(statusMap1, "healthSummary")
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.Equal(t, Progressing, healthSummary1["status"])
|
||||||
|
assert.Equal(t, "Pending", healthSummary1["reason"])
|
||||||
|
|
||||||
|
// Check second resource
|
||||||
|
assert.Equal(t, "test-pod-2", got[1].GetName())
|
||||||
|
statusMap2, found, _ := unstructured.NestedMap(got[1].Object, "status")
|
||||||
|
assert.True(t, found)
|
||||||
|
healthSummary2, found, _ := unstructured.NestedMap(statusMap2, "healthSummary")
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.Equal(t, Unhealthy, healthSummary2["status"])
|
||||||
|
assert.Equal(t, "Error", healthSummary2["reason"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnhanceStatus(t *testing.T) {
|
||||||
|
t.Run("healthy running pod", func(t *testing.T) {
|
||||||
|
// Create a ResourceStatus object
|
||||||
|
status := &ResourceStatus{
|
||||||
|
Phase: "Failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions := []metav1.Condition{}
|
||||||
|
|
||||||
|
result := summarizeStatus(status, conditions, "test-pod", "default", nil)
|
||||||
|
|
||||||
|
assert.Equal(t, Unhealthy, result.HealthSummary.Status)
|
||||||
|
assert.Equal(t, "Failed", result.HealthSummary.Reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unhealthy pod with error", func(t *testing.T) {
|
||||||
|
// Create a ResourceStatus object
|
||||||
|
status := &ResourceStatus{
|
||||||
|
Phase: "Error",
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions := []metav1.Condition{
|
||||||
|
{
|
||||||
|
Type: "DisruptionAllowed",
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
Reason: "InsufficientPods",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := summarizeStatus(status, conditions, "test-pod", "default", nil)
|
||||||
|
|
||||||
|
assert.Equal(t, Unhealthy, result.HealthSummary.Status)
|
||||||
|
assert.Equal(t, "Error", result.HealthSummary.Reason)
|
||||||
|
})
|
||||||
|
}
|
|
@ -4,7 +4,11 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
"helm.sh/helm/v3/pkg/action"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
|
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
|
||||||
|
@ -40,3 +44,75 @@ func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map
|
||||||
|
|
||||||
return vals, nil
|
return vals, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (release.Values, error) {
|
||||||
|
log.Debug().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOpts.Namespace).
|
||||||
|
Str("name", getOpts.Name).
|
||||||
|
Msg("Getting values")
|
||||||
|
|
||||||
|
actionConfig := new(action.Configuration)
|
||||||
|
err := hspm.initActionConfig(actionConfig, getOpts.Namespace, getOpts.KubernetesClusterAccess)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOpts.Namespace).
|
||||||
|
Err(err).Msg("Failed to initialise helm configuration")
|
||||||
|
return release.Values{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client for user supplied values
|
||||||
|
userValuesClient := action.NewGetValues(actionConfig)
|
||||||
|
userSuppliedValues, err := userValuesClient.Run(getOpts.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOpts.Namespace).
|
||||||
|
Err(err).Msg("Failed to get user supplied values")
|
||||||
|
return release.Values{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create separate client for computed values
|
||||||
|
computedValuesClient := action.NewGetValues(actionConfig)
|
||||||
|
computedValuesClient.AllValues = true
|
||||||
|
computedValues, err := computedValuesClient.Run(getOpts.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("namespace", getOpts.Namespace).
|
||||||
|
Err(err).Msg("Failed to get computed values")
|
||||||
|
return release.Values{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userSuppliedValuesByte, err := yaml.Marshal(userSuppliedValues)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Err(err).Msg("Failed to marshal user supplied values")
|
||||||
|
return release.Values{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
computedValuesByte, err := yaml.Marshal(computedValues)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Err(err).Msg("Failed to marshal computed values")
|
||||||
|
return release.Values{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the case where the values are an empty object
|
||||||
|
userSuppliedValuesString := string(userSuppliedValuesByte)
|
||||||
|
if userSuppliedValuesString == "{}\n" {
|
||||||
|
userSuppliedValuesString = ""
|
||||||
|
}
|
||||||
|
computedValuesString := string(computedValuesByte)
|
||||||
|
if computedValuesString == "{}\n" {
|
||||||
|
computedValuesString = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return release.Values{
|
||||||
|
UserSuppliedValues: userSuppliedValuesString,
|
||||||
|
ComputedValues: computedValuesString,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
|
@ -91,29 +92,11 @@ func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get release details - all, hooks, manifest, notes and values
|
|
||||||
func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
|
|
||||||
switch getOpts.ReleaseResource {
|
|
||||||
case options.GetAll:
|
|
||||||
return []byte(strings.Join([]string{MockReleaseHooks, MockReleaseManifest, MockReleaseNotes, MockReleaseValues}, "---\n")), nil
|
|
||||||
case options.GetHooks:
|
|
||||||
return []byte(MockReleaseHooks), nil
|
|
||||||
case options.GetManifest:
|
|
||||||
return []byte(MockReleaseManifest), nil
|
|
||||||
case options.GetNotes:
|
|
||||||
return []byte(MockReleaseNotes), nil
|
|
||||||
case options.GetValues:
|
|
||||||
return []byte(MockReleaseValues), nil
|
|
||||||
default:
|
|
||||||
return nil, errors.New("invalid release resource")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uninstall a helm chart (not thread safe)
|
// Uninstall a helm chart (not thread safe)
|
||||||
func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
|
func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
|
||||||
for i, rel := range mockCharts {
|
for i, rel := range mockCharts {
|
||||||
if rel.Name == uninstallOpts.Name && rel.Namespace == uninstallOpts.Namespace {
|
if rel.Name == uninstallOpts.Name && rel.Namespace == uninstallOpts.Namespace {
|
||||||
mockCharts = append(mockCharts[:i], mockCharts[i+1:]...)
|
mockCharts = slices.Delete(mockCharts, i, i+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -124,6 +107,25 @@ func (hpm *helmMockPackageManager) List(listOpts options.ListOptions) ([]release
|
||||||
return mockCharts, nil
|
return mockCharts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get a helm release (not thread safe)
|
||||||
|
func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) (*release.Release, error) {
|
||||||
|
index := slices.IndexFunc(mockCharts, func(re release.ReleaseElement) bool {
|
||||||
|
return re.Name == getOpts.Name && re.Namespace == getOpts.Namespace
|
||||||
|
})
|
||||||
|
return newMockRelease(&mockCharts[index]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hpm *helmMockPackageManager) GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error) {
|
||||||
|
var result []*release.Release
|
||||||
|
for i, v := range mockCharts {
|
||||||
|
if v.Name == historyOpts.Name && v.Namespace == historyOpts.Namespace {
|
||||||
|
result = append(result, newMockRelease(&mockCharts[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
const mockPortainerIndex = `apiVersion: v1
|
const mockPortainerIndex = `apiVersion: v1
|
||||||
entries:
|
entries:
|
||||||
portainer:
|
portainer:
|
||||||
|
|
|
@ -14,6 +14,8 @@ type HelmPackageManager interface {
|
||||||
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
|
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
|
||||||
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
|
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
|
||||||
Uninstall(uninstallOpts options.UninstallOptions) error
|
Uninstall(uninstallOpts options.UninstallOptions) error
|
||||||
|
Get(getOpts options.GetOptions) (*release.Release, error)
|
||||||
|
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
|
|
63
pkg/libkubectl/client.go
Normal file
63
pkg/libkubectl/client.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package libkubectl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||||
|
"k8s.io/cli-runtime/pkg/genericiooptions"
|
||||||
|
"k8s.io/kubectl/pkg/cmd/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientAccess struct {
|
||||||
|
Token string
|
||||||
|
ServerUrl string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
factory util.Factory
|
||||||
|
streams genericclioptions.IOStreams
|
||||||
|
out *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new kubectl client
|
||||||
|
func NewClient(libKubectlAccess *ClientAccess, namespace, kubeconfig string, insecure bool) (*Client, error) {
|
||||||
|
configFlags, err := generateConfigFlags(libKubectlAccess.Token, libKubectlAccess.ServerUrl, namespace, kubeconfig, insecure)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
streams, _, out, _ := genericiooptions.NewTestIOStreams()
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
factory: util.NewFactory(configFlags),
|
||||||
|
streams: streams,
|
||||||
|
out: out,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateConfigFlags generates the config flags for the kubectl client
|
||||||
|
// If kubeconfigPath is provided, it will be used instead of server and token
|
||||||
|
// If server and token are provided, they will be used to connect to the cluster
|
||||||
|
// If neither kubeconfigPath or server and token are provided, an error will be returned
|
||||||
|
func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecure bool) (*genericclioptions.ConfigFlags, error) {
|
||||||
|
if kubeconfigPath == "" && (server == "" || token == "") {
|
||||||
|
return nil, errors.New("must provide either a kubeconfig path or a server and token")
|
||||||
|
}
|
||||||
|
|
||||||
|
configFlags := genericclioptions.NewConfigFlags(true)
|
||||||
|
if namespace != "" {
|
||||||
|
configFlags.Namespace = &namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
if kubeconfigPath != "" {
|
||||||
|
configFlags.KubeConfig = &kubeconfigPath
|
||||||
|
} else {
|
||||||
|
configFlags.APIServer = &server
|
||||||
|
configFlags.BearerToken = &token
|
||||||
|
}
|
||||||
|
|
||||||
|
configFlags.Insecure = &insecure
|
||||||
|
|
||||||
|
return configFlags, nil
|
||||||
|
}
|
101
pkg/libkubectl/client_test.go
Normal file
101
pkg/libkubectl/client_test.go
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
package libkubectl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClient(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
libKubectlAccess ClientAccess
|
||||||
|
namespace string
|
||||||
|
kubeconfig string
|
||||||
|
insecure bool
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid client with token and server",
|
||||||
|
libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
|
||||||
|
namespace: "default",
|
||||||
|
insecure: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid client with kubeconfig",
|
||||||
|
kubeconfig: "/path/to/kubeconfig",
|
||||||
|
namespace: "test-namespace",
|
||||||
|
insecure: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing both token/server and kubeconfig",
|
||||||
|
namespace: "default",
|
||||||
|
insecure: false,
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "must provide either a kubeconfig path or a server and token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing token with server",
|
||||||
|
libKubectlAccess: ClientAccess{ServerUrl: "https://localhost:6443"},
|
||||||
|
namespace: "default",
|
||||||
|
insecure: false,
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "must provide either a kubeconfig path or a server and token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing server with token",
|
||||||
|
libKubectlAccess: ClientAccess{Token: "test-token"},
|
||||||
|
namespace: "default",
|
||||||
|
insecure: false,
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "must provide either a kubeconfig path or a server and token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty namespace is valid",
|
||||||
|
libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
|
||||||
|
namespace: "",
|
||||||
|
insecure: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "insecure true with valid credentials",
|
||||||
|
libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
|
||||||
|
namespace: "default",
|
||||||
|
insecure: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
client, err := NewClient(&tt.libKubectlAccess, tt.namespace, tt.kubeconfig, tt.insecure)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && tt.errContains != "" {
|
||||||
|
if got := err.Error(); got != tt.errContains {
|
||||||
|
t.Errorf("NewClient() error = %v, want error containing %v", got, tt.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr {
|
||||||
|
if client == nil {
|
||||||
|
t.Error("NewClient() returned nil client when no error was expected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client fields are properly initialized
|
||||||
|
if client.factory == nil {
|
||||||
|
t.Error("NewClient() client.factory is nil")
|
||||||
|
}
|
||||||
|
if client.out == nil {
|
||||||
|
t.Error("NewClient() client.out is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
40
pkg/libkubectl/describe.go
Normal file
40
pkg/libkubectl/describe.go
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
package libkubectl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
"k8s.io/cli-runtime/pkg/resource"
|
||||||
|
describecmd "k8s.io/kubectl/pkg/cmd/describe"
|
||||||
|
cmdutil "k8s.io/kubectl/pkg/cmd/util"
|
||||||
|
"k8s.io/kubectl/pkg/describe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Describe returns the description of a resource
|
||||||
|
// name is the name of the resource, kind is the kind of the resource, and namespace is the namespace of the resource
|
||||||
|
// this is identical to running `kubectl describe <kind> <name> --namespace <namespace>`
|
||||||
|
func (c *Client) Describe(namespace, name, kind string) (string, error) {
|
||||||
|
describeOptions := &describecmd.DescribeOptions{
|
||||||
|
BuilderArgs: []string{kind, name},
|
||||||
|
Describer: func(mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
|
||||||
|
return describe.DescriberFn(c.factory, mapping)
|
||||||
|
},
|
||||||
|
FilenameOptions: &resource.FilenameOptions{},
|
||||||
|
DescriberSettings: &describe.DescriberSettings{
|
||||||
|
ShowEvents: true,
|
||||||
|
ChunkSize: cmdutil.DefaultChunkSize,
|
||||||
|
},
|
||||||
|
IOStreams: c.streams,
|
||||||
|
NewBuilder: c.factory.NewBuilder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace != "" {
|
||||||
|
describeOptions.Namespace = namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := describeOptions.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("error describing resources: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.out.String(), nil
|
||||||
|
}
|
|
@ -13148,6 +13148,11 @@ markdown-to-jsx@^7.1.8:
|
||||||
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b"
|
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b"
|
||||||
integrity sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg==
|
integrity sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg==
|
||||||
|
|
||||||
|
markdown-to-jsx@^7.7.4:
|
||||||
|
version "7.7.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.7.4.tgz#507d17c15af72ddf970fca84a95f0243244fcfa9"
|
||||||
|
integrity sha512-1bSfXyBKi+EYS3YY+e0Csuxf8oZ3decdfhOav/Z7Wrk89tjudyL5FOmwZQUoy0/qVXGUl+6Q3s2SWtpDEWITfQ==
|
||||||
|
|
||||||
math-intrinsics@^1.1.0:
|
math-intrinsics@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue