diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go index aab9c523d..976558b9b 100644 --- a/api/http/handler/helm/helm_repo_search.go +++ b/api/http/handler/helm/helm_repo_search.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer/pkg/libhelm/options" httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" "github.com/pkg/errors" ) @@ -32,13 +33,18 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) * return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) } + chart, _ := request.RetrieveQueryParameter(r, "chart", false) + useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false) + _, err := url.ParseRequestURI(repo) if err != nil { return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) } searchOpts := options.SearchRepoOptions{ - Repo: repo, + Repo: repo, + Chart: chart, + UseCache: useCache, } result, err := handler.helmPackageManager.SearchRepo(searchOpts) diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx index 4bea0bf47..b45a54e79 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx @@ -33,6 +33,8 @@ vi.mock('../queries/useHelmRepositories', () => ({ ], isInitialLoading: false, isError: false, + isFetching: false, + refetch: vi.fn(() => Promise.resolve([])), })), useHelmRepositories: vi.fn(() => ({ data: ['repo1', 'repo2'], @@ -81,6 +83,8 @@ describe('UpgradeButton', () => { data, isInitialLoading: false, isError: false, + isFetching: false, + refetch: vi.fn(() => Promise.resolve([])), }); renderButton(); @@ -94,6 +98,8 @@ describe('UpgradeButton', () => { data: [], isInitialLoading: true, isError: false, + isFetching: false, + refetch: vi.fn(() => Promise.resolve([])), }); renderButton(); @@ -109,6 +115,8 @@ describe('UpgradeButton', () => { data, isInitialLoading: false, isError: false, + isFetching: false, + refetch: vi.fn(() => Promise.resolve([])), }); renderButton(); @@ -139,6 +147,8 @@ describe('UpgradeButton', () => { ], isInitialLoading: false, isError: false, + isFetching: false, + refetch: vi.fn(() => Promise.resolve([])), }); renderButton({ release: mockRelease }); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx index 4c385c388..7376877e4 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx @@ -1,5 +1,6 @@ import { ArrowUp } from 'lucide-react'; import { useRouter } from '@uirouter/react'; +import { useState } from 'react'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { notifySuccess } from '@/portainer/services/notifications'; @@ -41,16 +42,19 @@ export function UpgradeButton({ const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId); const repositoriesQuery = useHelmRepositories(); + const [useCache, setUseCache] = useState(true); const helmRepoVersionsQuery = useHelmRepoVersions( release?.chart.metadata?.name || '', 60 * 60 * 1000, // 1 hour - repositoriesQuery.data + repositoriesQuery.data, + useCache ); const versions = helmRepoVersionsQuery.data; // Combined loading state const isInitialLoading = repositoriesQuery.isInitialLoading || + helmRepoVersionsQuery.isFetching || helmRepoVersionsQuery.isInitialLoading; const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError; @@ -58,9 +62,10 @@ export function UpgradeButton({ select: (data) => data.chart.metadata?.version, }); const latestVersionAvailable = versions[0]?.Version ?? ''; - const isNewVersionAvailable = + const isNewVersionAvailable = Boolean( latestVersion?.data && - semverCompare(latestVersionAvailable, latestVersion?.data) === 1; + semverCompare(latestVersionAvailable, latestVersion?.data) === 1 + ); const editableHelmRelease: UpdateHelmReleasePayload = { name: releaseName, @@ -70,6 +75,14 @@ export function UpgradeButton({ version: release?.chart.metadata?.version, }; + function handleRefreshVersions() { + if (useCache === false) { + helmRepoVersionsQuery.refetch(); + } else { + setUseCache(false); + } + } + return (
)} - {versions.length === 0 && !isInitialLoading && !isError && ( + {!isInitialLoading && !isError && ( - No versions available - - Portainer is unable to find any versions for this chart in the - repositories saved. Try adding a new repository which contains - the chart in the{' '} - - Helm repositories settings - -
- } - /> - - )} - {isNewVersionAvailable && ( - - New version available ({latestVersionAvailable}) + {getStatusMessage( + versions.length === 0, + latestVersionAvailable, + isNewVersionAvailable + )} + {versions.length === 0 && ( + + Portainer is unable to find any versions for this chart in the + repositories saved. Try adding a new repository which contains + the chart in the{' '} + + Helm repositories settings + + + } + /> + )} + )} @@ -164,4 +185,18 @@ export function UpgradeButton({ }, }); } + + function getStatusMessage( + hasNoAvailableVersions: boolean, + latestVersionAvailable: string, + isNewVersionAvailable: boolean + ): string { + if (hasNoAvailableVersions) { + return 'No versions available '; + } + if (isNewVersionAvailable) { + return `New version available (${latestVersionAvailable}) `; + } + return ''; + } } diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts index 733b79dea..bf11eadb1 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts @@ -45,20 +45,21 @@ export function useHelmRepositories() { export function useHelmRepoVersions( chart: string, staleTime: number, - repositories: string[] = [] + repositories: string[] = [], + useCache: boolean = true ) { // Fetch versions from each repository in parallel as separate queries const versionQueries = useQueries({ queries: useMemo( () => repositories.map((repo) => ({ - queryKey: ['helm', 'repositories', chart, repo], - queryFn: () => getSearchHelmRepo(repo, chart), + queryKey: ['helm', 'repositories', chart, repo, useCache], + queryFn: () => getSearchHelmRepo(repo, chart, useCache), enabled: !!chart && repositories.length > 0, staleTime, ...withGlobalError(`Unable to retrieve versions from ${repo}`), })), - [repositories, chart, staleTime] + [repositories, chart, staleTime, useCache] ), }); @@ -72,6 +73,8 @@ export function useHelmRepoVersions( data: allVersions, isInitialLoading: versionQueries.some((q) => q.isLoading), isError: versionQueries.some((q) => q.isError), + isFetching: versionQueries.some((q) => q.isFetching), + refetch: () => Promise.all(versionQueries.map((q) => q.refetch())), }; } @@ -80,11 +83,12 @@ export function useHelmRepoVersions( */ async function getSearchHelmRepo( repo: string, - chart: string + chart: string, + useCache: boolean = true ): Promise { try { const { data } = await axios.get(`templates/helm`, { - params: { repo, chart }, + params: { repo, chart, useCache }, }); const versions = data.entries[chart]; return ( diff --git a/pkg/libhelm/options/search_repo_options.go b/pkg/libhelm/options/search_repo_options.go index 73acc5709..0b35c0bbd 100644 --- a/pkg/libhelm/options/search_repo_options.go +++ b/pkg/libhelm/options/search_repo_options.go @@ -3,6 +3,8 @@ package options import "net/http" type SearchRepoOptions struct { - Repo string `example:"https://charts.gitlab.io/"` - Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"` + Repo string `example:"https://charts.gitlab.io/"` + Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"` + Chart string `example:"my-chart"` + UseCache bool `example:"false"` } diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go index 90dd98529..63015330c 100644 --- a/pkg/libhelm/sdk/search_repo.go +++ b/pkg/libhelm/sdk/search_repo.go @@ -4,6 +4,8 @@ import ( "net/url" "os" "path/filepath" + "sync" + "time" "github.com/pkg/errors" "github.com/portainer/portainer/pkg/libhelm/options" @@ -25,6 +27,17 @@ type RepoIndex struct { Generated string `json:"generated"` } +type RepoIndexCache struct { + Index *repo.IndexFile + Timestamp time.Time +} + +var ( + indexCache = make(map[string]RepoIndexCache) + cacheMutex sync.RWMutex + cacheDuration = 60 * time.Minute +) + // SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller. func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) { // Validate input options @@ -53,6 +66,18 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO return nil, err } + // Check cache first + if searchRepoOpts.UseCache { + cacheMutex.RLock() + if cached, exists := indexCache[repoURL.String()]; exists { + if time.Since(cached.Timestamp) < cacheDuration { + cacheMutex.RUnlock() + return convertAndMarshalIndex(cached.Index, searchRepoOpts.Chart) + } + } + cacheMutex.RUnlock() + } + // Set up Helm CLI environment repoSettings := cli.New() @@ -92,23 +117,21 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO return nil, err } - // Convert the index file to our response format - result, err := convertIndexToResponse(indexFile) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Err(err). - Msg("Failed to convert index to response format") - return nil, errors.Wrap(err, "failed to convert index to response format") + // Update cache and remove old entries + cacheMutex.Lock() + indexCache[searchRepoOpts.Repo] = RepoIndexCache{ + Index: indexFile, + Timestamp: time.Now(), + } + for key, index := range indexCache { + if time.Since(index.Timestamp) > cacheDuration { + delete(indexCache, key) + } } - log.Debug(). - Str("context", "HelmClient"). - Str("repo", searchRepoOpts.Repo). - Int("entries_count", len(indexFile.Entries)). - Msg("Successfully searched repository") + cacheMutex.Unlock() - return json.Marshal(result) + return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart) } // validateSearchRepoOptions validates the required search repository options. @@ -216,7 +239,7 @@ func loadIndexFile(indexPath string) (*repo.IndexFile, error) { } // convertIndexToResponse converts the Helm index file to our response format. -func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) { +func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIndex, error) { result := RepoIndex{ APIVersion: indexFile.APIVersion, Entries: make(map[string][]ChartInfo), @@ -225,7 +248,9 @@ func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) { // Convert Helm SDK types to our response types for name, charts := range indexFile.Entries { - result.Entries[name] = convertChartsToChartInfo(charts) + if chartName == "" || name == chartName { + result.Entries[name] = convertChartsToChartInfo(charts) + } } return result, nil @@ -349,3 +374,23 @@ func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error { return nil } + +func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) { + // Convert the index file to our response format + result, err := convertIndexToResponse(indexFile, chartName) + if err != nil { + log.Error(). + Str("context", "HelmClient"). + Err(err). + Msg("Failed to convert index to response format") + return nil, errors.Wrap(err, "failed to convert index to response format") + } + + log.Debug(). + Str("context", "HelmClient"). + Str("repo", chartName). + Int("entries_count", len(indexFile.Entries)). + Msg("Successfully searched repository") + + return json.Marshal(result) +}