portainer/pkg/libhelm/sdk/resources.go

292 lines
8.3 KiB
Go

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
}