Ctrl K
ComunidadGuías…

pkg/autoscalerstatus/parse.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package autoscalerstatus

import (
	"errors"
	"regexp"
	"strconv"
	"strings"
	"time"

	"sigs.k8s.io/yaml"
)

// ErrUnrecognizedFormat is returned when a payload is neither the structured
// YAML status (>= 1.30) nor the legacy human-readable text.
var ErrUnrecognizedFormat = errors.New("unrecognized cluster-autoscaler status format")

// Parse decodes the cluster-autoscaler status ConfigMap's data.status value.
// It auto-detects the structured YAML format (>= 1.30) and the legacy text
// format (pre-1.30). Parsing is best-effort: any field a payload does not
// carry stays nil.
func Parse(raw string) (Status, error) {
	if strings.TrimSpace(raw) == "" {
		return Status{}, ErrUnrecognizedFormat
	}

	var probe map[string]any
	if err := yaml.Unmarshal([]byte(raw), &probe); err == nil && probe != nil {
		if _, ok := probe["autoscalerStatus"]; ok {
			return parseStructured(raw)
		}
		if _, ok := probe["nodeGroups"]; ok {
			return parseStructured(raw)
		}
	}

	trimmed := strings.TrimSpace(raw)
	if strings.HasPrefix(trimmed, "Cluster-autoscaler status") {
		return parseLegacyText(raw), nil
	}
	// Before the legacy autoscaler has its first health reading it writes the
	// bare word into data.status; treating that as unrecognized would report a
	// parse error for a perfectly healthy starting controller.
	if trimmed == legacyInitializing {
		return Status{Format: FormatLegacyText, AutoscalerState: legacyInitializing}, nil
	}

	return Status{}, ErrUnrecognizedFormat
}

const legacyInitializing = "Initializing"

// --- structured YAML (cluster-autoscaler >= 1.30) ---

// The struct tags are JSON: sigs.k8s.io/yaml routes YAML through JSON before
// unmarshaling.
type rawStatus struct {
	AutoscalerStatus string         `json:"autoscalerStatus"`
	Time             *string        `json:"time"`
	ClusterWide      rawClusterWide `json:"clusterWide"`
	NodeGroups       []rawNodeGroup `json:"nodeGroups"`
}

type rawClusterWide struct {
	Health    rawHealth    `json:"health"`
	ScaleUp   rawCondition `json:"scaleUp"`
	ScaleDown rawCondition `json:"scaleDown"`
}

type rawNodeGroup struct {
	Name      string       `json:"name"`
	Health    rawHealth    `json:"health"`
	ScaleUp   rawCondition `json:"scaleUp"`
	ScaleDown rawCondition `json:"scaleDown"`
}

type rawHealth struct {
	Status              string        `json:"status"`
	MinSize             *int          `json:"minSize"`
	MaxSize             *int          `json:"maxSize"`
	CloudProviderTarget *int          `json:"cloudProviderTarget"`
	NodeCounts          rawNodeCounts `json:"nodeCounts"`
	LastProbeTime       *string       `json:"lastProbeTime"`
	LastTransitionTime  *string       `json:"lastTransitionTime"`
}

type rawNodeCounts struct {
	LongUnregistered *int          `json:"longUnregistered"`
	Unregistered     *int          `json:"unregistered"`
	Registered       rawRegistered `json:"registered"`
}

type rawRegistered struct {
	NotStarted *int       `json:"notStarted"`
	Ready      *int       `json:"ready"`
	Total      *int       `json:"total"`
	Unready    rawUnready `json:"unready"`
}

type rawUnready struct {
	Total *int `json:"total"`
}

type rawCondition struct {
	Status             string         `json:"status"`
	Candidates         *int           `json:"candidates"`
	BackoffInfo        map[string]any `json:"backoffInfo"`
	LastProbeTime      *string        `json:"lastProbeTime"`
	LastTransitionTime *string        `json:"lastTransitionTime"`
}

func parseStructured(raw string) (Status, error) {
	var rs rawStatus
	if err := yaml.Unmarshal([]byte(raw), &rs); err != nil {
		return Status{}, err
	}

	st := Status{
		Format:          FormatStructured,
		AutoscalerState: rs.AutoscalerStatus,
		Time:            parseTimePtr(rs.Time),
		ClusterWide: ClusterWide{
			Health:    mapHealth(rs.ClusterWide.Health),
			ScaleUp:   mapCondition(rs.ClusterWide.ScaleUp),
			ScaleDown: mapCondition(rs.ClusterWide.ScaleDown),
		},
	}
	for _, rng := range rs.NodeGroups {
		st.NodeGroups = append(st.NodeGroups, NodeGroup{
			Name:      rng.Name,
			Basename:  basename(rng.Name),
			MinSize:   rng.Health.MinSize,
			MaxSize:   rng.Health.MaxSize,
			Target:    rng.Health.CloudProviderTarget,
			Health:    mapHealth(rng.Health),
			ScaleUp:   mapCondition(rng.ScaleUp),
			ScaleDown: mapCondition(rng.ScaleDown),
		})
	}
	return st, nil
}

func mapHealth(rh rawHealth) Health {
	reg := rh.NodeCounts.Registered
	return Health{
		Status:           rh.Status,
		Ready:            reg.Ready,
		Unready:          reg.Unready.Total,
		NotStarted:       reg.NotStarted,
		Registered:       reg.Total,
		LongUnregistered: rh.NodeCounts.LongUnregistered,
		Unregistered:     rh.NodeCounts.Unregistered,
		LastProbeTime:    parseTimePtr(rh.LastProbeTime),
		LastTransition:   parseTimePtr(rh.LastTransitionTime),
	}
}

func mapCondition(rc rawCondition) Condition {
	if rc.Status == "NoCandidates" && rc.Candidates == nil {
		// Upstream omitempty suppresses the candidate count when it is zero.
		zero := 0
		rc.Candidates = &zero
	}
	return Condition{
		Status:         rc.Status,
		Candidates:     rc.Candidates,
		Backoff:        backoffFromMap(rc.BackoffInfo),
		LastProbeTime:  parseTimePtr(rc.LastProbeTime),
		LastTransition: parseTimePtr(rc.LastTransitionTime),
	}
}

// backoffFromMap keeps the honesty contract's empty-vs-populated distinction:
// an empty backoffInfo map ({}) means "no backoff" and stays nil; only a
// populated map yields a Backoff.
func backoffFromMap(m map[string]any) *Backoff {
	if len(m) == 0 {
		return nil
	}
	b := &Backoff{}
	if v, ok := m["errorClass"].(string); ok {
		b.ErrorClass = v
	}
	if v, ok := m["errorCode"].(string); ok {
		b.ErrorCode = v
	}
	if v, ok := m["errorMessage"].(string); ok {
		b.ErrorMessage = v
	}
	return b
}

// --- legacy human-readable text (cluster-autoscaler pre-1.30) ---

var legacyKVRe = regexp.MustCompile(`(\w+)=(\d+)`)

type legacyTarget int

const (
	tgtNone legacyTarget = iota
	tgtHealth
	tgtScaleUp
	tgtScaleDown
)

func parseLegacyText(raw string) Status {
	st := Status{Format: FormatLegacyText}
	lines := strings.Split(raw, "\n")

	for _, line := range lines {
		if t := parseLegacyHeaderTime(line); t != nil {
			st.Time = t
			break
		}
	}

	// Node groups are collected as pointers so LastProbeTime/LastTransitionTime
	// lines (which arrive after the condition line they belong to) can be
	// attached without a slice-reallocation invalidating the reference.
	var groups []*NodeGroup
	var cur *NodeGroup // nil while inside the cluster-wide section
	var target legacyTarget

	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		switch {
		case trimmed == "Cluster-wide:", trimmed == "NodeGroups:":
			cur = nil
			target = tgtNone
		case strings.HasPrefix(trimmed, "Name:"):
			name := strings.TrimSpace(strings.TrimPrefix(trimmed, "Name:"))
			ng := &NodeGroup{Name: name, Basename: basename(name)}
			groups = append(groups, ng)
			cur = ng
			target = tgtNone
		case strings.HasPrefix(trimmed, "Health:"):
			target = tgtHealth
			rest := strings.TrimPrefix(trimmed, "Health:")
			h := legacyHealth(rest)
			if cur == nil {
				st.ClusterWide.Health = h
			} else {
				cur.Health = h
				kv := parseLegacyKV(rest)
				cur.MinSize = ptrFromMap(kv, "minSize")
				cur.MaxSize = ptrFromMap(kv, "maxSize")
				cur.Target = ptrFromMap(kv, "cloudProviderTarget")
			}
		case strings.HasPrefix(trimmed, "ScaleUp:"):
			target = tgtScaleUp
			c := Condition{Status: conditionStatus(strings.TrimPrefix(trimmed, "ScaleUp:"))}
			if cur == nil {
				st.ClusterWide.ScaleUp = c
			} else {
				cur.ScaleUp = c
			}
		case strings.HasPrefix(trimmed, "ScaleDown:"):
			target = tgtScaleDown
			rest := strings.TrimPrefix(trimmed, "ScaleDown:")
			c := Condition{Status: conditionStatus(rest), Candidates: ptrFromMap(parseLegacyKV(rest), "candidates")}
			if cur == nil {
				st.ClusterWide.ScaleDown = c
			} else {
				cur.ScaleDown = c
			}
		case strings.HasPrefix(trimmed, "LastProbeTime:"):
			t := parseFlexibleTime(strings.TrimPrefix(trimmed, "LastProbeTime:"))
			assignLegacyTime(&st, cur, target, t, true)
		case strings.HasPrefix(trimmed, "LastTransitionTime:"):
			t := parseFlexibleTime(strings.TrimPrefix(trimmed, "LastTransitionTime:"))
			assignLegacyTime(&st, cur, target, t, false)
		}
	}

	for _, ng := range groups {
		st.NodeGroups = append(st.NodeGroups, *ng)
	}
	// The legacy format has no autoscalerStatus field, but a payload carrying
	// health sections is exactly what the controller writes once it is running.
	// Leaving the state blank would make every legacy cluster's manager rollup
	// "unknown" — the rollup reads AutoscalerState to reach "healthy".
	if st.ClusterWide.Health.Status != "" || len(st.NodeGroups) > 0 {
		st.AutoscalerState = "Running"
	}
	return st
}

func legacyHealth(rest string) Health {
	kv := parseLegacyKV(rest)
	return Health{
		Status:           conditionStatus(rest),
		Ready:            ptrFromMap(kv, "ready"),
		Unready:          ptrFromMap(kv, "unready"),
		NotStarted:       ptrFromMap(kv, "notStarted"),
		Registered:       ptrFromMap(kv, "registered"),
		LongUnregistered: ptrFromMap(kv, "longUnregistered"),
	}
}

func assignLegacyTime(st *Status, cur *NodeGroup, target legacyTarget, t *time.Time, isProbe bool) {
	if t == nil {
		return
	}
	var h *Health
	var c *Condition
	if cur == nil {
		switch target {
		case tgtHealth:
			h = &st.ClusterWide.Health
		case tgtScaleUp:
			c = &st.ClusterWide.ScaleUp
		case tgtScaleDown:
			c = &st.ClusterWide.ScaleDown
		}
	} else {
		switch target {
		case tgtHealth:
			h = &cur.Health
		case tgtScaleUp:
			c = &cur.ScaleUp
		case tgtScaleDown:
			c = &cur.ScaleDown
		}
	}
	switch {
	case h != nil && isProbe:
		h.LastProbeTime = t
	case h != nil:
		h.LastTransition = t
	case c != nil && isProbe:
		c.LastProbeTime = t
	case c != nil:
		c.LastTransition = t
	}
}

func parseLegacyHeaderTime(line string) *time.Time {
	line = strings.TrimSpace(line)
	const prefix = "Cluster-autoscaler status at "
	if !strings.HasPrefix(line, prefix) {
		return nil
	}
	ts := strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(line, prefix)), ":")
	return parseFlexibleTime(ts)
}

// conditionStatus returns the published status token (the first word) from a
// condition line body, dropping any trailing "(...)" message.
func conditionStatus(rest string) string {
	rest = strings.TrimSpace(rest)
	if i := strings.IndexAny(rest, " ("); i >= 0 {
		return strings.TrimSpace(rest[:i])
	}
	return rest
}

func parseLegacyKV(line string) map[string]int {
	out := map[string]int{}
	for _, m := range legacyKVRe.FindAllStringSubmatch(line, -1) {
		if n, err := strconv.Atoi(m[2]); err == nil {
			out[m[1]] = n
		}
	}
	return out
}

func ptrFromMap(m map[string]int, key string) *int {
	if v, ok := m[key]; ok {
		n := v
		return &n
	}
	return nil
}

// --- shared helpers ---

// basename is the last "/"-segment of a node-group name. It is deliberately
// never parsed into a logical pool name: provider names truncate.
func basename(name string) string {
	if i := strings.LastIndex(name, "/"); i >= 0 {
		return name[i+1:]
	}
	return name
}

var timeLayouts = []string{
	time.RFC3339Nano,
	time.RFC3339,
	// time.Time.String() output — how metav1.Time renders under %v, and the
	// top-level `time` field's own format.
	"2006-01-02 15:04:05.999999999 -0700 MST",
	"2006-01-02 15:04:05 -0700 MST",
}

func parseTimePtr(s *string) *time.Time {
	if s == nil {
		return nil
	}
	return parseFlexibleTime(*s)
}

// parseFlexibleTime returns nil for anything it cannot parse into a real
// timestamp — including the empty string, an explicit "null", and the zero
// time — so a missing or unset value never fabricates a moment.
func parseFlexibleTime(s string) *time.Time {
	s = strings.TrimSpace(s)
	if s == "" || s == "null" || s == "<nil>" {
		return nil
	}
	for _, layout := range timeLayouts {
		if t, err := time.Parse(layout, s); err == nil {
			if t.IsZero() {
				return nil
			}
			return &t
		}
	}
	return nil
}