diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go index b776fe168..e87e98410 100644 --- a/api/http/handler/helm/handler.go +++ b/api/http/handler/helm/handler.go @@ -54,6 +54,14 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor h.Handle("/{id}/kubernetes/helm", 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 } diff --git a/api/http/handler/helm/helm_get.go b/api/http/handler/helm/helm_get.go new file mode 100644 index 000000000..bd8ea3e60 --- /dev/null +++ b/api/http/handler/helm/helm_get.go @@ -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) +} diff --git a/api/http/handler/helm/helm_get_test.go b/api/http/handler/helm/helm_get_test.go new file mode 100644 index 000000000..2dad6ef59 --- /dev/null +++ b/api/http/handler/helm/helm_get_test.go @@ -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) + }) +} diff --git a/api/http/handler/helm/helm_history.go b/api/http/handler/helm/helm_history.go new file mode 100644 index 000000000..68169bcd4 --- /dev/null +++ b/api/http/handler/helm/helm_history.go @@ -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) +} diff --git a/api/http/handler/helm/helm_history_test.go b/api/http/handler/helm/helm_history_test.go new file mode 100644 index 000000000..10fadfb1f --- /dev/null +++ b/api/http/handler/helm/helm_history_test.go @@ -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) + }) +} diff --git a/api/http/handler/kubernetes/describe.go b/api/http/handler/kubernetes/describe.go new file mode 100644 index 000000000..ce8f17418 --- /dev/null +++ b/api/http/handler/kubernetes/describe.go @@ -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}) +} diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index a47bd6ed9..cc068e4a4 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libkubectl" "github.com/rs/zerolog/log" "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_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet) endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost) + endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet) // namespaces // 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) }) } + +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 +} diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx index 206f7fc5b..09cc9591a 100644 --- a/app/react/components/CodeEditor.tsx +++ b/app/react/components/CodeEditor.tsx @@ -36,7 +36,7 @@ interface Props extends AutomationTestingProps { placeholder?: string; type?: Type; readonly?: boolean; - onChange: (value: string) => void; + onChange?: (value: string) => void; value: string; height?: string; versions?: number[]; @@ -136,7 +136,7 @@ function schemaValidationExtensions(schema: JSONSchema7) { export function CodeEditor({ id, - onChange, + onChange = () => {}, placeholder, readonly, value, diff --git a/app/react/components/NavTabs/index.ts b/app/react/components/NavTabs/index.ts index 20f2d03a6..cacdb47c6 100644 --- a/app/react/components/NavTabs/index.ts +++ b/app/react/components/NavTabs/index.ts @@ -1 +1,2 @@ export { NavTabs } from './NavTabs'; +export type { Option } from './NavTabs'; diff --git a/app/react/components/StatusBadge.tsx b/app/react/components/StatusBadge.tsx index 81b03f6ad..688fcd81f 100644 --- a/app/react/components/StatusBadge.tsx +++ b/app/react/components/StatusBadge.tsx @@ -3,6 +3,56 @@ import { AriaAttributes, PropsWithChildren } from 'react'; import { Icon, IconProps } from '@@/Icon'; +export type StatusBadgeType = + | 'success' + | 'danger' + | 'warning' + | 'info' + | 'successLite' + | 'dangerLite' + | 'warningLite' + | 'mutedLite' + | 'infoLite' + | 'default'; + +const typeClasses: Record = { + 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({ className, children, @@ -12,7 +62,7 @@ export function StatusBadge({ }: PropsWithChildren< { className?: string; - color?: 'success' | 'danger' | 'warning' | 'info' | 'default'; + color?: StatusBadgeType; icon?: IconProps['icon']; } & AriaAttributes >) { @@ -21,13 +71,8 @@ export function StatusBadge({ className={clsx( 'inline-flex items-center gap-1 rounded', 'w-fit px-1.5 py-0.5', - 'text-sm font-medium text-white', - { - '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', - }, + 'text-sm font-medium', + typeClasses[color], className )} // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index 4ca608aa3..2d0b2dfa8 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -70,6 +70,7 @@ export interface Props extends AutomationTestingProps { getRowCanExpand?(row: Row): boolean; noWidget?: boolean; extendTableOptions?: (options: TableOptions) => TableOptions; + includeSearch?: boolean; } export function Datatable({ @@ -98,6 +99,7 @@ export function Datatable({ totalCount = dataset.length, isServerSidePagination = false, extendTableOptions = (value) => value, + includeSearch, }: Props & PaginationProps) { const pageCount = useMemo( () => Math.ceil(totalCount / settings.pageSize), @@ -192,6 +194,7 @@ export function Datatable({ renderTableActions={() => renderTableActions(selectedItems)} renderTableSettings={() => renderTableSettings(tableInstance)} data-cy={`${dataCy}-header`} + includeSearch={includeSearch} /> diff --git a/app/react/components/datatables/DatatableHeader.tsx b/app/react/components/datatables/DatatableHeader.tsx index bba2cf405..810f13c7a 100644 --- a/app/react/components/datatables/DatatableHeader.tsx +++ b/app/react/components/datatables/DatatableHeader.tsx @@ -16,6 +16,7 @@ type Props = { renderTableActions?(): ReactNode; description?: ReactNode; titleId?: string; + includeSearch?: boolean; } & AutomationTestingProps; export function DatatableHeader({ @@ -28,8 +29,9 @@ export function DatatableHeader({ description, titleId, 'data-cy': dataCy, + includeSearch = !!title, }: Props) { - if (!title) { + if (!title && !includeSearch) { return null; } @@ -50,12 +52,12 @@ export function DatatableHeader({ return ( - {searchBar} + {includeSearch && searchBar} {tableActions} {tableTitleSettings} diff --git a/app/react/components/modals/Modal/Modal.tsx b/app/react/components/modals/Modal/Modal.tsx index 25f5a75aa..1f2cb729b 100644 --- a/app/react/components/modals/Modal/Modal.tsx +++ b/app/react/components/modals/Modal/Modal.tsx @@ -47,10 +47,14 @@ export function Modal({
{children} diff --git a/app/react/kubernetes/HelmView/.keep b/app/react/kubernetes/HelmView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx index 63b0468eb..c0e6b8d71 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx @@ -6,6 +6,7 @@ import { server, http } from '@/setup-tests/server'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { UserViewModel } from '@/portainer/models/user'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { mockCodeMirror } from '@/setup-tests/mock-codemirror'; import { HelmApplicationView } from './HelmApplicationView'; @@ -22,6 +23,41 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({ 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() { const user = new UserViewModel({ Username: 'user' }); const Wrapped = withTestQueryProvider( @@ -40,47 +76,52 @@ describe('HelmApplicationView', () => { 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 () => { - renderComponent(); + it('should display helm release details for minimal release when data is loaded', async () => { + 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 - expect(await screen.findByText('Helm details')).toBeInTheDocument(); + expect(await findByText('Helm details')).toBeInTheDocument(); - // Check for the release details - expect(await screen.findByText('Release')).toBeInTheDocument(); - - // Check for the table content - expect(await screen.findByText('Name')).toBeInTheDocument(); - expect(await screen.findByText('Chart')).toBeInTheDocument(); - expect(await screen.findByText('App version')).toBeInTheDocument(); + // Check for the badge content + expect(await findByText(/Namespace/)).toBeInTheDocument(); + expect(await findByText(/Chart version:/)).toBeInTheDocument(); + expect(await findByText(/Chart:/)).toBeInTheDocument(); + expect(await findByText(/Revision/)).toBeInTheDocument(); // Check for the actual values - expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent( - 'test-release' - ); - expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument(); - expect(await screen.findByText('1.0.0')).toBeInTheDocument(); + expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge + expect(await findAllByText(/test-chart/)).toHaveLength(2); + + // There shouldn't be a notes tab when there are no notes + 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 () => { // Mock API failure 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 @@ -97,23 +138,20 @@ describe('HelmApplicationView', () => { vi.spyOn(console, 'error').mockRestore(); }); - it('should display error message when release is not found', async () => { - // Mock empty response (no releases found) + it('should display additional details when available in helm release', async () => { 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 - vi.spyOn(console, 'error').mockImplementation(() => {}); + const { findByText } = renderComponent(); - renderComponent(); + // Check for the notes tab when notes are available + expect(await findByText(/Notes/)).toBeInTheDocument(); - // Wait for the error message to appear - expect( - await screen.findByText('Failed to load Helm application details') - ).toBeInTheDocument(); - - // Restore console.error - vi.spyOn(console, 'error').mockRestore(); + // Check for the app version badge when it's available + expect(await findByText(/App version/)).toBeInTheDocument(); + expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument(); }); }); diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx index fa02574cc..2fc5fb601 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx @@ -1,12 +1,21 @@ import { useCurrentStateAndParams } from '@uirouter/react'; +import helm from '@/assets/ico/vendor/helm.svg?c'; 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() { + const environmentId = useEnvironmentId(); const { params } = useCurrentStateAndParams(); - const { name, namespace } = params; return ( @@ -22,9 +31,58 @@ export function HelmApplicationView() {
- + + {name && } + + + +
); } + +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 ; + } + + if (isError) { + return ( + + ); + } + + if (!release) { + return ; + } + + return ( + <> + +
+ + + + + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx deleted file mode 100644 index 97703e0ff..000000000 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx +++ /dev/null @@ -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 ( - - - - {isInitialLoading && } - - {isError && ( - - )} - - {!isInitialLoading && !isError && release && ( - - - - - - - - - - - - - - - -
Name - {release.name} -
Chart{release.chart}
App version{release.app_version}
- )} -
-
- ); -} diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx new file mode 100644 index 000000000..29f9e07d8 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx @@ -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 ( +
+
+
+ + {getText(release.info?.status)} + +
+
+ {!!release.namespace && Namespace: {release.namespace}} + {!!release.version && Revision: #{release.version}} + {!!release.chart?.metadata?.name && ( + Chart: {release.chart.metadata.name} + )} + {!!release.chart?.metadata?.appVersion && ( + App version: {release.chart.metadata.appVersion} + )} + {!!release.chart?.metadata?.version && ( + + Chart version: {release.chart.metadata.name}- + {release.chart.metadata.version} + + )} +
+ {!!release.info?.description && !isSuccess && ( + + {release.info?.description} + + )} +
+
+ ); +} + +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'; + } +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx new file mode 100644 index 000000000..c95886d3f --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx @@ -0,0 +1,18 @@ +import { CodeEditor } from '@@/CodeEditor'; + +type Props = { + manifest: string; +}; + +export function ManifestDetails({ manifest }: Props) { + return ( + + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx new file mode 100644 index 000000000..74a6135bd --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx @@ -0,0 +1,9 @@ +import Markdown from 'markdown-to-jsx'; + +type Props = { + notes: string; +}; + +export function NotesDetails({ notes }: Props) { + return {notes}; +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx new file mode 100644 index 000000000..6a954d1fc --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx @@ -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[] { + return compact([ + { + label: 'Resources', + id: 'resources', + children: , + }, + { + label: 'Values', + id: 'values', + children: ( + + ), + }, + { + label: 'Manifest', + id: 'manifest', + children: , + }, + !!release.info?.notes && { + label: 'Notes', + id: 'notes', + children: , + }, + ]); +} + +export function ReleaseTabs({ release }: Props) { + const [tab, setTab] = useState('resources'); + // state is here so that the state isn't lost when the tab changes + const [isUserSupplied, setIsUserSupplied] = useState(true); + + return ( + + onSelect={setTab} + selectedId={tab} + type="pills" + justified + options={helmTabs(release, isUserSupplied, setIsUserSupplied)} + /> + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx new file mode 100644 index 000000000..a1510aef8 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx @@ -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( + + ); +} + +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 + ); + }); +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.tsx new file mode 100644 index 000000000..f75fbf802 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.tsx @@ -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 ( + + + + {isLoading ? ( + Loading... + ) : ( + <> + {isError ? ( + + Error loading resource details + + ) : ( + + )} + + )} + + + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx new file mode 100644 index 000000000..cf0dfde71 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx @@ -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(); +} + +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'); + }); +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx new file mode 100644 index 000000000..c8922ca04 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx @@ -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 ( + + row.id} + data-cy="helm-resources-datatable" + /> + + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/actions.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/actions.tsx new file mode 100644 index 000000000..434876342 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/actions.tsx @@ -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) { + const { describe } = row.original; + const [modalOpen, setModalOpen] = useState(false); + + return ( + <> + + + {modalOpen && ( + setModalOpen(false)} + /> + )} + + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/helper.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/helper.ts new file mode 100644 index 000000000..4ba26020e --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { ResourceRow } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/index.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/index.ts new file mode 100644 index 000000000..3f40e40c7 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/index.ts @@ -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]; diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx new file mode 100644 index 000000000..332e49f1c --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx @@ -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) { + const { name } = row.original; + + if (name.link && name.link.to) { + return ( + + {name.label} + + ); + } + + return name.label; +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/resourceType.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/resourceType.tsx new file mode 100644 index 000000000..e1df5ffbb --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/resourceType.tsx @@ -0,0 +1,6 @@ +import { columnHelper } from './helper'; + +export const resourceType = columnHelper.accessor((row) => row.resourceType, { + header: 'Resource type', + id: 'resourceType', +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx new file mode 100644 index 000000000..b9ff43fff --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx @@ -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) { + const { status } = row.original; + return {status.label}; +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/statusMessage.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/statusMessage.tsx new file mode 100644 index 000000000..5b5b8135e --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/statusMessage.tsx @@ -0,0 +1,11 @@ +import { columnHelper } from './helper'; + +export const statusMessage = columnHelper.accessor((row) => row.statusMessage, { + header: 'Status message', + id: 'statusMessage', + cell: ({ row }) => ( +
+ {row.original.statusMessage || '-'} +
+ ), +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/queries/useDescribeResource.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/queries/useDescribeResource.ts new file mode 100644 index 000000000..da09a6224 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/queries/useDescribeResource.ts @@ -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( + `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'), + } + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/types.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/types.ts new file mode 100644 index 000000000..ebc1bc10b --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/types.ts @@ -0,0 +1,27 @@ +import { StatusBadgeType } from '@@/StatusBadge'; + +export type ResourceLink = { + to?: string; + params?: Record; +}; + +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; +}; diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts new file mode 100644 index 000000000..e5301dc05 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts @@ -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 = { + 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, + }, + }; +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx new file mode 100644 index 000000000..b1f32ca3f --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx @@ -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 ( +
+ {/* bring in line with the code editor copy button */} +
+ setIsUserSupplied(!isUserSupplied)} + data-cy="values-details-user-supplied" + /> +
+ +
+ ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts index b7cfcb91c..f503aec7f 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts @@ -2,55 +2,33 @@ import { useQuery } from '@tanstack/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { withGlobalError } from '@/react-tools/react-query'; -import PortainerError from 'Portainer/error'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -interface HelmRelease { - 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 { - try { - const { namespace, filter, selector, output } = options; - const url = `endpoints/${environmentId}/kubernetes/helm`; - const { data } = await axios.get(url, { - params: { namespace, filter, selector, output }, - }); - return data; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve release list'); - } -} +import { HelmRelease } from '../../types'; /** * React hook to fetch a specific Helm release */ -export function useHelmRelease( +export function useHelmRelease( environmentId: EnvironmentId, name: string, - namespace: string + namespace: string, + options: { + select?: (data: HelmRelease) => T; + showResources?: boolean; + } = {} ) { return useQuery( - [environmentId, 'helm', namespace, name], - () => getHelmRelease(environmentId, name, namespace), + [environmentId, 'helm', 'releases', namespace, name, options.showResources], + () => + getHelmRelease(environmentId, name, { + namespace, + showResources: options.showResources, + }), { - enabled: !!environmentId, + enabled: !!environmentId && !!name && !!namespace, ...withGlobalError('Unable to retrieve helm application details'), + select: options.select, } ); } @@ -61,23 +39,20 @@ export function useHelmRelease( async function getHelmRelease( environmentId: EnvironmentId, name: string, - namespace: string -): Promise { + params: { + namespace: string; + showResources?: boolean; + } +) { try { - const releases = await listReleases(environmentId, { - filter: `^${name}$`, - namespace, - }); - - 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 + const { data } = await axios.get( + `endpoints/${environmentId}/kubernetes/helm/${name}`, + { + params, + } ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve helm application details'); } } diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts index 8b043a080..49445fa30 100644 --- a/app/react/kubernetes/helm/types.ts +++ b/app/react/kubernetes/helm/types.ts @@ -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 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; + /** 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 { repo: string; } diff --git a/go.mod b/go.mod index 4a28e80a1..ad6c86799 100644 --- a/go.mod +++ b/go.mod @@ -54,11 +54,14 @@ require ( golang.org/x/oauth2 v0.23.0 golang.org/x/sync v0.11.0 gopkg.in/alecthomas/kingpin.v2 v2.2.6 + gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.17.1 k8s.io/api 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/kubectl v0.32.1 k8s.io/metrics v0.32.1 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/evanphx/json-patch v5.9.0+incompatible // 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/felixge/httpsnoop v1.0.4 // 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/inf.v0 v0.9.1 // 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/apiserver v0.32.1 // indirect - k8s.io/cli-runtime v0.32.1 // indirect k8s.io/component-base v0.32.1 // indirect k8s.io/klog/v2 v2.130.1 // 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 oras.land/oras-go v1.2.5 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect diff --git a/go.sum b/go.sum index 8fbb68613..110e5eb4b 100644 --- a/go.sum +++ b/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/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/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/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 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/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/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/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= diff --git a/package.json b/package.json index b67cd56ff..e0cc82463 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "json-schema": "^0.4.0", "lodash": "^4.17.21", "lucide-react": "^0.468.0", + "markdown-to-jsx": "^7.7.4", "moment": "^2.29.1", "moment-timezone": "^0.5.40", "mustache": "^4.2.0", diff --git a/pkg/libhelm/options/get_options.go b/pkg/libhelm/options/get_options.go index a65cd75a7..fcac76ab9 100644 --- a/pkg/libhelm/options/get_options.go +++ b/pkg/libhelm/options/get_options.go @@ -1,21 +1,11 @@ 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 { - Name string - Namespace string - ReleaseResource releaseResource + Name string + Namespace string + // ShowResources indicates whether to display the resources of the named release + ShowResources bool + Revision int KubernetesClusterAccess *KubernetesClusterAccess Env []string diff --git a/pkg/libhelm/options/history_options.go b/pkg/libhelm/options/history_options.go new file mode 100644 index 000000000..76bb02e47 --- /dev/null +++ b/pkg/libhelm/options/history_options.go @@ -0,0 +1,9 @@ +package options + +type HistoryOptions struct { + Name string + Namespace string + KubernetesClusterAccess *KubernetesClusterAccess + + Env []string +} diff --git a/pkg/libhelm/release/release.go b/pkg/libhelm/release/release.go index bd1e32917..56888291c 100644 --- a/pkg/libhelm/release/release.go +++ b/pkg/libhelm/release/release.go @@ -1,6 +1,9 @@ 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. // The struct definitions have been copied from the official Helm Golang client/library. @@ -14,7 +17,7 @@ type ReleaseElement struct { Updated string `json:"updated"` Status string `json:"status"` Chart string `json:"chart"` - AppVersion string `json:"app_version"` + AppVersion string `json:"appVersion"` } // 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 string `json:"name,omitempty"` // Info provides information about a release - // Info *Info `json:"info,omitempty"` + Info *Info `json:"info,omitempty"` // Chart is the chart that was released. Chart Chart `json:"chart,omitempty"` // Config is the set of extra Values added to the chart. @@ -40,6 +43,13 @@ type Release struct { // Labels of the release. // Disabled encoding into Json cause labels are stored in storage driver metadata field. 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 @@ -183,6 +193,8 @@ type Info struct { Status Status `json:"status,omitempty"` // Contains the rendered templates/NOTES.txt if available 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 diff --git a/pkg/libhelm/sdk/get.go b/pkg/libhelm/sdk/get.go new file mode 100644 index 000000000..51a5c3bce --- /dev/null +++ b/pkg/libhelm/sdk/get.go @@ -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, + } +} diff --git a/pkg/libhelm/sdk/get_test.go b/pkg/libhelm/sdk/get_test.go new file mode 100644 index 000000000..992f5f9fd --- /dev/null +++ b/pkg/libhelm/sdk/get_test.go @@ -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) + }) +} diff --git a/pkg/libhelm/sdk/history.go b/pkg/libhelm/sdk/history.go new file mode 100644 index 000000000..ba6fa4d5d --- /dev/null +++ b/pkg/libhelm/sdk/history.go @@ -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, + }, + }, + } +} diff --git a/pkg/libhelm/sdk/history_test.go b/pkg/libhelm/sdk/history_test.go new file mode 100644 index 000000000..718911697 --- /dev/null +++ b/pkg/libhelm/sdk/history_test.go @@ -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) + }) +} diff --git a/pkg/libhelm/sdk/resources.go b/pkg/libhelm/sdk/resources.go new file mode 100644 index 000000000..0c1dce52a --- /dev/null +++ b/pkg/libhelm/sdk/resources.go @@ -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 +} diff --git a/pkg/libhelm/sdk/resources_test.go b/pkg/libhelm/sdk/resources_test.go new file mode 100644 index 000000000..8bd3b8c21 --- /dev/null +++ b/pkg/libhelm/sdk/resources_test.go @@ -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) + }) +} diff --git a/pkg/libhelm/sdk/values.go b/pkg/libhelm/sdk/values.go index 37ad46775..8818f998f 100644 --- a/pkg/libhelm/sdk/values.go +++ b/pkg/libhelm/sdk/values.go @@ -4,7 +4,11 @@ import ( "os" "github.com/pkg/errors" + "github.com/portainer/portainer/pkg/libhelm/options" + "github.com/portainer/portainer/pkg/libhelm/release" "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 @@ -40,3 +44,75 @@ func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map 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 +} diff --git a/pkg/libhelm/test/mock.go b/pkg/libhelm/test/mock.go index 58d53715c..518b53fbd 100644 --- a/pkg/libhelm/test/mock.go +++ b/pkg/libhelm/test/mock.go @@ -1,6 +1,7 @@ package test import ( + "slices" "strings" "github.com/portainer/portainer/pkg/libhelm/options" @@ -91,29 +92,11 @@ func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, e 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) func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error { for i, rel := range mockCharts { 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 @@ -124,6 +107,25 @@ func (hpm *helmMockPackageManager) List(listOpts options.ListOptions) ([]release 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 entries: portainer: diff --git a/pkg/libhelm/types/types.go b/pkg/libhelm/types/types.go index 8c0caa80b..7c78cbe88 100644 --- a/pkg/libhelm/types/types.go +++ b/pkg/libhelm/types/types.go @@ -14,6 +14,8 @@ type HelmPackageManager interface { List(listOpts options.ListOptions) ([]release.ReleaseElement, error) Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error) Uninstall(uninstallOpts options.UninstallOptions) error + Get(getOpts options.GetOptions) (*release.Release, error) + GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error) } type Repository interface { diff --git a/pkg/libkubectl/client.go b/pkg/libkubectl/client.go new file mode 100644 index 000000000..bfb86f172 --- /dev/null +++ b/pkg/libkubectl/client.go @@ -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 +} diff --git a/pkg/libkubectl/client_test.go b/pkg/libkubectl/client_test.go new file mode 100644 index 000000000..c2c7fd739 --- /dev/null +++ b/pkg/libkubectl/client_test.go @@ -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") + } + } + }) + } +} diff --git a/pkg/libkubectl/describe.go b/pkg/libkubectl/describe.go new file mode 100644 index 000000000..19588f606 --- /dev/null +++ b/pkg/libkubectl/describe.go @@ -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 --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 +} diff --git a/yarn.lock b/yarn.lock index a041f9578..4f1b90b95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" 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: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"