From 21741af65983c4fd82dc03cd8858a5c9cc896523 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Thu, 16 Jul 2026 14:52:15 +1200 Subject: [PATCH] feat(addons): install support [R8S-1138] (#3189) --- api/docs/swagger.yaml | 10 ++++--- api/http/handler/helm/helm_list.go | 2 +- api/http/handler/helm/helm_show.go | 43 +++++++++++++++++++----------- pkg/libhelm/sdk/client.go | 18 ++++++++++--- pkg/libhelm/sdk/get.go | 2 +- pkg/libhelm/sdk/history.go | 2 +- pkg/libhelm/sdk/install.go | 2 +- pkg/libhelm/sdk/list.go | 5 +++- pkg/libhelm/sdk/release.go | 2 +- pkg/libhelm/sdk/rollback.go | 2 +- pkg/libhelm/sdk/show.go | 11 +++++++- pkg/libhelm/sdk/show_test.go | 19 +++++++++++++ pkg/libhelm/sdk/uninstall.go | 4 +-- pkg/libhelm/sdk/upgrade.go | 2 +- pkg/libhelm/sdk/values.go | 2 +- 15 files changed, 91 insertions(+), 35 deletions(-) diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index f32ff11b83..df370dbf99 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -18966,15 +18966,17 @@ paths: get: consumes: - application/json - description: '**Access policy**: authenticated' + description: |- + **Access policy**: authenticated + `repo` may be omitted when `chart` is a self-contained "oci://host/path" reference. operationId: HelmShow parameters: - - description: Helm repository URL + - description: Helm repository URL (required unless chart is a self-contained + oci:// reference) in: query name: repo - required: true type: string - - description: Chart name + - description: Chart name, or a self-contained oci:// chart reference in: query name: chart required: true diff --git a/api/http/handler/helm/helm_list.go b/api/http/handler/helm/helm_list.go index 124d010959..bd7e2237df 100644 --- a/api/http/handler/helm/helm_list.go +++ b/api/http/handler/helm/helm_list.go @@ -38,7 +38,7 @@ func (handler *Handler) helmList(w http.ResponseWriter, r *http.Request) *httper KubernetesClusterAccess: clusterAccess, } - // optional namespace. The library defaults to "default" + // optional namespace; when omitted the library lists across all namespaces namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) if namespace != "" { listOpts.Namespace = namespace diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go index 1c5860ba4a..4fb848c069 100644 --- a/api/http/handler/helm/helm_show.go +++ b/api/http/handler/helm/helm_show.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/portainer/portainer/pkg/libhelm/options" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -18,9 +19,10 @@ import ( // @summary Show Helm Chart Information // @description // @description **Access policy**: authenticated +// @description `repo` may be omitted when `chart` is a self-contained "oci://host/path" reference. // @tags helm -// @param repo query string true "Helm repository URL" -// @param chart query string true "Chart name" +// @param repo query string false "Helm repository URL (required unless chart is a self-contained oci:// reference)" +// @param chart query string true "Chart name, or a self-contained oci:// chart reference" // @param version query string false "Chart version" // @param command path string true "chart/values/readme" // @security ApiKeyAuth @@ -33,24 +35,35 @@ import ( // @failure 500 "Server error" // @router /templates/helm/{command} [get] func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - repo := r.URL.Query().Get("repo") - if repo == "" { - return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) - } - _, err := url.ParseRequestURI(repo) - if err != nil { - return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) - } - - if err := ssrf.CheckURL(r.Context(), repo); err != nil { - return httperror.BadRequest("Repository URL blocked by SSRF policy", err) - } - chart := r.URL.Query().Get("chart") if chart == "" { return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter")) } + // A self-contained "oci://host/path" chart reference carries its own source + // (e.g. Portainer addon charts), so no repo is required to locate it. + selfContainedChart := strings.HasPrefix(chart, "oci://") + + repo := r.URL.Query().Get("repo") + if repo == "" && !selfContainedChart { + return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) + } + + if repo != "" { + if _, err := url.ParseRequestURI(repo); err != nil { + return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) + } + if err := ssrf.CheckURL(r.Context(), repo); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + } + + if selfContainedChart { + if err := ssrf.CheckURL(r.Context(), chart); err != nil { + return httperror.BadRequest("Chart reference blocked by SSRF policy", err) + } + } + version, err := request.RetrieveQueryParameter(r, "version", true) if err != nil { return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version))) diff --git a/pkg/libhelm/sdk/client.go b/pkg/libhelm/sdk/client.go index 3315ee4e8e..60fb0811ea 100644 --- a/pkg/libhelm/sdk/client.go +++ b/pkg/libhelm/sdk/client.go @@ -22,13 +22,23 @@ type clientConfigGetter struct { namespace string } -// initActionConfig initializes the action configuration with kubernetes config -func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error { - // If namespace is not provided, use the default namespace +// namespaceOrDefault returns "default" for an empty namespace. Actions that +// operate on a single release pass their namespace through this before calling +// initActionConfig, so an omitted namespace resolves to "default" rather than +// initializing the release storage cluster-wide. +func namespaceOrDefault(namespace string) string { if namespace == "" { - namespace = "default" + return "default" } + return namespace +} +// initActionConfig initializes the action configuration scoped to the given +// namespace, which may be empty: an empty namespace initializes the release +// storage cluster-wide, which is how `helm list --all-namespaces` sees +// releases in every namespace (action.List.AllNamespaces alone does not widen +// the storage scope). +func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error { // Setup logging for Helm SDK using zerolog logger := log.With().Str("context", "HelmClient").Logger() logOptions := slogzerolog.Option{ diff --git a/pkg/libhelm/sdk/get.go b/pkg/libhelm/sdk/get.go index 2bb4346b7f..dbf3c56fb3 100644 --- a/pkg/libhelm/sdk/get.go +++ b/pkg/libhelm/sdk/get.go @@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release. Msg("Get Helm release") actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, getOptions.Namespace, getOptions.KubernetesClusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOptions.Namespace), getOptions.KubernetesClusterAccess) if err != nil { log.Error(). diff --git a/pkg/libhelm/sdk/history.go b/pkg/libhelm/sdk/history.go index 27e22f8ee4..a6ed16badf 100644 --- a/pkg/libhelm/sdk/history.go +++ b/pkg/libhelm/sdk/history.go @@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOpti Msg("Get Helm history") actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, historyOptions.Namespace, historyOptions.KubernetesClusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(historyOptions.Namespace), historyOptions.KubernetesClusterAccess) if err != nil { log.Error(). diff --git a/pkg/libhelm/sdk/install.go b/pkg/libhelm/sdk/install.go index 07607b7852..e07246e03a 100644 --- a/pkg/libhelm/sdk/install.go +++ b/pkg/libhelm/sdk/install.go @@ -37,7 +37,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( // Initialize action configuration with kubernetes config actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(installOpts.Namespace), installOpts.KubernetesClusterAccess) if err != nil { // error is already logged in initActionConfig return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation") diff --git a/pkg/libhelm/sdk/list.go b/pkg/libhelm/sdk/list.go index ee7bb63e1f..5a010baab0 100644 --- a/pkg/libhelm/sdk/list.go +++ b/pkg/libhelm/sdk/list.go @@ -23,7 +23,10 @@ func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release Str("selector", listOpts.Selector). Msg("Listing Helm releases") - // Initialize action configuration with kubernetes config + // Initialize action configuration with kubernetes config. The namespace is + // passed through as-is: an empty namespace keeps the release storage + // cluster-wide so the list covers every namespace, mirroring how the Helm + // CLI implements `helm list --all-namespaces`. actionConfig := new(action.Configuration) err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess) if err != nil { diff --git a/pkg/libhelm/sdk/release.go b/pkg/libhelm/sdk/release.go index f2f4b4b8e2..d4cd99d7ba 100644 --- a/pkg/libhelm/sdk/release.go +++ b/pkg/libhelm/sdk/release.go @@ -14,7 +14,7 @@ import ( func (hspm *HelmSDKPackageManager) doesReleaseExist(releaseName, namespace string, clusterAccess *options.KubernetesClusterAccess) (bool, error) { // Initialize action configuration actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, namespace, clusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(namespace), clusterAccess) if err != nil { // error is already logged in initActionConfig return false, fmt.Errorf("failed to initialize helm configuration: %w", err) diff --git a/pkg/libhelm/sdk/rollback.go b/pkg/libhelm/sdk/rollback.go index 5af2ea21b6..646172f5c5 100644 --- a/pkg/libhelm/sdk/rollback.go +++ b/pkg/libhelm/sdk/rollback.go @@ -30,7 +30,7 @@ func (hspm *HelmSDKPackageManager) Rollback(rollbackOpts options.RollbackOptions // Initialize action configuration with kubernetes config actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, rollbackOpts.Namespace, rollbackOpts.KubernetesClusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(rollbackOpts.Namespace), rollbackOpts.KubernetesClusterAccess) if err != nil { return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release rollback") } diff --git a/pkg/libhelm/sdk/show.go b/pkg/libhelm/sdk/show.go index 0536ad67a0..50985e865a 100644 --- a/pkg/libhelm/sdk/show.go +++ b/pkg/libhelm/sdk/show.go @@ -2,6 +2,7 @@ package sdk import ( "fmt" + "strings" "github.com/pkg/errors" "github.com/portainer/portainer/pkg/libhelm/cache" @@ -12,10 +13,18 @@ import ( var errRequiredShowOptions = errors.New("chart, output format and either repo or registry are required") +// isSelfContainedOCIChartRef reports whether chart is a complete "oci://host/path" +// reference that needs no separate repo or registry to be located (e.g. Portainer +// addon charts, which are resolved from a bare oci:// ref with no Repo/Registry). +func isSelfContainedOCIChartRef(chart string) bool { + return strings.HasPrefix(chart, "oci://") +} + // Show implements the HelmPackageManager interface by using the Helm SDK to show chart information. // It supports showing chart values, readme, and chart details based on the provided ShowOptions. func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) { - if showOpts.Chart == "" || (showOpts.Repo == "" && IsHTTPRepository(showOpts.Registry)) || showOpts.OutputFormat == "" { + missingSource := showOpts.Repo == "" && IsHTTPRepository(showOpts.Registry) && !isSelfContainedOCIChartRef(showOpts.Chart) + if showOpts.Chart == "" || missingSource || showOpts.OutputFormat == "" { log.Error(). Str("context", "HelmClient"). Str("chart", showOpts.Chart). diff --git a/pkg/libhelm/sdk/show_test.go b/pkg/libhelm/sdk/show_test.go index 3c9de0dea0..445c863ee0 100644 --- a/pkg/libhelm/sdk/show_test.go +++ b/pkg/libhelm/sdk/show_test.go @@ -9,6 +9,12 @@ import ( "github.com/stretchr/testify/require" ) +func TestIsSelfContainedOCIChartRef(t *testing.T) { + assert.True(t, isSelfContainedOCIChartRef("oci://ghcr.io/portainer/charts/portainer-run")) + assert.False(t, isSelfContainedOCIChartRef("ingress-nginx")) + assert.False(t, isSelfContainedOCIChartRef("")) +} + func Test_Show(t *testing.T) { t.Parallel() test.EnsureIntegrationTest(t) @@ -54,6 +60,19 @@ func Test_Show(t *testing.T) { is.NotEmpty(values, "should return non-empty values") }) + t.Run("show chart values for a self-contained OCI ref with no repo or registry", func(t *testing.T) { + // Mirrors how Portainer addon charts are resolved: a bare "oci://host/path" + // reference with no separate Repo/Registry, same as Install/Upgrade already accept. + showOpts := options.ShowOptions{ + Chart: "oci://ghcr.io/portainer/charts/portal-template", + OutputFormat: options.ShowValues, + } + values, err := hspm.Show(showOpts) + + require.NoError(t, err, "a self-contained oci:// chart ref must not require a separate repo or registry") + is.NotEmpty(values, "should return non-empty values") + }) + t.Run("show chart readme", func(t *testing.T) { showOpts := options.ShowOptions{ Chart: "ingress-nginx", diff --git a/pkg/libhelm/sdk/uninstall.go b/pkg/libhelm/sdk/uninstall.go index 144f0d69cd..4ba588bf9e 100644 --- a/pkg/libhelm/sdk/uninstall.go +++ b/pkg/libhelm/sdk/uninstall.go @@ -28,7 +28,7 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti // Initialize action configuration with kubernetes config actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess) if err != nil { // error is already logged in initActionConfig return errors.Wrap(err, "failed to initialize helm configuration") @@ -103,7 +103,7 @@ func (hspm *HelmSDKPackageManager) ForceRemoveRelease(uninstallOpts options.Unin Msg("Force-removing release history (skipping resource deletion)") actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess) if err != nil { return errors.Wrap(err, "failed to initialize helm configuration for force-remove") } diff --git a/pkg/libhelm/sdk/upgrade.go b/pkg/libhelm/sdk/upgrade.go index d968326e01..3c89396ba8 100644 --- a/pkg/libhelm/sdk/upgrade.go +++ b/pkg/libhelm/sdk/upgrade.go @@ -61,7 +61,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) ( // Initialize action configuration with kubernetes config actionConfig := new(action.Configuration) - err = hspm.initActionConfig(actionConfig, upgradeOpts.Namespace, upgradeOpts.KubernetesClusterAccess) + err = hspm.initActionConfig(actionConfig, namespaceOrDefault(upgradeOpts.Namespace), upgradeOpts.KubernetesClusterAccess) if err != nil { // error is already logged in initActionConfig return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade") diff --git a/pkg/libhelm/sdk/values.go b/pkg/libhelm/sdk/values.go index 0bf903ba52..69d37cdea0 100644 --- a/pkg/libhelm/sdk/values.go +++ b/pkg/libhelm/sdk/values.go @@ -84,7 +84,7 @@ func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (releas Msg("Getting values") actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, getOpts.Namespace, getOpts.KubernetesClusterAccess) + err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOpts.Namespace), getOpts.KubernetesClusterAccess) if err != nil { log.Error(). Str("context", "HelmClient").