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"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|
|
@ -269,7 +269,8 @@ func populateApplicationFromDeployment(application *models.K8sApplication, deplo
|
||||||
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
||||||
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
|
||||||
|
@ -297,7 +298,8 @@ func populateApplicationFromStatefulSet(application *models.K8sApplication, stat
|
||||||
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
||||||
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
|
||||||
|
@ -322,7 +324,8 @@ func populateApplicationFromDaemonSet(application *models.K8sApplication, daemon
|
||||||
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
||||||
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 {
|
||||||
|
@ -351,7 +354,8 @@ func populateApplicationFromPod(application *models.K8sApplication, pod corev1.P
|
||||||
application.RunningPodsCount = runningPodsCount
|
application.RunningPodsCount = runningPodsCount
|
||||||
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
|
||||||
|
|
|
@ -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] ??
|
||||||
|
''
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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").
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue