diff --git a/frontend/packages/helm-plugin/locales/en/helm-plugin.json b/frontend/packages/helm-plugin/locales/en/helm-plugin.json index c743d7d62e6..dce32e8cc96 100644 --- a/frontend/packages/helm-plugin/locales/en/helm-plugin.json +++ b/frontend/packages/helm-plugin/locales/en/helm-plugin.json @@ -126,12 +126,16 @@ "The OCI URL or HTTP/HTTPS tar file for the Helm chart; for example - oci://registry.example.com/charts/mychart or https://example.com/chart-1.0.0.tgz.": "The OCI URL or HTTP/HTTPS tar file for the Helm chart; for example - oci://registry.example.com/charts/mychart or https://example.com/chart-1.0.0.tgz.", "Unique name for Helm release.": "Unique name for Helm release.", "The version of chart to install.": "The version of chart to install.", + "Secret for basic authentication.": "Secret for basic authentication.", + "Select a secret": "Select a secret", + "A secret with \"username\" and \"password\" keys for OCI/HTTP(S) authentication": "A secret with \"username\" and \"password\" keys for OCI/HTTP(S) authentication", "Next": "Next", "Install Helm chart from Helm registry.": "Install Helm chart from Helm registry.", "Helm release": "Helm release", "Complete the form to create a Helm release. The Helm chart authors might have provided some default values.": "Complete the form to create a Helm release. The Helm chart authors might have provided some default values.", "Configure Helm release": "Configure Helm release", "Version": "Version", + "Secret for basic authentication": "Secret for basic authentication", "Install": "Install", "Back": "Back", "Display Name": "Display Name", diff --git a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartForm.tsx b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartForm.tsx index 85443f2a883..31458acbe85 100644 --- a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartForm.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartForm.tsx @@ -1,10 +1,21 @@ import type { FC } from 'react'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { TextInputTypes, Grid, GridItem } from '@patternfly/react-core'; import type { FormikProps } from 'formik'; +import * as fuzzy from 'fuzzysearch'; import { useTranslation } from 'react-i18next'; import FormSection from '@console/dev-console/src/components/import/section/FormSection'; -import { InputField, FormFooter, FormBody, FormHeader, FlexForm } from '@console/shared'; +import { useK8sWatchResources } from '@console/internal/components/utils/k8s-watch-hook'; +import { SecretModel } from '@console/internal/models'; +import type { K8sResourceKind } from '@console/internal/module/k8s'; +import { + InputField, + FormFooter, + FormBody, + FormHeader, + FlexForm, + ResourceDropdownField, +} from '@console/shared'; import type { HelmURLChartFormData } from './types'; export interface HelmURLChartFormProps { @@ -17,6 +28,7 @@ const HelmURLChartForm: FC & HelmURLChartFormP status, isSubmitting, onNext, + namespace, isValid, dirty, values, @@ -25,6 +37,34 @@ const HelmURLChartForm: FC & HelmURLChartFormP }) => { const { t } = useTranslation(); + const autocompleteFilter = (strText: string, item: any): boolean => + fuzzy(strText, item?.props?.name); + + const watchedResources = useK8sWatchResources<{ + secrets: K8sResourceKind[]; + }>({ + secrets: { + isList: true, + kind: SecretModel.kind, + namespace, + optional: true, + }, + }); + const secretResources = useMemo( + () => [ + { + data: watchedResources.secrets?.data, + loaded: watchedResources.secrets?.loaded, + loadError: watchedResources.secrets?.loadError, + kind: SecretModel.kind, + }, + ], + [ + watchedResources.secrets?.data, + watchedResources.secrets?.loaded, + watchedResources.secrets?.loadError, + ], + ); const isNextDisabled = !isValid || !dirty || isSubmitting; // Auto-populate releaseName and chartVersion from URL @@ -116,6 +156,21 @@ const HelmURLChartForm: FC & HelmURLChartFormP data-test="oci-chart-version" /> + + + diff --git a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx index 81677aa01e8..c26206bbbe7 100644 --- a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx @@ -65,38 +65,48 @@ const HelmURLChartInstallPage: FunctionComponent = () => { chartURL: '', chartVersion: '', namespace, + basicAuthSecretName: '', }; - const fetchChartData = useCallback(async (chartURL: string, chartVersion: string) => { - setIsLoadingChart(true); - setChartError(null); + const fetchChartData = useCallback( + async (chartURL: string, chartVersion: string, basicAuthSecretName: string) => { + setIsLoadingChart(true); + setChartError(null); - try { - const fullChartURL = getFullChartURL(chartURL, chartVersion); - const apiUrl = `/api/helm/chart?url=${encodeURIComponent(fullChartURL)}&noRepo=true`; + try { + const fullChartURL = getFullChartURL(chartURL, chartVersion); + let authParam = ''; + if (basicAuthSecretName) { + authParam = `&basic_auth_secret_name=${encodeURIComponent(basicAuthSecretName)}`; + } + const apiUrl = `/api/helm/chart?url=${encodeURIComponent( + fullChartURL, + )}&noRepo=true&namespace=${namespace}${authParam}`; - const res = await coFetchJSON(apiUrl); - const chart: HelmChart = res?.chart || res; - const valuesYAML = getChartValuesYAML(chart); - const valuesJSON = chart?.values ?? {}; - const valuesSchema = chart?.schema && JSON.parse(atob(chart?.schema)); + const res = await coFetchJSON(apiUrl); + const chart: HelmChart = res?.chart || res; + const valuesYAML = getChartValuesYAML(chart); + const valuesJSON = chart?.values ?? {}; + const valuesSchema = chart?.schema && JSON.parse(atob(chart?.schema)); - setInitialYamlData(valuesYAML); - setInitialFormData(valuesJSON as Record); - setInitialFormSchema(valuesSchema); - setChartHasValues(!!valuesYAML); - setChartData(chart); - } catch (e) { - setChartError(e as Error); - } finally { - setIsLoadingChart(false); - } - }, []); + setInitialYamlData(valuesYAML); + setInitialFormData(valuesJSON as Record); + setInitialFormSchema(valuesSchema); + setChartHasValues(!!valuesYAML); + setChartData(chart); + } catch (e) { + setChartError(e as Error); + } finally { + setIsLoadingChart(false); + } + }, + [namespace], + ); const handleNextStep = useCallback( (values: HelmURLChartFormData) => { setChartDetails(values); - fetchChartData(values.chartURL, values.chartVersion); + fetchChartData(values.chartURL, values.chartVersion, values.basicAuthSecretName); setCurrentStep(WizardStep.ConfigureInstall); }, [fetchChartData], @@ -112,7 +122,15 @@ const HelmURLChartInstallPage: FunctionComponent = () => { values: HelmURLInstallFormData, actions: FormikHelpers, ) => { - const { releaseName, chartURL, chartVersion, yamlData, formData, editorType } = values; + const { + releaseName, + chartURL, + chartVersion, + yamlData, + formData, + editorType, + basicAuthSecretName, + } = values; let valuesObj: Record | undefined; if (editorType === EditorType.Form) { @@ -153,6 +171,7 @@ const HelmURLChartInstallPage: FunctionComponent = () => { chart_url: fullChartURL, // eslint-disable-line @typescript-eslint/naming-convention ...(chartVersion ? { chart_version: chartVersion } : {}), // eslint-disable-line @typescript-eslint/naming-convention ...(valuesObj ? { values: valuesObj } : {}), + ...(basicAuthSecretName ? { basic_auth_secret_name: basicAuthSecretName } : {}), // eslint-disable-line @typescript-eslint/naming-convention noRepo: true, }; @@ -197,6 +216,7 @@ const HelmURLChartInstallPage: FunctionComponent = () => { chartURL: chartDetails?.chartURL || '', chartVersion: chartDetails?.chartVersion || '', namespace, + basicAuthSecretName: chartDetails?.basicAuthSecretName || '', chartName: chartData?.metadata?.name || '', appVersion: chartData?.metadata?.appVersion || '', chartReadme: getChartReadme(chartData), diff --git a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLInstallForm.tsx b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLInstallForm.tsx index 88bcc42b530..d9427908202 100644 --- a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLInstallForm.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLInstallForm.tsx @@ -2,11 +2,16 @@ import type { ReactNode, FC } from 'react'; import { useMemo } from 'react'; import { TextInputTypes, Grid, GridItem, Button, Alert } from '@patternfly/react-core'; import type { FormikProps } from 'formik'; +import * as fuzzy from 'fuzzysearch'; import * as _ from 'lodash'; import { Trans, useTranslation } from 'react-i18next'; import FormSection from '@console/dev-console/src/components/import/section/FormSection'; +import { useK8sWatchResources } from '@console/internal/components/utils/k8s-watch-hook'; +import { SecretModel } from '@console/internal/models'; +import type { K8sResourceKind } from '@console/internal/module/k8s'; import { InputField, + ResourceDropdownField, FormFooter, FormBody, CodeEditorField, @@ -36,12 +41,44 @@ const HelmURLInstallForm: FC & HelmURLInstal values, chartMetaDescription, chartError, + namespace, onBack, }) => { const { t } = useTranslation(); const { chartReadme, formData, formSchema } = values; - const helmReadmeModalLauncher = useHelmReadmeModalLauncher({ readme: chartReadme }); + const autocompleteFilter = (strText: string, item: string): boolean => fuzzy(strText, item); + + const watchedResources = useK8sWatchResources<{ + secrets: K8sResourceKind[]; + }>({ + secrets: { + isList: true, + kind: SecretModel.kind, + namespace, + optional: true, + }, + }); + + const secretResources = useMemo( + () => [ + { + data: watchedResources.secrets?.data, + loaded: watchedResources.secrets?.loaded, + loadError: watchedResources.secrets?.loadError, + kind: SecretModel.kind, + }, + ], + [ + watchedResources.secrets?.data, + watchedResources.secrets?.loaded, + watchedResources.secrets?.loadError, + ], + ); + + const helmReadmeModalLauncher = useHelmReadmeModalLauncher({ + readme: chartReadme, + }); const isSubmitDisabled = isSubmitting || !_.isEmpty(errors) || !!chartError; @@ -142,6 +179,22 @@ const HelmURLInstallForm: FC & HelmURLInstal data-test="chart-version" /> + + + {!chartError && diff --git a/frontend/packages/helm-plugin/src/components/forms/url-chart/types.ts b/frontend/packages/helm-plugin/src/components/forms/url-chart/types.ts index 06975852dd5..7ae022819a1 100644 --- a/frontend/packages/helm-plugin/src/components/forms/url-chart/types.ts +++ b/frontend/packages/helm-plugin/src/components/forms/url-chart/types.ts @@ -6,6 +6,7 @@ export interface HelmURLChartFormData { chartURL: string; chartVersion: string; namespace: string; + basicAuthSecretName?: string; } export interface HelmURLInstallFormData extends HelmURLChartFormData { diff --git a/frontend/packages/helm-plugin/src/utils/helm-utils.ts b/frontend/packages/helm-plugin/src/utils/helm-utils.ts index 34814f31c00..02e3063ad5d 100644 --- a/frontend/packages/helm-plugin/src/utils/helm-utils.ts +++ b/frontend/packages/helm-plugin/src/utils/helm-utils.ts @@ -357,6 +357,7 @@ export const installChartFromURL = ( chartURL: string, chartVersion?: string, values?: Record, + basicAuthSecretName?: string, ) => { return coFetchJSON.post('/api/helm/release/async', { namespace, @@ -364,6 +365,7 @@ export const installChartFromURL = ( chart_url: chartURL, // eslint-disable-line @typescript-eslint/naming-convention ...(chartVersion ? { chart_version: chartVersion } : {}), // eslint-disable-line @typescript-eslint/naming-convention ...(values ? { values } : {}), + ...(basicAuthSecretName ? { basic_auth_secret_name: basicAuthSecretName } : {}), // eslint-disable-line @typescript-eslint/naming-convention noRepo: true, }); }; diff --git a/pkg/helm/actions/get_chart.go b/pkg/helm/actions/get_chart.go index 18e80ecbf49..5cfb71d8aa3 100644 --- a/pkg/helm/actions/get_chart.go +++ b/pkg/helm/actions/get_chart.go @@ -63,13 +63,25 @@ func GetChart(url string, conf *action.Configuration, repositoryNamespace string return loader.Load(chartPath) } -func GetChartFromURL(url string, conf *action.Configuration, namespace string, client dynamic.Interface, coreClient corev1client.CoreV1Interface, filesCleanup bool) (*chart.Chart, error) { +// GetChartFromURL loads a chart from an OCI or direct HTTP(S) URL. basicAuthSecretName names a +// Secret in namespace with username and password keys when the registry requires authentication. +func GetChartFromURL(url string, conf *action.Configuration, namespace string, client dynamic.Interface, coreClient corev1client.CoreV1Interface, filesCleanup bool, basicAuthSecretName string) (*chart.Chart, error) { if !isValidChartURL(url) { return nil, fmt.Errorf("invalid chart URL: %s, must be oci:// URL or http(s)://*.tgz", url) } cmd := action.NewInstall(conf) cmd.Namespace = namespace + if err := applyBasicAuthFromSecret(cmd, coreClient, namespace, basicAuthSecretName); err != nil { + return nil, err + } + if basicAuthSecretName != "" { + rc, err := RegistryClientWithBasicAuth(false, false, cmd.Username, cmd.Password) + if err != nil { + return nil, fmt.Errorf("failed to configure OCI registry client: %w", err) + } + cmd.SetRegistryClient(rc) + } chartLocation, err := cmd.ChartPathOptions.LocateChart(url, settings) if err != nil { return nil, fmt.Errorf("error getting chart from URL: %v", err) diff --git a/pkg/helm/actions/get_registry.go b/pkg/helm/actions/get_registry.go index a1014120d36..6683d85d192 100644 --- a/pkg/helm/actions/get_registry.go +++ b/pkg/helm/actions/get_registry.go @@ -12,14 +12,8 @@ import ( // newRegistryClient is a package-level variable to allow mocking in tests var newRegistryClient = registry.NewClient -func GetDefaultOCIRegistry(conf *action.Configuration) error { - return GetOCIRegistry(conf, false, false) -} - -func GetOCIRegistry(conf *action.Configuration, skipTLSVerify bool, plainHTTP bool) error { - if conf == nil { - return fmt.Errorf("action configuration cannot be nil") - } +// registryClientOptions returns the same options used by GetOCIRegistry for TLS / plain-HTTP behavior. +func registryClientOptions(skipTLSVerify, plainHTTP bool) []registry.ClientOption { opts := []registry.ClientOption{ registry.ClientOptDebug(false), } @@ -33,7 +27,28 @@ func GetOCIRegistry(conf *action.Configuration, skipTLSVerify bool, plainHTTP bo } opts = append(opts, registry.ClientOptHTTPClient(&http.Client{Transport: transport})) } - registryClient, err := newRegistryClient(opts...) + return opts +} + +// RegistryClientWithBasicAuth builds a registry.Client with the same TLS/plain-HTTP settings as +// GetDefaultOCIRegistry (skipTLSVerify=false, plainHTTP=false) plus OCI basic auth. +// Helm's OCI getter uses Configuration.RegistryClient when set and does not apply ChartPathOptions +// username/password to that client; credentials must be set on the registry client via ClientOptBasicAuth. +func RegistryClientWithBasicAuth(skipTLSVerify, plainHTTP bool, username, password string) (*registry.Client, error) { + opts := registryClientOptions(skipTLSVerify, plainHTTP) + opts = append(opts, registry.ClientOptBasicAuth(username, password)) + return newRegistryClient(opts...) +} + +func GetDefaultOCIRegistry(conf *action.Configuration) error { + return GetOCIRegistry(conf, false, false) +} + +func GetOCIRegistry(conf *action.Configuration, skipTLSVerify bool, plainHTTP bool) error { + if conf == nil { + return fmt.Errorf("action configuration cannot be nil") + } + registryClient, err := newRegistryClient(registryClientOptions(skipTLSVerify, plainHTTP)...) if err != nil { return fmt.Errorf("failed to create registry client: %w", err) } diff --git a/pkg/helm/actions/install_chart.go b/pkg/helm/actions/install_chart.go index dadfd8910b6..1f6792c5996 100644 --- a/pkg/helm/actions/install_chart.go +++ b/pkg/helm/actions/install_chart.go @@ -259,9 +259,33 @@ func InstallChartAsync(ns, name, url string, vals map[string]interface{}, conf * return &secret, nil } +// applyBasicAuthFromSecret sets cmd.Username and cmd.Password from a Secret in ns with +// keys "username" and "password" (same convention as HelmChartRepository connectionConfig). +func applyBasicAuthFromSecret(cmd *action.Install, coreClient corev1client.CoreV1Interface, ns, secretName string) error { + if secretName == "" { + return nil + } + secret, err := coreClient.Secrets(ns).Get(context.TODO(), secretName, v1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get secret %q from namespace %q: %w", secretName, ns, err) + } + u, uok := secret.Data[username] + p, pok := secret.Data[password] + if !uok { + return fmt.Errorf("failed to find %q key in secret %q/%q", username, ns, secretName) + } + if !pok { + return fmt.Errorf("failed to find %q key in secret %q/%q", password, ns, secretName) + } + cmd.Username = string(u) + cmd.Password = string(p) + return nil +} + // InstallChartFromURL installs a chart from an OCI or direct HTTP(S) chart URL. // If not provided, version is extracted from the OCI URL tag when applicable. -func InstallChartFromURL(ns, name, url string, vals map[string]interface{}, conf *action.Configuration, coreClient corev1client.CoreV1Interface, version string) (*kv1.Secret, error) { +// basicAuthSecretName names a Secret in ns containing username and password keys for registry auth. +func InstallChartFromURL(ns, name, url string, vals map[string]interface{}, conf *action.Configuration, coreClient corev1client.CoreV1Interface, version string, basicAuthSecretName string) (*kv1.Secret, error) { if !isValidChartURL(url) { return nil, fmt.Errorf("invalid chart URL: %s, must be oci:// URL or http(s)://*.tgz", url) @@ -271,10 +295,27 @@ func InstallChartFromURL(ns, name, url string, vals map[string]interface{}, conf cmd.ReleaseName = name cmd.Namespace = ns + if err := applyBasicAuthFromSecret(cmd, coreClient, ns, basicAuthSecretName); err != nil { + return nil, err + } + // OCI pulls use conf.RegistryClient when set; the getter does not merge ChartPathOptions username/password + // onto that client (see helm ocigetter). Rebuild the client with basic auth when credentials are supplied. + if basicAuthSecretName != "" { + rc, err := RegistryClientWithBasicAuth(false, false, cmd.Username, cmd.Password) + if err != nil { + return nil, fmt.Errorf("failed to configure OCI registry client: %w", err) + } + cmd.SetRegistryClient(rc) + } + // Set version so LocateChart (and Helm OCI) resolve the correct chart tag; matches InstallChart behavior. if version == "" { version = chartVersionFromURL(url) } + // Remove version from OCI URLs as LocateChart will use chartPathOptions.Version to resolve tag. + if strings.HasPrefix(url, "oci://") { + url = strings.TrimSuffix(url, ":"+version) + } cmd.ChartPathOptions.Version = version cp, err := cmd.ChartPathOptions.LocateChart(url, settings) diff --git a/pkg/helm/actions/install_chart_test.go b/pkg/helm/actions/install_chart_test.go index fe85cf1f9e9..4c5dfb57bce 100644 --- a/pkg/helm/actions/install_chart_test.go +++ b/pkg/helm/actions/install_chart_test.go @@ -406,14 +406,17 @@ func TestInstallChartAsync(t *testing.T) { func TestInstallChartFromURL(t *testing.T) { tests := []struct { - testName string - releaseName string - chartPath string - chartName string - chartVersion string - plainHTTP bool - skipTLSVerify bool - expectError bool + testName string + releaseName string + chartPath string + chartName string + chartVersion string + plainHTTP bool + skipTLSVerify bool + basicAuthSecretName string + basicAuthUser string + basicAuthPass string + expectError bool }{ { testName: "valid HTTP chart URL", @@ -445,6 +448,32 @@ func TestInstallChartFromURL(t *testing.T) { skipTLSVerify: true, expectError: true, }, + { + testName: "OCI chart with basic auth", + releaseName: "basicauth-oci", + chartPath: "oci://localhost:5001/helm-charts/mychart:0.1.0", + chartName: "mychart", + chartVersion: "0.1.0", + plainHTTP: true, + skipTLSVerify: true, + basicAuthSecretName: "oci-auth-secret", + basicAuthUser: "AzureDiamond", + basicAuthPass: "hunter2", + expectError: false, + }, + { + testName: "OCI chart with wrong basic auth credentials", + releaseName: "badauth-oci", + chartPath: "oci://localhost:5001/helm-charts/mychart:0.1.0", + chartName: "mychart", + chartVersion: "0.1.0", + plainHTTP: true, + skipTLSVerify: true, + basicAuthSecretName: "bad-auth-secret", + basicAuthUser: "wrong-user", + basicAuthPass: "wrong-pass", + expectError: true, + }, } for _, tt := range tests { t.Run(tt.testName, func(t *testing.T) { @@ -460,18 +489,28 @@ func TestInstallChartFromURL(t *testing.T) { require.NoError(t, err) objs := []runtime.Object{} + if tt.basicAuthSecretName != "" { + objs = append(objs, &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.basicAuthSecretName, + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "username": []byte(tt.basicAuthUser), + "password": []byte(tt.basicAuthPass), + }, + }) + } clientInterface := k8sfake.NewSimpleClientset(objs...) coreClient := clientInterface.CoreV1() if tt.expectError { - rel, err := InstallChartFromURL("test-namespace", tt.releaseName, tt.chartPath, nil, actionConfig, coreClient, tt.chartVersion) + rel, err := InstallChartFromURL("test-namespace", tt.releaseName, tt.chartPath, nil, actionConfig, coreClient, tt.chartVersion, tt.basicAuthSecretName) require.Error(t, err) require.Nil(t, rel) return } - // For valid URLs: create the release secret in a background goroutine - // to simulate what Helm's cmd.Run would do, unblocking getSecret's Watch. secretName := fmt.Sprintf("sh.helm.release.v1.%v.v1", tt.releaseName) go func() { time.Sleep(2 * time.Second) @@ -495,7 +534,7 @@ func TestInstallChartFromURL(t *testing.T) { secretsDriver.Create(secretName, &r) }() - rel, err := InstallChartFromURL("test-namespace", tt.releaseName, tt.chartPath, nil, actionConfig, coreClient, tt.chartVersion) + rel, err := InstallChartFromURL("test-namespace", tt.releaseName, tt.chartPath, nil, actionConfig, coreClient, tt.chartVersion, tt.basicAuthSecretName) require.NoError(t, err) require.NotNil(t, rel) require.Equal(t, secretName, rel.ObjectMeta.Name) diff --git a/pkg/helm/actions/setup_test.go b/pkg/helm/actions/setup_test.go index d196d8b76aa..78ed335c738 100644 --- a/pkg/helm/actions/setup_test.go +++ b/pkg/helm/actions/setup_test.go @@ -104,6 +104,9 @@ func startTests(m *testing.M) (exitCode int) { if err := setupTestBasicAuth(); err != nil { panic(err) } + if err := setupTestOCIBasicAuth(); err != nil { + panic(err) + } return m.Run() } @@ -168,6 +171,17 @@ func setupTestBasicAuth() error { return nil } +func setupTestOCIBasicAuth() error { + if err := ExecuteScript("./testdata/zotWithBasicAuth.sh", false); err != nil { + return err + } + time.Sleep(5 * time.Second) + if err := ExecuteScript("./testdata/uploadOciCharts.sh", true, "--basic-auth"); err != nil { + return err + } + return nil +} + func ExecuteScript(filepath string, waitForCompletion bool, args ...string) error { tlsCmd := exec.Command(filepath, args...) tlsCmd.Stdout = os.Stdout diff --git a/pkg/helm/actions/testdata/htpasswd b/pkg/helm/actions/testdata/htpasswd new file mode 100644 index 00000000000..f301fb5dfba --- /dev/null +++ b/pkg/helm/actions/testdata/htpasswd @@ -0,0 +1 @@ +AzureDiamond:$2y$05$R3muloAZfYflQ1dV5i8rZuyddR.X3CsFSBWO4jNy19MaCmiWslt3C diff --git a/pkg/helm/actions/testdata/uploadOciCharts.sh b/pkg/helm/actions/testdata/uploadOciCharts.sh index f4c30bffc5b..923c6165e8f 100755 --- a/pkg/helm/actions/testdata/uploadOciCharts.sh +++ b/pkg/helm/actions/testdata/uploadOciCharts.sh @@ -1,14 +1,12 @@ #!/bin/bash -e -# Upload Helm charts as OCI artifacts to zot registry (with TLS) +# Upload Helm charts as OCI artifacts to zot registry +# Usage: uploadOciCharts.sh --tls | --no-tls | --basic-auth # Change to the script's directory (pkg/helm/actions/testdata/) cd "$(dirname "$0")" - -if [[ $1 == "--tls" ]]; then - REGISTRY="localhost:5443" -else - REGISTRY="localhost:5000" -fi +export HELM_CONFIG_HOME="${TMPDIR:-/tmp}/helm-config" +export HELM_REGISTRY_CONFIG="${HELM_CONFIG_HOME}/registry/config.json" +mkdir -p "${HELM_CONFIG_HOME}/registry" CACERT="../cacert.pem" CHARTS_DIR="../../testdata" @@ -26,12 +24,28 @@ else fi # Push charts to OCI registry using helm push -if [[ $1 == "--tls" ]]; then - echo "Pushing mariadb-7.3.5.tgz to oci://$REGISTRY/helm-charts..." - $HELM push $CHARTS_DIR/mariadb-7.3.5.tgz oci://$REGISTRY/helm-charts --ca-file=$CACERT -else - echo "Pushing mychart-0.1.0.tgz to oci://$REGISTRY/helm-charts..." - $HELM push $CHARTS_DIR/mychart-0.1.0.tgz oci://$REGISTRY/helm-charts --plain-http -fi - +mode="${1:-"--no-tls"}" +case $mode in + "--tls") + REGISTRY="localhost:5443" + echo "Pushing mariadb-7.3.5.tgz to oci://$REGISTRY/helm-charts..." + $HELM push $CHARTS_DIR/mariadb-7.3.5.tgz oci://$REGISTRY/helm-charts --ca-file=$CACERT + ;; + "--basic-auth") + REGISTRY="localhost:5001" + echo "Logging in to oci://$REGISTRY with basic auth..." + echo "hunter2" | $HELM registry login $REGISTRY --username AzureDiamond --password-stdin --plain-http + echo "Pushing mychart-0.1.0.tgz to oci://$REGISTRY/helm-charts..." + $HELM push $CHARTS_DIR/mychart-0.1.0.tgz oci://$REGISTRY/helm-charts --plain-http + ;; + "--no-tls" ) + REGISTRY="localhost:5000" + echo "Pushing mychart-0.1.0.tgz to oci://$REGISTRY/helm-charts..." + $HELM push $CHARTS_DIR/mychart-0.1.0.tgz oci://$REGISTRY/helm-charts --plain-http + ;; + *) + echo "Usage: uploadOciCharts.sh --tls | --no-tls | --basic-auth" >&2 + exit 2 + ;; +esac echo "Charts pushed successfully!" diff --git a/pkg/helm/actions/testdata/zot-config-basicauth.json b/pkg/helm/actions/testdata/zot-config-basicauth.json new file mode 100644 index 00000000000..c557eaeab1b --- /dev/null +++ b/pkg/helm/actions/testdata/zot-config-basicauth.json @@ -0,0 +1,19 @@ +{ + "distSpecVersion": "1.1.0", + "storage": { + "rootDirectory": "./zot-storage-5001", + "gc": false + }, + "http": { + "address": "127.0.0.1", + "port": "5001", + "auth": { + "htpasswd": { + "path": "./testdata/htpasswd" + } + } + }, + "log": { + "level": "debug" + } +} diff --git a/pkg/helm/actions/testdata/zot-stop.sh b/pkg/helm/actions/testdata/zot-stop.sh index 3bd9da392ad..77e4ed9df79 100755 --- a/pkg/helm/actions/testdata/zot-stop.sh +++ b/pkg/helm/actions/testdata/zot-stop.sh @@ -2,4 +2,5 @@ kill -TERM $(< zot.pid) || echo "Zot is not currently running." kill -TERM $(< zot-no-tls.pid) || echo "Zot is not currently running." -rm -f zot.pid zot-no-tls.pid +kill -TERM "$(< zot-basicauth.pid)" || echo "Zot (basic auth) is not currently running." +rm -f zot.pid zot-no-tls.pid zot-basicauth.pid diff --git a/pkg/helm/actions/testdata/zotWithBasicAuth.sh b/pkg/helm/actions/testdata/zotWithBasicAuth.sh new file mode 100755 index 00000000000..5dfbb6b0060 --- /dev/null +++ b/pkg/helm/actions/testdata/zotWithBasicAuth.sh @@ -0,0 +1,9 @@ +#!/bin/bash -e +# Start zot OCI registry server with basic auth (htpasswd) +GOOS=${GOOS:-$(go env GOOS)} +GOARCH=${GOARCH:-$(go env GOARCH)} + +mkdir -p ./zot-storage-5001 + +./$GOOS-$GOARCH/zot serve ./testdata/zot-config-basicauth.json & +echo $! > ./zot-basicauth.pid diff --git a/pkg/helm/handlers/handler_test.go b/pkg/helm/handlers/handler_test.go index 7be5df7ed2d..27b9d5e0def 100644 --- a/pkg/helm/handlers/handler_test.go +++ b/pkg/helm/handlers/handler_test.go @@ -201,8 +201,8 @@ func getFakeActionConfigurations(string, string, string, *http.RoundTripper) *ac } } -func fakeInstallChartFromURL(mockedSecret *kv1.Secret, err error) func(ns string, name string, url string, values map[string]interface{}, conf *action.Configuration, coreClient corev1client.CoreV1Interface, version string) (*kv1.Secret, error) { - return func(ns string, name string, url string, values map[string]interface{}, conf *action.Configuration, coreClient corev1client.CoreV1Interface, version string) (*kv1.Secret, error) { +func fakeInstallChartFromURL(mockedSecret *kv1.Secret, err error) func(ns string, name string, url string, values map[string]interface{}, conf *action.Configuration, coreClient corev1client.CoreV1Interface, version string, basicAuthSecretName string) (*kv1.Secret, error) { + return func(ns string, name string, url string, values map[string]interface{}, conf *action.Configuration, coreClient corev1client.CoreV1Interface, version string, basicAuthSecretName string) (*kv1.Secret, error) { return mockedSecret, err } } diff --git a/pkg/helm/handlers/handlers.go b/pkg/helm/handlers/handlers.go index 7d166cc8f50..6d37df337ec 100644 --- a/pkg/helm/handlers/handlers.go +++ b/pkg/helm/handlers/handlers.go @@ -64,7 +64,7 @@ type helmHandlers struct { renderManifests func(string, string, map[string]interface{}, *action.Configuration, dynamic.Interface, corev1client.CoreV1Interface, string, string, bool) (string, error) installChartAsync func(string, string, string, map[string]interface{}, *action.Configuration, dynamic.Interface, corev1client.CoreV1Interface, bool, string) (*kv1.Secret, error) installChart func(string, string, string, map[string]interface{}, *action.Configuration, dynamic.Interface, corev1client.CoreV1Interface, bool, string) (*release.Release, error) - installChartFromURL func(string, string, string, map[string]interface{}, *action.Configuration, corev1client.CoreV1Interface, string) (*kv1.Secret, error) + installChartFromURL func(string, string, string, map[string]interface{}, *action.Configuration, corev1client.CoreV1Interface, string, string) (*kv1.Secret, error) listReleases func(*action.Configuration, bool) ([]*release.Release, error) upgradeReleaseAsync func(string, string, string, map[string]interface{}, *action.Configuration, dynamic.Interface, corev1client.CoreV1Interface, bool, string) (*kv1.Secret, error) upgradeRelease func(string, string, string, map[string]interface{}, *action.Configuration, dynamic.Interface, corev1client.CoreV1Interface, bool, string) (*release.Release, error) @@ -73,7 +73,7 @@ type helmHandlers struct { rollbackRelease func(string, int, *action.Configuration) (*release.Release, error) getRelease func(string, *action.Configuration) (*release.Release, error) getChart func(chartUrl string, conf *action.Configuration, namespace string, client dynamic.Interface, coreClient corev1client.CoreV1Interface, filesCleanup bool, indexEntry string) (*chart.Chart, error) - getChartFromURL func(url string, conf *action.Configuration, namespace string, client dynamic.Interface, coreClient corev1client.CoreV1Interface, filesCleanup bool) (*chart.Chart, error) + getChartFromURL func(url string, conf *action.Configuration, namespace string, client dynamic.Interface, coreClient corev1client.CoreV1Interface, filesCleanup bool, basicAuthSecretName string) (*chart.Chart, error) getReleaseHistory func(releaseName string, conf *action.Configuration) ([]*release.Release, error) newProxy func(bearerToken string) (chartproxy.Proxy, error) } @@ -159,7 +159,7 @@ func (h *helmHandlers) HandleHelmInstallAsync(user *auth.User, w http.ResponseWr } if req.NoRepo { - resp, err := h.installChartFromURL(namespace, req.Name, req.ChartUrl, req.Values, conf, handlerClients.CoreClient, req.ChartVersion) + resp, err := h.installChartFromURL(namespace, req.Name, req.ChartUrl, req.Values, conf, handlerClients.CoreClient, req.ChartVersion, req.BasicAuthSecretName) if err != nil { serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: fmt.Sprintf("Failed to install helm chart: %v", err)}) return @@ -229,12 +229,13 @@ func (h *helmHandlers) HandleChartGet(user *auth.User, w http.ResponseWriter, r namespace := params.Get("namespace") indexEntry := params.Get("indexEntry") noRepo := params.Get("noRepo") == "true" + basicAuthSecretName := params.Get("basic_auth_secret_name") if namespace == "" { namespace = "default" } - conf := h.getActionConfigurations(h.ApiServerHost, "default", user.Token, &h.Transport) + conf := h.getActionConfigurations(h.ApiServerHost, namespace, user.Token, &h.Transport) handlerClients, err := NewHandlerClients(conf) if err != nil { serverutils.SendResponse(w, http.StatusBadGateway, serverutils.ApiError{Err: err.Error()}) @@ -247,7 +248,7 @@ func (h *helmHandlers) HandleChartGet(user *auth.User, w http.ResponseWriter, r serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: "chart URL is required"}) return } - resp, err = h.getChartFromURL(chartUrl, conf, namespace, handlerClients.DynamicClient, handlerClients.CoreClient, true) + resp, err = h.getChartFromURL(chartUrl, conf, namespace, handlerClients.DynamicClient, handlerClients.CoreClient, true, basicAuthSecretName) } else { resp, err = h.getChart(chartUrl, conf, namespace, handlerClients.DynamicClient, handlerClients.CoreClient, true, indexEntry) } @@ -467,7 +468,7 @@ func (h *helmHandlers) HandleURLChartGet(user *auth.User, w http.ResponseWriter, serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: err.Error()}) return } - resp, err := h.getChartFromURL(chartUrl, conf, namespace, handlerClients.DynamicClient, handlerClients.CoreClient, true) + resp, err := h.getChartFromURL(chartUrl, conf, namespace, handlerClients.DynamicClient, handlerClients.CoreClient, true, params.Get("basic_auth_secret_name")) if err != nil { serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: fmt.Sprintf("Failed to retrieve chart: %v", err)}) return diff --git a/pkg/helm/handlers/request.go b/pkg/helm/handlers/request.go index cfc60ae5cbb..5c9385cf6b5 100644 --- a/pkg/helm/handlers/request.go +++ b/pkg/helm/handlers/request.go @@ -9,6 +9,8 @@ type HelmRequest struct { Version int `json:"version"` IndexEntry string `json:"indexEntry"` NoRepo bool `json:"noRepo"` + // BasicAuthSecretName is optional; names a Secret in Namespace with keys username and password for OCI/HTTP chart pull when NoRepo is true. + BasicAuthSecretName string `json:"basic_auth_secret_name"` } type HelmVerifierRequest struct {