Skip to content

Commit 9c17446

Browse files
Marvin9hiddecokrancour
authored
feat: annotate argocd context to stage and move deep link back to stage details (#3603)
Signed-off-by: Mayursinh Sarvaiya <[email protected]> Signed-off-by: Hidde Beydals <[email protected]> Co-authored-by: Hidde Beydals <[email protected]> Co-authored-by: Kent Rancourt <[email protected]>
1 parent a7f60ec commit 9c17446

File tree

12 files changed

+356
-105
lines changed

12 files changed

+356
-105
lines changed

api/v1alpha1/annotations.go

+4
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,8 @@ const (
3838
// AnnotationKeyPromotion is an annotation key that can be set on a
3939
// resource to indicate that it is related to a specific promotion.
4040
AnnotationKeyPromotion = "kargo.akuity.io/promotion"
41+
42+
// AnnotationKeyArgoCDContext is an annotation key that is set on a Stage
43+
// to reference the last ArgoCD Applications that were part of a Promotion.
44+
AnnotationKeyArgoCDContext = "kargo.akuity.io/argocd-context"
4145
)

internal/api/finalizers.go

+6
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ func patchAnnotation(ctx context.Context, c client.Client, obj client.Object, ke
8484
})
8585
}
8686

87+
func deleteAnnotation(ctx context.Context, c client.Client, obj client.Object, key string) error {
88+
return patchAnnotations(ctx, c, obj, map[string]*string{
89+
key: nil,
90+
})
91+
}
92+
8793
func patchAnnotations(
8894
ctx context.Context,
8995
c client.Client,

internal/api/finalizers_test.go

+77
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,80 @@ func Test_patchAnnotation(t *testing.T) {
224224
})
225225
}
226226
}
227+
228+
func Test_deleteAnnotation(t *testing.T) {
229+
scheme := k8sruntime.NewScheme()
230+
require.NoError(t, kargoapi.SchemeBuilder.AddToScheme(scheme))
231+
232+
newFakeClient := func(obj ...client.Object) client.Client {
233+
return fake.NewClientBuilder().
234+
WithScheme(scheme).
235+
WithObjects(obj...).
236+
Build()
237+
}
238+
239+
testCases := map[string]struct {
240+
obj client.Object
241+
client client.Client
242+
key string
243+
value string
244+
errExpected bool
245+
}{
246+
"stage without annotation": {
247+
obj: &kargoapi.Stage{
248+
ObjectMeta: metav1.ObjectMeta{
249+
Namespace: "test",
250+
Name: "stage",
251+
},
252+
},
253+
client: newFakeClient(&kargoapi.Stage{
254+
ObjectMeta: metav1.ObjectMeta{
255+
Namespace: "test",
256+
Name: "stage",
257+
},
258+
}),
259+
key: "key",
260+
},
261+
"stage with existing annotation": {
262+
obj: &kargoapi.Stage{
263+
ObjectMeta: metav1.ObjectMeta{
264+
Namespace: "test",
265+
Name: "stage",
266+
},
267+
},
268+
client: newFakeClient(&kargoapi.Stage{
269+
ObjectMeta: metav1.ObjectMeta{
270+
Namespace: "test",
271+
Name: "stage",
272+
Annotations: map[string]string{
273+
"key": "value",
274+
},
275+
},
276+
}),
277+
key: "key",
278+
},
279+
"non-existing stage": {
280+
obj: &kargoapi.Stage{
281+
ObjectMeta: metav1.ObjectMeta{
282+
Namespace: "test",
283+
Name: "stage",
284+
},
285+
},
286+
client: newFakeClient(),
287+
key: "key",
288+
value: "value",
289+
errExpected: true,
290+
},
291+
}
292+
for name, tc := range testCases {
293+
t.Run(name, func(t *testing.T) {
294+
t.Parallel()
295+
err := deleteAnnotation(context.Background(), tc.client, tc.obj, tc.key)
296+
if tc.errExpected {
297+
require.Error(t, err)
298+
return
299+
}
300+
require.NotContains(t, tc.obj.GetAnnotations(), tc.key)
301+
})
302+
}
303+
}

internal/api/stage.go

+49
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"slices"
@@ -126,6 +127,54 @@ func RefreshStage(
126127
return stage, nil
127128
}
128129

130+
// AnnotateStageWithArgoCDContext annotates a Stage with the ArgoCD context
131+
// necessary for the frontend to display ArgoCD information for the Stage.
132+
//
133+
// The annotation value is a JSON-encoded list of ArgoCD apps that are
134+
// associated with the Stage, constructed from the HealthCheckSteps from
135+
// the latest Promotion.
136+
//
137+
// If no ArgoCD apps are found, the annotation is removed.
138+
func AnnotateStageWithArgoCDContext(
139+
ctx context.Context,
140+
c client.Client,
141+
healthChecks []kargoapi.HealthCheckStep,
142+
stage *kargoapi.Stage,
143+
) error {
144+
var argoCDApps []map[string]any
145+
for _, healthCheck := range healthChecks {
146+
healthCheckConfig := healthCheck.GetConfig()
147+
148+
appsList, ok := healthCheckConfig["apps"].([]any)
149+
if !ok {
150+
continue
151+
}
152+
153+
for _, rawApp := range appsList {
154+
appConfig, ok := rawApp.(map[string]any)
155+
if !ok {
156+
continue
157+
}
158+
argoCDApps = append(argoCDApps, map[string]any{
159+
"name": appConfig["name"],
160+
"namespace": appConfig["namespace"],
161+
})
162+
}
163+
}
164+
165+
// If we did not find any ArgoCD apps, we should remove the annotation.
166+
if len(argoCDApps) == 0 {
167+
return deleteAnnotation(ctx, c, stage, kargoapi.AnnotationKeyArgoCDContext)
168+
}
169+
170+
// Marshal the ArgoCD context to JSON and set the annotation on the Stage.
171+
argoCDAppsJSON, err := json.Marshal(argoCDApps)
172+
if err != nil {
173+
return fmt.Errorf("failed to marshal ArgoCD context: %w", err)
174+
}
175+
return patchAnnotation(ctx, c, stage, kargoapi.AnnotationKeyArgoCDContext, string(argoCDAppsJSON))
176+
}
177+
129178
// ReverifyStageFreight forces reconfirmation of the verification of the
130179
// Freight associated with a Stage by setting an AnnotationKeyReverify
131180
// annotation on the Stage, causing the controller to rerun the verification.

internal/api/stage_test.go

+83
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/stretchr/testify/require"
10+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1011
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1112
k8sruntime "k8s.io/apimachinery/pkg/runtime"
1213
"k8s.io/apimachinery/pkg/types"
@@ -551,3 +552,85 @@ func TestAbortStageFreightVerification(t *testing.T) {
551552
}).String(), stage.Annotations[kargoapi.AnnotationKeyAbort])
552553
})
553554
}
555+
556+
func TestAnnotateStageWithArgoCDContext(t *testing.T) {
557+
scheme := k8sruntime.NewScheme()
558+
require.NoError(t, kargoapi.SchemeBuilder.AddToScheme(scheme))
559+
560+
t.Run("not found", func(t *testing.T) {
561+
c := fake.NewClientBuilder().WithScheme(scheme).Build()
562+
563+
err := AnnotateStageWithArgoCDContext(context.TODO(), c, []kargoapi.HealthCheckStep{}, &kargoapi.Stage{
564+
ObjectMeta: metav1.ObjectMeta{
565+
Name: "fake-stage",
566+
Namespace: "fake-namespace",
567+
},
568+
})
569+
require.ErrorContains(t, err, "not found")
570+
})
571+
572+
t.Run("success", func(t *testing.T) {
573+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
574+
&kargoapi.Stage{
575+
ObjectMeta: metav1.ObjectMeta{
576+
Name: "fake-stage",
577+
Namespace: "fake-namespace",
578+
},
579+
},
580+
).Build()
581+
582+
err := AnnotateStageWithArgoCDContext(context.TODO(), c, []kargoapi.HealthCheckStep{
583+
{
584+
Uses: "argocd-update",
585+
Config: &apiextensionsv1.JSON{
586+
Raw: []byte(`{"apps": [{"name": "fake-argo-app", "namespace": "fake-argo-namespace"}]}`),
587+
},
588+
},
589+
}, &kargoapi.Stage{
590+
ObjectMeta: metav1.ObjectMeta{
591+
Name: "fake-stage",
592+
Namespace: "fake-namespace",
593+
},
594+
})
595+
require.NoError(t, err)
596+
597+
stage, err := GetStage(context.TODO(), c, types.NamespacedName{
598+
Namespace: "fake-namespace",
599+
Name: "fake-stage",
600+
})
601+
require.NoError(t, err)
602+
require.Equal(t,
603+
`[{"name":"fake-argo-app","namespace":"fake-argo-namespace"}]`,
604+
stage.Annotations[kargoapi.AnnotationKeyArgoCDContext])
605+
})
606+
607+
t.Run("no ArgoCD apps", func(t *testing.T) {
608+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
609+
&kargoapi.Stage{
610+
ObjectMeta: metav1.ObjectMeta{
611+
Name: "fake-stage",
612+
Namespace: "fake-namespace",
613+
Annotations: map[string]string{
614+
kargoapi.AnnotationKeyArgoCDContext: "fake-annotation",
615+
},
616+
},
617+
},
618+
).Build()
619+
620+
err := AnnotateStageWithArgoCDContext(context.TODO(), c, []kargoapi.HealthCheckStep{}, &kargoapi.Stage{
621+
ObjectMeta: metav1.ObjectMeta{
622+
Name: "fake-stage",
623+
Namespace: "fake-namespace",
624+
},
625+
})
626+
require.NoError(t, err)
627+
628+
stage, err := GetStage(context.TODO(), c, types.NamespacedName{
629+
Namespace: "fake-namespace",
630+
Name: "fake-stage",
631+
})
632+
require.NoError(t, err)
633+
634+
require.NotContains(t, stage.Annotations, kargoapi.AnnotationKeyArgoCDContext)
635+
})
636+
}

internal/controller/stages/control_flow_stages.go

+8
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ func (r *ControlFlowStageReconciler) Reconcile(ctx context.Context, req ctrl.Req
215215
return ctrl.Result{Requeue: ok}, err
216216
}
217217

218+
// Remove any stale annotations from the Stage which are not relevant to
219+
// a control flow Stage.
220+
if stage.GetAnnotations()[kargoapi.AnnotationKeyArgoCDContext] != "" {
221+
if err := api.AnnotateStageWithArgoCDContext(ctx, r.client, nil, stage); err != nil {
222+
logger.Error(err, "failed to remove Argo CD context annotation from Stage")
223+
}
224+
}
225+
218226
// Reconcile the Stage.
219227
logger.Debug("reconciling Stage")
220228
newStatus, reconcileErr := r.reconcile(ctx, stage, time.Now())

internal/controller/stages/control_flow_stages_test.go

+24
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,30 @@ func TestControlFlowStageReconciler_Reconcile(t *testing.T) {
147147
assert.Contains(t, stage.Finalizers, kargoapi.FinalizerName)
148148
},
149149
},
150+
{
151+
name: "removes stale annotations",
152+
req: ctrl.Request{NamespacedName: testStage},
153+
stage: &kargoapi.Stage{
154+
ObjectMeta: metav1.ObjectMeta{
155+
Namespace: testProject,
156+
Name: testStageName,
157+
Finalizers: []string{kargoapi.FinalizerName},
158+
Annotations: map[string]string{
159+
kargoapi.AnnotationKeyArgoCDContext: "old-argocd-context",
160+
},
161+
},
162+
},
163+
assertions: func(t *testing.T, c client.Client, result ctrl.Result, err error) {
164+
require.NoError(t, err)
165+
assert.Equal(t, ctrl.Result{}, result)
166+
167+
// Verify annotation was removed
168+
stage := &kargoapi.Stage{}
169+
err = c.Get(context.Background(), testStage, stage)
170+
require.NoError(t, err)
171+
assert.NotContains(t, stage.Annotations, kargoapi.AnnotationKeyArgoCDContext)
172+
},
173+
},
150174
{
151175
name: "reconcile error",
152176
req: ctrl.Request{NamespacedName: testStage},

internal/controller/stages/regular_stages.go

+12
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,18 @@ func (r *RegularStageReconciler) syncPromotions(
636636
Message: "Waiting for verification to be performed after successful promotion",
637637
ObservedGeneration: stage.Generation,
638638
})
639+
640+
// Annotate the Stage with the latest information related to
641+
// ArgoCD Applications. This is used to provide deep links to the
642+
// ArgoCD UI for the Stage in the Kargo UI.
643+
//
644+
// NB: If the health checks do not include ArgoCD Applications,
645+
// then the annotation will be removed.
646+
if err := api.AnnotateStageWithArgoCDContext(ctx, r.client, p.Status.HealthChecks, stage); err != nil {
647+
// Let the error be logged, but do not return it as it is not
648+
// critical to the operation of the Stage.
649+
logger.Error(err, "failed to annotate Stage with ArgoCD context")
650+
}
639651
}
640652
}
641653

0 commit comments

Comments
 (0)