1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-04 21:35:23 +02:00

feat(helm): update helm view [r8s-256] (#582)

Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
Ali 2025-04-10 16:08:24 +12:00 committed by GitHub
parent 46eddbe7b9
commit 0ca9321db1
57 changed files with 2635 additions and 222 deletions

100
pkg/libhelm/sdk/get.go Normal file
View file

@ -0,0 +1,100 @@
package sdk
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
// Get implements the HelmPackageManager interface by using the Helm SDK to get a release.
// It returns a Release.
func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Str("name", getOptions.Name).
Msg("Get Helm release")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, getOptions.Namespace, getOptions.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Err(err).Msg("Failed to initialise helm configuration")
return nil, err
}
statusClient, err := hspm.initStatusClient(actionConfig, getOptions)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Err(err).Msg("Failed to initialise helm status client")
return nil, err
}
release, err := statusClient.Run(getOptions.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOptions.Namespace).
Err(err).Msg("Failed to query helm chart")
return nil, err
}
values, err := hspm.getValues(getOptions)
if err != nil {
// error is already logged in getValuesFromStatus
return nil, err
}
return convert(release, values), nil
}
// Helm status is just an extended helm get command with resources added on (when flagged), so use the status client with the optional show resources flag
// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/get.go#L40-L47
// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/status.go#L48-L82
func (hspm *HelmSDKPackageManager) initStatusClient(actionConfig *action.Configuration, getOptions options.GetOptions) (*action.Status, error) {
statusClient := action.NewStatus(actionConfig)
statusClient.ShowResources = getOptions.ShowResources
if getOptions.Revision > 0 {
statusClient.Version = getOptions.Revision
}
return statusClient, nil
}
func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Release {
resources, err := parseResources(sdkRelease.Info.Resources)
if err != nil {
log.Warn().
Str("context", "HelmClient").
Str("namespace", sdkRelease.Namespace).
Str("name", sdkRelease.Name).
Err(err).Msg("Failed to parse resources")
}
return &release.Release{
Name: sdkRelease.Name,
Namespace: sdkRelease.Namespace,
Version: sdkRelease.Version,
Info: &release.Info{
Status: release.Status(sdkRelease.Info.Status),
Notes: sdkRelease.Info.Notes,
Resources: resources,
Description: sdkRelease.Info.Description,
},
Manifest: sdkRelease.Manifest,
Chart: release.Chart{
Metadata: &release.Metadata{
Name: sdkRelease.Chart.Metadata.Name,
Version: sdkRelease.Chart.Metadata.Version,
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
},
},
Values: values,
}
}

View file

@ -0,0 +1,39 @@
package sdk
import (
"testing"
libhelmrelease "github.com/portainer/portainer/pkg/libhelm/release"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/chart"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
func Test_Convert(t *testing.T) {
t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "releaseName",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chartName",
Version: "chartVersion",
AppVersion: "chartAppVersion",
},
},
}
values := libhelmrelease.Values{
UserSuppliedValues: `{"key": "value"}`,
ComputedValues: `{"key": "value"}`,
}
result := convert(&release, values)
is.Equal(release.Name, result.Name)
})
}

View file

@ -0,0 +1,68 @@
package sdk
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/time"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
// GetHistory implements the HelmPackageManager interface by using the Helm SDK to get a release.
// It returns a Release.
func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOptions) ([]*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", historyOptions.Namespace).
Str("name", historyOptions.Name).
Msg("Get Helm history")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, historyOptions.Namespace, historyOptions.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", historyOptions.Namespace).
Err(err).Msg("Failed to initialise helm configuration")
return nil, err
}
historyClient := action.NewHistory(actionConfig)
history, err := historyClient.Run(historyOptions.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", historyOptions.Namespace).
Err(err).Msg("Failed to query helm release history")
return nil, err
}
var result []*release.Release
for _, r := range history {
result = append(result, convertHistory(r))
}
return result, nil
}
func convertHistory(sdkRelease *sdkrelease.Release) *release.Release {
return &release.Release{
Name: sdkRelease.Name,
Namespace: sdkRelease.Namespace,
Version: sdkRelease.Version,
Info: &release.Info{
Status: release.Status(sdkRelease.Info.Status),
Notes: sdkRelease.Info.Notes,
LastDeployed: time.Time(sdkRelease.Info.LastDeployed),
},
Chart: release.Chart{
Metadata: &release.Metadata{
Name: sdkRelease.Chart.Metadata.Name,
Version: sdkRelease.Chart.Metadata.Version,
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
},
},
}
}

View file

@ -0,0 +1,33 @@
package sdk
import (
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/chart"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
func Test_ConvertHistory(t *testing.T) {
t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "releaseName",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chartName",
Version: "chartVersion",
AppVersion: "chartAppVersion",
},
},
}
result := convertHistory(&release)
is.Equal(release.Name, result.Name)
})
}

View file

@ -0,0 +1,291 @@
package sdk
import (
"time"
"github.com/segmentio/encoding/json"
"github.com/rs/zerolog/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
const (
Unknown = "Unknown"
Healthy = "Healthy"
Unhealthy = "Unhealthy"
Progressing = "Progressing"
)
// ResourceStatus represents a generic status for any Kubernetes resource.
type ResourceStatus struct {
// Phase is a simple, high-level summary of where the resource is in its lifecycle.
Phase string `json:"phase,omitempty"`
// HealthSummary represents the summarized health status of the resource
HealthSummary *HealthCondition `json:"healthSummary,omitempty"`
// Reason is a brief CamelCase string containing the reason for the resource's current status.
Reason string `json:"reason,omitempty"`
// Message is a human-readable description of the current status.
Message string `json:"message,omitempty"`
}
// HealthCondition represents a summarized health condition for a resource
type HealthCondition struct {
Status string `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
// parseResources returns a list of resources with additional status information, in a consistent format.
func parseResources(resourceTypesLists map[string][]runtime.Object) ([]*unstructured.Unstructured, error) {
flattenedResources := flattenResources(resourceTypesLists)
resourcesInfo := []*unstructured.Unstructured{}
for _, resource := range flattenedResources {
info, err := getResourceInfo(resource)
if err != nil {
return nil, err
}
resourcesInfo = append(resourcesInfo, info)
}
return resourcesInfo, nil
}
func getResourceInfo(obj runtime.Object) (*unstructured.Unstructured, error) {
data, err := json.Marshal(obj)
if err != nil {
return nil, err
}
res := &unstructured.Unstructured{}
err = json.Unmarshal(data, res)
if err != nil {
return nil, err
}
status, conditions, err := extractStatus(res)
if err == nil {
summarizeStatus(status, conditions, res.GetName(), res.GetNamespace(), err)
applyStatusToResource(res, status)
}
// only keep metadata, kind and status (other fields are not needed)
res.Object = map[string]any{
"metadata": res.Object["metadata"],
"kind": res.Object["kind"],
"status": res.Object["status"],
}
return res, nil
}
// extractStatus extracts the status from an unstructured resource
func extractStatus(res *unstructured.Unstructured) (*ResourceStatus, []metav1.Condition, error) {
statusMap, found, err := unstructured.NestedMap(res.Object, "status")
if !found || err != nil {
return &ResourceStatus{}, nil, nil
}
// Extract basic status fields
phase, _, _ := unstructured.NestedString(statusMap, "phase")
reason, _, _ := unstructured.NestedString(statusMap, "reason")
message, _, _ := unstructured.NestedString(statusMap, "message")
// Extract conditions for analysis
conditions := []metav1.Condition{}
conditionsData, found, _ := unstructured.NestedSlice(statusMap, "conditions")
if found {
for _, condData := range conditionsData {
condMap, ok := condData.(map[string]any)
if !ok {
continue
}
cond := metav1.Condition{}
if typeStr, ok := condMap["type"].(string); ok {
cond.Type = typeStr
}
if statusStr, ok := condMap["status"].(string); ok {
cond.Status = metav1.ConditionStatus(statusStr)
}
if reasonStr, ok := condMap["reason"].(string); ok {
cond.Reason = reasonStr
}
if msgStr, ok := condMap["message"].(string); ok {
cond.Message = msgStr
}
if timeStr, ok := condMap["lastTransitionTime"].(string); ok {
t, _ := time.Parse(time.RFC3339, timeStr)
cond.LastTransitionTime = metav1.Time{Time: t}
}
conditions = append(conditions, cond)
}
}
return &ResourceStatus{
Phase: phase,
Reason: reason,
Message: message,
}, conditions, nil
}
// summarizeStatus creates a health summary based on resource status and conditions
func summarizeStatus(status *ResourceStatus, conditions []metav1.Condition, name string, namespace string, err error) *ResourceStatus {
healthSummary := &HealthCondition{
Status: Unknown,
Reason: status.Reason,
Message: status.Message,
}
// Handle error case first
if err != nil {
healthSummary.Reason = "ErrorGettingStatus"
healthSummary.Message = err.Error()
status.HealthSummary = healthSummary
return status
}
// Handle phase-based status
switch status.Phase {
case "Error":
healthSummary.Status = Unhealthy
healthSummary.Reason = status.Phase
case "Running":
healthSummary.Status = Healthy
healthSummary.Reason = status.Phase
case "Pending":
healthSummary.Status = Progressing
healthSummary.Reason = status.Phase
case "Failed":
healthSummary.Status = Unhealthy
healthSummary.Reason = status.Phase
case "Available", "Active", "Established", "Bound", "Ready", "Succeeded":
healthSummary.Status = Healthy
healthSummary.Reason = status.Phase
case "":
// Empty phase - check conditions or default to "Exists"
if len(conditions) > 0 {
analyzeConditions(conditions, healthSummary)
} else {
healthSummary.Status = Healthy
healthSummary.Reason = "Exists"
}
default:
log.Warn().
Str("context", "HelmClient").
Str("namespace", namespace).
Str("name", name).
Str("phase", status.Phase).
Msg("Unhandled status")
healthSummary.Reason = status.Phase
}
// Set message from first condition if available
if len(conditions) > 0 && healthSummary.Message == "" {
healthSummary.Message = conditions[0].Message
}
status.HealthSummary = healthSummary
return status
}
// analyzeConditions determines resource health based on standard condition types
func analyzeConditions(conditions []metav1.Condition, healthSummary *HealthCondition) {
for _, cond := range conditions {
switch cond.Type {
case "Progressing":
if cond.Status == "False" {
healthSummary.Status = Unhealthy
healthSummary.Reason = cond.Reason
} else if cond.Reason != "NewReplicaSetAvailable" {
healthSummary.Status = Unknown
healthSummary.Reason = cond.Reason
}
case "Available", "Ready", "DisruptionAllowed", "Established", "NamesAccepted":
if healthSummary.Status == Unknown ||
(cond.Type == "Established" && healthSummary.Status == Healthy) ||
(cond.Type == "NamesAccepted" && healthSummary.Status == Healthy) {
if cond.Status == "False" {
healthSummary.Status = Unhealthy
} else {
healthSummary.Status = Healthy
}
healthSummary.Reason = cond.Reason
}
case "ContainersReady":
if healthSummary.Status == Unknown && cond.Status == "False" {
healthSummary.Status = Unhealthy
healthSummary.Reason = cond.Reason
}
}
}
}
// applyStatusToResource applies the typed ResourceStatus back to the unstructured resource
func applyStatusToResource(res *unstructured.Unstructured, status *ResourceStatus) {
statusMap := map[string]any{
"phase": status.Phase,
"reason": status.Reason,
"message": status.Message,
}
if status.HealthSummary != nil {
statusMap["healthSummary"] = map[string]any{
"status": status.HealthSummary.Status,
"reason": status.HealthSummary.Reason,
"message": status.HealthSummary.Message,
}
}
unstructured.SetNestedMap(res.Object, statusMap, "status")
}
// flattenResources extracts items from a list resource and convert them to runtime.Objects
func flattenResources(resourceTypesLists map[string][]runtime.Object) []runtime.Object {
flattenedResources := []runtime.Object{}
for _, resourceTypeList := range resourceTypesLists {
for _, resourceItem := range resourceTypeList {
// if the resource item is a list, we need to flatten it too e.g. PodList
items := extractItemsIfList(resourceItem)
if items != nil {
flattenedResources = append(flattenedResources, items...)
} else {
flattenedResources = append(flattenedResources, resourceItem)
}
}
}
return flattenedResources
}
// extractItemsIfList extracts items if the resource is a list, or returns nil if not a list
func extractItemsIfList(resource runtime.Object) []runtime.Object {
unstructuredObj, ok := resource.(runtime.Unstructured)
if !ok {
return nil
}
if !unstructuredObj.IsList() {
return nil
}
extractedItems := []runtime.Object{}
err := unstructuredObj.EachListItem(func(obj runtime.Object) error {
extractedItems = append(extractedItems, obj)
return nil
})
if err != nil {
return nil
}
return extractedItems
}

View file

@ -0,0 +1,143 @@
package sdk
import (
"testing"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
func TestParseResources(t *testing.T) {
t.Run("successfully parse single resource", func(t *testing.T) {
resourceTypesLists := map[string][]runtime.Object{
"v1/Pod(related)": {
&unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "test-pod",
"namespace": "default",
},
"status": map[string]any{
"phase": "Available",
},
},
},
},
}
got, err := parseResources(resourceTypesLists)
assert.NoError(t, err)
assert.Equal(t, 1, len(got))
// Check resource metadata
assert.Equal(t, "test-pod", got[0].GetName())
assert.Equal(t, "default", got[0].GetNamespace())
// Check status and condition
statusMap, found, _ := unstructured.NestedMap(got[0].Object, "status")
assert.True(t, found)
assert.Equal(t, "Available", statusMap["phase"])
healthSummary, found, _ := unstructured.NestedMap(statusMap, "healthSummary")
assert.True(t, found)
assert.Equal(t, "Healthy", healthSummary["status"])
assert.Equal(t, "Available", healthSummary["reason"])
})
t.Run("successfully parse multiple resources", func(t *testing.T) {
resourceTypesLists := map[string][]runtime.Object{
"v1/Pod(related)": {
&unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "test-pod-1",
"namespace": "default",
},
"status": map[string]any{
"phase": "Pending",
},
},
},
&unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "test-pod-2",
"namespace": "default",
},
"status": map[string]any{
"phase": "Error",
},
},
},
},
}
got, err := parseResources(resourceTypesLists)
assert.NoError(t, err)
assert.Equal(t, 2, len(got))
// Check first resource
assert.Equal(t, "test-pod-1", got[0].GetName())
statusMap1, found, _ := unstructured.NestedMap(got[0].Object, "status")
assert.True(t, found)
assert.Equal(t, "Pending", statusMap1["phase"])
healthSummary1, found, _ := unstructured.NestedMap(statusMap1, "healthSummary")
assert.True(t, found)
assert.Equal(t, Progressing, healthSummary1["status"])
assert.Equal(t, "Pending", healthSummary1["reason"])
// Check second resource
assert.Equal(t, "test-pod-2", got[1].GetName())
statusMap2, found, _ := unstructured.NestedMap(got[1].Object, "status")
assert.True(t, found)
healthSummary2, found, _ := unstructured.NestedMap(statusMap2, "healthSummary")
assert.True(t, found)
assert.Equal(t, Unhealthy, healthSummary2["status"])
assert.Equal(t, "Error", healthSummary2["reason"])
})
}
func TestEnhanceStatus(t *testing.T) {
t.Run("healthy running pod", func(t *testing.T) {
// Create a ResourceStatus object
status := &ResourceStatus{
Phase: "Failed",
}
conditions := []metav1.Condition{}
result := summarizeStatus(status, conditions, "test-pod", "default", nil)
assert.Equal(t, Unhealthy, result.HealthSummary.Status)
assert.Equal(t, "Failed", result.HealthSummary.Reason)
})
t.Run("unhealthy pod with error", func(t *testing.T) {
// Create a ResourceStatus object
status := &ResourceStatus{
Phase: "Error",
}
conditions := []metav1.Condition{
{
Type: "DisruptionAllowed",
Status: metav1.ConditionFalse,
Reason: "InsufficientPods",
},
}
result := summarizeStatus(status, conditions, "test-pod", "default", nil)
assert.Equal(t, Unhealthy, result.HealthSummary.Status)
assert.Equal(t, "Error", result.HealthSummary.Reason)
})
}

View file

@ -4,7 +4,11 @@ import (
"os"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
"helm.sh/helm/v3/pkg/action"
)
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
@ -40,3 +44,75 @@ func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map
return vals, nil
}
func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (release.Values, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Str("name", getOpts.Name).
Msg("Getting values")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, getOpts.Namespace, getOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Err(err).Msg("Failed to initialise helm configuration")
return release.Values{}, err
}
// Create client for user supplied values
userValuesClient := action.NewGetValues(actionConfig)
userSuppliedValues, err := userValuesClient.Run(getOpts.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Err(err).Msg("Failed to get user supplied values")
return release.Values{}, err
}
// Create separate client for computed values
computedValuesClient := action.NewGetValues(actionConfig)
computedValuesClient.AllValues = true
computedValues, err := computedValuesClient.Run(getOpts.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", getOpts.Namespace).
Err(err).Msg("Failed to get computed values")
return release.Values{}, err
}
userSuppliedValuesByte, err := yaml.Marshal(userSuppliedValues)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).Msg("Failed to marshal user supplied values")
return release.Values{}, err
}
computedValuesByte, err := yaml.Marshal(computedValues)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).Msg("Failed to marshal computed values")
return release.Values{}, err
}
// Handle the case where the values are an empty object
userSuppliedValuesString := string(userSuppliedValuesByte)
if userSuppliedValuesString == "{}\n" {
userSuppliedValuesString = ""
}
computedValuesString := string(computedValuesByte)
if computedValuesString == "{}\n" {
computedValuesString = ""
}
return release.Values{
UserSuppliedValues: userSuppliedValuesString,
ComputedValues: computedValuesString,
}, nil
}