1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

feat(helm): update helm view [r8s-256] (#582)

Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
Ali 2025-04-10 16:08:24 +12:00 committed by GitHub
parent 46eddbe7b9
commit 0ca9321db1
57 changed files with 2635 additions and 222 deletions

View file

@ -54,6 +54,14 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm", h.Handle("/{id}/kubernetes/helm",
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost) httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
// `helm get all [RELEASE_NAME]`
h.Handle("/{id}/kubernetes/helm/{release}",
httperror.LoggerHandler(h.helmGet)).Methods(http.MethodGet)
// `helm history [RELEASE_NAME]`
h.Handle("/{id}/kubernetes/helm/{release}/history",
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
return h return h
} }

View file

@ -0,0 +1,67 @@
package helm
import (
"net/http"
"github.com/portainer/portainer/pkg/libhelm/options"
_ "github.com/portainer/portainer/pkg/libhelm/release"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id HelmGet
// @summary Get a helm release
// @description Get details of a helm release by release name
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param name path string true "Helm release name"
// @param namespace query string false "specify an optional namespace"
// @param showResources query boolean false "show resources of the release"
// @param revision query int false "specify an optional revision"
// @success 200 {object} release.Release "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve the release."
// @router /endpoints/{id}/kubernetes/helm/{name} [get]
func (handler *Handler) helmGet(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
release, err := request.RetrieveRouteVariableValue(r, "release")
if err != nil {
return httperror.BadRequest("No release specified", err)
}
clusterAccess, httperr := handler.getHelmClusterAccess(r)
if httperr != nil {
return httperr
}
// build the get options
getOpts := options.GetOptions{
KubernetesClusterAccess: clusterAccess,
Name: release,
}
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
// optional namespace. The library defaults to "default"
if namespace != "" {
getOpts.Namespace = namespace
}
showResources, _ := request.RetrieveBooleanQueryParameter(r, "showResources", true)
getOpts.ShowResources = showResources
revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
// optional revision. The library defaults to the latest revision if not specified
if revision > 0 {
getOpts.Revision = revision
}
releases, err := handler.helmPackageManager.Get(getOpts)
if err != nil {
return httperror.InternalServerError("Helm returned an error", err)
}
return response.JSON(w, releases)
}

View file

@ -0,0 +1,66 @@
package helm
import (
"io"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/exec/exectest"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
)
func Test_helmGet(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
is.NoError(err, "Error creating environment")
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "Error creating a user")
jwtService, err := jwt.NewService("1h", store)
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
is.NotNil(h, "Handler should not fail")
// Install a single chart, to be retrieved by the handler
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
h.helmPackageManager.Upgrade(options)
t.Run("helmGet sucessfuly retrieves helm release", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"?namespace="+options.Namespace, nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
data := release.Release{}
body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error")
json.Unmarshal(body, &data)
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
is.Equal("nginx-1", data.Name)
})
}

View file

@ -0,0 +1,58 @@
package helm
import (
"net/http"
"github.com/portainer/portainer/pkg/libhelm/options"
_ "github.com/portainer/portainer/pkg/libhelm/release"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id HelmGetHistory
// @summary Get a historical list of releases
// @description Get a historical list of releases by release name
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param name path string true "Helm release name"
// @param namespace query string false "specify an optional namespace"
// @success 200 {array} release.Release "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve the historical list of releases."
// @router /endpoints/{id}/kubernetes/helm/{release}/history [get]
func (handler *Handler) helmGetHistory(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
release, err := request.RetrieveRouteVariableValue(r, "release")
if err != nil {
return httperror.BadRequest("No release specified", err)
}
clusterAccess, httperr := handler.getHelmClusterAccess(r)
if httperr != nil {
return httperr
}
historyOptions := options.HistoryOptions{
KubernetesClusterAccess: clusterAccess,
Name: release,
}
// optional namespace. The library defaults to "default"
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
if namespace != "" {
historyOptions.Namespace = namespace
}
releases, err := handler.helmPackageManager.GetHistory(historyOptions)
if err != nil {
return httperror.InternalServerError("Helm returned an error", err)
}
return response.JSON(w, releases)
}

View file

@ -0,0 +1,67 @@
package helm
import (
"io"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/exec/exectest"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
)
func Test_helmGetHistory(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
is.NoError(err, "Error creating environment")
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "Error creating a user")
jwtService, err := jwt.NewService("1h", store)
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
is.NotNil(h, "Handler should not fail")
// Install a single chart, to be retrieved by the handler
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
h.helmPackageManager.Upgrade(options)
t.Run("helmGetHistory sucessfuly retrieves helm release history", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"/history?namespace="+options.Namespace, nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
data := []release.Release{}
body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error")
json.Unmarshal(body, &data)
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
is.Equal(1, len(data))
is.Equal("nginx-1", data[0].Name)
})
}

View file

@ -0,0 +1,73 @@
package kubernetes
import (
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/libkubectl"
"github.com/rs/zerolog/log"
)
type describeResourceResponse struct {
Describe string `json:"describe"`
}
// @id DescribeResource
// @summary Get a description of a kubernetes resource
// @description Get a description of a kubernetes resource.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param name query string true "Resource name"
// @param kind query string true "Resource kind"
// @param namespace query string false "Namespace"
// @success 200 {object} describeResourceResponse "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve resource description"
// @router /kubernetes/{id}/describe [get]
func (handler *Handler) describeResource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
name, err := request.RetrieveQueryParameter(r, "name", false)
if err != nil {
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter name")
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter name. Error: ", err)
}
kind, err := request.RetrieveQueryParameter(r, "kind", false)
if err != nil {
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter kind")
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter kind. Error: ", err)
}
namespace, err := request.RetrieveQueryParameter(r, "namespace", true)
if err != nil {
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter namespace")
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter namespace. Error: ", err)
}
// fetches the token and the correct server URL for the endpoint, similar to getHelmClusterAccess
libKubectlAccess, err := handler.getLibKubectlAccess(r)
if err != nil {
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to get libKubectlAccess. Error: ", err)
}
client, err := libkubectl.NewClient(libKubectlAccess, namespace, "", true)
if err != nil {
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to create kubernetes client")
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to create kubernetes client. Error: ", err)
}
out, err := client.Describe(namespace, name, kind)
if err != nil {
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to describe kubernetes resource")
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to describe kubernetes resource. Error: ", err)
}
return response.JSON(w, describeResourceResponse{Describe: out})
}

View file

@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libkubectl"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -102,6 +103,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost) endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet) endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost) endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet)
// namespaces // namespaces
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?) // in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
@ -269,3 +271,36 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
func (handler *Handler) getLibKubectlAccess(r *http.Request) (*libkubectl.ClientAccess, error) {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
bearerToken, _, err := handler.JwtService.GenerateToken(tokenData)
if err != nil {
return nil, httperror.Unauthorized("Unauthorized", err)
}
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to find the Kubernetes endpoint associated to the request.", err)
}
sslSettings, err := handler.DataStore.SSLSettings().Settings()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
hostURL := "localhost"
if !sslSettings.SelfSigned {
hostURL = r.Host
}
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
return &libkubectl.ClientAccess{
Token: bearerToken,
ServerUrl: kubeConfigInternal.ClusterServerURL,
}, nil
}

View file

@ -36,7 +36,7 @@ interface Props extends AutomationTestingProps {
placeholder?: string; placeholder?: string;
type?: Type; type?: Type;
readonly?: boolean; readonly?: boolean;
onChange: (value: string) => void; onChange?: (value: string) => void;
value: string; value: string;
height?: string; height?: string;
versions?: number[]; versions?: number[];
@ -136,7 +136,7 @@ function schemaValidationExtensions(schema: JSONSchema7) {
export function CodeEditor({ export function CodeEditor({
id, id,
onChange, onChange = () => {},
placeholder, placeholder,
readonly, readonly,
value, value,

View file

@ -1 +1,2 @@
export { NavTabs } from './NavTabs'; export { NavTabs } from './NavTabs';
export type { Option } from './NavTabs';

View file

@ -3,6 +3,56 @@ import { AriaAttributes, PropsWithChildren } from 'react';
import { Icon, IconProps } from '@@/Icon'; import { Icon, IconProps } from '@@/Icon';
export type StatusBadgeType =
| 'success'
| 'danger'
| 'warning'
| 'info'
| 'successLite'
| 'dangerLite'
| 'warningLite'
| 'mutedLite'
| 'infoLite'
| 'default';
const typeClasses: Record<StatusBadgeType, string> = {
success: clsx(
'text-white bg-success-7',
'th-dark:text-white th-dark:bg-success-9'
),
warning: clsx(
'text-white bg-warning-7',
'th-dark:text-white th-dark:bg-warning-9'
),
danger: clsx(
'text-white bg-error-7',
'th-dark:text-white th-dark:bg-error-9'
),
info: clsx('text-white bg-blue-7', 'th-dark:text-white th-dark:bg-blue-9'),
// the lite classes are a bit lighter in light mode and the same in dark mode
successLite: clsx(
'text-success-9 bg-success-3',
'th-dark:text-white th-dark:bg-success-9'
),
warningLite: clsx(
'text-warning-9 bg-warning-3',
'th-dark:text-white th-dark:bg-warning-9'
),
dangerLite: clsx(
'text-error-9 bg-error-3',
'th-dark:text-white th-dark:bg-error-9'
),
mutedLite: clsx(
'text-gray-9 bg-gray-3',
'th-dark:text-white th-dark:bg-gray-9'
),
infoLite: clsx(
'text-blue-9 bg-blue-3',
'th-dark:text-white th-dark:bg-blue-9'
),
default: '',
};
export function StatusBadge({ export function StatusBadge({
className, className,
children, children,
@ -12,7 +62,7 @@ export function StatusBadge({
}: PropsWithChildren< }: PropsWithChildren<
{ {
className?: string; className?: string;
color?: 'success' | 'danger' | 'warning' | 'info' | 'default'; color?: StatusBadgeType;
icon?: IconProps['icon']; icon?: IconProps['icon'];
} & AriaAttributes } & AriaAttributes
>) { >) {
@ -21,13 +71,8 @@ export function StatusBadge({
className={clsx( className={clsx(
'inline-flex items-center gap-1 rounded', 'inline-flex items-center gap-1 rounded',
'w-fit px-1.5 py-0.5', 'w-fit px-1.5 py-0.5',
'text-sm font-medium text-white', 'text-sm font-medium',
{ typeClasses[color],
'bg-success-7 th-dark:bg-success-9': color === 'success',
'bg-warning-7 th-dark:bg-warning-9': color === 'warning',
'bg-error-7 th-dark:bg-error-9': color === 'danger',
'bg-blue-9': color === 'info',
},
className className
)} )}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading

View file

@ -70,6 +70,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
getRowCanExpand?(row: Row<D>): boolean; getRowCanExpand?(row: Row<D>): boolean;
noWidget?: boolean; noWidget?: boolean;
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>; extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
includeSearch?: boolean;
} }
export function Datatable<D extends DefaultType>({ export function Datatable<D extends DefaultType>({
@ -98,6 +99,7 @@ export function Datatable<D extends DefaultType>({
totalCount = dataset.length, totalCount = dataset.length,
isServerSidePagination = false, isServerSidePagination = false,
extendTableOptions = (value) => value, extendTableOptions = (value) => value,
includeSearch,
}: Props<D> & PaginationProps) { }: Props<D> & PaginationProps) {
const pageCount = useMemo( const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize), () => Math.ceil(totalCount / settings.pageSize),
@ -192,6 +194,7 @@ export function Datatable<D extends DefaultType>({
renderTableActions={() => renderTableActions(selectedItems)} renderTableActions={() => renderTableActions(selectedItems)}
renderTableSettings={() => renderTableSettings(tableInstance)} renderTableSettings={() => renderTableSettings(tableInstance)}
data-cy={`${dataCy}-header`} data-cy={`${dataCy}-header`}
includeSearch={includeSearch}
/> />
<DatatableContent<D> <DatatableContent<D>

View file

@ -16,6 +16,7 @@ type Props = {
renderTableActions?(): ReactNode; renderTableActions?(): ReactNode;
description?: ReactNode; description?: ReactNode;
titleId?: string; titleId?: string;
includeSearch?: boolean;
} & AutomationTestingProps; } & AutomationTestingProps;
export function DatatableHeader({ export function DatatableHeader({
@ -28,8 +29,9 @@ export function DatatableHeader({
description, description,
titleId, titleId,
'data-cy': dataCy, 'data-cy': dataCy,
includeSearch = !!title,
}: Props) { }: Props) {
if (!title) { if (!title && !includeSearch) {
return null; return null;
} }
@ -50,12 +52,12 @@ export function DatatableHeader({
return ( return (
<Table.Title <Table.Title
id={titleId} id={titleId}
label={title} label={title ?? ''}
icon={titleIcon} icon={titleIcon}
description={description} description={description}
data-cy={dataCy} data-cy={dataCy}
> >
{searchBar} {includeSearch && searchBar}
{tableActions} {tableActions}
{tableTitleSettings} {tableTitleSettings}
</Table.Title> </Table.Title>

View file

@ -47,10 +47,14 @@ export function Modal({
<DialogContent <DialogContent
aria-label={ariaLabel} aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy} aria-labelledby={ariaLabelledBy}
className={clsx(styles.modalDialog, 'bg-transparent p-0', { className={clsx(
'w-[450px]': size === 'md', styles.modalDialog,
'w-[700px]': size === 'lg', 'max-w-[calc(100vw-2rem)] bg-transparent p-0',
})} {
'w-[450px]': size === 'md',
'w-[700px]': size === 'lg',
}
)}
> >
<div className={clsx(styles.modalContent, 'relative', className)}> <div className={clsx(styles.modalContent, 'relative', className)}>
{children} {children}

View file

@ -6,6 +6,7 @@ import { server, http } from '@/setup-tests/server';
import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user'; import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
import { HelmApplicationView } from './HelmApplicationView'; import { HelmApplicationView } from './HelmApplicationView';
@ -22,6 +23,41 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(), useEnvironmentId: () => mockUseEnvironmentId(),
})); }));
mockCodeMirror();
const minimalHelmRelease = {
name: 'test-release',
version: '1',
namespace: 'default',
chart: {
metadata: {
name: 'test-chart',
// appVersion: '1.0.0', // can be missing for a minimal release
version: '2.2.2',
},
},
info: {
status: 'deployed',
// notes: 'This is a test note', // can be missing for a minimal release
},
manifest: 'This is a test manifest',
};
const helmReleaseWithAdditionalDetails = {
...minimalHelmRelease,
info: {
...minimalHelmRelease.info,
notes: 'This is a test note',
},
chart: {
...minimalHelmRelease.chart,
metadata: {
...minimalHelmRelease.chart.metadata,
appVersion: '1.0.0',
},
},
};
function renderComponent() { function renderComponent() {
const user = new UserViewModel({ Username: 'user' }); const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider( const Wrapped = withTestQueryProvider(
@ -40,47 +76,52 @@ describe('HelmApplicationView', () => {
namespace: 'default', namespace: 'default',
}, },
}); });
// Set up default mock API responses
server.use(
http.get('/api/endpoints/3/kubernetes/helm', () =>
HttpResponse.json([
{
name: 'test-release',
chart: 'test-chart-1.0.0',
app_version: '1.0.0',
},
])
)
);
}); });
it('should display helm release details when data is loaded', async () => { it('should display helm release details for minimal release when data is loaded', async () => {
renderComponent(); vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
HttpResponse.json(minimalHelmRelease)
)
);
const { findByText, findAllByText } = renderComponent();
// Check for the page header // Check for the page header
expect(await screen.findByText('Helm details')).toBeInTheDocument(); expect(await findByText('Helm details')).toBeInTheDocument();
// Check for the release details // Check for the badge content
expect(await screen.findByText('Release')).toBeInTheDocument(); expect(await findByText(/Namespace/)).toBeInTheDocument();
expect(await findByText(/Chart version:/)).toBeInTheDocument();
// Check for the table content expect(await findByText(/Chart:/)).toBeInTheDocument();
expect(await screen.findByText('Name')).toBeInTheDocument(); expect(await findByText(/Revision/)).toBeInTheDocument();
expect(await screen.findByText('Chart')).toBeInTheDocument();
expect(await screen.findByText('App version')).toBeInTheDocument();
// Check for the actual values // Check for the actual values
expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent( expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
'test-release' expect(await findAllByText(/test-chart/)).toHaveLength(2);
);
expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument(); // There shouldn't be a notes tab when there are no notes
expect(await screen.findByText('1.0.0')).toBeInTheDocument(); expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
// There shouldn't be an app version badge when it's missing
expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
// Ensure there are no console errors
// eslint-disable-next-line no-console
expect(console.error).not.toHaveBeenCalled();
// Restore console.error
vi.spyOn(console, 'error').mockRestore();
}); });
it('should display error message when API request fails', async () => { it('should display error message when API request fails', async () => {
// Mock API failure // Mock API failure
server.use( server.use(
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error()) http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
HttpResponse.error()
)
); );
// Mock console.error to prevent test output pollution // Mock console.error to prevent test output pollution
@ -97,23 +138,20 @@ describe('HelmApplicationView', () => {
vi.spyOn(console, 'error').mockRestore(); vi.spyOn(console, 'error').mockRestore();
}); });
it('should display error message when release is not found', async () => { it('should display additional details when available in helm release', async () => {
// Mock empty response (no releases found)
server.use( server.use(
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([])) http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
HttpResponse.json(helmReleaseWithAdditionalDetails)
)
); );
// Mock console.error to prevent test output pollution const { findByText } = renderComponent();
vi.spyOn(console, 'error').mockImplementation(() => {});
renderComponent(); // Check for the notes tab when notes are available
expect(await findByText(/Notes/)).toBeInTheDocument();
// Wait for the error message to appear // Check for the app version badge when it's available
expect( expect(await findByText(/App version/)).toBeInTheDocument();
await screen.findByText('Failed to load Helm application details') expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument();
).toBeInTheDocument();
// Restore console.error
vi.spyOn(console, 'error').mockRestore();
}); });
}); });

View file

@ -1,12 +1,21 @@
import { useCurrentStateAndParams } from '@uirouter/react'; import { useCurrentStateAndParams } from '@uirouter/react';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { PageHeader } from '@/react/components/PageHeader'; import { PageHeader } from '@/react/components/PageHeader';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { HelmDetailsWidget } from './HelmDetailsWidget'; import { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
import { Card } from '@@/Card';
import { Alert } from '@@/Alert';
import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
import { useHelmRelease } from './queries/useHelmRelease';
export function HelmApplicationView() { export function HelmApplicationView() {
const environmentId = useEnvironmentId();
const { params } = useCurrentStateAndParams(); const { params } = useCurrentStateAndParams();
const { name, namespace } = params; const { name, namespace } = params;
return ( return (
@ -22,9 +31,58 @@ export function HelmApplicationView() {
<div className="row"> <div className="row">
<div className="col-sm-12"> <div className="col-sm-12">
<HelmDetailsWidget name={name} namespace={namespace} /> <Widget>
{name && <WidgetTitle icon={helm} title={name} />}
<WidgetBody className="!pt-1">
<HelmDetails
name={name}
namespace={namespace}
environmentId={environmentId}
/>
</WidgetBody>
</Widget>
</div> </div>
</div> </div>
</> </>
); );
} }
type HelmDetailsProps = {
name: string;
namespace: string;
environmentId: EnvironmentId;
};
function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
const {
data: release,
isInitialLoading,
isError,
} = useHelmRelease(environmentId, name, namespace, {
showResources: true,
});
if (isInitialLoading) {
return <Loading />;
}
if (isError) {
return (
<Alert color="error" title="Failed to load Helm application details" />
);
}
if (!release) {
return <Alert color="error" title="No Helm application details found" />;
}
return (
<>
<HelmSummary release={release} />
<div className="my-6 h-[1px] w-full bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-white" />
<Card className="bg-inherit">
<ReleaseTabs release={release} />
</Card>
</>
);
}

View file

@ -1,67 +0,0 @@
import {
Loading,
Widget,
WidgetBody,
WidgetTitle,
} from '@/react/components/Widget';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Alert } from '@@/Alert';
import { useHelmRelease } from './queries/useHelmRelease';
interface HelmDetailsWidgetProps {
name: string;
namespace: string;
}
export function HelmDetailsWidget({ name, namespace }: HelmDetailsWidgetProps) {
const environmentId = useEnvironmentId();
const {
data: release,
isInitialLoading,
isError,
} = useHelmRelease(environmentId, name, namespace);
return (
<Widget>
<WidgetTitle icon={helm} title="Release" />
<WidgetBody>
{isInitialLoading && <Loading />}
{isError && (
<Alert
color="error"
title="Failed to load Helm application details"
/>
)}
{!isInitialLoading && !isError && release && (
<table className="table">
<tbody>
<tr>
<td className="!border-none w-40">Name</td>
<td
className="!border-none min-w-[140px]"
data-cy="k8sAppDetail-appName"
>
{release.name}
</td>
</tr>
<tr>
<td className="!border-t">Chart</td>
<td className="!border-t">{release.chart}</td>
</tr>
<tr>
<td>App version</td>
<td>{release.app_version}</td>
</tr>
</tbody>
</table>
)}
</WidgetBody>
</Widget>
);
}

View file

@ -0,0 +1,111 @@
import { Badge } from '@/react/components/Badge';
import { Alert } from '@@/Alert';
import { HelmRelease } from '../types';
interface Props {
release: HelmRelease;
}
export enum DeploymentStatus {
DEPLOYED = 'deployed',
FAILED = 'failed',
PENDING = 'pending-install',
PENDINGUPGRADE = 'pending-upgrade',
PENDINGROLLBACK = 'pending-rollback',
SUPERSEDED = 'superseded',
UNINSTALLED = 'uninstalled',
UNINSTALLING = 'uninstalling',
}
export function HelmSummary({ release }: Props) {
const isSuccess =
release.info?.status === DeploymentStatus.DEPLOYED ||
release.info?.status === DeploymentStatus.SUPERSEDED;
return (
<div>
<div className="flex flex-col gap-y-4">
<div>
<Badge type={getStatusColor(release.info?.status)}>
{getText(release.info?.status)}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{!!release.namespace && <Badge>Namespace: {release.namespace}</Badge>}
{!!release.version && <Badge>Revision: #{release.version}</Badge>}
{!!release.chart?.metadata?.name && (
<Badge>Chart: {release.chart.metadata.name}</Badge>
)}
{!!release.chart?.metadata?.appVersion && (
<Badge>App version: {release.chart.metadata.appVersion}</Badge>
)}
{!!release.chart?.metadata?.version && (
<Badge>
Chart version: {release.chart.metadata.name}-
{release.chart.metadata.version}
</Badge>
)}
</div>
{!!release.info?.description && !isSuccess && (
<Alert color={getAlertColor(release.info?.status)}>
{release.info?.description}
</Alert>
)}
</div>
</div>
);
}
function getAlertColor(status?: string) {
switch (status?.toLowerCase()) {
case DeploymentStatus.DEPLOYED:
return 'success';
case DeploymentStatus.FAILED:
return 'error';
case DeploymentStatus.PENDING:
case DeploymentStatus.PENDINGUPGRADE:
case DeploymentStatus.PENDINGROLLBACK:
case DeploymentStatus.UNINSTALLING:
return 'warn';
case DeploymentStatus.SUPERSEDED:
default:
return 'info';
}
}
function getStatusColor(status?: string) {
switch (status?.toLowerCase()) {
case DeploymentStatus.DEPLOYED:
return 'success';
case DeploymentStatus.FAILED:
return 'danger';
case DeploymentStatus.PENDING:
case DeploymentStatus.PENDINGUPGRADE:
case DeploymentStatus.PENDINGROLLBACK:
case DeploymentStatus.UNINSTALLING:
return 'warn';
case DeploymentStatus.SUPERSEDED:
default:
return 'info';
}
}
function getText(status?: string) {
switch (status?.toLowerCase()) {
case DeploymentStatus.DEPLOYED:
return 'Deployed';
case DeploymentStatus.FAILED:
return 'Failed';
case DeploymentStatus.PENDING:
case DeploymentStatus.PENDINGUPGRADE:
case DeploymentStatus.PENDINGROLLBACK:
case DeploymentStatus.UNINSTALLING:
return 'Pending';
case DeploymentStatus.SUPERSEDED:
return 'Superseded';
default:
return 'Unknown';
}
}

View file

@ -0,0 +1,18 @@
import { CodeEditor } from '@@/CodeEditor';
type Props = {
manifest: string;
};
export function ManifestDetails({ manifest }: Props) {
return (
<CodeEditor
id="helm-manifest"
type="yaml"
data-cy="helm-manifest"
value={manifest}
height="600px"
readonly
/>
);
}

View file

@ -0,0 +1,9 @@
import Markdown from 'markdown-to-jsx';
type Props = {
notes: string;
};
export function NotesDetails({ notes }: Props) {
return <Markdown className="list-inside mt-6">{notes}</Markdown>;
}

View file

@ -0,0 +1,68 @@
import { useState } from 'react';
import { compact } from 'lodash';
import { NavTabs, Option } from '@@/NavTabs';
import { HelmRelease } from '../../types';
import { ManifestDetails } from './ManifestDetails';
import { NotesDetails } from './NotesDetails';
import { ValuesDetails } from './ValuesDetails';
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
type Props = {
release: HelmRelease;
};
type Tab = 'values' | 'notes' | 'manifest' | 'resources';
function helmTabs(
release: HelmRelease,
isUserSupplied: boolean,
setIsUserSupplied: (isUserSupplied: boolean) => void
): Option<Tab>[] {
return compact([
{
label: 'Resources',
id: 'resources',
children: <ResourcesTable resources={release.info?.resources ?? []} />,
},
{
label: 'Values',
id: 'values',
children: (
<ValuesDetails
values={release.values}
isUserSupplied={isUserSupplied}
setIsUserSupplied={setIsUserSupplied}
/>
),
},
{
label: 'Manifest',
id: 'manifest',
children: <ManifestDetails manifest={release.manifest} />,
},
!!release.info?.notes && {
label: 'Notes',
id: 'notes',
children: <NotesDetails notes={release.info.notes} />,
},
]);
}
export function ReleaseTabs({ release }: Props) {
const [tab, setTab] = useState<Tab>('resources');
// state is here so that the state isn't lost when the tab changes
const [isUserSupplied, setIsUserSupplied] = useState(true);
return (
<NavTabs<Tab>
onSelect={setTab}
selectedId={tab}
type="pills"
justified
options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
/>
);
}

View file

@ -0,0 +1,123 @@
import { render, screen } from '@testing-library/react';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { DescribeModal } from './DescribeModal';
const mockUseDescribeResource = vi.fn();
vi.mock('yaml-schema', () => ({}));
vi.mock('./queries/useDescribeResource', () => ({
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
}));
function renderComponent({
name = 'test-resource',
resourceType = 'Deployment',
namespace = 'default',
onDismiss = vi.fn(),
} = {}) {
const Wrapped = withTestQueryProvider(DescribeModal);
return render(
<Wrapped
name={name}
resourceType={resourceType}
namespace={namespace}
onDismiss={onDismiss}
/>
);
}
describe('DescribeModal', () => {
beforeEach(() => {
mockUseDescribeResource.mockReset();
});
it('should display loading state initially', () => {
mockUseDescribeResource.mockReturnValue({
isLoading: true,
data: undefined,
isError: false,
});
renderComponent();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should display resource details when data is loaded successfully', () => {
const mockDescribeData = {
describe: 'Name: test-resource\nNamespace: default\nStatus: Running',
};
mockUseDescribeResource.mockReturnValue({
isLoading: false,
data: mockDescribeData,
isError: false,
});
renderComponent();
// Check for modal title
expect(screen.getByText('Describe Deployment')).toBeInTheDocument();
// Check for content
const editor = screen.getByTestId('describe-resource');
expect(editor).toBeInTheDocument();
expect(editor).toHaveTextContent('Name: test-resource');
expect(editor).toHaveTextContent('Namespace: default');
expect(editor).toHaveTextContent('Status: Running');
});
it('should display error message when query fails', () => {
mockUseDescribeResource.mockReturnValue({
isLoading: false,
data: undefined,
isError: true,
});
renderComponent();
expect(
screen.getByText('Error loading resource details')
).toBeInTheDocument();
});
it('should call onDismiss when modal is closed', () => {
mockUseDescribeResource.mockReturnValue({
isLoading: false,
data: { describe: '' },
isError: false,
});
const onDismiss = vi.fn();
renderComponent({ onDismiss });
// Find and click the close button
const closeButton = screen.getByText('×');
closeButton.click();
expect(onDismiss).toHaveBeenCalled();
});
it('should pass correct parameters to useDescribeResource', () => {
mockUseDescribeResource.mockReturnValue({
isLoading: true,
data: undefined,
isError: false,
});
const props = {
name: 'my-resource',
resourceType: 'Pod',
namespace: 'kube-system',
};
renderComponent(props);
expect(mockUseDescribeResource).toHaveBeenCalledWith(
props.name,
props.resourceType,
props.namespace
);
});
});

View file

@ -0,0 +1,57 @@
import { Alert } from '@@/Alert';
import { InlineLoader } from '@@/InlineLoader';
import { Modal } from '@@/modals';
import { ModalBody } from '@@/modals/Modal/ModalBody';
import { ModalHeader } from '@@/modals/Modal/ModalHeader';
import { CodeEditor } from '@@/CodeEditor';
import { useDescribeResource } from './queries/useDescribeResource';
type Props = {
name: string;
resourceType?: string;
namespace?: string;
onDismiss: () => void;
};
export function DescribeModal({
name,
resourceType,
namespace,
onDismiss,
}: Props) {
const title = `Describe ${resourceType}`;
const { data, isLoading, isError } = useDescribeResource(
name,
resourceType,
namespace
);
return (
<Modal onDismiss={onDismiss} size="lg" aria-label={title}>
<ModalHeader title={title} />
<ModalBody>
{isLoading ? (
<InlineLoader>Loading...</InlineLoader>
) : (
<>
{isError ? (
<Alert color="error" title="Error">
Error loading resource details
</Alert>
) : (
<CodeEditor
id="describe-resource"
data-cy="describe-resource"
readonly
value={data?.describe}
type="yaml"
/>
)}
</>
)}
</ModalBody>
</Modal>
);
}

View file

@ -0,0 +1,148 @@
import { render, screen, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { GenericResource } from '../../../types';
import { ResourcesTable } from './ResourcesTable';
const successResources = [
{
kind: 'ValidatingWebhookConfiguration',
apiVersion: 'admissionregistration.k8s.io/v1',
metadata: {
name: 'ingress-nginx-1743063493-admission',
uid: 'e5388792-c184-479d-9133-390759c4bded',
labels: {
'app.kubernetes.io/name': 'ingress-nginx',
},
},
status: {
healthSummary: {
status: 'Healthy',
reason: 'Exists',
},
},
},
{
kind: 'Deployment',
apiVersion: 'apps/v1',
metadata: {
name: 'ingress-nginx-1743063493-controller2',
namespace: 'default',
uid: 'dcfe325b-7065-47ed-91e3-47f60301cf2e',
labels: {
'app.kubernetes.io/name': 'ingress-nginx',
},
},
status: {
healthSummary: {
status: 'Healthy',
reason: 'MinimumReplicasAvailable',
message: 'Deployment has minimum availability.',
},
},
},
{
kind: 'Pod',
apiVersion: 'v1',
metadata: {
name: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-lsf9p',
generateName: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-',
namespace: 'default',
uid: '7176ad7c-0f83-4a65-a45e-d40076adc302',
labels: {
'app.kubernetes.io/name': 'ingress-nginx',
'pod-template-hash': '54d8f7d8c5',
},
},
status: {
phase: 'Running',
healthSummary: {
status: 'Unknown',
reason: 'Running',
},
hostIP: '198.19.249.2',
startTime: '2025-03-27T20:39:05Z',
},
},
];
const failedResources = [
{
kind: 'PodDisruptionBudget',
metadata: {
name: 'probe-failure-nginx-bad',
namespace: 'my-namespace',
uid: 'e4e15f7a-9a68-448e-86b3-d74ef29c718c',
labels: {
'app.kubernetes.io/name': 'nginx',
},
},
status: {
healthSummary: {
status: 'Unhealthy',
reason: 'InsufficientPods',
},
},
},
{
kind: 'Service',
apiVersion: 'v1',
metadata: {
name: 'probe-failure-nginx',
namespace: 'my-namespace',
uid: 'de9cdffc-6af8-43b2-9750-3ac764b25627',
labels: {
'app.kubernetes.io/name': 'nginx',
},
},
status: {
healthSummary: {
status: 'Healthy',
reason: 'Exists',
},
},
},
];
function renderResourcesTable(resources: GenericResource[]) {
const Wrapped = withTestQueryProvider(withTestRouter(ResourcesTable));
return render(<Wrapped resources={resources} />);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ResourcesTable', () => {
it('should show successful resources, including a link for the deployment and a message', () => {
renderResourcesTable(successResources);
// Check that the deployment is rendered with a link
const deploymentLink = screen.getByText(
'ingress-nginx-1743063493-controller2'
);
expect(deploymentLink).toBeInTheDocument();
expect(deploymentLink.closest('a')).toHaveTextContent(
'ingress-nginx-1743063493-controller2'
);
// Check that success badge is rendered
const successBadge = screen.getByText('MinimumReplicasAvailable');
expect(successBadge).toBeInTheDocument();
expect(successBadge.className).toContain('bg-success');
});
it('should show error badges for failed resources', () => {
renderResourcesTable(failedResources);
expect(screen.getByText('probe-failure-nginx-bad')).toBeInTheDocument();
// Check for the unhealthy status badge and make sure it has the error styling
const errorBadge = screen.getByText('InsufficientPods');
expect(errorBadge).toBeInTheDocument();
expect(errorBadge.className).toContain('bg-error');
});
});

View file

@ -0,0 +1,38 @@
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Widget } from '@@/Widget';
import { GenericResource } from '../../../types';
import { columns } from './columns';
import { useResourceRows } from './useResourceRows';
type Props = {
resources: GenericResource[];
};
const storageKey = 'helm-resources';
const settingsStore = createPersistedStore(storageKey, 'resourceType');
export function ResourcesTable({ resources }: Props) {
const tableState = useTableState(settingsStore, storageKey);
const rows = useResourceRows(resources);
return (
<Widget>
<Datatable
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
noWidget
dataset={rows}
columns={columns}
includeSearch
settingsManager={tableState}
emptyContentLabel="No resources found"
disableSelect
getRowId={(row) => row.id}
data-cy="helm-resources-datatable"
/>
</Widget>
);
}

View file

@ -0,0 +1,46 @@
import { useState } from 'react';
import { CellContext } from '@tanstack/react-table';
import { FileText } from 'lucide-react';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { ResourceRow } from '../types';
import { DescribeModal } from '../DescribeModal';
import { columnHelper } from './helper';
export const actions = columnHelper.accessor((row) => row.status.label, {
header: 'Actions',
id: 'actions',
cell: Cell,
enableSorting: false,
});
function Cell({ row }: CellContext<ResourceRow, string>) {
const { describe } = row.original;
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<Button
color="link"
data-cy="helm-resource-describe"
onClick={() => setModalOpen(true)}
className="pl-0 !ml-0"
>
<Icon icon={FileText} />
Describe
</Button>
{modalOpen && (
<DescribeModal
name={describe.name}
resourceType={describe.resourceType}
namespace={describe.namespace}
onDismiss={() => setModalOpen(false)}
/>
)}
</>
);
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { ResourceRow } from '../types';
export const columnHelper = createColumnHelper<ResourceRow>();

View file

@ -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];

View file

@ -0,0 +1,33 @@
import { CellContext } from '@tanstack/react-table';
import { Link } from '@@/Link';
import { ResourceRow } from '../types';
import { columnHelper } from './helper';
export const name = columnHelper.accessor((row) => row.name.label, {
header: 'Name',
cell: Cell,
id: 'name',
});
function Cell({ row }: CellContext<ResourceRow, string>) {
const { name } = row.original;
if (name.link && name.link.to) {
return (
<Link
to={name.link.to}
params={name.link.params}
title={name.label}
className="w-fit max-w-xs truncate xl:max-w-sm 2xl:max-w-md"
data-cy={`helm-resource-link-${name.label}`}
>
{name.label}
</Link>
);
}
return name.label;
}

View file

@ -0,0 +1,6 @@
import { columnHelper } from './helper';
export const resourceType = columnHelper.accessor((row) => row.resourceType, {
header: 'Resource type',
id: 'resourceType',
});

View file

@ -0,0 +1,18 @@
import { CellContext } from '@tanstack/react-table';
import { StatusBadge } from '@@/StatusBadge';
import { ResourceRow } from '../types';
import { columnHelper } from './helper';
export const status = columnHelper.accessor((row) => row.status.label, {
header: 'Status',
id: 'status',
cell: Cell,
});
function Cell({ row }: CellContext<ResourceRow, string>) {
const { status } = row.original;
return <StatusBadge color={status.type}>{status.label}</StatusBadge>;
}

View file

@ -0,0 +1,11 @@
import { columnHelper } from './helper';
export const statusMessage = columnHelper.accessor((row) => row.statusMessage, {
header: 'Status message',
id: 'statusMessage',
cell: ({ row }) => (
<div className="whitespace-pre-wrap">
<span>{row.original.statusMessage || '-'}</span>
</div>
),
});

View file

@ -0,0 +1,62 @@
import { useQuery } from '@tanstack/react-query';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
type DescribeAPIParams = {
name: string;
kind: string;
namespace?: string;
};
type DescribeResourceResponse = {
describe: string;
};
async function getDescribeResource(
environmentId: number,
name: string,
resourceType?: string,
namespace?: string
) {
try {
// This should never happen, but to keep the linter happy...
if (!name || !resourceType) {
throw new Error('Name and kind are required');
}
const params: DescribeAPIParams = {
name,
namespace,
kind: resourceType,
};
const { data } = await axios.get<DescribeResourceResponse>(
`kubernetes/${environmentId}/describe`,
{
params,
}
);
return data;
} catch (err) {
throw parseAxiosError(err, 'Unable to retrieve resource details');
}
}
export function useDescribeResource(
name: string,
resourceType?: string,
namespace?: string
) {
const environmentId = useEnvironmentId();
return useQuery(
[environmentId, 'kubernetes', 'describe', namespace, resourceType, name],
() => getDescribeResource(environmentId, name, resourceType, namespace),
{
enabled: !!environmentId && !!name && !!resourceType,
...withGlobalError('Enable to retrieve data for resource'),
}
);
}

View file

@ -0,0 +1,27 @@
import { StatusBadgeType } from '@@/StatusBadge';
export type ResourceLink = {
to?: string;
params?: Record<string, string>;
};
export type ResourceRow = {
// for the table row id
id: string;
// for the table row name (link to resource if available)
name: {
label: string;
link: ResourceLink | null;
};
resourceType: string;
describe: {
name: string;
resourceType?: string;
namespace?: string;
};
status: {
label: string;
type: StatusBadgeType;
};
statusMessage: string;
};

View file

@ -0,0 +1,93 @@
import { useMemo } from 'react';
import { StatusBadgeType } from '@@/StatusBadge';
import { GenericResource } from '../../../types';
import { ResourceLink, ResourceRow } from './types';
// from defined routes in app/kubernetes/__module.js
const kindToUrlMap = {
Deployment: 'kubernetes.applications.application',
DaemonSet: 'kubernetes.applications.application',
StatefulSet: 'kubernetes.applications.application',
Pod: 'kubernetes.applications.application',
Ingress: 'kubernetes.ingresses',
ConfigMap: 'kubernetes.configmaps.configmap',
Secret: 'kubernetes.secrets.secret',
PersistentVolumeClaim: 'kubernetes.volumes.volume',
};
const statusToColorMap: Record<string, StatusBadgeType> = {
Healthy: 'success',
Progressing: 'warning',
Degraded: 'danger',
Failed: 'danger',
Unhealthy: 'danger',
Unknown: 'mutedLite',
};
export function useResourceRows(resources: GenericResource[]): ResourceRow[] {
return useMemo(() => getResourceRows(resources), [resources]);
}
function getResourceRows(resources: GenericResource[]): ResourceRow[] {
return resources.map(getResourceRow);
}
function getResourceRow(resource: GenericResource): ResourceRow {
const {
reason = '',
status = '',
message = '',
} = resource.status.healthSummary || {};
return {
id: `${resource.kind}/${resource.metadata.name}/${resource.metadata.namespace}`,
name: {
label: resource.metadata.name,
link: getResourceLink(resource),
},
resourceType: resource.kind ?? '-',
describe: {
name: resource.metadata.name,
namespace: resource.metadata.namespace,
resourceType: resource.kind,
},
status: {
label: reason ?? 'Unknown',
type: statusToColorMap[status] ?? 'default',
},
statusMessage: message ?? '-',
};
}
function getResourceLink(resource: GenericResource): ResourceLink | null {
const { namespace, name } = resource.metadata;
const to = kindToUrlMap[resource.kind as keyof typeof kindToUrlMap];
// If the resource kind is not supported, return null
if (!to) {
return null;
}
// If the resource is not namespaced, return the link to the resource with the name only
if (!namespace) {
return {
to,
params: {
name,
},
};
}
// If the resource is namespaced, return the link to the resource with the namespace and name
return {
to,
params: {
namespace,
name,
},
};
}

View file

@ -0,0 +1,44 @@
import { Checkbox } from '@@/form-components/Checkbox';
import { CodeEditor } from '@@/CodeEditor';
import { Values } from '../../types';
interface Props {
values?: Values;
isUserSupplied: boolean;
setIsUserSupplied: (isUserSupplied: boolean) => void;
}
const noValuesMessage = 'No values found';
export function ValuesDetails({
values,
isUserSupplied,
setIsUserSupplied,
}: Props) {
return (
<div className="relative">
{/* bring in line with the code editor copy button */}
<div className="absolute top-1 left-0">
<Checkbox
label="User defined only"
id="values-details-user-supplied"
checked={isUserSupplied}
onChange={() => setIsUserSupplied(!isUserSupplied)}
data-cy="values-details-user-supplied"
/>
</div>
<CodeEditor
type="yaml"
id="values-details-code-editor"
data-cy="values-details-code-editor"
value={
isUserSupplied
? values?.userSuppliedValues ?? noValuesMessage
: values?.computedValues ?? noValuesMessage
}
readonly
/>
</div>
);
}

View file

@ -2,55 +2,33 @@ import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import PortainerError from 'Portainer/error';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
interface HelmRelease { import { HelmRelease } from '../../types';
name: string;
chart: string;
app_version: string;
}
/**
* List all helm releases based on passed in options
* @param environmentId - Environment ID
* @param options - Options for filtering releases
* @returns List of helm releases
*/
export async function listReleases(
environmentId: EnvironmentId,
options: {
namespace?: string;
filter?: string;
selector?: string;
output?: string;
} = {}
): Promise<HelmRelease[]> {
try {
const { namespace, filter, selector, output } = options;
const url = `endpoints/${environmentId}/kubernetes/helm`;
const { data } = await axios.get<HelmRelease[]>(url, {
params: { namespace, filter, selector, output },
});
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve release list');
}
}
/** /**
* React hook to fetch a specific Helm release * React hook to fetch a specific Helm release
*/ */
export function useHelmRelease( export function useHelmRelease<T = HelmRelease>(
environmentId: EnvironmentId, environmentId: EnvironmentId,
name: string, name: string,
namespace: string namespace: string,
options: {
select?: (data: HelmRelease) => T;
showResources?: boolean;
} = {}
) { ) {
return useQuery( return useQuery(
[environmentId, 'helm', namespace, name], [environmentId, 'helm', 'releases', namespace, name, options.showResources],
() => getHelmRelease(environmentId, name, namespace), () =>
getHelmRelease(environmentId, name, {
namespace,
showResources: options.showResources,
}),
{ {
enabled: !!environmentId, enabled: !!environmentId && !!name && !!namespace,
...withGlobalError('Unable to retrieve helm application details'), ...withGlobalError('Unable to retrieve helm application details'),
select: options.select,
} }
); );
} }
@ -61,23 +39,20 @@ export function useHelmRelease(
async function getHelmRelease( async function getHelmRelease(
environmentId: EnvironmentId, environmentId: EnvironmentId,
name: string, name: string,
namespace: string params: {
): Promise<HelmRelease> { namespace: string;
showResources?: boolean;
}
) {
try { try {
const releases = await listReleases(environmentId, { const { data } = await axios.get<HelmRelease>(
filter: `^${name}$`, `endpoints/${environmentId}/kubernetes/helm/${name}`,
namespace, {
}); params,
}
if (releases.length > 0) {
return releases[0];
}
throw new PortainerError(`Release ${name} not found`);
} catch (err) {
throw new PortainerError(
'Unable to retrieve helm application details',
err as Error
); );
return data;
} catch (err) {
throw parseAxiosError(err, 'Unable to retrieve helm application details');
} }
} }

View file

@ -1,3 +1,79 @@
interface ResourceStatus {
phase?: string;
reason?: string;
message?: string;
healthSummary?: {
status: string;
reason: string;
message?: string;
};
}
// A resource has a bunch of common fields that are shared by all kubernetes resources, so can be used when we're unsure about the resource type we have
export interface GenericResource {
apiVersion?: string;
kind?: string;
metadata: {
name: string;
namespace?: string;
};
status: ResourceStatus;
}
export interface HelmRelease {
/** The name of the release */
name: string;
/** Information about the release */
info?: {
status?: string;
notes?: string;
description?: string;
resources?: GenericResource[];
};
/** The chart that was released */
chart: HelmChart;
/** Extra values added to the chart that override the default values */
config?: Record<string, unknown>;
/** String representation of the rendered template */
manifest: string;
/** All hooks declared for this release */
hooks?: unknown[];
/** Integer representing the revision of the release */
version?: number;
/** Kubernetes namespace of the release */
namespace?: string;
/** Values of the release */
values?: Values;
}
export interface Values {
/** User supplied values */
userSuppliedValues?: string;
/** Computed values */
computedValues?: string;
}
export interface HelmChart {
/** Raw contents of the files originally contained in the chart archive. Only used in special cases like `helm show values` */
raw?: unknown[];
/** Contents of the Chartfile */
metadata?: {
name?: string;
version?: string;
appVersion?: string;
};
/** Contents of Chart.lock */
lock?: unknown;
/** Templates for this chart */
templates?: unknown[];
/** Default config for this chart */
values?: Record<string, unknown>;
/** Optional JSON schema for imposing structure on Values */
schema?: unknown;
/** Miscellaneous files in a chart archive (e.g. README, LICENSE) */
files?: unknown[];
}
export interface Chart extends HelmChartResponse { export interface Chart extends HelmChartResponse {
repo: string; repo: string;
} }

7
go.mod
View file

@ -54,11 +54,14 @@ require (
golang.org/x/oauth2 v0.23.0 golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.11.0 golang.org/x/sync v0.11.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v3 v3.17.1 helm.sh/helm/v3 v3.17.1
k8s.io/api v0.32.1 k8s.io/api v0.32.1
k8s.io/apimachinery v0.32.1 k8s.io/apimachinery v0.32.1
k8s.io/cli-runtime v0.32.1
k8s.io/client-go v0.32.1 k8s.io/client-go v0.32.1
k8s.io/kubectl v0.32.1
k8s.io/metrics v0.32.1 k8s.io/metrics v0.32.1
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
) )
@ -129,6 +132,7 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch v5.9.0+incompatible // indirect
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/fatih/camelcase v1.0.0 // indirect
github.com/fatih/color v1.15.0 // indirect github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect github.com/fsnotify/fsevents v0.2.0 // indirect
@ -288,14 +292,11 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/apiextensions-apiserver v0.32.1 // indirect k8s.io/apiextensions-apiserver v0.32.1 // indirect
k8s.io/apiserver v0.32.1 // indirect k8s.io/apiserver v0.32.1 // indirect
k8s.io/cli-runtime v0.32.1 // indirect
k8s.io/component-base v0.32.1 // indirect k8s.io/component-base v0.32.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/kubectl v0.32.1 // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
oras.land/oras-go v1.2.5 // indirect oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect

4
go.sum
View file

@ -229,6 +229,8 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8=
github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -451,6 +453,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y= github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=

View file

@ -106,6 +106,7 @@
"json-schema": "^0.4.0", "json-schema": "^0.4.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"markdown-to-jsx": "^7.7.4",
"moment": "^2.29.1", "moment": "^2.29.1",
"moment-timezone": "^0.5.40", "moment-timezone": "^0.5.40",
"mustache": "^4.2.0", "mustache": "^4.2.0",

View file

@ -1,21 +1,11 @@
package options package options
// releaseResource are the supported `helm get` sub-commands
// to see all available sub-commands run `helm get --help`
type releaseResource string
const (
GetAll releaseResource = "all"
GetHooks releaseResource = "hooks"
GetManifest releaseResource = "manifest"
GetNotes releaseResource = "notes"
GetValues releaseResource = "values"
)
type GetOptions struct { type GetOptions struct {
Name string Name string
Namespace string Namespace string
ReleaseResource releaseResource // ShowResources indicates whether to display the resources of the named release
ShowResources bool
Revision int
KubernetesClusterAccess *KubernetesClusterAccess KubernetesClusterAccess *KubernetesClusterAccess
Env []string Env []string

View file

@ -0,0 +1,9 @@
package options
type HistoryOptions struct {
Name string
Namespace string
KubernetesClusterAccess *KubernetesClusterAccess
Env []string
}

View file

@ -1,6 +1,9 @@
package release package release
import "github.com/portainer/portainer/pkg/libhelm/time" import (
"github.com/portainer/portainer/pkg/libhelm/time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// Release is the struct that holds the information for a helm release. // Release is the struct that holds the information for a helm release.
// The struct definitions have been copied from the official Helm Golang client/library. // The struct definitions have been copied from the official Helm Golang client/library.
@ -14,7 +17,7 @@ type ReleaseElement struct {
Updated string `json:"updated"` Updated string `json:"updated"`
Status string `json:"status"` Status string `json:"status"`
Chart string `json:"chart"` Chart string `json:"chart"`
AppVersion string `json:"app_version"` AppVersion string `json:"appVersion"`
} }
// Release describes a deployment of a chart, together with the chart // Release describes a deployment of a chart, together with the chart
@ -23,7 +26,7 @@ type Release struct {
// Name is the name of the release // Name is the name of the release
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
// Info provides information about a release // Info provides information about a release
// Info *Info `json:"info,omitempty"` Info *Info `json:"info,omitempty"`
// Chart is the chart that was released. // Chart is the chart that was released.
Chart Chart `json:"chart,omitempty"` Chart Chart `json:"chart,omitempty"`
// Config is the set of extra Values added to the chart. // Config is the set of extra Values added to the chart.
@ -40,6 +43,13 @@ type Release struct {
// Labels of the release. // Labels of the release.
// Disabled encoding into Json cause labels are stored in storage driver metadata field. // Disabled encoding into Json cause labels are stored in storage driver metadata field.
Labels map[string]string `json:"-"` Labels map[string]string `json:"-"`
// Values are the values used to deploy the chart.
Values Values `json:"values,omitempty"`
}
type Values struct {
UserSuppliedValues string `json:"userSuppliedValues,omitempty"`
ComputedValues string `json:"computedValues,omitempty"`
} }
// Chart is a helm package that contains metadata, a default config, zero or more // Chart is a helm package that contains metadata, a default config, zero or more
@ -183,6 +193,8 @@ type Info struct {
Status Status `json:"status,omitempty"` Status Status `json:"status,omitempty"`
// Contains the rendered templates/NOTES.txt if available // Contains the rendered templates/NOTES.txt if available
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
// Resources is the list of resources that are part of the release
Resources []*unstructured.Unstructured `json:"resources,omitempty"`
} }
// Status is the status of a release // Status is the status of a release

100
pkg/libhelm/sdk/get.go Normal file
View file

@ -0,0 +1,100 @@
package sdk
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
// Get implements the HelmPackageManager interface by using the Helm SDK to get a release.
// It returns a Release.
func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Str("name", getOptions.Name).
Msg("Get Helm release")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, getOptions.Namespace, getOptions.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Err(err).Msg("Failed to initialise helm configuration")
return nil, err
}
statusClient, err := hspm.initStatusClient(actionConfig, getOptions)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Err(err).Msg("Failed to initialise helm status client")
return nil, err
}
release, err := statusClient.Run(getOptions.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Err(err).Msg("Failed to query helm chart")
return nil, err
}
values, err := hspm.getValues(getOptions)
if err != nil {
// error is already logged in getValuesFromStatus
return nil, err
}
return convert(release, values), nil
}
// Helm status is just an extended helm get command with resources added on (when flagged), so use the status client with the optional show resources flag
// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/get.go#L40-L47
// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/status.go#L48-L82
func (hspm *HelmSDKPackageManager) initStatusClient(actionConfig *action.Configuration, getOptions options.GetOptions) (*action.Status, error) {
statusClient := action.NewStatus(actionConfig)
statusClient.ShowResources = getOptions.ShowResources
if getOptions.Revision > 0 {
statusClient.Version = getOptions.Revision
}
return statusClient, nil
}
func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Release {
resources, err := parseResources(sdkRelease.Info.Resources)
if err != nil {
log.Warn().
Str("context", "HelmClient").
Str("namespace", sdkRelease.Namespace).
Str("name", sdkRelease.Name).
Err(err).Msg("Failed to parse resources")
}
return &release.Release{
Name: sdkRelease.Name,
Namespace: sdkRelease.Namespace,
Version: sdkRelease.Version,
Info: &release.Info{
Status: release.Status(sdkRelease.Info.Status),
Notes: sdkRelease.Info.Notes,
Resources: resources,
Description: sdkRelease.Info.Description,
},
Manifest: sdkRelease.Manifest,
Chart: release.Chart{
Metadata: &release.Metadata{
Name: sdkRelease.Chart.Metadata.Name,
Version: sdkRelease.Chart.Metadata.Version,
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
},
},
Values: values,
}
}

View file

@ -0,0 +1,39 @@
package sdk
import (
"testing"
libhelmrelease "github.com/portainer/portainer/pkg/libhelm/release"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/chart"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
func Test_Convert(t *testing.T) {
t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "releaseName",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chartName",
Version: "chartVersion",
AppVersion: "chartAppVersion",
},
},
}
values := libhelmrelease.Values{
UserSuppliedValues: `{"key": "value"}`,
ComputedValues: `{"key": "value"}`,
}
result := convert(&release, values)
is.Equal(release.Name, result.Name)
})
}

View file

@ -0,0 +1,68 @@
package sdk
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/time"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
// GetHistory implements the HelmPackageManager interface by using the Helm SDK to get a release.
// It returns a Release.
func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOptions) ([]*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", historyOptions.Namespace).
Str("name", historyOptions.Name).
Msg("Get Helm history")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, historyOptions.Namespace, historyOptions.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", historyOptions.Namespace).
Err(err).Msg("Failed to initialise helm configuration")
return nil, err
}
historyClient := action.NewHistory(actionConfig)
history, err := historyClient.Run(historyOptions.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", historyOptions.Namespace).
Err(err).Msg("Failed to query helm release history")
return nil, err
}
var result []*release.Release
for _, r := range history {
result = append(result, convertHistory(r))
}
return result, nil
}
func convertHistory(sdkRelease *sdkrelease.Release) *release.Release {
return &release.Release{
Name: sdkRelease.Name,
Namespace: sdkRelease.Namespace,
Version: sdkRelease.Version,
Info: &release.Info{
Status: release.Status(sdkRelease.Info.Status),
Notes: sdkRelease.Info.Notes,
LastDeployed: time.Time(sdkRelease.Info.LastDeployed),
},
Chart: release.Chart{
Metadata: &release.Metadata{
Name: sdkRelease.Chart.Metadata.Name,
Version: sdkRelease.Chart.Metadata.Version,
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
},
},
}
}

View file

@ -0,0 +1,33 @@
package sdk
import (
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/chart"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
func Test_ConvertHistory(t *testing.T) {
t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "releaseName",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chartName",
Version: "chartVersion",
AppVersion: "chartAppVersion",
},
},
}
result := convertHistory(&release)
is.Equal(release.Name, result.Name)
})
}

View file

@ -0,0 +1,291 @@
package sdk
import (
"time"
"github.com/segmentio/encoding/json"
"github.com/rs/zerolog/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
const (
Unknown = "Unknown"
Healthy = "Healthy"
Unhealthy = "Unhealthy"
Progressing = "Progressing"
)
// ResourceStatus represents a generic status for any Kubernetes resource.
type ResourceStatus struct {
// Phase is a simple, high-level summary of where the resource is in its lifecycle.
Phase string `json:"phase,omitempty"`
// HealthSummary represents the summarized health status of the resource
HealthSummary *HealthCondition `json:"healthSummary,omitempty"`
// Reason is a brief CamelCase string containing the reason for the resource's current status.
Reason string `json:"reason,omitempty"`
// Message is a human-readable description of the current status.
Message string `json:"message,omitempty"`
}
// HealthCondition represents a summarized health condition for a resource
type HealthCondition struct {
Status string `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
// parseResources returns a list of resources with additional status information, in a consistent format.
func parseResources(resourceTypesLists map[string][]runtime.Object) ([]*unstructured.Unstructured, error) {
flattenedResources := flattenResources(resourceTypesLists)
resourcesInfo := []*unstructured.Unstructured{}
for _, resource := range flattenedResources {
info, err := getResourceInfo(resource)
if err != nil {
return nil, err
}
resourcesInfo = append(resourcesInfo, info)
}
return resourcesInfo, nil
}
func getResourceInfo(obj runtime.Object) (*unstructured.Unstructured, error) {
data, err := json.Marshal(obj)
if err != nil {
return nil, err
}
res := &unstructured.Unstructured{}
err = json.Unmarshal(data, res)
if err != nil {
return nil, err
}
status, conditions, err := extractStatus(res)
if err == nil {
summarizeStatus(status, conditions, res.GetName(), res.GetNamespace(), err)
applyStatusToResource(res, status)
}
// only keep metadata, kind and status (other fields are not needed)
res.Object = map[string]any{
"metadata": res.Object["metadata"],
"kind": res.Object["kind"],
"status": res.Object["status"],
}
return res, nil
}
// extractStatus extracts the status from an unstructured resource
func extractStatus(res *unstructured.Unstructured) (*ResourceStatus, []metav1.Condition, error) {
statusMap, found, err := unstructured.NestedMap(res.Object, "status")
if !found || err != nil {
return &ResourceStatus{}, nil, nil
}
// Extract basic status fields
phase, _, _ := unstructured.NestedString(statusMap, "phase")
reason, _, _ := unstructured.NestedString(statusMap, "reason")
message, _, _ := unstructured.NestedString(statusMap, "message")
// Extract conditions for analysis
conditions := []metav1.Condition{}
conditionsData, found, _ := unstructured.NestedSlice(statusMap, "conditions")
if found {
for _, condData := range conditionsData {
condMap, ok := condData.(map[string]any)
if !ok {
continue
}
cond := metav1.Condition{}
if typeStr, ok := condMap["type"].(string); ok {
cond.Type = typeStr
}
if statusStr, ok := condMap["status"].(string); ok {
cond.Status = metav1.ConditionStatus(statusStr)
}
if reasonStr, ok := condMap["reason"].(string); ok {
cond.Reason = reasonStr
}
if msgStr, ok := condMap["message"].(string); ok {
cond.Message = msgStr
}
if timeStr, ok := condMap["lastTransitionTime"].(string); ok {
t, _ := time.Parse(time.RFC3339, timeStr)
cond.LastTransitionTime = metav1.Time{Time: t}
}
conditions = append(conditions, cond)
}
}
return &ResourceStatus{
Phase: phase,
Reason: reason,
Message: message,
}, conditions, nil
}
// summarizeStatus creates a health summary based on resource status and conditions
func summarizeStatus(status *ResourceStatus, conditions []metav1.Condition, name string, namespace string, err error) *ResourceStatus {
healthSummary := &HealthCondition{
Status: Unknown,
Reason: status.Reason,
Message: status.Message,
}
// Handle error case first
if err != nil {
healthSummary.Reason = "ErrorGettingStatus"
healthSummary.Message = err.Error()
status.HealthSummary = healthSummary
return status
}
// Handle phase-based status
switch status.Phase {
case "Error":
healthSummary.Status = Unhealthy
healthSummary.Reason = status.Phase
case "Running":
healthSummary.Status = Healthy
healthSummary.Reason = status.Phase
case "Pending":
healthSummary.Status = Progressing
healthSummary.Reason = status.Phase
case "Failed":
healthSummary.Status = Unhealthy
healthSummary.Reason = status.Phase
case "Available", "Active", "Established", "Bound", "Ready", "Succeeded":
healthSummary.Status = Healthy
healthSummary.Reason = status.Phase
case "":
// Empty phase - check conditions or default to "Exists"
if len(conditions) > 0 {
analyzeConditions(conditions, healthSummary)
} else {
healthSummary.Status = Healthy
healthSummary.Reason = "Exists"
}
default:
log.Warn().
Str("context", "HelmClient").
Str("namespace", namespace).
Str("name", name).
Str("phase", status.Phase).
Msg("Unhandled status")
healthSummary.Reason = status.Phase
}
// Set message from first condition if available
if len(conditions) > 0 && healthSummary.Message == "" {
healthSummary.Message = conditions[0].Message
}
status.HealthSummary = healthSummary
return status
}
// analyzeConditions determines resource health based on standard condition types
func analyzeConditions(conditions []metav1.Condition, healthSummary *HealthCondition) {
for _, cond := range conditions {
switch cond.Type {
case "Progressing":
if cond.Status == "False" {
healthSummary.Status = Unhealthy
healthSummary.Reason = cond.Reason
} else if cond.Reason != "NewReplicaSetAvailable" {
healthSummary.Status = Unknown
healthSummary.Reason = cond.Reason
}
case "Available", "Ready", "DisruptionAllowed", "Established", "NamesAccepted":
if healthSummary.Status == Unknown ||
(cond.Type == "Established" && healthSummary.Status == Healthy) ||
(cond.Type == "NamesAccepted" && healthSummary.Status == Healthy) {
if cond.Status == "False" {
healthSummary.Status = Unhealthy
} else {
healthSummary.Status = Healthy
}
healthSummary.Reason = cond.Reason
}
case "ContainersReady":
if healthSummary.Status == Unknown && cond.Status == "False" {
healthSummary.Status = Unhealthy
healthSummary.Reason = cond.Reason
}
}
}
}
// applyStatusToResource applies the typed ResourceStatus back to the unstructured resource
func applyStatusToResource(res *unstructured.Unstructured, status *ResourceStatus) {
statusMap := map[string]any{
"phase": status.Phase,
"reason": status.Reason,
"message": status.Message,
}
if status.HealthSummary != nil {
statusMap["healthSummary"] = map[string]any{
"status": status.HealthSummary.Status,
"reason": status.HealthSummary.Reason,
"message": status.HealthSummary.Message,
}
}
unstructured.SetNestedMap(res.Object, statusMap, "status")
}
// flattenResources extracts items from a list resource and convert them to runtime.Objects
func flattenResources(resourceTypesLists map[string][]runtime.Object) []runtime.Object {
flattenedResources := []runtime.Object{}
for _, resourceTypeList := range resourceTypesLists {
for _, resourceItem := range resourceTypeList {
// if the resource item is a list, we need to flatten it too e.g. PodList
items := extractItemsIfList(resourceItem)
if items != nil {
flattenedResources = append(flattenedResources, items...)
} else {
flattenedResources = append(flattenedResources, resourceItem)
}
}
}
return flattenedResources
}
// extractItemsIfList extracts items if the resource is a list, or returns nil if not a list
func extractItemsIfList(resource runtime.Object) []runtime.Object {
unstructuredObj, ok := resource.(runtime.Unstructured)
if !ok {
return nil
}
if !unstructuredObj.IsList() {
return nil
}
extractedItems := []runtime.Object{}
err := unstructuredObj.EachListItem(func(obj runtime.Object) error {
extractedItems = append(extractedItems, obj)
return nil
})
if err != nil {
return nil
}
return extractedItems
}

View file

@ -0,0 +1,143 @@
package sdk
import (
"testing"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
func TestParseResources(t *testing.T) {
t.Run("successfully parse single resource", func(t *testing.T) {
resourceTypesLists := map[string][]runtime.Object{
"v1/Pod(related)": {
&unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "test-pod",
"namespace": "default",
},
"status": map[string]any{
"phase": "Available",
},
},
},
},
}
got, err := parseResources(resourceTypesLists)
assert.NoError(t, err)
assert.Equal(t, 1, len(got))
// Check resource metadata
assert.Equal(t, "test-pod", got[0].GetName())
assert.Equal(t, "default", got[0].GetNamespace())
// Check status and condition
statusMap, found, _ := unstructured.NestedMap(got[0].Object, "status")
assert.True(t, found)
assert.Equal(t, "Available", statusMap["phase"])
healthSummary, found, _ := unstructured.NestedMap(statusMap, "healthSummary")
assert.True(t, found)
assert.Equal(t, "Healthy", healthSummary["status"])
assert.Equal(t, "Available", healthSummary["reason"])
})
t.Run("successfully parse multiple resources", func(t *testing.T) {
resourceTypesLists := map[string][]runtime.Object{
"v1/Pod(related)": {
&unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "test-pod-1",
"namespace": "default",
},
"status": map[string]any{
"phase": "Pending",
},
},
},
&unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "test-pod-2",
"namespace": "default",
},
"status": map[string]any{
"phase": "Error",
},
},
},
},
}
got, err := parseResources(resourceTypesLists)
assert.NoError(t, err)
assert.Equal(t, 2, len(got))
// Check first resource
assert.Equal(t, "test-pod-1", got[0].GetName())
statusMap1, found, _ := unstructured.NestedMap(got[0].Object, "status")
assert.True(t, found)
assert.Equal(t, "Pending", statusMap1["phase"])
healthSummary1, found, _ := unstructured.NestedMap(statusMap1, "healthSummary")
assert.True(t, found)
assert.Equal(t, Progressing, healthSummary1["status"])
assert.Equal(t, "Pending", healthSummary1["reason"])
// Check second resource
assert.Equal(t, "test-pod-2", got[1].GetName())
statusMap2, found, _ := unstructured.NestedMap(got[1].Object, "status")
assert.True(t, found)
healthSummary2, found, _ := unstructured.NestedMap(statusMap2, "healthSummary")
assert.True(t, found)
assert.Equal(t, Unhealthy, healthSummary2["status"])
assert.Equal(t, "Error", healthSummary2["reason"])
})
}
func TestEnhanceStatus(t *testing.T) {
t.Run("healthy running pod", func(t *testing.T) {
// Create a ResourceStatus object
status := &ResourceStatus{
Phase: "Failed",
}
conditions := []metav1.Condition{}
result := summarizeStatus(status, conditions, "test-pod", "default", nil)
assert.Equal(t, Unhealthy, result.HealthSummary.Status)
assert.Equal(t, "Failed", result.HealthSummary.Reason)
})
t.Run("unhealthy pod with error", func(t *testing.T) {
// Create a ResourceStatus object
status := &ResourceStatus{
Phase: "Error",
}
conditions := []metav1.Condition{
{
Type: "DisruptionAllowed",
Status: metav1.ConditionFalse,
Reason: "InsufficientPods",
},
}
result := summarizeStatus(status, conditions, "test-pod", "default", nil)
assert.Equal(t, Unhealthy, result.HealthSummary.Status)
assert.Equal(t, "Error", result.HealthSummary.Reason)
})
}

View file

@ -4,7 +4,11 @@ import (
"os" "os"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
"helm.sh/helm/v3/pkg/action"
) )
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any // GetHelmValuesFromFile reads the values file and parses it into a map[string]any
@ -40,3 +44,75 @@ func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map
return vals, nil return vals, nil
} }
func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (release.Values, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Str("name", getOpts.Name).
Msg("Getting values")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, getOpts.Namespace, getOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Err(err).Msg("Failed to initialise helm configuration")
return release.Values{}, err
}
// Create client for user supplied values
userValuesClient := action.NewGetValues(actionConfig)
userSuppliedValues, err := userValuesClient.Run(getOpts.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Err(err).Msg("Failed to get user supplied values")
return release.Values{}, err
}
// Create separate client for computed values
computedValuesClient := action.NewGetValues(actionConfig)
computedValuesClient.AllValues = true
computedValues, err := computedValuesClient.Run(getOpts.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Err(err).Msg("Failed to get computed values")
return release.Values{}, err
}
userSuppliedValuesByte, err := yaml.Marshal(userSuppliedValues)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).Msg("Failed to marshal user supplied values")
return release.Values{}, err
}
computedValuesByte, err := yaml.Marshal(computedValues)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).Msg("Failed to marshal computed values")
return release.Values{}, err
}
// Handle the case where the values are an empty object
userSuppliedValuesString := string(userSuppliedValuesByte)
if userSuppliedValuesString == "{}\n" {
userSuppliedValuesString = ""
}
computedValuesString := string(computedValuesByte)
if computedValuesString == "{}\n" {
computedValuesString = ""
}
return release.Values{
UserSuppliedValues: userSuppliedValuesString,
ComputedValues: computedValuesString,
}, nil
}

View file

@ -1,6 +1,7 @@
package test package test
import ( import (
"slices"
"strings" "strings"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
@ -91,29 +92,11 @@ func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
return nil, nil return nil, nil
} }
// Get release details - all, hooks, manifest, notes and values
func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
switch getOpts.ReleaseResource {
case options.GetAll:
return []byte(strings.Join([]string{MockReleaseHooks, MockReleaseManifest, MockReleaseNotes, MockReleaseValues}, "---\n")), nil
case options.GetHooks:
return []byte(MockReleaseHooks), nil
case options.GetManifest:
return []byte(MockReleaseManifest), nil
case options.GetNotes:
return []byte(MockReleaseNotes), nil
case options.GetValues:
return []byte(MockReleaseValues), nil
default:
return nil, errors.New("invalid release resource")
}
}
// Uninstall a helm chart (not thread safe) // Uninstall a helm chart (not thread safe)
func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error { func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
for i, rel := range mockCharts { for i, rel := range mockCharts {
if rel.Name == uninstallOpts.Name && rel.Namespace == uninstallOpts.Namespace { if rel.Name == uninstallOpts.Name && rel.Namespace == uninstallOpts.Namespace {
mockCharts = append(mockCharts[:i], mockCharts[i+1:]...) mockCharts = slices.Delete(mockCharts, i, i+1)
} }
} }
return nil return nil
@ -124,6 +107,25 @@ func (hpm *helmMockPackageManager) List(listOpts options.ListOptions) ([]release
return mockCharts, nil return mockCharts, nil
} }
// Get a helm release (not thread safe)
func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) (*release.Release, error) {
index := slices.IndexFunc(mockCharts, func(re release.ReleaseElement) bool {
return re.Name == getOpts.Name && re.Namespace == getOpts.Namespace
})
return newMockRelease(&mockCharts[index]), nil
}
func (hpm *helmMockPackageManager) GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error) {
var result []*release.Release
for i, v := range mockCharts {
if v.Name == historyOpts.Name && v.Namespace == historyOpts.Namespace {
result = append(result, newMockRelease(&mockCharts[i]))
}
}
return result, nil
}
const mockPortainerIndex = `apiVersion: v1 const mockPortainerIndex = `apiVersion: v1
entries: entries:
portainer: portainer:

View file

@ -14,6 +14,8 @@ type HelmPackageManager interface {
List(listOpts options.ListOptions) ([]release.ReleaseElement, error) List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error) Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error Uninstall(uninstallOpts options.UninstallOptions) error
Get(getOpts options.GetOptions) (*release.Release, error)
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
} }
type Repository interface { type Repository interface {

63
pkg/libkubectl/client.go Normal file
View file

@ -0,0 +1,63 @@
package libkubectl
import (
"bytes"
"errors"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/genericiooptions"
"k8s.io/kubectl/pkg/cmd/util"
)
type ClientAccess struct {
Token string
ServerUrl string
}
type Client struct {
factory util.Factory
streams genericclioptions.IOStreams
out *bytes.Buffer
}
// NewClient creates a new kubectl client
func NewClient(libKubectlAccess *ClientAccess, namespace, kubeconfig string, insecure bool) (*Client, error) {
configFlags, err := generateConfigFlags(libKubectlAccess.Token, libKubectlAccess.ServerUrl, namespace, kubeconfig, insecure)
if err != nil {
return nil, err
}
streams, _, out, _ := genericiooptions.NewTestIOStreams()
return &Client{
factory: util.NewFactory(configFlags),
streams: streams,
out: out,
}, nil
}
// generateConfigFlags generates the config flags for the kubectl client
// If kubeconfigPath is provided, it will be used instead of server and token
// If server and token are provided, they will be used to connect to the cluster
// If neither kubeconfigPath or server and token are provided, an error will be returned
func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecure bool) (*genericclioptions.ConfigFlags, error) {
if kubeconfigPath == "" && (server == "" || token == "") {
return nil, errors.New("must provide either a kubeconfig path or a server and token")
}
configFlags := genericclioptions.NewConfigFlags(true)
if namespace != "" {
configFlags.Namespace = &namespace
}
if kubeconfigPath != "" {
configFlags.KubeConfig = &kubeconfigPath
} else {
configFlags.APIServer = &server
configFlags.BearerToken = &token
}
configFlags.Insecure = &insecure
return configFlags, nil
}

View file

@ -0,0 +1,101 @@
package libkubectl
import (
"testing"
)
func TestNewClient(t *testing.T) {
tests := []struct {
name string
libKubectlAccess ClientAccess
namespace string
kubeconfig string
insecure bool
wantErr bool
errContains string
}{
{
name: "valid client with token and server",
libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
namespace: "default",
insecure: true,
wantErr: false,
},
{
name: "valid client with kubeconfig",
kubeconfig: "/path/to/kubeconfig",
namespace: "test-namespace",
insecure: false,
wantErr: false,
},
{
name: "missing both token/server and kubeconfig",
namespace: "default",
insecure: false,
wantErr: true,
errContains: "must provide either a kubeconfig path or a server and token",
},
{
name: "missing token with server",
libKubectlAccess: ClientAccess{ServerUrl: "https://localhost:6443"},
namespace: "default",
insecure: false,
wantErr: true,
errContains: "must provide either a kubeconfig path or a server and token",
},
{
name: "missing server with token",
libKubectlAccess: ClientAccess{Token: "test-token"},
namespace: "default",
insecure: false,
wantErr: true,
errContains: "must provide either a kubeconfig path or a server and token",
},
{
name: "empty namespace is valid",
libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
namespace: "",
insecure: false,
wantErr: false,
},
{
name: "insecure true with valid credentials",
libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
namespace: "default",
insecure: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewClient(&tt.libKubectlAccess, tt.namespace, tt.kubeconfig, tt.insecure)
if (err != nil) != tt.wantErr {
t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errContains != "" {
if got := err.Error(); got != tt.errContains {
t.Errorf("NewClient() error = %v, want error containing %v", got, tt.errContains)
}
return
}
if !tt.wantErr {
if client == nil {
t.Error("NewClient() returned nil client when no error was expected")
return
}
// Verify client fields are properly initialized
if client.factory == nil {
t.Error("NewClient() client.factory is nil")
}
if client.out == nil {
t.Error("NewClient() client.out is nil")
}
}
})
}
}

View file

@ -0,0 +1,40 @@
package libkubectl
import (
"fmt"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/cli-runtime/pkg/resource"
describecmd "k8s.io/kubectl/pkg/cmd/describe"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/describe"
)
// Describe returns the description of a resource
// name is the name of the resource, kind is the kind of the resource, and namespace is the namespace of the resource
// this is identical to running `kubectl describe <kind> <name> --namespace <namespace>`
func (c *Client) Describe(namespace, name, kind string) (string, error) {
describeOptions := &describecmd.DescribeOptions{
BuilderArgs: []string{kind, name},
Describer: func(mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
return describe.DescriberFn(c.factory, mapping)
},
FilenameOptions: &resource.FilenameOptions{},
DescriberSettings: &describe.DescriberSettings{
ShowEvents: true,
ChunkSize: cmdutil.DefaultChunkSize,
},
IOStreams: c.streams,
NewBuilder: c.factory.NewBuilder,
}
if namespace != "" {
describeOptions.Namespace = namespace
}
if err := describeOptions.Run(); err != nil {
return "", fmt.Errorf("error describing resources: %w", err)
}
return c.out.String(), nil
}

View file

@ -13148,6 +13148,11 @@ markdown-to-jsx@^7.1.8:
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b" resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b"
integrity sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg== integrity sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg==
markdown-to-jsx@^7.7.4:
version "7.7.4"
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.7.4.tgz#507d17c15af72ddf970fca84a95f0243244fcfa9"
integrity sha512-1bSfXyBKi+EYS3YY+e0Csuxf8oZ3decdfhOav/Z7Wrk89tjudyL5FOmwZQUoy0/qVXGUl+6Q3s2SWtpDEWITfQ==
math-intrinsics@^1.1.0: math-intrinsics@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"