feat(appset): add Health field to ApplicationSet status (#25753)

Signed-off-by: Peter Jiang <peterjiang823@gmail.com>
This commit is contained in:
Peter Jiang
2026-01-12 06:35:49 -08:00
committed by GitHub
parent 3c01ab15ee
commit 05504d623c
19 changed files with 1252 additions and 789 deletions

5
assets/swagger.json generated
View File

@@ -7299,7 +7299,7 @@
"type": "object",
"properties": {
"applyNestedSelectors": {
"description": "ApplyNestedSelectors enables selectors defined within the generators of two level-nested matrix or merge generators\nDeprecated: This field is ignored, and the behavior is always enabled. The field will be removed in a future\nversion of the ApplicationSet CRD.",
"description": "ApplyNestedSelectors enables selectors defined within the generators of two level-nested matrix or merge generators.\n\nDeprecated: This field is ignored, and the behavior is always enabled. The field will be removed in a future\nversion of the ApplicationSet CRD.",
"type": "boolean"
},
"generators": {
@@ -7357,6 +7357,9 @@
"$ref": "#/definitions/v1alpha1ApplicationSetCondition"
}
},
"health": {
"$ref": "#/definitions/v1alpha1HealthStatus"
},
"resources": {
"description": "Resources is a list of Applications resources managed by this application set.",
"type": "array",

View File

@@ -295,7 +295,8 @@ spec:
spec:
destination: {}
project: ""
status: {}
status:
health: {}
---
`,
},
@@ -325,7 +326,8 @@ spec:
spec:
destination: {}
project: ""
status: {}
status:
health: {}
---
`,
},

View File

@@ -395,12 +395,12 @@ func printApplicationSetNames(apps []arogappsetv1.ApplicationSet) {
func printApplicationSetTable(apps []arogappsetv1.ApplicationSet, output *string) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
var fmtStr string
headers := []any{"NAME", "PROJECT", "SYNCPOLICY", "CONDITIONS"}
headers := []any{"NAME", "PROJECT", "SYNCPOLICY", "HEALTH", "CONDITIONS"}
if *output == "wide" {
fmtStr = "%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
fmtStr = "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
headers = append(headers, "REPO", "PATH", "TARGET")
} else {
fmtStr = "%s\t%s\t%s\t%s\n"
fmtStr = "%s\t%s\t%s\t%s\t%s\n"
}
_, _ = fmt.Fprintf(w, fmtStr, headers...)
for _, app := range apps {
@@ -414,6 +414,7 @@ func printApplicationSetTable(apps []arogappsetv1.ApplicationSet, output *string
app.QualifiedName(),
app.Spec.Template.Spec.Project,
app.Spec.SyncPolicy,
app.Status.Health.Status,
conditions,
}
if *output == "wide" {
@@ -437,6 +438,7 @@ func printAppSetSummaryTable(appSet *arogappsetv1.ApplicationSet) {
fmt.Printf(printOpFmtStr, "Project:", appSet.Spec.Template.Spec.GetProject())
fmt.Printf(printOpFmtStr, "Server:", getServerForAppSet(appSet))
fmt.Printf(printOpFmtStr, "Namespace:", appSet.Spec.Template.Spec.Destination.Namespace)
fmt.Printf(printOpFmtStr, "Health Status:", appSet.Status.Health.Status)
if !appSet.Spec.Template.Spec.HasMultipleSources() {
fmt.Println("Source:")
} else {

View File

@@ -107,7 +107,7 @@ func TestPrintApplicationSetTable(t *testing.T) {
return nil
})
require.NoError(t, err)
expectation := "NAME PROJECT SYNCPOLICY CONDITIONS\napp-name default nil [{ResourcesUpToDate <nil> True }]\nteam-two/app-name default nil [{ResourcesUpToDate <nil> True }]\n"
expectation := "NAME PROJECT SYNCPOLICY HEALTH CONDITIONS\napp-name default nil [{ResourcesUpToDate <nil> True }]\nteam-two/app-name default nil [{ResourcesUpToDate <nil> True }]\n"
assert.Equal(t, expectation, output)
}
@@ -200,6 +200,7 @@ func TestPrintAppSetSummaryTable(t *testing.T) {
Project: default
Server:
Namespace:
Health Status:
Source:
- Repo:
Target:
@@ -213,6 +214,7 @@ SyncPolicy: <none>
Project: default
Server:
Namespace:
Health Status:
Source:
- Repo:
Target:
@@ -226,6 +228,7 @@ SyncPolicy: Automated
Project: default
Server:
Namespace:
Health Status:
Source:
- Repo:
Target:
@@ -239,6 +242,7 @@ SyncPolicy: Automated
Project: default
Server:
Namespace:
Health Status:
Source:
- Repo: test1
Target: master1
@@ -253,6 +257,7 @@ SyncPolicy: <none>
Project: default
Server:
Namespace:
Health Status:
Sources:
- Repo: test1
Target: master1

View File

@@ -30119,6 +30119,16 @@ spec:
- type
type: object
type: array
health:
properties:
lastTransitionTime:
format: date-time
type: string
message:
type: string
status:
type: string
type: object
resources:
items:
properties:

View File

@@ -30119,6 +30119,16 @@ spec:
- type
type: object
type: array
health:
properties:
lastTransitionTime:
format: date-time
type: string
message:
type: string
status:
type: string
type: object
resources:
items:
properties:

View File

@@ -23115,6 +23115,16 @@ spec:
- type
type: object
type: array
health:
properties:
lastTransitionTime:
format: date-time
type: string
message:
type: string
status:
type: string
type: object
resources:
items:
properties:

View File

@@ -30119,6 +30119,16 @@ spec:
- type
type: object
type: array
health:
properties:
lastTransitionTime:
format: date-time
type: string
message:
type: string
status:
type: string
type: object
resources:
items:
properties:

View File

@@ -30119,6 +30119,16 @@ spec:
- type
type: object
type: array
health:
properties:
lastTransitionTime:
format: date-time
type: string
message:
type: string
status:
type: string
type: object
resources:
items:
properties:

View File

@@ -30119,6 +30119,16 @@ spec:
- type
type: object
type: array
health:
properties:
lastTransitionTime:
format: date-time
type: string
message:
type: string
status:
type: string
type: object
resources:
items:
properties:

10
manifests/install.yaml generated
View File

@@ -30119,6 +30119,16 @@ spec:
- type
type: object
type: array
health:
properties:
lastTransitionTime:
format: date-time
type: string
message:
type: string
status:
type: string
type: object
resources:
items:
properties:

View File

@@ -20,12 +20,13 @@ import (
"encoding/json"
"sort"
"github.com/argoproj/argo-cd/v3/common"
"github.com/argoproj/argo-cd/v3/util/security"
"github.com/argoproj/gitops-engine/pkg/health"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"github.com/argoproj/argo-cd/v3/common"
"github.com/argoproj/argo-cd/v3/util/security"
)
// SecretRef struct for a reference to a secret key.
@@ -70,7 +71,8 @@ type ApplicationSetSpec struct {
Strategy *ApplicationSetStrategy `json:"strategy,omitempty" protobuf:"bytes,5,opt,name=strategy"`
PreservedFields *ApplicationPreservedFields `json:"preservedFields,omitempty" protobuf:"bytes,6,opt,name=preservedFields"`
GoTemplateOptions []string `json:"goTemplateOptions,omitempty" protobuf:"bytes,7,opt,name=goTemplateOptions"`
// ApplyNestedSelectors enables selectors defined within the generators of two level-nested matrix or merge generators
// ApplyNestedSelectors enables selectors defined within the generators of two level-nested matrix or merge generators.
//
// Deprecated: This field is ignored, and the behavior is always enabled. The field will be removed in a future
// version of the ApplicationSet CRD.
ApplyNestedSelectors bool `json:"applyNestedSelectors,omitempty" protobuf:"bytes,8,name=applyNestedSelectors"`
@@ -811,6 +813,8 @@ type ApplicationSetStatus struct {
// ResourcesCount is the total number of resources managed by this application set. The count may be higher than actual number of items in the Resources field when
// the number of managed resources exceeds the limit imposed by the controller (to avoid making the status field too large).
ResourcesCount int64 `json:"resourcesCount,omitempty" protobuf:"varint,4,opt,name=resourcesCount"`
// Health contains information about the applicationset's current health status based on the applicationset conditions
Health HealthStatus `json:"health,omitempty" protobuf:"bytes,5,opt,name=health"`
}
// ApplicationSetCondition contains details about an applicationset condition, which is usually an error or warning
@@ -937,6 +941,61 @@ func (a *ApplicationSet) RefreshRequired() bool {
return found
}
// CalculateHealth derives the health status from the applicationset conditions.
// Health is determined by priority:
// 1. ErrorOccurred=True → Degraded
// 2. RolloutProgressing=True → Progressing
// 3. ResourcesUpToDate=True → Healthy
// 4. Otherwise → Unknown
func (status *ApplicationSetStatus) CalculateHealth() HealthStatus {
if len(status.Conditions) == 0 {
return HealthStatus{
Status: health.HealthStatusUnknown,
Message: "No status conditions found for ApplicationSet",
}
}
var (
progressing *ApplicationSetCondition
healthy *ApplicationSetCondition
)
for _, c := range status.Conditions {
if c.Status != ApplicationSetConditionStatusTrue {
continue
}
switch c.Type {
case ApplicationSetConditionErrorOccurred:
return HealthStatus{
Status: health.HealthStatusDegraded,
Message: c.Message,
}
case ApplicationSetConditionRolloutProgressing:
progressing = &c
case ApplicationSetConditionResourcesUpToDate:
healthy = &c
}
}
if progressing != nil {
return HealthStatus{
Status: health.HealthStatusProgressing,
Message: progressing.Message,
}
}
if healthy != nil {
return HealthStatus{
Status: health.HealthStatusHealthy,
Message: healthy.Message,
}
}
return HealthStatus{
Status: health.HealthStatusUnknown,
Message: "Waiting for health status to be determined",
}
}
// SetConditions updates the applicationset status conditions for a subset of evaluated types.
// If the applicationset has a pre-existing condition of a type that is not in the evaluated list,
// it will be preserved. If the applicationset has a pre-existing condition of a type, status, reason that
@@ -995,6 +1054,9 @@ func (status *ApplicationSetStatus) SetConditions(conditions []ApplicationSetCon
return left.LastTransitionTime.Before(right.LastTransitionTime)
})
status.Conditions = newConditions
// Recalculate health based on the updated conditions
status.Health = status.CalculateHealth()
}
func (t ApplicationSetConditionType) findConditionIndex(conditions []ApplicationSetCondition) int {

View File

@@ -4,6 +4,7 @@ import (
"testing"
"time"
"github.com/argoproj/gitops-engine/pkg/health"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
@@ -172,6 +173,143 @@ func assertAppSetConditions(t *testing.T, expected []ApplicationSetCondition, ac
}
}
func TestApplicationSetCalculateHealth(t *testing.T) {
tests := []struct {
name string
conditions []ApplicationSetCondition
expectedHealth health.HealthStatusCode
expectedMsg string
}{
{
name: "no conditions returns unknown",
conditions: []ApplicationSetCondition{},
expectedHealth: health.HealthStatusUnknown,
expectedMsg: "No status conditions found for ApplicationSet",
},
{
name: "error occurred returns degraded",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionErrorOccurred, Status: ApplicationSetConditionStatusTrue, Message: "generator failed"},
},
expectedHealth: health.HealthStatusDegraded,
expectedMsg: "generator failed",
},
{
name: "error occurred false does not indicate degraded",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionErrorOccurred, Status: ApplicationSetConditionStatusFalse, Message: "no error"},
{Type: ApplicationSetConditionResourcesUpToDate, Status: ApplicationSetConditionStatusTrue, Message: "all good"},
},
expectedHealth: health.HealthStatusHealthy,
expectedMsg: "all good",
},
{
name: "rollout progressing returns progressing",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionRolloutProgressing, Status: ApplicationSetConditionStatusTrue, Message: "rolling out 2/5"},
},
expectedHealth: health.HealthStatusProgressing,
expectedMsg: "rolling out 2/5",
},
{
name: "resources up to date returns healthy",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionResourcesUpToDate, Status: ApplicationSetConditionStatusTrue, Message: "all applications synced"},
},
expectedHealth: health.HealthStatusHealthy,
expectedMsg: "all applications synced",
},
{
name: "error takes priority over resources up to date",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionResourcesUpToDate, Status: ApplicationSetConditionStatusTrue, Message: "synced"},
{Type: ApplicationSetConditionErrorOccurred, Status: ApplicationSetConditionStatusTrue, Message: "validation error"},
},
expectedHealth: health.HealthStatusDegraded,
expectedMsg: "validation error",
},
{
name: "error takes priority over progressing",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionRolloutProgressing, Status: ApplicationSetConditionStatusTrue, Message: "rolling"},
{Type: ApplicationSetConditionErrorOccurred, Status: ApplicationSetConditionStatusTrue, Message: "error during rollout"},
},
expectedHealth: health.HealthStatusDegraded,
expectedMsg: "error during rollout",
},
{
name: "progressing takes priority over resources up to date",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionResourcesUpToDate, Status: ApplicationSetConditionStatusTrue, Message: "synced"},
{Type: ApplicationSetConditionRolloutProgressing, Status: ApplicationSetConditionStatusTrue, Message: "rolling out"},
},
expectedHealth: health.HealthStatusProgressing,
expectedMsg: "rolling out",
},
{
name: "parameters generated only returns unknown",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionParametersGenerated, Status: ApplicationSetConditionStatusTrue, Message: "params ok"},
},
expectedHealth: health.HealthStatusUnknown,
expectedMsg: "Waiting for health status to be determined",
},
{
name: "all conditions false returns unknown",
conditions: []ApplicationSetCondition{
{Type: ApplicationSetConditionErrorOccurred, Status: ApplicationSetConditionStatusFalse},
{Type: ApplicationSetConditionResourcesUpToDate, Status: ApplicationSetConditionStatusFalse},
},
expectedHealth: health.HealthStatusUnknown,
expectedMsg: "Waiting for health status to be determined",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status := &ApplicationSetStatus{Conditions: tt.conditions}
healthStatus := status.CalculateHealth()
assert.Equal(t, tt.expectedHealth, healthStatus.Status)
assert.Equal(t, tt.expectedMsg, healthStatus.Message)
})
}
}
func TestSetConditionsUpdatesHealth(t *testing.T) {
testRepo := "https://github.com/org/repo"
namespace := "test"
a := newTestAppSet("sample-app-set", namespace, testRepo)
// Initially no conditions, health should be unknown
assert.Equal(t, health.HealthStatusCode(""), a.Status.Health.Status)
// Set ResourcesUpToDate condition
a.Status.SetConditions([]ApplicationSetCondition{
{Type: ApplicationSetConditionResourcesUpToDate, Status: ApplicationSetConditionStatusTrue, Message: "all synced"},
}, map[ApplicationSetConditionType]bool{
ApplicationSetConditionResourcesUpToDate: true,
})
assert.Equal(t, health.HealthStatusHealthy, a.Status.Health.Status)
assert.Equal(t, "all synced", a.Status.Health.Message)
// Add error condition, health should become degraded
a.Status.SetConditions([]ApplicationSetCondition{
{Type: ApplicationSetConditionErrorOccurred, Status: ApplicationSetConditionStatusTrue, Message: "something broke"},
}, map[ApplicationSetConditionType]bool{
ApplicationSetConditionErrorOccurred: true,
})
assert.Equal(t, health.HealthStatusDegraded, a.Status.Health.Status)
assert.Equal(t, "something broke", a.Status.Health.Message)
// Clear error, health should return to healthy
a.Status.SetConditions([]ApplicationSetCondition{
{Type: ApplicationSetConditionErrorOccurred, Status: ApplicationSetConditionStatusFalse, Message: ""},
}, map[ApplicationSetConditionType]bool{
ApplicationSetConditionErrorOccurred: true,
})
assert.Equal(t, health.HealthStatusHealthy, a.Status.Health.Status)
}
func TestSCMProviderGeneratorGitlab_WillIncludeSharedProjects(t *testing.T) {
settings := SCMProviderGeneratorGitlab{}
assert.True(t, settings.WillIncludeSharedProjects())

File diff suppressed because it is too large Load Diff

View File

@@ -355,7 +355,8 @@ message ApplicationSetSpec {
repeated string goTemplateOptions = 7;
// ApplyNestedSelectors enables selectors defined within the generators of two level-nested matrix or merge generators
// ApplyNestedSelectors enables selectors defined within the generators of two level-nested matrix or merge generators.
//
// Deprecated: This field is ignored, and the behavior is always enabled. The field will be removed in a future
// version of the ApplicationSet CRD.
optional bool applyNestedSelectors = 8;
@@ -379,6 +380,9 @@ message ApplicationSetStatus {
// ResourcesCount is the total number of resources managed by this application set. The count may be higher than actual number of items in the Resources field when
// the number of managed resources exceeds the limit imposed by the controller (to avoid making the status field too large).
optional int64 resourcesCount = 4;
// Health contains information about the applicationset's current health status based on the applicationset conditions
optional HealthStatus health = 5;
}
// ApplicationSetStrategy configures how generated Applications are updated in sequence.

View File

@@ -834,6 +834,7 @@ func (in *ApplicationSetStatus) DeepCopyInto(out *ApplicationSetStatus) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
in.Health.DeepCopyInto(&out.Health)
return
}

View File

@@ -2174,3 +2174,78 @@ func TestApplicationSetAPIListResourceEvents(t *testing.T) {
}).
When().Delete().Then().Expect(ApplicationsDoNotExist([]v1alpha1.Application{}))
}
// TestApplicationSetHealthStatusCLI tests that the CLI commands display the health status field for an ApplicationSet.
func TestApplicationSetHealthStatusCLI(t *testing.T) {
expectedApp := v1alpha1.Application{
TypeMeta: metav1.TypeMeta{
Kind: application.ApplicationKind,
APIVersion: "argoproj.io/v1alpha1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "health-cli-guestbook",
Namespace: utils.ArgoCDNamespace,
Finalizers: []string{v1alpha1.ResourcesFinalizerName},
},
Spec: v1alpha1.ApplicationSpec{
Project: "default",
Source: &v1alpha1.ApplicationSource{
RepoURL: "https://github.com/argoproj/argocd-example-apps.git",
TargetRevision: "HEAD",
Path: "guestbook",
},
Destination: v1alpha1.ApplicationDestination{
Server: "https://kubernetes.default.svc",
Namespace: "guestbook",
},
},
}
Given(t).
When().Create(v1alpha1.ApplicationSet{
ObjectMeta: metav1.ObjectMeta{
Name: "health-status-cli-test",
Namespace: utils.ArgoCDNamespace,
},
Spec: v1alpha1.ApplicationSetSpec{
GoTemplate: true,
Template: v1alpha1.ApplicationSetTemplate{
ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{Name: "health-cli-guestbook"},
Spec: v1alpha1.ApplicationSpec{
Project: "default",
Source: &v1alpha1.ApplicationSource{
RepoURL: "https://github.com/argoproj/argocd-example-apps.git",
TargetRevision: "HEAD",
Path: "guestbook",
},
Destination: v1alpha1.ApplicationDestination{
Server: "https://kubernetes.default.svc",
Namespace: "guestbook",
},
},
},
Generators: []v1alpha1.ApplicationSetGenerator{
{
List: &v1alpha1.ListGenerator{
Elements: []apiextensionsv1.JSON{{
Raw: []byte(`{"cluster": "my-cluster"}`),
}},
},
},
},
},
}).Then().
// Wait for the ApplicationSet to be ready
Expect(ApplicationSetHasConditions("health-status-cli-test", ExpectedConditions)).
// Test 'argocd appset get' shows Health Status field
When().AppSetGet("health-status-cli-test").
Then().Expect(OutputContains("Health Status:")).
// Test 'argocd appset list' shows HEALTH column header
When().AppSetList().
Then().Expect(OutputContains("HEALTH")).
// Cleanup
When().Delete().Then().Expect(ApplicationsDoNotExist([]v1alpha1.Application{expectedApp}))
}

View File

@@ -537,6 +537,29 @@ func (a *Actions) AppSet(appName string, flags ...string) *Actions {
return a
}
// AppSetGet runs 'argocd appset get' CLI command and stores the output
func (a *Actions) AppSetGet(appSetName string, flags ...string) *Actions {
a.context.t.Helper()
args := []string{"appset", "get", appSetName}
args = append(args, flags...)
a.runCli(args...)
return a
}
// AppSetList runs 'argocd appset list' CLI command and stores the output
func (a *Actions) AppSetList(flags ...string) *Actions {
a.context.t.Helper()
args := []string{"appset", "list"}
args = append(args, flags...)
a.runCli(args...)
return a
}
// GetLastOutput returns the output from the last CLI command
func (a *Actions) GetLastOutput() string {
return a.lastOutput
}
func (a *Actions) runCli(args ...string) {
a.context.T().Helper()
a.lastOutput, a.lastError = fixture.RunCli(args...)

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/argoproj/gitops-engine/pkg/diff"
"github.com/argoproj/gitops-engine/pkg/health"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -39,6 +40,19 @@ func Success(message string) Expectation {
}
}
// OutputContains asserts that the last command output contains the expected substring
func OutputContains(expected string) Expectation {
return func(c *Consequences) (state, string) {
if c.actions.lastError != nil {
return failed, fmt.Sprintf("error: %v", c.actions.lastError)
}
if !strings.Contains(c.actions.lastOutput, expected) {
return failed, fmt.Sprintf("output did not contain '%s', got: %s", expected, c.actions.lastOutput)
}
return succeeded, fmt.Sprintf("output contained '%s'", expected)
}
}
// Error asserts that the last command was an error with substring match
func Error(message, err string) Expectation {
return func(c *Consequences) (state, string) {
@@ -79,6 +93,23 @@ func ApplicationsExist(expectedApps []v1alpha1.Application) Expectation {
}
}
// ApplicationSetHasHealthStatus checks whether the ApplicationSet has the expected health status.
func ApplicationSetHasHealthStatus(applicationSetName string, expectedHealthStatus health.HealthStatusCode) Expectation {
return func(c *Consequences) (state, string) {
// retrieve the application set
foundApplicationSet := c.applicationSet(applicationSetName)
if foundApplicationSet == nil {
return pending, fmt.Sprintf("application set '%s' not found", applicationSetName)
}
if foundApplicationSet.Status.Health.Status != expectedHealthStatus {
return pending, fmt.Sprintf("application set health status is '%s', expected '%s'",
foundApplicationSet.Status.Health.Status, expectedHealthStatus)
}
return succeeded, fmt.Sprintf("application set has expected health status '%s'", expectedHealthStatus)
}
}
// ApplicationSetHasConditions checks whether each of the 'expectedConditions' exist in the ApplicationSet status, and are
// equivalent to provided values.
func ApplicationSetHasConditions(expectedConditions []v1alpha1.ApplicationSetCondition) Expectation {