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] (#747)
This commit is contained in:
parent
b96328e098
commit
a80b185e10
6 changed files with 153 additions and 51 deletions
|
@ -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"
|
||||||
)
|
)
|
||||||
|
@ -32,6 +33,9 @@ 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)
|
||||||
|
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 +43,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)
|
||||||
|
|
|
@ -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: false,
|
||||||
|
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 });
|
||||||
|
|
|
@ -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,16 +42,19 @@ 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 isInitialLoading =
|
||||||
repositoriesQuery.isInitialLoading ||
|
repositoriesQuery.isInitialLoading ||
|
||||||
|
helmRepoVersionsQuery.isFetching ||
|
||||||
helmRepoVersionsQuery.isInitialLoading;
|
helmRepoVersionsQuery.isInitialLoading;
|
||||||
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
|
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
|
||||||
|
|
||||||
|
@ -58,9 +62,10 @@ export function UpgradeButton({
|
||||||
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 +75,14 @@ export function UpgradeButton({
|
||||||
version: release?.chart.metadata?.version,
|
version: release?.chart.metadata?.version,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function handleRefreshVersions() {
|
||||||
|
if (useCache === false) {
|
||||||
|
helmRepoVersionsQuery.refetch();
|
||||||
|
} else {
|
||||||
|
setUseCache(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
|
@ -97,9 +110,14 @@ export function UpgradeButton({
|
||||||
Checking for new versions...
|
Checking for new versions...
|
||||||
</InlineLoader>
|
</InlineLoader>
|
||||||
)}
|
)}
|
||||||
{versions.length === 0 && !isInitialLoading && !isError && (
|
{!isInitialLoading && !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 +134,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 +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 '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue