feat(addons): install support [R8S-1138] (#3189)

This commit is contained in:
Steven Kang
2026-07-16 14:52:15 +12:00
committed by GitHub
parent 71a0290bb1
commit 21741af659
15 changed files with 91 additions and 35 deletions
+6 -4
View File
@@ -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
+1 -1
View File
@@ -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
+28 -15
View File
@@ -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)))
+14 -4
View File
@@ -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{
+1 -1
View File
@@ -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().
+1 -1
View File
@@ -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().
+1 -1
View File
@@ -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")
+4 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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")
}
+10 -1
View File
@@ -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).
+19
View File
@@ -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",
+2 -2
View File
@@ -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")
}
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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").