1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-22 06:49:40 +02:00

feat(helm): filter on chart versions at API level [R8S-324] (#754)

This commit is contained in:
Cara Ryan 2025-05-27 15:20:28 +12:00 committed by GitHub
parent 07dfd981a2
commit 731afbee46
6 changed files with 159 additions and 56 deletions

View file

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
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/pkg/errors" "github.com/pkg/errors"
) )
@ -17,6 +18,8 @@ import (
// @description **Access policy**: authenticated // @description **Access policy**: authenticated
// @tags helm // @tags helm
// @param repo query string true "Helm repository URL" // @param repo query string true "Helm repository URL"
// @param chart query string false "Helm chart name"
// @param useCache query string false "If true will use cache to search"
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
// @produce json // @produce json
@ -32,6 +35,10 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter"))
} }
chart, _ := request.RetrieveQueryParameter(r, "chart", false)
// If true will useCache to search, will always add to cache after
useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false)
_, err := url.ParseRequestURI(repo) _, err := url.ParseRequestURI(repo)
if err != nil { if err != nil {
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
@ -39,6 +46,8 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
searchOpts := options.SearchRepoOptions{ searchOpts := options.SearchRepoOptions{
Repo: repo, Repo: repo,
Chart: chart,
UseCache: useCache,
} }
result, err := handler.helmPackageManager.SearchRepo(searchOpts) result, err := handler.helmPackageManager.SearchRepo(searchOpts)

View file

@ -33,6 +33,8 @@ vi.mock('../queries/useHelmRepositories', () => ({
], ],
isInitialLoading: false, isInitialLoading: false,
isError: false, isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
})), })),
useHelmRepositories: vi.fn(() => ({ useHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'], data: ['repo1', 'repo2'],
@ -81,6 +83,8 @@ describe('UpgradeButton', () => {
data, data,
isInitialLoading: false, isInitialLoading: false,
isError: false, isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
}); });
renderButton(); renderButton();
@ -94,6 +98,8 @@ describe('UpgradeButton', () => {
data: [], data: [],
isInitialLoading: true, isInitialLoading: true,
isError: false, isError: false,
isFetching: true,
refetch: vi.fn(() => Promise.resolve([])),
}); });
renderButton(); renderButton();
@ -109,6 +115,8 @@ describe('UpgradeButton', () => {
data, data,
isInitialLoading: false, isInitialLoading: false,
isError: false, isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
}); });
renderButton(); renderButton();
@ -139,6 +147,8 @@ describe('UpgradeButton', () => {
], ],
isInitialLoading: false, isInitialLoading: false,
isError: false, isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
}); });
renderButton({ release: mockRelease }); renderButton({ release: mockRelease });

View file

@ -1,5 +1,6 @@
import { ArrowUp } from 'lucide-react'; import { ArrowUp } from 'lucide-react';
import { useRouter } from '@uirouter/react'; import { useRouter } from '@uirouter/react';
import { useState } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications'; import { notifySuccess } from '@/portainer/services/notifications';
@ -41,26 +42,28 @@ export function UpgradeButton({
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId); const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const repositoriesQuery = useHelmRepositories(); const repositoriesQuery = useHelmRepositories();
const [useCache, setUseCache] = useState(true);
const helmRepoVersionsQuery = useHelmRepoVersions( const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '', release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour 60 * 60 * 1000, // 1 hour
repositoriesQuery.data repositoriesQuery.data,
useCache
); );
const versions = helmRepoVersionsQuery.data; const versions = helmRepoVersionsQuery.data;
// Combined loading state // Combined loading state
const isInitialLoading = const isLoading =
repositoriesQuery.isInitialLoading || repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching;
helmRepoVersionsQuery.isInitialLoading;
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError; const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, { const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
select: (data) => data.chart.metadata?.version, select: (data) => data.chart.metadata?.version,
}); });
const latestVersionAvailable = versions[0]?.Version ?? ''; const latestVersionAvailable = versions[0]?.Version ?? '';
const isNewVersionAvailable = const isNewVersionAvailable = Boolean(
latestVersion?.data && latestVersion?.data &&
semverCompare(latestVersionAvailable, latestVersion?.data) === 1; semverCompare(latestVersionAvailable, latestVersion?.data) === 1
);
const editableHelmRelease: UpdateHelmReleasePayload = { const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName, name: releaseName,
@ -70,6 +73,14 @@ export function UpgradeButton({
version: release?.chart.metadata?.version, version: release?.chart.metadata?.version,
}; };
function handleRefreshVersions() {
if (!useCache) {
helmRepoVersionsQuery.refetch();
} else {
setUseCache(false);
}
}
return ( return (
<div className="relative"> <div className="relative">
<LoadingButton <LoadingButton
@ -78,7 +89,7 @@ export function UpgradeButton({
onClick={() => openUpgradeForm(versions, release)} onClick={() => openUpgradeForm(versions, release)}
disabled={ disabled={
versions.length === 0 || versions.length === 0 ||
isInitialLoading || isLoading ||
isError || isError ||
release?.info?.status?.startsWith('pending') release?.info?.status?.startsWith('pending')
} }
@ -89,7 +100,7 @@ export function UpgradeButton({
> >
Upgrade Upgrade
</LoadingButton> </LoadingButton>
{versions.length === 0 && isInitialLoading && ( {isLoading && (
<InlineLoader <InlineLoader
size="xs" size="xs"
className="absolute -bottom-5 left-0 right-0 whitespace-nowrap" className="absolute -bottom-5 left-0 right-0 whitespace-nowrap"
@ -97,9 +108,14 @@ export function UpgradeButton({
Checking for new versions... Checking for new versions...
</InlineLoader> </InlineLoader>
)} )}
{versions.length === 0 && !isInitialLoading && !isError && ( {!isLoading && !isError && (
<span className="absolute flex items-center -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap"> <span className="absolute flex items-center -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
No versions available {getStatusMessage(
versions.length === 0,
latestVersionAvailable,
isNewVersionAvailable
)}
{versions.length === 0 && (
<Tooltip <Tooltip
message={ message={
<div> <div>
@ -116,11 +132,14 @@ export function UpgradeButton({
</div> </div>
} }
/> />
</span>
)} )}
{isNewVersionAvailable && ( <button
<span className="absolute -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap"> onClick={handleRefreshVersions}
New version available ({latestVersionAvailable}) className="text-primary hover:text-primary-light cursor-pointer bg-transparent border-0 pl-1 p-0"
type="button"
>
Refresh versions
</button>
</span> </span>
)} )}
</div> </div>
@ -164,4 +183,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 '';
}
} }

View file

@ -45,20 +45,21 @@ export function useHelmRepositories() {
export function useHelmRepoVersions( export function useHelmRepoVersions(
chart: string, chart: string,
staleTime: number, staleTime: number,
repositories: string[] = [] repositories: string[] = [],
useCache: boolean = true
) { ) {
// Fetch versions from each repository in parallel as separate queries // Fetch versions from each repository in parallel as separate queries
const versionQueries = useQueries({ const versionQueries = useQueries({
queries: useMemo( queries: useMemo(
() => () =>
repositories.map((repo) => ({ repositories.map((repo) => ({
queryKey: ['helm', 'repositories', chart, repo], queryKey: ['helm', 'repositories', chart, repo, useCache],
queryFn: () => getSearchHelmRepo(repo, chart), queryFn: () => getSearchHelmRepo(repo, chart, useCache),
enabled: !!chart && repositories.length > 0, enabled: !!chart && repositories.length > 0,
staleTime, staleTime,
...withGlobalError(`Unable to retrieve versions from ${repo}`), ...withGlobalError(`Unable to retrieve versions from ${repo}`),
})), })),
[repositories, chart, staleTime] [repositories, chart, staleTime, useCache]
), ),
}); });
@ -72,6 +73,8 @@ export function useHelmRepoVersions(
data: allVersions, data: allVersions,
isInitialLoading: versionQueries.some((q) => q.isLoading), isInitialLoading: versionQueries.some((q) => q.isLoading),
isError: versionQueries.some((q) => q.isError), 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( async function getSearchHelmRepo(
repo: string, repo: string,
chart: string chart: string,
useCache: boolean = true
): Promise<ChartVersion[]> { ): Promise<ChartVersion[]> {
try { try {
const { data } = await axios.get<HelmSearch>(`templates/helm`, { const { data } = await axios.get<HelmSearch>(`templates/helm`, {
params: { repo, chart }, params: { repo, chart, useCache },
}); });
const versions = data.entries[chart]; const versions = data.entries[chart];
return ( return (

View file

@ -5,4 +5,6 @@ import "net/http"
type SearchRepoOptions struct { type SearchRepoOptions struct {
Repo string `example:"https://charts.gitlab.io/"` Repo string `example:"https://charts.gitlab.io/"`
Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"` Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
Chart string `example:"my-chart"`
UseCache bool `example:"false"`
} }

View file

@ -4,6 +4,8 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
@ -25,6 +27,17 @@ type RepoIndex struct {
Generated string `json:"generated"` 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. // 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) { func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options // Validate input options
@ -53,6 +66,18 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err 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 // Set up Helm CLI environment
repoSettings := cli.New() repoSettings := cli.New()
@ -92,23 +117,21 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err return nil, err
} }
// Convert the index file to our response format // Update cache and remove old entries
result, err := convertIndexToResponse(indexFile) cacheMutex.Lock()
if err != nil { indexCache[searchRepoOpts.Repo] = RepoIndexCache{
log.Error(). Index: indexFile,
Str("context", "HelmClient"). Timestamp: time.Now(),
Err(err). }
Msg("Failed to convert index to response format") for key, index := range indexCache {
return nil, errors.Wrap(err, "failed to convert index to response format") if time.Since(index.Timestamp) > cacheDuration {
delete(indexCache, key)
}
} }
log.Debug(). cacheMutex.Unlock()
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Int("entries_count", len(indexFile.Entries)).
Msg("Successfully searched repository")
return json.Marshal(result) return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
} }
// validateSearchRepoOptions validates the required search repository options. // 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. // 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{ result := RepoIndex{
APIVersion: indexFile.APIVersion, APIVersion: indexFile.APIVersion,
Entries: make(map[string][]ChartInfo), Entries: make(map[string][]ChartInfo),
@ -225,8 +248,10 @@ func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) {
// Convert Helm SDK types to our response types // Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries { for name, charts := range indexFile.Entries {
if chartName == "" || name == chartName {
result.Entries[name] = convertChartsToChartInfo(charts) result.Entries[name] = convertChartsToChartInfo(charts)
} }
}
return result, nil return result, nil
} }
@ -349,3 +374,23 @@ func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
return nil 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)
}