mirror of https://github.com/portainer/portainer
fix(helm): update helm repo validation to match helm cli [r8s-531] (#1142)
parent
c0f6410d80
commit
7a9376cbaf
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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] ??
|
||||
''
|
||||
}`
|
||||
);
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ export interface Application {
|
|||
ApplicationType: AppType;
|
||||
Metadata?: {
|
||||
labels: Record<string, string>;
|
||||
annotations: Record<string, string>;
|
||||
};
|
||||
Status: 'Ready' | string;
|
||||
TotalPodsCount: number;
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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").
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue