mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 13:55:21 +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:
parent
46eddbe7b9
commit
0ca9321db1
57 changed files with 2635 additions and 222 deletions
|
@ -1,21 +1,11 @@
|
|||
package options
|
||||
|
||||
// releaseResource are the supported `helm get` sub-commands
|
||||
// to see all available sub-commands run `helm get --help`
|
||||
type releaseResource string
|
||||
|
||||
const (
|
||||
GetAll releaseResource = "all"
|
||||
GetHooks releaseResource = "hooks"
|
||||
GetManifest releaseResource = "manifest"
|
||||
GetNotes releaseResource = "notes"
|
||||
GetValues releaseResource = "values"
|
||||
)
|
||||
|
||||
type GetOptions struct {
|
||||
Name string
|
||||
Namespace string
|
||||
ReleaseResource releaseResource
|
||||
Name string
|
||||
Namespace string
|
||||
// ShowResources indicates whether to display the resources of the named release
|
||||
ShowResources bool
|
||||
Revision int
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
|
||||
Env []string
|
||||
|
|
9
pkg/libhelm/options/history_options.go
Normal file
9
pkg/libhelm/options/history_options.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package options
|
||||
|
||||
type HistoryOptions struct {
|
||||
Name string
|
||||
Namespace string
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
|
||||
Env []string
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
package release
|
||||
|
||||
import "github.com/portainer/portainer/pkg/libhelm/time"
|
||||
import (
|
||||
"github.com/portainer/portainer/pkg/libhelm/time"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// Release is the struct that holds the information for a helm release.
|
||||
// The struct definitions have been copied from the official Helm Golang client/library.
|
||||
|
@ -14,7 +17,7 @@ type ReleaseElement struct {
|
|||
Updated string `json:"updated"`
|
||||
Status string `json:"status"`
|
||||
Chart string `json:"chart"`
|
||||
AppVersion string `json:"app_version"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
}
|
||||
|
||||
// Release describes a deployment of a chart, together with the chart
|
||||
|
@ -23,7 +26,7 @@ type Release struct {
|
|||
// Name is the name of the release
|
||||
Name string `json:"name,omitempty"`
|
||||
// Info provides information about a release
|
||||
// Info *Info `json:"info,omitempty"`
|
||||
Info *Info `json:"info,omitempty"`
|
||||
// Chart is the chart that was released.
|
||||
Chart Chart `json:"chart,omitempty"`
|
||||
// Config is the set of extra Values added to the chart.
|
||||
|
@ -40,6 +43,13 @@ type Release struct {
|
|||
// Labels of the release.
|
||||
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
|
||||
Labels map[string]string `json:"-"`
|
||||
// Values are the values used to deploy the chart.
|
||||
Values Values `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
type Values struct {
|
||||
UserSuppliedValues string `json:"userSuppliedValues,omitempty"`
|
||||
ComputedValues string `json:"computedValues,omitempty"`
|
||||
}
|
||||
|
||||
// Chart is a helm package that contains metadata, a default config, zero or more
|
||||
|
@ -183,6 +193,8 @@ type Info struct {
|
|||
Status Status `json:"status,omitempty"`
|
||||
// Contains the rendered templates/NOTES.txt if available
|
||||
Notes string `json:"notes,omitempty"`
|
||||
// Resources is the list of resources that are part of the release
|
||||
Resources []*unstructured.Unstructured `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// Status is the status of a release
|
||||
|
|
100
pkg/libhelm/sdk/get.go
Normal file
100
pkg/libhelm/sdk/get.go
Normal 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,
|
||||
}
|
||||
}
|
39
pkg/libhelm/sdk/get_test.go
Normal file
39
pkg/libhelm/sdk/get_test.go
Normal 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)
|
||||
})
|
||||
}
|
68
pkg/libhelm/sdk/history.go
Normal file
68
pkg/libhelm/sdk/history.go
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
33
pkg/libhelm/sdk/history_test.go
Normal file
33
pkg/libhelm/sdk/history_test.go
Normal 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)
|
||||
})
|
||||
}
|
291
pkg/libhelm/sdk/resources.go
Normal file
291
pkg/libhelm/sdk/resources.go
Normal 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
|
||||
}
|
143
pkg/libhelm/sdk/resources_test.go
Normal file
143
pkg/libhelm/sdk/resources_test.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
|
@ -91,29 +92,11 @@ func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
// Get release details - all, hooks, manifest, notes and values
|
||||
func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
|
||||
switch getOpts.ReleaseResource {
|
||||
case options.GetAll:
|
||||
return []byte(strings.Join([]string{MockReleaseHooks, MockReleaseManifest, MockReleaseNotes, MockReleaseValues}, "---\n")), nil
|
||||
case options.GetHooks:
|
||||
return []byte(MockReleaseHooks), nil
|
||||
case options.GetManifest:
|
||||
return []byte(MockReleaseManifest), nil
|
||||
case options.GetNotes:
|
||||
return []byte(MockReleaseNotes), nil
|
||||
case options.GetValues:
|
||||
return []byte(MockReleaseValues), nil
|
||||
default:
|
||||
return nil, errors.New("invalid release resource")
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall a helm chart (not thread safe)
|
||||
func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
|
||||
for i, rel := range mockCharts {
|
||||
if rel.Name == uninstallOpts.Name && rel.Namespace == uninstallOpts.Namespace {
|
||||
mockCharts = append(mockCharts[:i], mockCharts[i+1:]...)
|
||||
mockCharts = slices.Delete(mockCharts, i, i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
@ -124,6 +107,25 @@ func (hpm *helmMockPackageManager) List(listOpts options.ListOptions) ([]release
|
|||
return mockCharts, nil
|
||||
}
|
||||
|
||||
// Get a helm release (not thread safe)
|
||||
func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) (*release.Release, error) {
|
||||
index := slices.IndexFunc(mockCharts, func(re release.ReleaseElement) bool {
|
||||
return re.Name == getOpts.Name && re.Namespace == getOpts.Namespace
|
||||
})
|
||||
return newMockRelease(&mockCharts[index]), nil
|
||||
}
|
||||
|
||||
func (hpm *helmMockPackageManager) GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error) {
|
||||
var result []*release.Release
|
||||
for i, v := range mockCharts {
|
||||
if v.Name == historyOpts.Name && v.Namespace == historyOpts.Namespace {
|
||||
result = append(result, newMockRelease(&mockCharts[i]))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
const mockPortainerIndex = `apiVersion: v1
|
||||
entries:
|
||||
portainer:
|
||||
|
|
|
@ -14,6 +14,8 @@ type HelmPackageManager interface {
|
|||
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
|
||||
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
|
||||
Uninstall(uninstallOpts options.UninstallOptions) error
|
||||
Get(getOpts options.GetOptions) (*release.Release, error)
|
||||
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue