diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go index 4759d9214..6f0264c1b 100644 --- a/api/http/models/kubernetes/application.go +++ b/api/http/models/kubernetes/application.go @@ -36,13 +36,15 @@ type K8sApplication struct { Kind string `json:"Kind,omitempty"` MatchLabels map[string]string `json:"MatchLabels,omitempty"` Labels map[string]string `json:"Labels,omitempty"` + Annotations map[string]string `json:"Annotations,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"` HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"` } type Metadata struct { - Labels map[string]string `json:"labels"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` } type CustomResourceMetadata struct { diff --git a/api/kubernetes/cli/applications.go b/api/kubernetes/cli/applications.go index 8dd4d72b2..e8807afd4 100644 --- a/api/kubernetes/cli/applications.go +++ b/api/kubernetes/cli/applications.go @@ -269,7 +269,8 @@ func populateApplicationFromDeployment(application *models.K8sApplication, deplo application.RunningPodsCount = int(deployment.Status.ReadyReplicas) application.DeploymentType = "Replicated" application.Metadata = &models.Metadata{ - Labels: deployment.Labels, + Labels: deployment.Labels, + Annotations: deployment.Annotations, } // If the deployment has containers, use the first container's image @@ -297,7 +298,8 @@ func populateApplicationFromStatefulSet(application *models.K8sApplication, stat application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas) application.DeploymentType = "Replicated" application.Metadata = &models.Metadata{ - Labels: statefulSet.Labels, + Labels: statefulSet.Labels, + Annotations: statefulSet.Annotations, } // If the statefulSet has containers, use the first container's image @@ -322,7 +324,8 @@ func populateApplicationFromDaemonSet(application *models.K8sApplication, daemon application.RunningPodsCount = int(daemonSet.Status.NumberReady) application.DeploymentType = "Global" application.Metadata = &models.Metadata{ - Labels: daemonSet.Labels, + Labels: daemonSet.Labels, + Annotations: daemonSet.Annotations, } if len(daemonSet.Spec.Template.Spec.Containers) > 0 { @@ -351,7 +354,8 @@ func populateApplicationFromPod(application *models.K8sApplication, pod corev1.P application.RunningPodsCount = runningPodsCount application.DeploymentType = string(pod.Status.Phase) application.Metadata = &models.Metadata{ - Labels: pod.Labels, + Labels: pod.Labels, + Annotations: pod.Annotations, } // If the pod has containers, use the first container's image diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx index 038d3bd82..907724653 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx @@ -20,7 +20,11 @@ import { AddButton } from '@@/buttons'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { NamespaceFilter } from '../ApplicationsStacksDatatable/NamespaceFilter'; -import { PodKubernetesInstanceLabel, PodManagedByLabel } from '../../constants'; +import { + HelmReleaseNameAnnotation, + PodKubernetesInstanceLabel, + PodManagedByLabel, +} from '../../constants'; import { useApplications } from '../../queries/useApplications'; import { ApplicationsTableSettings } from '../useKubeAppsTableStore'; import { useDeleteApplicationsMutation } from '../../queries/useDeleteApplicationsMutation'; @@ -164,7 +168,9 @@ function separateHelmApps(applications: Application[]): ApplicationRowData[] { applications, (app) => app.Metadata?.labels && - app.Metadata.labels[PodKubernetesInstanceLabel] && + (app.Metadata.labels[PodKubernetesInstanceLabel] || + // 'meta.helm.sh/release-name' annotation fallback + app.Metadata.annotations?.[HelmReleaseNameAnnotation]) && app.Metadata.labels[PodManagedByLabel] === 'Helm' ); @@ -172,7 +178,9 @@ function separateHelmApps(applications: Application[]): ApplicationRowData[] { helmApps, (app) => `${app.ResourcePool}/${ - app.Metadata?.labels[PodKubernetesInstanceLabel] ?? '' + app.Metadata?.labels[PodKubernetesInstanceLabel] ?? + app.Metadata?.annotations?.[HelmReleaseNameAnnotation] ?? + '' }` ); diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts index d30208e81..73a396414 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts @@ -20,6 +20,7 @@ export interface Application { ApplicationType: AppType; Metadata?: { labels: Record; + annotations: Record; }; Status: 'Ready' | string; TotalPodsCount: number; diff --git a/app/react/kubernetes/applications/constants.ts b/app/react/kubernetes/applications/constants.ts index 19e1eca56..aac321e01 100644 --- a/app/react/kubernetes/applications/constants.ts +++ b/app/react/kubernetes/applications/constants.ts @@ -10,6 +10,7 @@ export const defaultDeploymentUniqueLabel = 'pod-template-hash'; export const appNameLabel = 'io.portainer.kubernetes.application.name'; export const PodKubernetesInstanceLabel = 'app.kubernetes.io/instance'; export const PodManagedByLabel = 'app.kubernetes.io/managed-by'; +export const HelmReleaseNameAnnotation = 'meta.helm.sh/release-name'; export const appRevisionAnnotation = 'deployment.kubernetes.io/revision'; diff --git a/pkg/libhelm/sdk/common.go b/pkg/libhelm/sdk/common.go index c831a69fa..0043165a3 100644 --- a/pkg/libhelm/sdk/common.go +++ b/pkg/libhelm/sdk/common.go @@ -116,10 +116,10 @@ func parseRepoURL(repoURL string) (*url.URL, error) { return parsedURL, nil } -// getRepoNameFromURL generates a unique repository identifier from a URL. +// GetRepoNameFromURL generates a unique repository identifier from a URL. // Combines hostname and path for uniqueness (e.g., "charts.helm.sh/stable" → "charts.helm.sh-stable"). // Used for Helm's repositories.yaml entries, caching, and chart references. -func getRepoNameFromURL(urlStr string) (string, error) { +func GetRepoNameFromURL(urlStr string) (string, error) { parsedURL, err := url.Parse(urlStr) if err != nil { return "", fmt.Errorf("failed to parse URL: %w", err) diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go index 42011d4ae..8da4a0363 100644 --- a/pkg/libhelm/sdk/search_repo.go +++ b/pkg/libhelm/sdk/search_repo.go @@ -85,7 +85,7 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO // Update cache for HTTP repos if IsHTTPRepository(searchRepoOpts.Registry) { - hspm.updateCache(searchRepoOpts.Repo, indexFile) + UpdateCache(searchRepoOpts.Repo, indexFile) } return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart) @@ -114,7 +114,7 @@ func (hspm *HelmSDKPackageManager) tryGetFromCache(repoURL, chartName string) [] } // updateCache updates the cache with the provided index file and cleans up expired entries -func (hspm *HelmSDKPackageManager) updateCache(repoURL string, indexFile *repo.IndexFile) { +func UpdateCache(repoURL string, indexFile *repo.IndexFile) { cacheMutex.Lock() defer cacheMutex.Unlock() @@ -151,7 +151,7 @@ func (hspm *HelmSDKPackageManager) downloadHTTPRepoIndex(repoURL string, repoSet return nil, err } - repoName, err := getRepoNameFromURL(parsedURL.String()) + repoName, err := GetRepoNameFromURL(parsedURL.String()) if err != nil { log.Error(). Str("context", "HelmClient"). diff --git a/pkg/libhelm/validate_repo.go b/pkg/libhelm/validate_repo.go index 163b414e4..087980962 100644 --- a/pkg/libhelm/validate_repo.go +++ b/pkg/libhelm/validate_repo.go @@ -5,12 +5,15 @@ import ( "fmt" "net/http" "net/url" - "path" "strings" - "time" + + "github.com/portainer/portainer/pkg/libhelm/sdk" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" ) -func ValidateHelmRepositoryURL(repoUrl string, client *http.Client) error { +func ValidateHelmRepositoryURL(repoUrl string, _ *http.Client) error { if repoUrl == "" { return errors.New("URL is required") } @@ -28,26 +31,34 @@ func ValidateHelmRepositoryURL(repoUrl string, client *http.Client) error { return fmt.Errorf("invalid helm repository URL '%s'", repoUrl) } - url.Path = path.Join(url.Path, "index.yaml") + // Mirror Helm CLI behavior: download and parse index.yaml using getters + settings := cli.New() - if client == nil { - client = &http.Client{ - Timeout: 120 * time.Second, - Transport: http.DefaultTransport, - } + // Use a deterministic repo name shared with the SDK helper so cache aligns + repoName, err := sdk.GetRepoNameFromURL(repoUrl) + if err != nil { + return fmt.Errorf("failed to derive repo name: %w", err) } - response, err := client.Head(url.String()) + r, err := repo.NewChartRepository( + &repo.Entry{ + Name: repoName, + URL: repoUrl, + }, + getter.All(settings), + ) if err != nil { return fmt.Errorf("%s is not a valid chart repository or cannot be reached: %w", repoUrl, err) } - response.Body.Close() + indexPath, err := r.DownloadIndexFile() + if err != nil { + return fmt.Errorf("%s is not a valid chart repository or cannot be reached: %w", repoUrl, err) + } - // Success is indicated with 2xx status codes. 3xx status codes indicate a redirect. - statusOK := response.StatusCode >= 200 && response.StatusCode < 300 - if !statusOK { - return fmt.Errorf("%s is not a valid chart repository or cannot be reached", repoUrl) + // Best-effort: load and seed in-memory cache for future SearchRepo calls + if indexFile, err := repo.LoadIndexFile(indexPath); err == nil { + sdk.UpdateCache(repoUrl, indexFile) } return nil diff --git a/pkg/libhelm/validate_repo_test.go b/pkg/libhelm/validate_repo_test.go index 5b9f6cd09..a5f5fbfe9 100644 --- a/pkg/libhelm/validate_repo_test.go +++ b/pkg/libhelm/validate_repo_test.go @@ -3,6 +3,7 @@ package libhelm import ( "net/http" "net/http/httptest" + "sync/atomic" "testing" "github.com/portainer/portainer/pkg/libhelm/test" @@ -56,12 +57,19 @@ func Test_ValidateHelmRepositoryURL(t *testing.T) { func TestValidateHelmRepositoryURL(t *testing.T) { var fail bool + const indexYAML = "apiVersion: v1\nentries: {}\ngenerated: \"2020-01-01T00:00:00Z\"\n" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if fail { w.WriteHeader(http.StatusNotFound) - return } + if r.URL.Path == "/index.yaml" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(indexYAML)) + return + } + w.WriteHeader(http.StatusNotFound) })) defer srv.Close() @@ -88,3 +96,37 @@ func TestValidateHelmRepositoryURL(t *testing.T) { require.Error(t, err) } } + +func Test_ValidateSeedsCacheAndSearchUsesCache(t *testing.T) { + const indexYAML = "apiVersion: v1\nentries: {}\ngenerated: \"2020-01-01T00:00:00Z\"\n" + + var requestCount int32 + var fail bool + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/index.yaml" { + if fail { + w.WriteHeader(http.StatusNotFound) + return + } + atomic.AddInt32(&requestCount, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(indexYAML)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + // isolate helm cache/config + temp := t.TempDir() + t.Setenv("HELM_REPOSITORY_CONFIG", temp+"/repositories.yaml") + t.Setenv("HELM_REPOSITORY_CACHE", temp+"/cache") + t.Setenv("HELM_REGISTRY_CONFIG", temp+"/registry.json") + t.Setenv("HELM_PLUGINS", temp+"/plugins") + + // validate cache is used + err := ValidateHelmRepositoryURL(srv.URL, nil) + require.NoError(t, err) + require.Equal(t, int32(1), atomic.LoadInt32(&requestCount)) +}