fix(helm): update helm repo validation to match helm cli [r8s-531] (#1142)

release/2.33
Ali 2025-09-08 08:55:57 +12:00 committed by GitHub
parent c0f6410d80
commit 7a9376cbaf
9 changed files with 98 additions and 29 deletions

View File

@ -36,6 +36,7 @@ type K8sApplication struct {
Kind string `json:"Kind,omitempty"` Kind string `json:"Kind,omitempty"`
MatchLabels map[string]string `json:"MatchLabels,omitempty"` MatchLabels map[string]string `json:"MatchLabels,omitempty"`
Labels map[string]string `json:"Labels,omitempty"` Labels map[string]string `json:"Labels,omitempty"`
Annotations map[string]string `json:"Annotations,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"` CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
@ -43,6 +44,7 @@ type K8sApplication struct {
type Metadata struct { type Metadata struct {
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
} }
type CustomResourceMetadata struct { type CustomResourceMetadata struct {

View File

@ -270,6 +270,7 @@ func populateApplicationFromDeployment(application *models.K8sApplication, deplo
application.DeploymentType = "Replicated" application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{ application.Metadata = &models.Metadata{
Labels: deployment.Labels, Labels: deployment.Labels,
Annotations: deployment.Annotations,
} }
// If the deployment has containers, use the first container's image // If the deployment has containers, use the first container's image
@ -298,6 +299,7 @@ func populateApplicationFromStatefulSet(application *models.K8sApplication, stat
application.DeploymentType = "Replicated" application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{ application.Metadata = &models.Metadata{
Labels: statefulSet.Labels, Labels: statefulSet.Labels,
Annotations: statefulSet.Annotations,
} }
// If the statefulSet has containers, use the first container's image // If the statefulSet has containers, use the first container's image
@ -323,6 +325,7 @@ func populateApplicationFromDaemonSet(application *models.K8sApplication, daemon
application.DeploymentType = "Global" application.DeploymentType = "Global"
application.Metadata = &models.Metadata{ application.Metadata = &models.Metadata{
Labels: daemonSet.Labels, Labels: daemonSet.Labels,
Annotations: daemonSet.Annotations,
} }
if len(daemonSet.Spec.Template.Spec.Containers) > 0 { if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
@ -352,6 +355,7 @@ func populateApplicationFromPod(application *models.K8sApplication, pod corev1.P
application.DeploymentType = string(pod.Status.Phase) application.DeploymentType = string(pod.Status.Phase)
application.Metadata = &models.Metadata{ application.Metadata = &models.Metadata{
Labels: pod.Labels, Labels: pod.Labels,
Annotations: pod.Annotations,
} }
// If the pod has containers, use the first container's image // If the pod has containers, use the first container's image

View File

@ -20,7 +20,11 @@ import { AddButton } from '@@/buttons';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { NamespaceFilter } from '../ApplicationsStacksDatatable/NamespaceFilter'; import { NamespaceFilter } from '../ApplicationsStacksDatatable/NamespaceFilter';
import { PodKubernetesInstanceLabel, PodManagedByLabel } from '../../constants'; import {
HelmReleaseNameAnnotation,
PodKubernetesInstanceLabel,
PodManagedByLabel,
} from '../../constants';
import { useApplications } from '../../queries/useApplications'; import { useApplications } from '../../queries/useApplications';
import { ApplicationsTableSettings } from '../useKubeAppsTableStore'; import { ApplicationsTableSettings } from '../useKubeAppsTableStore';
import { useDeleteApplicationsMutation } from '../../queries/useDeleteApplicationsMutation'; import { useDeleteApplicationsMutation } from '../../queries/useDeleteApplicationsMutation';
@ -164,7 +168,9 @@ function separateHelmApps(applications: Application[]): ApplicationRowData[] {
applications, applications,
(app) => (app) =>
app.Metadata?.labels && 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' app.Metadata.labels[PodManagedByLabel] === 'Helm'
); );
@ -172,7 +178,9 @@ function separateHelmApps(applications: Application[]): ApplicationRowData[] {
helmApps, helmApps,
(app) => (app) =>
`${app.ResourcePool}/${ `${app.ResourcePool}/${
app.Metadata?.labels[PodKubernetesInstanceLabel] ?? '' app.Metadata?.labels[PodKubernetesInstanceLabel] ??
app.Metadata?.annotations?.[HelmReleaseNameAnnotation] ??
''
}` }`
); );

View File

@ -20,6 +20,7 @@ export interface Application {
ApplicationType: AppType; ApplicationType: AppType;
Metadata?: { Metadata?: {
labels: Record<string, string>; labels: Record<string, string>;
annotations: Record<string, string>;
}; };
Status: 'Ready' | string; Status: 'Ready' | string;
TotalPodsCount: number; TotalPodsCount: number;

View File

@ -10,6 +10,7 @@ export const defaultDeploymentUniqueLabel = 'pod-template-hash';
export const appNameLabel = 'io.portainer.kubernetes.application.name'; export const appNameLabel = 'io.portainer.kubernetes.application.name';
export const PodKubernetesInstanceLabel = 'app.kubernetes.io/instance'; export const PodKubernetesInstanceLabel = 'app.kubernetes.io/instance';
export const PodManagedByLabel = 'app.kubernetes.io/managed-by'; export const PodManagedByLabel = 'app.kubernetes.io/managed-by';
export const HelmReleaseNameAnnotation = 'meta.helm.sh/release-name';
export const appRevisionAnnotation = 'deployment.kubernetes.io/revision'; export const appRevisionAnnotation = 'deployment.kubernetes.io/revision';

View File

@ -116,10 +116,10 @@ func parseRepoURL(repoURL string) (*url.URL, error) {
return parsedURL, nil 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"). // 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. // 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) parsedURL, err := url.Parse(urlStr)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err) return "", fmt.Errorf("failed to parse URL: %w", err)

View File

@ -85,7 +85,7 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
// Update cache for HTTP repos // Update cache for HTTP repos
if IsHTTPRepository(searchRepoOpts.Registry) { if IsHTTPRepository(searchRepoOpts.Registry) {
hspm.updateCache(searchRepoOpts.Repo, indexFile) UpdateCache(searchRepoOpts.Repo, indexFile)
} }
return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart) 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 // 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() cacheMutex.Lock()
defer cacheMutex.Unlock() defer cacheMutex.Unlock()
@ -151,7 +151,7 @@ func (hspm *HelmSDKPackageManager) downloadHTTPRepoIndex(repoURL string, repoSet
return nil, err return nil, err
} }
repoName, err := getRepoNameFromURL(parsedURL.String()) repoName, err := GetRepoNameFromURL(parsedURL.String())
if err != nil { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").

View File

@ -5,12 +5,15 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"path"
"strings" "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 == "" { if repoUrl == "" {
return errors.New("URL is required") 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) 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 { // Use a deterministic repo name shared with the SDK helper so cache aligns
client = &http.Client{ repoName, err := sdk.GetRepoNameFromURL(repoUrl)
Timeout: 120 * time.Second, if err != nil {
Transport: http.DefaultTransport, 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 { if err != nil {
return fmt.Errorf("%s is not a valid chart repository or cannot be reached: %w", repoUrl, err) 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. // Best-effort: load and seed in-memory cache for future SearchRepo calls
statusOK := response.StatusCode >= 200 && response.StatusCode < 300 if indexFile, err := repo.LoadIndexFile(indexPath); err == nil {
if !statusOK { sdk.UpdateCache(repoUrl, indexFile)
return fmt.Errorf("%s is not a valid chart repository or cannot be reached", repoUrl)
} }
return nil return nil

View File

@ -3,6 +3,7 @@ package libhelm
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic"
"testing" "testing"
"github.com/portainer/portainer/pkg/libhelm/test" "github.com/portainer/portainer/pkg/libhelm/test"
@ -56,12 +57,19 @@ func Test_ValidateHelmRepositoryURL(t *testing.T) {
func TestValidateHelmRepositoryURL(t *testing.T) { func TestValidateHelmRepositoryURL(t *testing.T) {
var fail bool 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) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail { if fail {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
if r.URL.Path == "/index.yaml" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(indexYAML))
return
}
w.WriteHeader(http.StatusNotFound)
})) }))
defer srv.Close() defer srv.Close()
@ -88,3 +96,37 @@ func TestValidateHelmRepositoryURL(t *testing.T) {
require.Error(t, err) 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))
}