diff --git a/api/v1alpha1/annotations.go b/api/v1alpha1/annotations.go index 45c17be07..21cc55c5b 100644 --- a/api/v1alpha1/annotations.go +++ b/api/v1alpha1/annotations.go @@ -3,6 +3,10 @@ package v1alpha1 import "encoding/json" const ( + // AnnotationKeyCreateActor is an annotation key that can be injected to a resource + // by the Kargo controlplane to indicate the actor that created the resource. + AnnotationKeyCreateActor = "kargo.akuity.io/create-actor" + // AnnotationKeyRefresh is an annotation key that can be set on a resource // to trigger a refresh of the resource by the controller. The value of the // annotation is interpreted as a token, and any change to the value of the diff --git a/api/v1alpha1/event.go b/api/v1alpha1/event.go index 5405bfc28..6b4ac4bde 100644 --- a/api/v1alpha1/event.go +++ b/api/v1alpha1/event.go @@ -93,9 +93,16 @@ func NewPromotionEventAnnotations( AnnotationKeyEventStageName: p.Spec.Stage, AnnotationKeyEventPromotionCreateTime: p.GetCreationTimestamp().Format(time.RFC3339), } + if actor != "" { annotations[AnnotationKeyEventActor] = actor } + // All Promotion-related events are emitted after the promotion was created. + // Therefore, if the promotion knows who triggered it, set them as an actor. + if promoteActor, ok := p.Annotations[AnnotationKeyCreateActor]; ok { + annotations[AnnotationKeyEventActor] = promoteActor + } + if f != nil { annotations[AnnotationKeyEventFreightCreateTime] = f.CreationTimestamp.Format(time.RFC3339) annotations[AnnotationKeyEventFreightAlias] = f.Alias diff --git a/internal/api/promote_to_stage_subscribers_v1alpha1.go b/internal/api/promote_to_stage_subscribers_v1alpha1.go index 3b84143d2..08ef153a2 100644 --- a/internal/api/promote_to_stage_subscribers_v1alpha1.go +++ b/internal/api/promote_to_stage_subscribers_v1alpha1.go @@ -133,7 +133,7 @@ func (s *server) PromoteToStageSubscribers( promoteErrs := make([]error, 0, len(subscribers)) createdPromos := make([]*kargoapi.Promotion, 0, len(subscribers)) for _, subscriber := range subscribers { - newPromo := kargo.NewPromotion(subscriber, freight.Name) + newPromo := kargo.NewPromotion(ctx, subscriber, freight.Name) if subscriber.Spec.PromotionMechanisms == nil { // Avoid creating a Promotion if the subscriber has no // PromotionMechanisms, and is a "control flow" Stage. diff --git a/internal/api/promote_to_stage_v1alpha1.go b/internal/api/promote_to_stage_v1alpha1.go index 5b5580d0e..e22e99602 100644 --- a/internal/api/promote_to_stage_v1alpha1.go +++ b/internal/api/promote_to_stage_v1alpha1.go @@ -118,7 +118,7 @@ func (s *server) PromoteToStage( return nil, err } - promotion := kargo.NewPromotion(*stage, freight.Name) + promotion := kargo.NewPromotion(ctx, *stage, freight.Name) if err := s.createPromotionFn(ctx, &promotion); err != nil { return nil, fmt.Errorf("create promotion: %w", err) } diff --git a/internal/controller/stages/stages.go b/internal/controller/stages/stages.go index 2e7f4e063..a63ab8ef5 100644 --- a/internal/controller/stages/stages.go +++ b/internal/controller/stages/stages.go @@ -989,7 +989,7 @@ func (r *reconciler) syncNormalStage( logger.Debug("auto-promotion will proceed") - promo := kargo.NewPromotion(*stage, latestFreight.Name) + promo := kargo.NewPromotion(ctx, *stage, latestFreight.Name) if err := r.createPromotionFn(ctx, &promo); err != nil { return status, fmt.Errorf( diff --git a/internal/kargo/kargo.go b/internal/kargo/kargo.go index 68c32dd18..4824a061a 100644 --- a/internal/kargo/kargo.go +++ b/internal/kargo/kargo.go @@ -1,6 +1,7 @@ package kargo import ( + "context" "fmt" "strings" @@ -11,6 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/internal/api/user" ) const ( @@ -22,7 +24,11 @@ const ( // NewPromotion returns a new Promotion from a given stage and freight with our // naming convention. -func NewPromotion(stage kargoapi.Stage, freight string) kargoapi.Promotion { +func NewPromotion( + ctx context.Context, + stage kargoapi.Stage, + freight string, +) kargoapi.Promotion { shortHash := freight if len(shortHash) > 7 { shortHash = freight[0:7] @@ -32,14 +38,21 @@ func NewPromotion(stage kargoapi.Stage, freight string) kargoapi.Promotion { shortStageName = shortStageName[0:maxStageNamePrefixLength] } + annotations := make(map[string]string, 1) + // Put actor information to track on the controller side + if u, ok := user.InfoFromContext(ctx); ok { + annotations[kargoapi.AnnotationKeyCreateActor] = kargoapi.FormatEventUserActor(u) + } + // ulid.Make() is pseudo-random, not crypto-random, but we don't care. // We just want a unique ID that can be sorted lexicographically promoName := strings.ToLower(fmt.Sprintf("%s.%s.%s", shortStageName, ulid.Make(), shortHash)) promotion := kargoapi.Promotion{ ObjectMeta: metav1.ObjectMeta{ - Name: promoName, - Namespace: stage.Namespace, + Name: promoName, + Namespace: stage.Namespace, + Annotations: annotations, }, Spec: &kargoapi.PromotionSpec{ Stage: stage.Name, diff --git a/internal/kargo/kargo_test.go b/internal/kargo/kargo_test.go index 7ef06e47e..cca706582 100644 --- a/internal/kargo/kargo_test.go +++ b/internal/kargo/kargo_test.go @@ -1,6 +1,7 @@ package kargo import ( + "context" "strings" "testing" @@ -64,7 +65,7 @@ func TestNewPromotion(t *testing.T) { for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { - promo := NewPromotion(tc.stage, tc.freight) + promo := NewPromotion(context.TODO(), tc.stage, tc.freight) require.Equal(t, tc.freight, promo.Spec.Freight) require.Equal(t, tc.stage.Name, promo.Spec.Stage) require.Equal(t, tc.freight, promo.Spec.Freight) diff --git a/internal/webhook/promotion/webhook.go b/internal/webhook/promotion/webhook.go index 579f390a8..69b3c1748 100644 --- a/internal/webhook/promotion/webhook.go +++ b/internal/webhook/promotion/webhook.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + admissionv1 "k8s.io/api/admission/v1" authzv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -35,7 +36,8 @@ var ( ) type webhook struct { - client client.Client + client client.Client + decoder *admission.Decoder recorder record.EventRecorder @@ -85,6 +87,7 @@ func SetupWebhookWithManager( w := newWebhook( cfg, mgr.GetClient(), + admission.NewDecoder(mgr.GetScheme()), libEvent.NewRecorder(ctx, mgr.GetScheme(), mgr.GetClient(), "promotion-webhook"), ) return ctrl.NewWebhookManagedBy(mgr). @@ -97,10 +100,12 @@ func SetupWebhookWithManager( func newWebhook( cfg libWebhook.Config, kubeClient client.Client, + decoder *admission.Decoder, recorder record.EventRecorder, ) *webhook { w := &webhook{ client: kubeClient, + decoder: decoder, recorder: recorder, } w.getFreightFn = kargoapi.GetFreight @@ -115,7 +120,40 @@ func newWebhook( } func (w *webhook) Default(ctx context.Context, obj runtime.Object) error { + req, err := w.admissionRequestFromContextFn(ctx) + if err != nil { + return fmt.Errorf("get admission request from context: %w", err) + } + promo := obj.(*kargoapi.Promotion) // nolint: forcetypeassert + var oldPromo *kargoapi.Promotion + // We need to decode old object manually since controller-runtime doesn't decode it for us. + if req.Operation == admissionv1.Update { + oldPromo = &kargoapi.Promotion{} + if err = w.decoder.DecodeRaw(req.OldObject, oldPromo); err != nil { + return fmt.Errorf("decode old object: %w", err) + } + } + + if promo.Annotations == nil { + promo.Annotations = make(map[string]string, 1) + } + if req.Operation == admissionv1.Create { + // Set actor as an admission request's user info when the promotion is created + // to allow controllers to track who created it. + if !w.isRequestFromKargoControlplaneFn(req) { + promo.Annotations[kargoapi.AnnotationKeyCreateActor] = + kargoapi.FormatEventKubernetesUserActor(req.UserInfo) + } + } else if req.Operation == admissionv1.Update { + // Ensure actor annotation immutability + if oldActor, ok := oldPromo.Annotations[kargoapi.AnnotationKeyCreateActor]; ok { + promo.Annotations[kargoapi.AnnotationKeyCreateActor] = oldActor + } else { + delete(promo.Annotations, kargoapi.AnnotationKeyCreateActor) + } + } + stage, err := w.getStageFn( ctx, w.client, diff --git a/internal/webhook/promotion/webhook_test.go b/internal/webhook/promotion/webhook_test.go index 334797922..cf7e66f3e 100644 --- a/internal/webhook/promotion/webhook_test.go +++ b/internal/webhook/promotion/webhook_test.go @@ -27,6 +27,7 @@ func TestNewWebhook(t *testing.T) { w := newWebhook( libWebhook.Config{}, kubeClient, + admission.NewDecoder(kubeClient.Scheme()), &fakeevent.EventRecorder{}, ) // Assert that all overridable behaviors were initialized to a default: @@ -48,6 +49,9 @@ func TestDefault(t *testing.T) { { name: "error getting stage", webhook: &webhook{ + admissionRequestFromContextFn: func(context.Context) (admission.Request, error) { + return admission.Request{}, nil + }, getStageFn: func( context.Context, client.Client, @@ -64,6 +68,9 @@ func TestDefault(t *testing.T) { { name: "stage not found", webhook: &webhook{ + admissionRequestFromContextFn: func(context.Context) (admission.Request, error) { + return admission.Request{}, nil + }, getStageFn: func( context.Context, client.Client, @@ -80,6 +87,9 @@ func TestDefault(t *testing.T) { { name: "stage without promotion mechanisms", webhook: &webhook{ + admissionRequestFromContextFn: func(context.Context) (admission.Request, error) { + return admission.Request{}, nil + }, getStageFn: func( context.Context, client.Client, @@ -98,6 +108,9 @@ func TestDefault(t *testing.T) { { name: "success", webhook: &webhook{ + admissionRequestFromContextFn: func(context.Context) (admission.Request, error) { + return admission.Request{}, nil + }, getStageFn: func( context.Context, client.Client,