mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 10:04:49 +00:00
feat(addons): install support [R8S-1138] (#3189)
This commit is contained in:
@@ -18966,15 +18966,17 @@ paths:
|
|||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- 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
|
operationId: HelmShow
|
||||||
parameters:
|
parameters:
|
||||||
- description: Helm repository URL
|
- description: Helm repository URL (required unless chart is a self-contained
|
||||||
|
oci:// reference)
|
||||||
in: query
|
in: query
|
||||||
name: repo
|
name: repo
|
||||||
required: true
|
|
||||||
type: string
|
type: string
|
||||||
- description: Chart name
|
- description: Chart name, or a self-contained oci:// chart reference
|
||||||
in: query
|
in: query
|
||||||
name: chart
|
name: chart
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (handler *Handler) helmList(w http.ResponseWriter, r *http.Request) *httper
|
|||||||
KubernetesClusterAccess: clusterAccess,
|
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)
|
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||||
if namespace != "" {
|
if namespace != "" {
|
||||||
listOpts.Namespace = namespace
|
listOpts.Namespace = namespace
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
@@ -18,9 +19,10 @@ import (
|
|||||||
// @summary Show Helm Chart Information
|
// @summary Show Helm Chart Information
|
||||||
// @description
|
// @description
|
||||||
// @description **Access policy**: authenticated
|
// @description **Access policy**: authenticated
|
||||||
|
// @description `repo` may be omitted when `chart` is a self-contained "oci://host/path" reference.
|
||||||
// @tags helm
|
// @tags helm
|
||||||
// @param repo query string true "Helm repository URL"
|
// @param repo query string false "Helm repository URL (required unless chart is a self-contained oci:// reference)"
|
||||||
// @param chart query string true "Chart name"
|
// @param chart query string true "Chart name, or a self-contained oci:// chart reference"
|
||||||
// @param version query string false "Chart version"
|
// @param version query string false "Chart version"
|
||||||
// @param command path string true "chart/values/readme"
|
// @param command path string true "chart/values/readme"
|
||||||
// @security ApiKeyAuth
|
// @security ApiKeyAuth
|
||||||
@@ -33,24 +35,35 @@ import (
|
|||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /templates/helm/{command} [get]
|
// @router /templates/helm/{command} [get]
|
||||||
func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
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")
|
chart := r.URL.Query().Get("chart")
|
||||||
if chart == "" {
|
if chart == "" {
|
||||||
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
|
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)
|
version, err := request.RetrieveQueryParameter(r, "version", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version)))
|
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version)))
|
||||||
|
|||||||
@@ -22,13 +22,23 @@ type clientConfigGetter struct {
|
|||||||
namespace string
|
namespace string
|
||||||
}
|
}
|
||||||
|
|
||||||
// initActionConfig initializes the action configuration with kubernetes config
|
// namespaceOrDefault returns "default" for an empty namespace. Actions that
|
||||||
func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error {
|
// operate on a single release pass their namespace through this before calling
|
||||||
// If namespace is not provided, use the default namespace
|
// initActionConfig, so an omitted namespace resolves to "default" rather than
|
||||||
|
// initializing the release storage cluster-wide.
|
||||||
|
func namespaceOrDefault(namespace string) string {
|
||||||
if namespace == "" {
|
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
|
// Setup logging for Helm SDK using zerolog
|
||||||
logger := log.With().Str("context", "HelmClient").Logger()
|
logger := log.With().Str("context", "HelmClient").Logger()
|
||||||
logOptions := slogzerolog.Option{
|
logOptions := slogzerolog.Option{
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release.
|
|||||||
Msg("Get Helm release")
|
Msg("Get Helm release")
|
||||||
|
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, getOptions.Namespace, getOptions.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOptions.Namespace), getOptions.KubernetesClusterAccess)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOpti
|
|||||||
Msg("Get Helm history")
|
Msg("Get Helm history")
|
||||||
|
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, historyOptions.Namespace, historyOptions.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(historyOptions.Namespace), historyOptions.KubernetesClusterAccess)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||||||
|
|
||||||
// Initialize action configuration with kubernetes config
|
// Initialize action configuration with kubernetes config
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(installOpts.Namespace), installOpts.KubernetesClusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// error is already logged in initActionConfig
|
// error is already logged in initActionConfig
|
||||||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
|
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release
|
|||||||
Str("selector", listOpts.Selector).
|
Str("selector", listOpts.Selector).
|
||||||
Msg("Listing Helm releases")
|
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)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
func (hspm *HelmSDKPackageManager) doesReleaseExist(releaseName, namespace string, clusterAccess *options.KubernetesClusterAccess) (bool, error) {
|
func (hspm *HelmSDKPackageManager) doesReleaseExist(releaseName, namespace string, clusterAccess *options.KubernetesClusterAccess) (bool, error) {
|
||||||
// Initialize action configuration
|
// Initialize action configuration
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, namespace, clusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(namespace), clusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// error is already logged in initActionConfig
|
// error is already logged in initActionConfig
|
||||||
return false, fmt.Errorf("failed to initialize helm configuration: %w", err)
|
return false, fmt.Errorf("failed to initialize helm configuration: %w", err)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (hspm *HelmSDKPackageManager) Rollback(rollbackOpts options.RollbackOptions
|
|||||||
|
|
||||||
// Initialize action configuration with kubernetes config
|
// Initialize action configuration with kubernetes config
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, rollbackOpts.Namespace, rollbackOpts.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(rollbackOpts.Namespace), rollbackOpts.KubernetesClusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release rollback")
|
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release rollback")
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,7 @@ package sdk
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/portainer/portainer/pkg/libhelm/cache"
|
"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")
|
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.
|
// 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.
|
// It supports showing chart values, readme, and chart details based on the provided ShowOptions.
|
||||||
func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
|
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().
|
log.Error().
|
||||||
Str("context", "HelmClient").
|
Str("context", "HelmClient").
|
||||||
Str("chart", showOpts.Chart).
|
Str("chart", showOpts.Chart).
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func Test_Show(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
test.EnsureIntegrationTest(t)
|
test.EnsureIntegrationTest(t)
|
||||||
@@ -54,6 +60,19 @@ func Test_Show(t *testing.T) {
|
|||||||
is.NotEmpty(values, "should return non-empty values")
|
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) {
|
t.Run("show chart readme", func(t *testing.T) {
|
||||||
showOpts := options.ShowOptions{
|
showOpts := options.ShowOptions{
|
||||||
Chart: "ingress-nginx",
|
Chart: "ingress-nginx",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
|
|||||||
|
|
||||||
// Initialize action configuration with kubernetes config
|
// Initialize action configuration with kubernetes config
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// error is already logged in initActionConfig
|
// error is already logged in initActionConfig
|
||||||
return errors.Wrap(err, "failed to initialize helm configuration")
|
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)")
|
Msg("Force-removing release history (skipping resource deletion)")
|
||||||
|
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to initialize helm configuration for force-remove")
|
return errors.Wrap(err, "failed to initialize helm configuration for force-remove")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
|
|||||||
|
|
||||||
// Initialize action configuration with kubernetes config
|
// Initialize action configuration with kubernetes config
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err = hspm.initActionConfig(actionConfig, upgradeOpts.Namespace, upgradeOpts.KubernetesClusterAccess)
|
err = hspm.initActionConfig(actionConfig, namespaceOrDefault(upgradeOpts.Namespace), upgradeOpts.KubernetesClusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// error is already logged in initActionConfig
|
// error is already logged in initActionConfig
|
||||||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
|
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (releas
|
|||||||
Msg("Getting values")
|
Msg("Getting values")
|
||||||
|
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
err := hspm.initActionConfig(actionConfig, getOpts.Namespace, getOpts.KubernetesClusterAccess)
|
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOpts.Namespace), getOpts.KubernetesClusterAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("context", "HelmClient").
|
Str("context", "HelmClient").
|
||||||
|
|||||||
Reference in New Issue
Block a user