Ctrl K
ComunidadGuías…

pkg/gitops/fluxstatus_test.go

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

Archivo original ↗Preguntar a KiBot
package gitops

import (
	"testing"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func fluxObj(suspend bool, gen, obs any, conds ...map[string]any) *unstructured.Unstructured {
	raw := make([]any, 0, len(conds))
	for _, c := range conds {
		raw = append(raw, c)
	}
	meta := map[string]any{"name": "k1", "namespace": "flux-system"}
	if gen != nil {
		meta["generation"] = gen
	}
	status := map[string]any{"conditions": raw}
	if obs != nil {
		status["observedGeneration"] = obs
	}
	obj := map[string]any{
		"apiVersion": "kustomize.toolkit.fluxcd.io/v1",
		"kind":       "Kustomization",
		"metadata":   meta,
		"status":     status,
	}
	if suspend {
		obj["spec"] = map[string]any{"suspend": true}
	}
	return &unstructured.Unstructured{Object: obj}
}

func cond(typ, status string) map[string]any {
	return map[string]any{"type": typ, "status": status}
}

func condGen(c map[string]any, generation int64) map[string]any {
	c["observedGeneration"] = generation
	return c
}

func condReason(typ, status, reason string) map[string]any {
	return map[string]any{"type": typ, "status": status, "reason": reason}
}

func TestFluxStatusTruthTable(t *testing.T) {
	cases := []struct {
		name       string
		obj        *unstructured.Unstructured
		wantSync   string
		wantHealth string
	}{
		{
			// The reported bug. Ready and Reconciling are independent upstream, so a
			// stale Reconciling sat on a fully-applied object and outvoted it, while
			// kubectl and k9s read the printer columns and showed it Ready.
			name:     "stale Reconciling does not outvote an applied Ready",
			obj:      fluxObj(false, int64(1), int64(1), cond("Ready", "True"), cond("Healthy", "True"), cond("Reconciling", "True")),
			wantSync: "Synced", wantHealth: "Healthy",
		},
		{
			name:     "ready and applied",
			obj:      fluxObj(false, int64(1), int64(1), cond("Ready", "True")),
			wantSync: "Synced", wantHealth: "Healthy",
		},
		{
			// Generation drift is the only state that genuinely means "not applied",
			// and it holds even with Ready=True from the previous spec.
			name:     "generation drift outranks a stale Ready",
			obj:      fluxObj(false, int64(2), int64(1), cond("Ready", "True")),
			wantSync: "Reconciling", wantHealth: "Progressing",
		},
		{
			// Flux writes -1 on an object it has never reconciled. Present and
			// different, so it counts. (A first reconcile reports Ready=Unknown; a
			// reported failure is covered below and outranks drift.)
			name:     "the -1 sentinel counts as drift",
			obj:      fluxObj(false, int64(1), int64(-1), cond("Ready", "Unknown"), cond("Reconciling", "True")),
			wantSync: "Reconciling", wantHealth: "Progressing",
		},
		{
			// Absence is no signal. Reading it as drift would mark every healthy
			// object on kinds that never set it as out of date.
			name:     "absent observedGeneration is not drift",
			obj:      fluxObj(false, int64(3), nil, cond("Ready", "True")),
			wantSync: "Synced", wantHealth: "Healthy",
		},
		{
			name:     "absent generation is not drift",
			obj:      fluxObj(false, nil, int64(3), cond("Ready", "True")),
			wantSync: "Synced", wantHealth: "Healthy",
		},
		{
			// A behaviour change from the previous ordering, asserted rather than
			// assumed: the controller has given up, whatever the last apply left.
			name:     "Stalled outranks Ready",
			obj:      fluxObj(false, int64(1), int64(1), cond("Ready", "True"), cond("Stalled", "True")),
			wantSync: "OutOfSync", wantHealth: "Degraded",
		},
		{
			name:     "reconciling while not yet ready",
			obj:      fluxObj(false, int64(1), int64(1), cond("Ready", "Unknown"), cond("Reconciling", "True")),
			wantSync: "Reconciling", wantHealth: "Progressing",
		},
		{
			name:     "not ready",
			obj:      fluxObj(false, int64(1), int64(1), cond("Ready", "False")),
			wantSync: "OutOfSync", wantHealth: "Degraded",
		},
		{
			// Applied but the workloads are unhealthy — two different facts, and
			// the sync axis must not absorb the health one.
			name:     "unhealthy deployed resources are applied but degraded",
			obj:      fluxObj(false, int64(1), int64(1), cond("Ready", "True"), cond("Healthy", "False")),
			wantSync: "Synced", wantHealth: "Degraded",
		},
		{
			// Suspending does not un-apply what was applied.
			name:     "suspended and applied stays Synced",
			obj:      fluxObj(true, int64(1), int64(1), cond("Ready", "True")),
			wantSync: "Synced", wantHealth: "Suspended",
		},
		{
			// A spec change made while suspended will never be picked up.
			name:     "suspended with drift is not Synced",
			obj:      fluxObj(true, int64(2), int64(1), cond("Ready", "True")),
			wantSync: "Unknown", wantHealth: "Suspended",
		},
		{
			// A retry or remediation in flight is out of sync but not settled
			// failure. Mirrors the frontend mapper, which has always drawn this
			// distinction — the two must agree.
			name:     "a retrying Ready=False is progressing, not degraded",
			obj:      fluxObj(false, int64(1), int64(1), condReason("Ready", "False", "ProgressingWithRetry")),
			wantSync: "OutOfSync", wantHealth: "Progressing",
		},
		{
			name:     "a settled Ready=False is degraded",
			obj:      fluxObj(false, int64(1), int64(1), condReason("Ready", "False", "BuildFailed")),
			wantSync: "OutOfSync", wantHealth: "Degraded",
		},
		{
			// Live shape of a GitRepository whose repository doesn't exist:
			// never reconciled (-1) and retrying, but the clone failure is settled.
			name: "a failure while retrying and never reconciled is degraded",
			obj: fluxObj(false, int64(1), int64(-1),
				condReason("Reconciling", "True", "ProgressingWithRetry"),
				condGen(condReason("Ready", "False", "GitOperationFailed"), 1)),
			wantSync: "OutOfSync", wantHealth: "Degraded",
		},
		{
			// A source edited to a ref that doesn't exist: the new generation is
			// never recorded while the fetch fails.
			name:     "a failure after a spec change is degraded",
			obj:      fluxObj(false, int64(2), int64(1), condGen(condReason("Ready", "False", "ArtifactFailed"), 2)),
			wantSync: "OutOfSync", wantHealth: "Degraded",
		},
		{
			// The spec was fixed (generation 3); the failure still describes
			// generation 2, so it says nothing about the new spec yet.
			name:     "a failure about an older generation does not outrank a new spec",
			obj:      fluxObj(false, int64(3), int64(1), condGen(condReason("Ready", "False", "BuildFailed"), 2)),
			wantSync: "Reconciling", wantHealth: "Progressing",
		},
		{
			name:     "a failure without a condition generation does not outrank drift",
			obj:      fluxObj(false, int64(2), int64(1), condReason("Ready", "False", "BuildFailed")),
			wantSync: "Reconciling", wantHealth: "Progressing",
		},
		{
			name:     "a retry while reconciling stays progressing",
			obj:      fluxObj(false, int64(2), int64(1), condReason("Reconciling", "True", "Progressing"), condReason("Ready", "False", "ProgressingWithRetry")),
			wantSync: "Reconciling", wantHealth: "Progressing",
		},
		{
			name:     "no conditions at all",
			obj:      fluxObj(false, int64(1), int64(1)),
			wantSync: "Unknown", wantHealth: "Unknown",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := FluxStatus(tc.obj)
			if got.Sync != tc.wantSync || got.Health != tc.wantHealth {
				t.Errorf("FluxStatus = %s/%s, want %s/%s", got.Sync, got.Health, tc.wantSync, tc.wantHealth)
			}
		})
	}
}

// The in-flight fact survives even when it no longer drives the status, so a
// wedged controller can be surfaced next to an otherwise-correct Synced.
func TestFluxStatusCarriesReconcilingActivity(t *testing.T) {
	stuck := fluxObj(false, int64(1), int64(1), cond("Ready", "True"), cond("Reconciling", "True"))
	got := FluxStatus(stuck)
	if got.Sync != "Synced" {
		t.Fatalf("Sync = %q, want Synced", got.Sync)
	}
	if !got.Reconciling {
		t.Error("Reconciling activity was dropped; a wedged controller would be invisible")
	}

	quiet := FluxStatus(fluxObj(false, int64(1), int64(1), cond("Ready", "True")))
	if quiet.Reconciling {
		t.Error("Reconciling reported on an object with no Reconciling condition")
	}
}

// Source kinds carry neither Healthy nor, in some versions, observedGeneration.
// Measured shapes: GitRepository/HelmRepository expose Ready + ArtifactInStorage,
// HelmRelease exposes Ready + Released.
func TestFluxStatusHandlesNonKustomizationKinds(t *testing.T) {
	cases := []struct {
		name  string
		conds []map[string]any
	}{
		{"GitRepository", []map[string]any{cond("Ready", "True"), cond("ArtifactInStorage", "True")}},
		{"HelmRepository", []map[string]any{cond("Ready", "True"), cond("ArtifactInStorage", "True")}},
		{"HelmRelease", []map[string]any{cond("Ready", "True"), cond("Released", "True")}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := FluxStatus(fluxObj(false, int64(1), int64(1), tc.conds...))
			if got.Sync != "Synced" || got.Health != "Healthy" {
				t.Errorf("FluxStatus = %s/%s, want Synced/Healthy", got.Sync, got.Health)
			}
		})
	}
}