pkg/health/workload_rollout.go
Rama main · réplica de lectura del repositorio original.
package health
import (
"fmt"
"strconv"
"strings"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
type RolloutPhase string
const (
RolloutIdle RolloutPhase = "idle"
RolloutApplying RolloutPhase = "applying"
RolloutProgressing RolloutPhase = "progressing"
RolloutWaiting RolloutPhase = "waiting"
RolloutPaused RolloutPhase = "paused"
RolloutPartitionReached RolloutPhase = "partition-reached"
RolloutStalled RolloutPhase = "stalled"
)
type WorkloadRolloutActivity struct {
Phase RolloutPhase `json:"phase"`
Active bool `json:"active"`
Manual bool `json:"manual"`
Label string `json:"label"`
Detail string `json:"detail,omitempty"`
Desired int32 `json:"desired"`
Updated int32 `json:"updated"`
Ready int32 `json:"ready"`
Available int32 `json:"available"`
}
func WorkloadRollout(obj any) WorkloadRolloutActivity {
switch o := obj.(type) {
case *appsv1.Deployment:
return deploymentRollout(o)
case *appsv1.StatefulSet:
return statefulSetRollout(o)
case *appsv1.DaemonSet:
return daemonSetRollout(o)
case *unstructured.Unstructured:
if strings.EqualFold(o.GetKind(), "Rollout") && strings.HasPrefix(o.GetAPIVersion(), "argoproj.io/") {
return argoRollout(o)
}
}
return activity(RolloutIdle, false, "Stable", "", 0, 0, 0, 0)
}
func deploymentRollout(d *appsv1.Deployment) WorkloadRolloutActivity {
desired := specReplicas(d.Spec.Replicas)
updated := d.Status.UpdatedReplicas
ready := d.Status.ReadyReplicas
available := d.Status.AvailableReplicas
base := activityCounts(desired, updated, ready, available)
if d.Status.ObservedGeneration < d.Generation {
return base.with(RolloutApplying, true, "Applying change", "Waiting for the Deployment controller to observe generation")
}
if conditionFailed(d.Status.Conditions, "Progressing", "ProgressDeadlineExceeded") {
return base.with(RolloutStalled, false, "Rollout stalled", conditionMessage(d.Status.Conditions, "Progressing", "Progress deadline exceeded"))
}
old := d.Status.Replicas - updated
if old < 0 {
old = 0
}
pending := updated < desired || old > 0
if d.Spec.Paused {
detail := "No rollout is pending"
if pending {
detail = fmt.Sprintf("Paused at %d/%d updated", updated, desired)
}
return base.with(RolloutPaused, pending, "Rollout paused", detail)
}
if desired == 0 {
return base.with(RolloutIdle, false, "Scaled to zero", "No replicas requested")
}
if !pending {
return base.with(RolloutIdle, false, "Stable", fmt.Sprintf("%d/%d available", available, desired))
}
if d.Spec.Strategy.Type == appsv1.RecreateDeploymentStrategyType {
if updated == 0 && old > 0 {
return base.with(RolloutWaiting, true, "Replacing replicas", fmt.Sprintf("Waiting for old replicas to stop · %d available", available))
}
if updated == 0 {
return base.with(RolloutWaiting, true, "Waiting for new revision", replicaDetail(updated, desired, available))
}
return base.with(RolloutProgressing, true, "Replacing replicas", replicaDetail(updated, desired, available))
}
if updated == 0 {
return base.with(RolloutWaiting, true, "Waiting for new revision", replicaDetail(updated, desired, available))
}
if old == 0 && updated == d.Status.Replicas {
return base.with(RolloutProgressing, true, "Scaling", replicaDetail(updated, desired, available))
}
return base.with(RolloutProgressing, true, "Rolling out", replicaDetail(updated, desired, available))
}
func statefulSetRollout(s *appsv1.StatefulSet) WorkloadRolloutActivity {
desired := specReplicas(s.Spec.Replicas)
updated := s.Status.UpdatedReplicas
ready := s.Status.ReadyReplicas
base := activityCounts(desired, updated, ready, ready)
if s.Status.ObservedGeneration < s.Generation {
return base.with(RolloutApplying, true, "Applying change", "Waiting for the StatefulSet controller to observe generation")
}
if desired == 0 {
return base.with(RolloutIdle, false, "Scaled to zero", "No replicas requested")
}
if s.Spec.UpdateStrategy.Type == appsv1.OnDeleteStatefulSetStrategyType {
if updated == desired && ready == desired {
return base.with(RolloutIdle, false, "Stable", fmt.Sprintf("%d/%d ready", ready, desired))
}
return base.withAction(RolloutWaiting, true, "Waiting for Pod restart", fmt.Sprintf("OnDelete strategy · %d/%d updated", updated, desired))
}
partition := int32(0)
if s.Spec.UpdateStrategy.RollingUpdate != nil && s.Spec.UpdateStrategy.RollingUpdate.Partition != nil {
partition = *s.Spec.UpdateStrategy.RollingUpdate.Partition
}
target := desired - partition
if target < 0 {
target = 0
}
if updated >= target {
if partition > 0 && s.Status.CurrentRevision != "" && s.Status.UpdateRevision != "" && s.Status.CurrentRevision != s.Status.UpdateRevision {
return base.with(RolloutPartitionReached, false, "Partition reached", fmt.Sprintf("%d/%d Pods intentionally retained", partition, desired))
}
return base.with(RolloutIdle, false, "Stable", fmt.Sprintf("%d/%d ready", ready, desired))
}
if updated == 0 {
return base.with(RolloutWaiting, true, "Waiting for new revision", fmt.Sprintf("%d/%d target Pods updated · %d/%d ready", updated, target, ready, desired))
}
return base.with(RolloutProgressing, true, "Rolling out", fmt.Sprintf("%d/%d target Pods updated · %d/%d ready", updated, target, ready, desired))
}
func daemonSetRollout(d *appsv1.DaemonSet) WorkloadRolloutActivity {
desired := d.Status.DesiredNumberScheduled
updated := d.Status.UpdatedNumberScheduled
ready := d.Status.NumberReady
available := d.Status.NumberAvailable
base := activityCounts(desired, updated, ready, available)
if d.Status.ObservedGeneration < d.Generation {
return base.with(RolloutApplying, true, "Applying change", "Waiting for the DaemonSet controller to observe generation")
}
if desired == 0 {
return base.with(RolloutIdle, false, "No targets", "Selector matches no nodes")
}
if d.Spec.UpdateStrategy.Type == appsv1.OnDeleteDaemonSetStrategyType {
if updated == desired && available == desired {
return base.with(RolloutIdle, false, "Stable", fmt.Sprintf("%d/%d available", available, desired))
}
return base.withAction(RolloutWaiting, true, "Waiting for Pod restart", fmt.Sprintf("OnDelete strategy · %d/%d updated", updated, desired))
}
if updated == desired {
return base.with(RolloutIdle, false, "Stable", fmt.Sprintf("%d/%d available", available, desired))
}
if updated == 0 {
return base.with(RolloutWaiting, true, "Waiting for new revision", replicaDetail(updated, desired, available))
}
return base.with(RolloutProgressing, true, "Rolling out", replicaDetail(updated, desired, available))
}
func argoRollout(r *unstructured.Unstructured) WorkloadRolloutActivity {
desired := nestedInt32(r.Object, "spec", "replicas")
if _, found, _ := unstructured.NestedFieldNoCopy(r.Object, "spec", "replicas"); !found {
desired = nestedInt32(r.Object, "status", "replicas")
if desired == 0 {
desired = 1
}
}
updated := nestedInt32(r.Object, "status", "updatedReplicas")
ready := nestedInt32(r.Object, "status", "readyReplicas")
available := nestedInt32(r.Object, "status", "availableReplicas")
base := activityCounts(desired, updated, ready, available)
phase, _, _ := unstructured.NestedString(r.Object, "status", "phase")
message, _, _ := unstructured.NestedString(r.Object, "status", "message")
aborted, _, _ := unstructured.NestedBool(r.Object, "status", "abort")
if observed, found, _ := unstructured.NestedString(r.Object, "status", "observedGeneration"); found {
generation, err := strconv.ParseInt(observed, 10, 64)
if err == nil && generation > 0 && generation < r.GetGeneration() {
return base.with(RolloutApplying, true, "Applying change", "Waiting for the Rollout controller to observe generation")
}
}
if failedMessage, failed := argoFailureMessage(r); failed {
if failedMessage == "" {
failedMessage = message
}
if failedMessage == "" {
failedMessage = "The Rollout controller reported a failed revision"
}
return base.with(RolloutStalled, false, "Rollout stalled", failedMessage)
}
if aborted {
if message == "" {
message = "The Rollout was aborted"
}
return base.with(RolloutStalled, false, "Rollout stalled", message)
}
switch strings.ToLower(phase) {
case "degraded", "error", "failed", "aborted":
if message == "" {
message = "The Rollout controller reported a failed revision"
}
return base.with(RolloutStalled, false, "Rollout stalled", message)
case "paused":
return base.with(RolloutPaused, true, "Rollout paused", argoStepDetail(r, updated, desired, available))
case "progressing":
if updated == 0 {
return base.with(RolloutWaiting, true, "Waiting for new revision", argoStepDetail(r, updated, desired, available))
}
return base.with(RolloutProgressing, true, "Rolling out", argoStepDetail(r, updated, desired, available))
case "healthy":
return base.with(RolloutIdle, false, "Stable", fmt.Sprintf("%d/%d available", available, desired))
default:
if desired > 0 && (updated < desired || available < desired) {
return base.with(RolloutApplying, true, "Applying change", argoStepDetail(r, updated, desired, available))
}
return base.with(RolloutIdle, false, "Stable", message)
}
}
func argoFailureMessage(r *unstructured.Unstructured) (string, bool) {
conditions, _, _ := unstructured.NestedSlice(r.Object, "status", "conditions")
for _, raw := range conditions {
condition, ok := raw.(map[string]any)
if !ok {
continue
}
conditionType, _ := condition["type"].(string)
conditionStatus, _ := condition["status"].(string)
reason, _ := condition["reason"].(string)
failed := (conditionType == "InvalidSpec" && conditionStatus == "True") ||
(conditionType == "Progressing" && (conditionStatus == "False" || reason == "ProgressDeadlineExceeded"))
if !failed {
continue
}
if message, _ := condition["message"].(string); message != "" {
return message, true
}
return reason, true
}
return "", false
}
func argoStepDetail(r *unstructured.Unstructured, updated, desired, available int32) string {
// A mesh-routed Rollout can report status.canary.weights with no
// explicit canary steps at all (the mesh integration drives the split,
// not a step list) — the weight suffix applies independent of whether a
// step prefix does.
weightSuffix := ""
if weight, wFound, _ := unstructured.NestedInt64(r.Object, "status", "canary", "weights", "canary", "weight"); wFound {
weightSuffix = fmt.Sprintf(" · %d%% canary traffic", weight)
}
steps, stepsFound, _ := unstructured.NestedSlice(r.Object, "spec", "strategy", "canary", "steps")
step, found, _ := unstructured.NestedInt64(r.Object, "status", "currentStepIndex")
if !found || !stepsFound || len(steps) == 0 {
return replicaDetail(updated, desired, available) + weightSuffix
}
displayStep := step + 1
if displayStep < 1 {
displayStep = 1
}
if displayStep > int64(len(steps)) {
displayStep = int64(len(steps))
}
label := ""
if currentStep, ok := steps[displayStep-1].(map[string]any); ok {
label = fmt.Sprintf(" (%s)", canaryStepLabel(currentStep))
}
return fmt.Sprintf("Step %d%s · %d/%d updated · %d available%s", displayStep, label, updated, desired, available, weightSuffix)
}
// canaryStepLabel mirrors packages/k8s-ui/src/components/resources/renderers/
// RolloutRenderer.tsx's canaryStepLabel exactly (same output strings, same
// step-type coverage) - the two are checked against the same golden fixture
// (testdata/workload_rollout_vectors.json), so a change to one without the
// other silently breaks cross-language parity rather than failing loudly.
func canaryStepLabel(step map[string]any) string {
if len(step) == 0 {
return "Unknown step"
}
if weight, ok := step["setWeight"]; ok {
return fmt.Sprintf("Set weight: %s%%", stepNumberString(weight))
}
if pause, ok := step["pause"].(map[string]any); ok {
if duration, ok := pause["duration"]; ok && duration != nil && duration != "" {
return fmt.Sprintf("Pause: %v", duration)
}
return "Pause: until promoted"
}
if _, ok := step["pause"]; ok {
return "Pause: until promoted"
}
if analysis, ok := step["analysis"].(map[string]any); ok {
names := templateNames(analysis["templates"])
if len(names) > 0 {
return fmt.Sprintf("Analysis: %s", strings.Join(names, ", "))
}
return "Analysis"
}
if experiment, ok := step["experiment"].(map[string]any); ok {
names := experimentTemplateNames(experiment["templates"])
duration := ""
if d, ok := experiment["duration"]; ok && d != nil && d != "" {
duration = fmt.Sprintf(" for %v", d)
}
if len(names) > 0 {
return fmt.Sprintf("Experiment: %s%s", strings.Join(names, ", "), duration)
}
return fmt.Sprintf("Experiment%s", duration)
}
if scale, ok := step["setCanaryScale"].(map[string]any); ok {
if match, _ := scale["matchTrafficWeight"].(bool); match {
return "Set canary scale: match traffic weight"
}
if replicas, ok := scale["replicas"]; ok {
return fmt.Sprintf("Set canary scale: %s replicas", stepNumberString(replicas))
}
if weight, ok := scale["weight"]; ok {
return fmt.Sprintf("Set canary scale: %s%%", stepNumberString(weight))
}
return "Set canary scale"
}
if route, ok := step["setHeaderRoute"].(map[string]any); ok {
name, _ := route["name"].(string)
match, _ := route["match"].([]any)
if len(match) == 0 {
if name != "" {
return fmt.Sprintf("Remove header route: %s", name)
}
return "Remove header route"
}
var headers []string
for _, m := range match {
if mm, ok := m.(map[string]any); ok {
if headerName, ok := mm["headerName"].(string); ok && headerName != "" {
headers = append(headers, headerName)
}
}
}
label := "Header route"
if name != "" {
label += " " + name
}
if len(headers) > 0 {
label += ": " + strings.Join(headers, ", ")
}
return label
}
if route, ok := step["setMirrorRoute"].(map[string]any); ok {
name, _ := route["name"].(string)
match, _ := route["match"].([]any)
if len(match) == 0 {
if name != "" {
return fmt.Sprintf("Remove mirror route: %s", name)
}
return "Remove mirror route"
}
label := "Mirror route"
if name != "" {
label += " " + name
}
if pct, ok := route["percentage"]; ok {
label += fmt.Sprintf(" (%s%%)", stepNumberString(pct))
}
return label
}
if plugin, ok := step["plugin"].(map[string]any); ok {
name, _ := plugin["name"].(string)
if name == "" {
name = "unnamed"
}
return fmt.Sprintf("Plugin: %s", name)
}
for key := range step {
return fmt.Sprintf("Unrecognized step: %s", key)
}
return "Unknown step"
}
// templateNames extracts templateName/clusterTemplateName from an
// analysis.templates list (AnalysisTemplate/ClusterAnalysisTemplate refs),
// same as the TS version. NOT used for experiment.templates — those are pod
// templates, a different shape entirely; see experimentTemplateNames.
func templateNames(raw any) []string {
list, ok := raw.([]any)
if !ok {
return nil
}
var names []string
for _, item := range list {
t, ok := item.(map[string]any)
if !ok {
continue
}
if name, ok := t["templateName"].(string); ok && name != "" {
names = append(names, name)
continue
}
if name, ok := t["clusterTemplateName"].(string); ok && name != "" {
names = append(names, name)
}
}
return names
}
// experimentTemplateNames extracts `name` from an experiment.templates
// list — Argo's RolloutExperimentTemplate entries (pod template variants
// like "baseline"/"canary" an Experiment spins up replicas of) carry their
// identifier under `name`, not `templateName`/`clusterTemplateName` (the
// AnalysisTemplate ref shape templateNames handles). Mirrors the TS version.
func experimentTemplateNames(raw any) []string {
list, ok := raw.([]any)
if !ok {
return nil
}
var names []string
for _, item := range list {
t, ok := item.(map[string]any)
if !ok {
continue
}
if name, ok := t["name"].(string); ok && name != "" {
names = append(names, name)
}
}
return names
}
// stepNumberString formats a JSON-decoded numeric value the way it appears
// in the Rollout spec (e.g. int64(20) -> "20"), matching JS's implicit
// number->string coercion used by the TS version's template literals.
func stepNumberString(v any) string {
switch n := v.(type) {
case int64:
return strconv.FormatInt(n, 10)
case float64:
return strconv.FormatFloat(n, 'f', -1, 64)
default:
return fmt.Sprintf("%v", v)
}
}
func conditionFailed(conditions []appsv1.DeploymentCondition, conditionType appsv1.DeploymentConditionType, reason string) bool {
for _, condition := range conditions {
if condition.Type == conditionType && (condition.Status == "False" || condition.Reason == reason) {
return true
}
}
return false
}
func conditionMessage(conditions []appsv1.DeploymentCondition, conditionType appsv1.DeploymentConditionType, fallback string) string {
for _, condition := range conditions {
if condition.Type == conditionType && condition.Message != "" {
return condition.Message
}
}
return fallback
}
func nestedInt32(obj map[string]any, fields ...string) int32 {
value, found, _ := unstructured.NestedInt64(obj, fields...)
if found {
return int32(value)
}
return 0
}
func activityCounts(desired, updated, ready, available int32) WorkloadRolloutActivity {
return activity(RolloutIdle, false, "Stable", "", desired, updated, ready, available)
}
func activity(phase RolloutPhase, active bool, label, detail string, desired, updated, ready, available int32) WorkloadRolloutActivity {
return WorkloadRolloutActivity{Phase: phase, Active: active, Label: label, Detail: detail, Desired: desired, Updated: updated, Ready: ready, Available: available}
}
func (a WorkloadRolloutActivity) with(phase RolloutPhase, active bool, label, detail string) WorkloadRolloutActivity {
a.Phase = phase
a.Active = active
a.Manual = false
a.Label = label
a.Detail = detail
return a
}
func (a WorkloadRolloutActivity) withAction(phase RolloutPhase, active bool, label, detail string) WorkloadRolloutActivity {
a = a.with(phase, active, label, detail)
a.Manual = true
return a
}
func replicaDetail(updated, desired, available int32) string {
return fmt.Sprintf("%d/%d updated · %d available", updated, desired, available)
}