diff --git a/api/v1alpha1/stage_types.go b/api/v1alpha1/stage_types.go index 7c535822fe..aa6d4d50f8 100644 --- a/api/v1alpha1/stage_types.go +++ b/api/v1alpha1/stage_types.go @@ -479,14 +479,15 @@ type StageStatus struct { // fanning Freight out to this Stage's Targets. It is absent for a Stage that // governs no Targets. // - // Kargo Enterprise only: This field is ignored in Kargo OSS. + // Fanning Freight out to Targets is a Kargo Enterprise-only feature. Kargo + // OSS maintains this field all the same, but the PromotionRequest it refers + // to never gets further than being marked Errored for that reason. // // +optional CurrentPromotionRequest *PromotionRequestReference `json:"currentPromotionRequest,omitempty"` // LastPromotionRequest is a reference to the last PromotionRequest to reach a - // terminal phase. It is absent for a Stage that governs no Targets. - // - // Kargo Enterprise only: This field is ignored in Kargo OSS. + // terminal phase. It is absent for a Stage that governs no Targets, and only + // ever moves forward, so it outlives the PromotionRequest it refers to. // // +optional LastPromotionRequest *PromotionRequestReference `json:"lastPromotionRequest,omitempty"` diff --git a/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml b/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml index 3b7ba20a35..00340c543c 100644 --- a/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml +++ b/charts/kargo/resources/crds/kargo.akuity.io_stages.yaml @@ -1431,7 +1431,9 @@ spec: fanning Freight out to this Stage's Targets. It is absent for a Stage that governs no Targets. - Kargo Enterprise only: This field is ignored in Kargo OSS. + Fanning Freight out to Targets is a Kargo Enterprise-only feature. Kargo + OSS maintains this field all the same, but the PromotionRequest it refers + to never gets further than being marked Errored for that reason. properties: finishedAt: description: FinishedAt is the time at which the PromotionRequest @@ -2571,9 +2573,8 @@ spec: lastPromotionRequest: description: |- LastPromotionRequest is a reference to the last PromotionRequest to reach a - terminal phase. It is absent for a Stage that governs no Targets. - - Kargo Enterprise only: This field is ignored in Kargo OSS. + terminal phase. It is absent for a Stage that governs no Targets, and only + ever moves forward, so it outlives the PromotionRequest it refers to. properties: finishedAt: description: FinishedAt is the time at which the PromotionRequest diff --git a/pkg/api/promotion_request.go b/pkg/api/promotion_request.go index 47e6390a17..81cd7fa627 100644 --- a/pkg/api/promotion_request.go +++ b/pkg/api/promotion_request.go @@ -3,6 +3,7 @@ package api import ( "context" "fmt" + "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -39,6 +40,80 @@ func GenerateChildPromotionName(stageName, targetName, freight string) string { ) } +// ComparePromotionRequestByPhaseAndCreationTime compares two PromotionRequests +// by their phase and creation time. It returns a negative value if +// PromotionRequest `a` should come before PromotionRequest `b`, a positive +// value if `a` should come after `b`, or zero if they are considered equal for +// sorting purposes. It can be used in conjunction with slices.SortFunc to sort +// a list of PromotionRequests. +// +// The order is the one ComparePromotionByPhaseAndCreationTime imposes on +// Promotions, so that a Stage chooses the PromotionRequest it is promoting +// through exactly as it chooses its current Promotion: +// +// 1. Running PromotionRequests +// 2. Non-terminal PromotionRequests (ordered by ULID in ascending order) +// 3. Terminal PromotionRequests (ordered by ULID in descending order) +// +// As there, name order stands in for creation order: a generated +// PromotionRequest name is .., so among the requests +// of a single Stage everything left of the ULID is identical and comparing +// names whole is comparing the ULIDs. +func ComparePromotionRequestByPhaseAndCreationTime(a, b kargoapi.PromotionRequest) int { + // Compare the phases of the PromotionRequests first. + if phaseCompare := ComparePromotionRequestPhase( + a.Status.Phase, + b.Status.Phase, + ); phaseCompare != 0 { + return phaseCompare + } + + switch { + case !a.Status.Phase.IsTerminal(): + // Non-terminal PromotionRequests are ordered in ascending order, so that + // the request which was (or will be) worked first is at the top. + return strings.Compare(a.Name, b.Name) + default: + // Terminal PromotionRequests are ordered in descending order, so that the + // most recent request is at the top, limiting the number of requests which + // have to be further inspected. + return strings.Compare(b.Name, a.Name) + } +} + +// ComparePromotionRequestPhase compares two PromotionRequest phases. It returns +// a negative value if phase `a` should come before phase `b`, a positive value +// if phase `a` should come after phase `b`, or zero if they are considered +// equal for sorting purposes. It can be used in combination with +// slices.SortFunc to sort a list of PromotionRequest phases. +// +// The order of PromotionRequest phases matches the one ComparePromotionPhase +// imposes on Promotion phases: +// +// 1. Running +// 2. Non-terminal phases +// 3. Terminal phases +func ComparePromotionRequestPhase(a, b kargoapi.PromotionRequestPhase) int { + aRunning := a == kargoapi.PromotionRequestPhaseRunning + bRunning := b == kargoapi.PromotionRequestPhaseRunning + aTerminal, bTerminal := a.IsTerminal(), b.IsTerminal() + + // NB: As in ComparePromotionPhase, the order of the cases here is important: + // "Running" is a special case that should always come before any other phase. + switch { + case aRunning && !bRunning: + return -1 + case !aRunning && bRunning: + return 1 + case !aTerminal && bTerminal: + return -1 + case aTerminal && !bTerminal: + return 1 + default: + return 0 + } +} + // NewPromotionRequest constructs a PromotionRequest expressing the intent to // promote the given Freight to the Targets the Stage governs. // diff --git a/pkg/api/promotion_request_test.go b/pkg/api/promotion_request_test.go index 2e190f5eff..56d701e82f 100644 --- a/pkg/api/promotion_request_test.go +++ b/pkg/api/promotion_request_test.go @@ -143,6 +143,135 @@ func TestGenerateChildPromotionName(t *testing.T) { } } +func TestComparePromotionRequestPhase(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + a kargoapi.PromotionRequestPhase + b kargoapi.PromotionRequestPhase + expected int + }{ + { + name: "Running before Pending", + a: kargoapi.PromotionRequestPhaseRunning, + b: kargoapi.PromotionRequestPhasePending, + expected: -1, + }, + { + name: "Pending after Running", + a: kargoapi.PromotionRequestPhasePending, + b: kargoapi.PromotionRequestPhaseRunning, + expected: 1, + }, + { + name: "non-terminal before terminal", + a: kargoapi.PromotionRequestPhasePending, + b: kargoapi.PromotionRequestPhaseSucceeded, + expected: -1, + }, + { + name: "terminal after non-terminal", + a: kargoapi.PromotionRequestPhaseSucceeded, + b: kargoapi.PromotionRequestPhasePending, + expected: 1, + }, + { + name: "a PromotionRequest without a phase yet is non-terminal", + a: "", + b: kargoapi.PromotionRequestPhaseErrored, + // The reconciler has yet to record a phase, so the request still has + // work ahead of it. + expected: -1, + }, + { + name: "terminal phases are equal to one another", + a: kargoapi.PromotionRequestPhaseSucceeded, + b: kargoapi.PromotionRequestPhaseFailed, + expected: 0, + }, + { + name: "identical phases", + a: kargoapi.PromotionRequestPhaseRunning, + b: kargoapi.PromotionRequestPhaseRunning, + expected: 0, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + require.Equal( + t, + testCase.expected, + ComparePromotionRequestPhase(testCase.a, testCase.b), + ) + }) + } +} + +func TestComparePromotionRequestByPhaseAndCreationTime(t *testing.T) { + t.Parallel() + + // Generated in this order, so the ULID in older precedes the ULID in newer. + older := GeneratePromotionRequestName("test-stage", "fake-freight") + newer := GeneratePromotionRequestName("test-stage", "fake-freight") + + request := func(name string, phase kargoapi.PromotionRequestPhase) kargoapi.PromotionRequest { + return kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: kargoapi.PromotionRequestStatus{Phase: phase}, + } + } + + testCases := []struct { + name string + a kargoapi.PromotionRequest + b kargoapi.PromotionRequest + assertions func(*testing.T, int) + }{ + { + name: "phase is compared before name", + // The Running request is the newer of the two, so name order alone + // would put the other one first. + a: request(newer, kargoapi.PromotionRequestPhaseRunning), + b: request(older, kargoapi.PromotionRequestPhasePending), + assertions: func(t *testing.T, result int) { + require.Negative(t, result) + }, + }, + { + name: "older of two non-terminal requests comes first", + a: request(older, kargoapi.PromotionRequestPhasePending), + b: request(newer, kargoapi.PromotionRequestPhasePending), + assertions: func(t *testing.T, result int) { + require.Negative(t, result) + }, + }, + { + name: "newer of two terminal requests comes first", + a: request(newer, kargoapi.PromotionRequestPhaseSucceeded), + b: request(older, kargoapi.PromotionRequestPhaseFailed), + assertions: func(t *testing.T, result int) { + require.Negative(t, result) + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + testCase.assertions( + t, + ComparePromotionRequestByPhaseAndCreationTime(testCase.a, testCase.b), + ) + // The comparator must be antisymmetric, or slices.SortFunc gives no + // guarantees about the order it produces. + forward := ComparePromotionRequestByPhaseAndCreationTime(testCase.a, testCase.b) + reverse := ComparePromotionRequestByPhaseAndCreationTime(testCase.b, testCase.a) + require.Equal(t, forward, -reverse) + }) + } +} + func TestNewPromotionRequest(t *testing.T) { t.Parallel() diff --git a/pkg/controller/stages/regular_stages.go b/pkg/controller/stages/regular_stages.go index ecc593ed9c..b982ad466b 100644 --- a/pkg/controller/stages/regular_stages.go +++ b/pkg/controller/stages/regular_stages.go @@ -167,6 +167,19 @@ func (r *RegularStageReconciler) SetupWithManager( ) } + // This index is used to find all PromotionRequests that promote Freight on + // behalf of a specific Stage. + if err := sharedIndexer.IndexField( + ctx, + &kargoapi.PromotionRequest{}, + indexer.PromotionRequestsByStageField, + indexer.PromotionRequestsByStage, + ); err != nil { + return fmt.Errorf( + "error setting up index for PromotionRequests by Stage: %w", err, + ) + } + // This index is used to find Freight that are directly available from a // Warehouse and can be automatically promoted to a Stage. if err := sharedIndexer.IndexField( @@ -248,6 +261,24 @@ func (r *RegularStageReconciler) SetupWithManager( return fmt.Errorf("unable to watch Promotions: %w", err) } + // Watch for PromotionRequests for which the phase changed and enqueue the + // related Stage for reconciliation. + if err = c.Watch( + source.Kind( + kargoMgr.GetCache(), + &kargoapi.PromotionRequest{}, + handler.TypedEnqueueRequestForOwner[*kargoapi.PromotionRequest]( + kargoMgr.GetScheme(), + kargoMgr.GetRESTMapper(), + &kargoapi.Stage{}, + handler.OnlyControllerOwner(), + ), + kargo.NewPromotionRequestPhaseChangedPredicate(logger), + ), + ); err != nil { + return fmt.Errorf("unable to watch PromotionRequests: %w", err) + } + // Watch for Freight that have been newly promoted to a Stage or newly marked // as verified in a Stage and enqueue downstream Stages for reconciliation. if err = c.Watch( @@ -480,6 +511,16 @@ func (r *RegularStageReconciler) reconcile( return status, err }, }, + { + name: "syncing PromotionRequests", + reconcile: func() (kargoapi.StageStatus, error) { + status, err := r.syncPromotionRequests(ctx, working) + if err != nil { + err = fmt.Errorf("failed to sync PromotionRequests: %w", err) + } + return status, err + }, + }, { name: "syncing Freight", reconcile: func() (kargoapi.StageStatus, error) { @@ -887,6 +928,119 @@ func (r *RegularStageReconciler) syncPromotions( return newStatus, hasNonTerminalPromotions, nil } +// syncPromotionRequests records in the Stage's status which PromotionRequest is +// currently fanning Freight out to the Stage's Targets, and which was the last +// to reach a terminal phase. +// +// Both references are mirrors, kept so that a reader of the Stage can see the +// round of fan-out it is in, and how the previous round ended, without listing +// PromotionRequests. Nothing in the Stage's own progression is decided from +// them: a PromotionRequest effects no promotion itself, and the Promotions it +// creates reach the Stage through syncPromotions like any other. +// +// A Stage can have more than one PromotionRequest in flight, exactly as it can +// have more than one Promotion in flight: auto-promotion creates a request only +// when none exists in any phase, but the promote endpoints create one per call, +// so consecutive promotions queue up. Which one the Stage records as current is +// therefore decided by the same ordering syncPromotions applies to Promotions. +// +// The references mirror the requests that exist, not the Stage's spec: a +// request in flight for a Stage whose selectors currently govern no Targets +// -- or one left behind by a Stage that no longer governs any -- is recorded +// all the same. Only for a Stage with no PromotionRequests at all is this a +// no-op beyond clearing a stale current reference. +func (r *RegularStageReconciler) syncPromotionRequests( + ctx context.Context, + stage *kargoapi.Stage, +) (kargoapi.StageStatus, error) { + newStatus := *stage.Status.DeepCopy() + + promotionRequests := &kargoapi.PromotionRequestList{} + if err := r.client.List( + ctx, + promotionRequests, + client.InNamespace(stage.Namespace), + client.MatchingFieldsSelector{ + Selector: fields.OneTermEqualSelector( + indexer.PromotionRequestsByStageField, + stage.Name, + ), + }, + ); err != nil { + return newStatus, fmt.Errorf( + "failed to list PromotionRequests for Stage %q in namespace %q: %w", + stage.Name, stage.Namespace, err, + ) + } + + // If there are no PromotionRequests, the Stage is fanning nothing out. Clear + // any current reference it was left with. + if len(promotionRequests.Items) == 0 { + newStatus.CurrentPromotionRequest = nil + return newStatus, nil + } + + // Sort the PromotionRequests exactly as syncPromotions sorts a Stage's + // Promotions -- Running first, then non-terminal by ULID ascending, then + // terminal by ULID descending -- so that the request a Stage records as + // current is chosen the same way its current Promotion is. + slices.SortFunc( + promotionRequests.Items, + api.ComparePromotionRequestByPhaseAndCreationTime, + ) + + // The PromotionRequest with the highest priority is the one the Stage is + // promoting through, unless it has finished -- in which case the Stage is + // promoting through none, and a finished request must not be left looking + // like an active one. + newStatus.CurrentPromotionRequest = nil + if highestPrioRequest := &promotionRequests.Items[0]; !highestPrioRequest.Status.Phase.IsTerminal() { + newStatus.CurrentPromotionRequest = newPromotionRequestReference(highestPrioRequest) + } + + // Terminal PromotionRequests sort newest-first, so the first terminal request + // in the sorted list is the newest, and nothing after it can supersede it. + // + // It is recorded only when it is newer than the request already recorded. A + // Stage's account of how its last round of fan-out ended should outlive the + // request that produced it, so garbage collection of the newest request must + // not let an older one take its place. + // + // NB: As in syncPromotions, this makes use of the fact that PromotionRequest + // names are generated with an embedded ULID, so among one Stage's requests + // lex order over names is creation order. + for i := range promotionRequests.Items { + promotionRequest := &promotionRequests.Items[i] + if !promotionRequest.Status.Phase.IsTerminal() { + continue + } + if last := newStatus.LastPromotionRequest; last == nil || + strings.Compare(promotionRequest.Name, last.Name) > 0 { + newStatus.LastPromotionRequest = newPromotionRequestReference(promotionRequest) + } + break + } + + return newStatus, nil +} + +// newPromotionRequestReference builds the reference a Stage records for one of +// its PromotionRequests. The reference names the PromotionRequest's Freight +// rather than describing it; a reader that needs the Freight's contents can +// look them up from the Freight itself. +func newPromotionRequestReference( + promotionRequest *kargoapi.PromotionRequest, +) *kargoapi.PromotionRequestReference { + return &kargoapi.PromotionRequestReference{ + Name: promotionRequest.Name, + Phase: promotionRequest.Status.Phase, + FinishedAt: promotionRequest.Status.FinishedAt, + Freight: &kargoapi.PromotionRequestFreightReference{ + Name: promotionRequest.Spec.Freight, + }, + } +} + // assessHealth assesses the health of a Stage based on the health checks from // the last Promotion. func (r *RegularStageReconciler) assessHealth(ctx context.Context, stage *kargoapi.Stage) kargoapi.StageStatus { @@ -2074,8 +2228,10 @@ func (r *RegularStageReconciler) autoPromoteFreight( // // The guard against duplicate work here is deliberately stricter than the one // autoPromoteFreight applies to Promotions: a PromotionRequest is created only when -// no PromotionRequest for this Stage and Freight exists at all, in any phase. Stage -// status does not yet track PromotionRequests, so there is no equivalent of +// no PromotionRequest for this Stage and Freight exists at all, in any phase. +// Stage status now records the current and last PromotionRequest, but those are +// mirrors of a request's own phase, not of a Stage having absorbed its outcome: +// a PromotionRequest promotes nothing itself, so there is still no equivalent of // "succeeded, but the outcome is not yet recorded in status" to reason about -- // and absent a guard that holds unconditionally, every reconcile would create // another PromotionRequest. diff --git a/pkg/controller/stages/regular_stages_test.go b/pkg/controller/stages/regular_stages_test.go index 8b4292df39..9d3bf9ec15 100644 --- a/pkg/controller/stages/regular_stages_test.go +++ b/pkg/controller/stages/regular_stages_test.go @@ -416,6 +416,11 @@ func TestRegularStageReconciler_Reconcile(t *testing.T) { indexer.PromotionsByStageAndFreightField, indexer.PromotionsByStageAndFreight, ). + WithIndex( + &kargoapi.PromotionRequest{}, + indexer.PromotionRequestsByStageField, + indexer.PromotionRequestsByStage, + ). WithInterceptorFuncs(tt.interceptor). Build() @@ -629,6 +634,11 @@ func TestRegularStagesReconciler_reconcile(t *testing.T) { indexer.PromotionsByStageAndFreightField, indexer.PromotionsByStageAndFreight, ). + WithIndex( + &kargoapi.PromotionRequest{}, + indexer.PromotionRequestsByStageField, + indexer.PromotionRequestsByStage, + ). WithInterceptorFuncs(tt.interceptor). Build() @@ -2278,6 +2288,446 @@ func TestRegularStageReconciler_syncPromotions(t *testing.T) { } } +func TestRegularStageReconciler_syncPromotionRequests(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, kargoapi.AddToScheme(scheme)) + + now := metav1.NewTime(time.Now().Truncate(time.Second)) + // Generated in this order, so the ULID in olderRequest precedes the ULID in + // newerRequest, and lex order over the two names is creation order. + olderRequest := api.GeneratePromotionRequestName("test-stage", "test-freight") + newerRequest := api.GeneratePromotionRequestName("test-stage", "test-freight") + otherStageRequest := api.GeneratePromotionRequestName("other-stage", "test-freight") + + tests := []struct { + name string + stage *kargoapi.Stage + objects []client.Object + interceptor interceptor.Funcs + assertions func(*testing.T, kargoapi.StageStatus, error) + }{ + { + name: "list PromotionRequests error", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + }, + interceptor: interceptor.Funcs{ + List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error { + return fmt.Errorf("list error") + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.ErrorContains(t, err, "failed to list PromotionRequests") + assert.Nil(t, status.CurrentPromotionRequest) + assert.Nil(t, status.LastPromotionRequest) + }, + }, + { + name: "no PromotionRequests clears a stale current reference", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + Status: kargoapi.StageStatus{ + CurrentPromotionRequest: &kargoapi.PromotionRequestReference{Name: olderRequest}, + LastPromotionRequest: &kargoapi.PromotionRequestReference{Name: olderRequest}, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + assert.Nil(t, status.CurrentPromotionRequest) + // The last PromotionRequest outlives the request itself. + require.NotNil(t, status.LastPromotionRequest) + assert.Equal(t, olderRequest, status.LastPromotionRequest.Name) + }, + }, + { + name: "PromotionRequests for other Stages are ignored", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: otherStageRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "other-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseRunning, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + assert.Nil(t, status.CurrentPromotionRequest) + assert.Nil(t, status.LastPromotionRequest) + }, + }, + { + name: "non-terminal PromotionRequest becomes the current one", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseRunning, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + require.NotNil(t, status.CurrentPromotionRequest) + assert.Equal(t, olderRequest, status.CurrentPromotionRequest.Name) + assert.Equal( + t, + kargoapi.PromotionRequestPhaseRunning, + status.CurrentPromotionRequest.Phase, + ) + assert.Nil(t, status.CurrentPromotionRequest.FinishedAt) + // The reference names the Freight; it does not describe it. + assert.Equal( + t, + &kargoapi.PromotionRequestFreightReference{Name: "test-freight"}, + status.CurrentPromotionRequest.Freight, + ) + assert.Nil(t, status.LastPromotionRequest) + }, + }, + { + name: "a Stage whose selectors govern no Targets still records its current request", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + // An empty selector list is target-aware but selects nothing: + // the Stage still governs Targets, it just governs none at the + // moment. The promote endpoints create PromotionRequests for + // such a Stage all the same, and the references mirror the + // requests that exist, not the Stage's spec. + Spec: kargoapi.StageSpec{ + Targets: &kargoapi.StageTargets{Selectors: []metav1.LabelSelector{}}, + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseRunning, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + require.NotNil(t, status.CurrentPromotionRequest) + assert.Equal(t, olderRequest, status.CurrentPromotionRequest.Name) + }, + }, + { + name: "a Stage that no longer governs Targets still mirrors a leftover request", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + // No spec.targets at all: a Stage converted back to classic + // with a request still in flight. The request exists, so it is + // recorded; only its absence clears the reference. + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhasePending, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + require.NotNil(t, status.CurrentPromotionRequest) + assert.Equal(t, olderRequest, status.CurrentPromotionRequest.Name) + }, + }, + { + name: "a Running PromotionRequest outranks a Pending one", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhasePending, + }, + }, + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: newerRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseRunning, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + require.NotNil(t, status.CurrentPromotionRequest) + assert.Equal(t, newerRequest, status.CurrentPromotionRequest.Name) + }, + }, + { + name: "the older of two Pending PromotionRequests becomes the current one", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: newerRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhasePending, + }, + }, + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhasePending, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + require.NotNil(t, status.CurrentPromotionRequest) + assert.Equal(t, olderRequest, status.CurrentPromotionRequest.Name) + }, + }, + { + name: "terminal PromotionRequest becomes the last one", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + Status: kargoapi.StageStatus{ + CurrentPromotionRequest: &kargoapi.PromotionRequestReference{Name: olderRequest}, + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseSucceeded, + FinishedAt: &now, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + assert.Nil(t, status.CurrentPromotionRequest) + require.NotNil(t, status.LastPromotionRequest) + assert.Equal(t, olderRequest, status.LastPromotionRequest.Name) + assert.Equal( + t, + kargoapi.PromotionRequestPhaseSucceeded, + status.LastPromotionRequest.Phase, + ) + assert.Equal(t, &now, status.LastPromotionRequest.FinishedAt) + }, + }, + { + name: "a terminal and a non-terminal PromotionRequest are recorded separately", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseErrored, + FinishedAt: &now, + }, + }, + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: newerRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseRunning, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + require.NotNil(t, status.CurrentPromotionRequest) + assert.Equal(t, newerRequest, status.CurrentPromotionRequest.Name) + require.NotNil(t, status.LastPromotionRequest) + assert.Equal(t, olderRequest, status.LastPromotionRequest.Name) + }, + }, + { + name: "the last PromotionRequest never moves backwards", + stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: "test-stage", + }, + Status: kargoapi.StageStatus{ + // The PromotionRequest this refers to is gone, and the one + // still around is older than it. + LastPromotionRequest: &kargoapi.PromotionRequestReference{ + Name: newerRequest, + Phase: kargoapi.PromotionRequestPhaseSucceeded, + }, + }, + }, + objects: []client.Object{ + &kargoapi.PromotionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "fake-project", + Name: olderRequest, + }, + Spec: kargoapi.PromotionRequestSpec{ + Stage: "test-stage", + Freight: "test-freight", + }, + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseFailed, + FinishedAt: &now, + }, + }, + }, + assertions: func(t *testing.T, status kargoapi.StageStatus, err error) { + require.NoError(t, err) + require.NotNil(t, status.LastPromotionRequest) + assert.Equal(t, newerRequest, status.LastPromotionRequest.Name) + assert.Equal( + t, + kargoapi.PromotionRequestPhaseSucceeded, + status.LastPromotionRequest.Phase, + ) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objects := []client.Object{tt.stage.DeepCopy()} + for _, obj := range tt.objects { + objects = append(objects, obj.DeepCopyObject().(client.Object)) // nolint: forcetypeassert + } + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex( + &kargoapi.PromotionRequest{}, + indexer.PromotionRequestsByStageField, + indexer.PromotionRequestsByStage, + ). + WithStatusSubresource(&kargoapi.Stage{}, &kargoapi.PromotionRequest{}). + WithInterceptorFuncs(tt.interceptor). + Build() + + r := &RegularStageReconciler{ + client: c, + eventSender: k8sevent.NewEventSender(fakeevent.NewEventRecorder(10)), + } + + status, err := r.syncPromotionRequests(t.Context(), tt.stage) + tt.assertions(t, status, err) + }) + } +} + func TestRegularStageReconciler_syncFreight(t *testing.T) { testProject := "fake-project" diff --git a/pkg/indexer/indexer.go b/pkg/indexer/indexer.go index 5cc24ea303..369e9b2d29 100644 --- a/pkg/indexer/indexer.go +++ b/pkg/indexer/indexer.go @@ -34,6 +34,7 @@ const ( PromotionsByTerminalField = "terminal" PromotionRequestsByStageAndFreightField = "stageAndFreight" + PromotionRequestsByStageField = "stage" RunningPromotionsByArgoCDApplicationsField = "applications" RunningPromotionsByArgoCDSelectorsField = "argoCDSelectors" @@ -445,6 +446,16 @@ func PromotionRequestsByStageAndFreight(obj client.Object) []string { } } +// PromotionRequestsByStage is a client.IndexerFunc that indexes +// PromotionRequests by the Stage on whose behalf they promote Freight. +func PromotionRequestsByStage(obj client.Object) []string { + promotionRequest, ok := obj.(*kargoapi.PromotionRequest) + if !ok { + return nil + } + return []string{promotionRequest.Spec.Stage} +} + // StageAndFreightKey returns a key that uniquely identifies a Stage and // Freight. func StageAndFreightKey(stage, freight string) string { diff --git a/pkg/indexer/indexer_test.go b/pkg/indexer/indexer_test.go index e4ade60c62..497f648dc0 100644 --- a/pkg/indexer/indexer_test.go +++ b/pkg/indexer/indexer_test.go @@ -928,6 +928,19 @@ func TestPromotionRequestsByStageAndFreight(t *testing.T) { }) } +func TestPromotionRequestsByStage(t *testing.T) { + t.Run("PromotionRequest", func(t *testing.T) { + promotionRequest := &kargoapi.PromotionRequest{ + Spec: kargoapi.PromotionRequestSpec{Stage: "fake-stage"}, + } + require.Equal(t, []string{"fake-stage"}, PromotionRequestsByStage(promotionRequest)) + }) + + t.Run("not a PromotionRequest", func(t *testing.T) { + require.Nil(t, PromotionRequestsByStage(&kargoapi.Promotion{})) + }) +} + func TestFreightByWarehouse(t *testing.T) { testCases := []struct { name string diff --git a/pkg/kargo/kargo.go b/pkg/kargo/kargo.go index 0ef03b0f76..41c5549298 100644 --- a/pkg/kargo/kargo.go +++ b/pkg/kargo/kargo.go @@ -61,6 +61,67 @@ func (p PromoPhaseChanged[T]) Update(e event.TypedUpdateEvent[T]) bool { return newPromo.Status.Phase != oldPromo.Status.Phase } +// NewPromotionRequestPhaseChangedPredicate returns a predicate that reacts to +// changes in the phase of a PromotionRequest. +func NewPromotionRequestPhaseChangedPredicate( + logger *logging.Logger, +) PromotionRequestPhaseChanged { + return PromotionRequestPhaseChanged{logger: logger} +} + +// PromotionRequestPhaseChanged is a predicate that returns true if the phase of +// a PromotionRequest has changed. It is the PromotionRequest counterpart of +// PromoPhaseChanged, and is used to trigger the reconciliation of the Stage a +// PromotionRequest promotes on behalf of, so that the Stage can update the +// current and last PromotionRequest references in its status. +type PromotionRequestPhaseChanged struct { + predicate.TypedFuncs[*kargoapi.PromotionRequest] + logger *logging.Logger +} + +func (p PromotionRequestPhaseChanged) Create( + event.TypedCreateEvent[*kargoapi.PromotionRequest], +) bool { + // A PromotionRequest is created without a phase, so its creation tells the + // Stage nothing that the first phase change will not. + return false +} + +func (p PromotionRequestPhaseChanged) Delete( + e event.TypedDeleteEvent[*kargoapi.PromotionRequest], +) bool { + // If a PromotionRequest is deleted while it is non-terminal, we want to + // enqueue the associated Stage so that it can reset its + // status.currentPromotionRequest. + return e.Object != nil && !e.Object.Status.Phase.IsTerminal() +} + +func (p PromotionRequestPhaseChanged) Generic( + event.TypedGenericEvent[*kargoapi.PromotionRequest], +) bool { + return false +} + +func (p PromotionRequestPhaseChanged) Update( + e event.TypedUpdateEvent[*kargoapi.PromotionRequest], +) bool { + if e.ObjectOld == nil { + p.logger.Error( + nil, "Update event has no old object for update", + "event", e, + ) + return false + } + if e.ObjectNew == nil { + p.logger.Error( + nil, "Update event has no new object for update", + "event", e, + ) + return false + } + return e.ObjectNew.Status.Phase != e.ObjectOld.Status.Phase +} + // RefreshRequested is a predicate that returns true if the refresh annotation // has been set on a resource, or the value of the annotation has changed // compared to the previous state. diff --git a/pkg/kargo/kargo_test.go b/pkg/kargo/kargo_test.go index 82f93f0f79..77575dc45a 100644 --- a/pkg/kargo/kargo_test.go +++ b/pkg/kargo/kargo_test.go @@ -78,6 +78,128 @@ func TestPromoPhaseChanged_Update(t *testing.T) { } } +func TestPromotionRequestPhaseChanged(t *testing.T) { + p := NewPromotionRequestPhaseChangedPredicate( + logging.NewLoggerOrDie(logging.InfoLevel, logging.DefaultFormat), + ) + + t.Run("create", func(t *testing.T) { + // A PromotionRequest is created without a phase; the Stage learns nothing + // from its creation that the first phase change will not tell it. + require.False(t, p.Create(event.TypedCreateEvent[*kargoapi.PromotionRequest]{ + Object: &kargoapi.PromotionRequest{}, + })) + }) + + t.Run("generic", func(t *testing.T) { + require.False(t, p.Generic(event.TypedGenericEvent[*kargoapi.PromotionRequest]{ + Object: &kargoapi.PromotionRequest{}, + })) + }) + + t.Run("delete", func(t *testing.T) { + deleteTests := []struct { + name string + object *kargoapi.PromotionRequest + want bool + }{ + { + name: "no object", + object: nil, + want: false, + }, + { + name: "non-terminal PromotionRequest deleted", + object: &kargoapi.PromotionRequest{ + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseRunning, + }, + }, + // The Stage must reset its status.currentPromotionRequest. + want: true, + }, + { + name: "terminal PromotionRequest deleted", + object: &kargoapi.PromotionRequest{ + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseSucceeded, + }, + }, + want: false, + }, + } + for _, tt := range deleteTests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, p.Delete( + event.TypedDeleteEvent[*kargoapi.PromotionRequest]{Object: tt.object}, + )) + }) + } + }) + + t.Run("update", func(t *testing.T) { + updateTests := []struct { + name string + oldObject *kargoapi.PromotionRequest + newObject *kargoapi.PromotionRequest + want bool + }{ + { + name: "no old object", + oldObject: nil, + newObject: &kargoapi.PromotionRequest{}, + want: false, + }, + { + name: "no new object", + oldObject: &kargoapi.PromotionRequest{}, + newObject: nil, + want: false, + }, + { + name: "phase unchanged", + oldObject: &kargoapi.PromotionRequest{}, + newObject: &kargoapi.PromotionRequest{}, + want: false, + }, + { + name: "phase set", + oldObject: &kargoapi.PromotionRequest{}, + newObject: &kargoapi.PromotionRequest{ + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhasePending, + }, + }, + want: true, + }, + { + name: "phase changed", + oldObject: &kargoapi.PromotionRequest{ + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseRunning, + }, + }, + newObject: &kargoapi.PromotionRequest{ + Status: kargoapi.PromotionRequestStatus{ + Phase: kargoapi.PromotionRequestPhaseSucceeded, + }, + }, + want: true, + }, + } + for _, tt := range updateTests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, p.Update( + event.TypedUpdateEvent[*kargoapi.PromotionRequest]{ + ObjectOld: tt.oldObject, + ObjectNew: tt.newObject, + }, + )) + }) + } + }) +} + func TestRefreshRequested_Update(t *testing.T) { tests := []struct { name string diff --git a/pkg/x/client/generated/api/openapi.yaml b/pkg/x/client/generated/api/openapi.yaml index 689b82febf..df37647034 100644 --- a/pkg/x/client/generated/api/openapi.yaml +++ b/pkg/x/client/generated/api/openapi.yaml @@ -14252,7 +14252,9 @@ components: fanning Freight out to this Stage's Targets. It is absent for a Stage that governs no Targets. - Kargo Enterprise only: This field is ignored in Kargo OSS. + Fanning Freight out to Targets is a Kargo Enterprise-only feature. Kargo + OSS maintains this field all the same, but the PromotionRequest it refers + to never gets further than being marked Errored for that reason. +optional type: object @@ -14309,9 +14311,8 @@ components: - $ref: "#/components/schemas/PromotionRequestReference" description: |- LastPromotionRequest is a reference to the last PromotionRequest to reach a - terminal phase. It is absent for a Stage that governs no Targets. - - Kargo Enterprise only: This field is ignored in Kargo OSS. + terminal phase. It is absent for a Stage that governs no Targets, and only + ever moves forward, so it outlives the PromotionRequest it refers to. +optional type: object diff --git a/pkg/x/client/generated/model_stage_status.go b/pkg/x/client/generated/model_stage_status.go index 6058e07286..a998a34ea2 100644 --- a/pkg/x/client/generated/model_stage_status.go +++ b/pkg/x/client/generated/model_stage_status.go @@ -27,7 +27,7 @@ type StageStatus struct { Conditions []V1Condition `json:"conditions,omitempty"` // CurrentPromotion is a reference to the currently Running promotion. CurrentPromotion *PromotionReference `json:"currentPromotion,omitempty"` - // CurrentPromotionRequest is a reference to the PromotionRequest currently fanning Freight out to this Stage's Targets. It is absent for a Stage that governs no Targets. Kargo Enterprise only: This field is ignored in Kargo OSS. +optional + // CurrentPromotionRequest is a reference to the PromotionRequest currently fanning Freight out to this Stage's Targets. It is absent for a Stage that governs no Targets. Fanning Freight out to Targets is a Kargo Enterprise-only feature. Kargo OSS maintains this field all the same, but the PromotionRequest it refers to never gets further than being marked Errored for that reason. +optional CurrentPromotionRequest *PromotionRequestReference `json:"currentPromotionRequest,omitempty"` // EffectiveAutoPromotionHolds is the set of auto-promotion holds in effect right now. It is recomputed every reconciliation from AutoPromotionHolds plus the newest in-flight Promotions, and unlike AutoPromotionHolds is not durable. Clients should read this map to reflect current hold state. EffectiveAutoPromotionHolds *map[string]AutoPromotionHold `json:"effectiveAutoPromotionHolds,omitempty"` @@ -41,7 +41,7 @@ type StageStatus struct { LastHandledRefresh *string `json:"lastHandledRefresh,omitempty"` // LastPromotion is a reference to the last completed promotion. LastPromotion *PromotionReference `json:"lastPromotion,omitempty"` - // LastPromotionRequest is a reference to the last PromotionRequest to reach a terminal phase. It is absent for a Stage that governs no Targets. Kargo Enterprise only: This field is ignored in Kargo OSS. +optional + // LastPromotionRequest is a reference to the last PromotionRequest to reach a terminal phase. It is absent for a Stage that governs no Targets, and only ever moves forward, so it outlives the PromotionRequest it refers to. +optional LastPromotionRequest *PromotionRequestReference `json:"lastPromotionRequest,omitempty"` // Metadata is a map of arbitrary metadata associated with the Stage. This is useful for storing additional information about the Stage that can be shared across promotions, verifications, or other processes. Metadata map[string]any `json:"metadata,omitempty"` diff --git a/swagger.json b/swagger.json index ff16c2b7c1..c75634b197 100644 --- a/swagger.json +++ b/swagger.json @@ -8376,7 +8376,7 @@ ] }, "currentPromotionRequest": { - "description": "CurrentPromotionRequest is a reference to the PromotionRequest currently\nfanning Freight out to this Stage's Targets. It is absent for a Stage that\ngoverns no Targets.\n\nKargo Enterprise only: This field is ignored in Kargo OSS.\n\n+optional", + "description": "CurrentPromotionRequest is a reference to the PromotionRequest currently\nfanning Freight out to this Stage's Targets. It is absent for a Stage that\ngoverns no Targets.\n\nFanning Freight out to Targets is a Kargo Enterprise-only feature. Kargo\nOSS maintains this field all the same, but the PromotionRequest it refers\nto never gets further than being marked Errored for that reason.\n\n+optional", "allOf": [ { "$ref": "#/definitions/PromotionRequestReference" @@ -8422,7 +8422,7 @@ ] }, "lastPromotionRequest": { - "description": "LastPromotionRequest is a reference to the last PromotionRequest to reach a\nterminal phase. It is absent for a Stage that governs no Targets.\n\nKargo Enterprise only: This field is ignored in Kargo OSS.\n\n+optional", + "description": "LastPromotionRequest is a reference to the last PromotionRequest to reach a\nterminal phase. It is absent for a Stage that governs no Targets, and only\never moves forward, so it outlives the PromotionRequest it refers to.\n\n+optional", "allOf": [ { "$ref": "#/definitions/PromotionRequestReference" diff --git a/ui/src/gen/api/v2/models/stageStatus.ts b/ui/src/gen/api/v2/models/stageStatus.ts index ee33e7f7df..3463f7ebe2 100644 --- a/ui/src/gen/api/v2/models/stageStatus.ts +++ b/ui/src/gen/api/v2/models/stageStatus.ts @@ -40,7 +40,9 @@ state. fanning Freight out to this Stage's Targets. It is absent for a Stage that governs no Targets. -Kargo Enterprise only: This field is ignored in Kargo OSS. +Fanning Freight out to Targets is a Kargo Enterprise-only feature. Kargo +OSS maintains this field all the same, but the PromotionRequest it refers +to never gets further than being marked Errored for that reason. +optional */ currentPromotionRequest?: PromotionRequestReference; @@ -75,9 +77,8 @@ determine whether the request to refresh the resource has been handled. /** LastPromotion is a reference to the last completed promotion. */ lastPromotion?: PromotionReference; /** LastPromotionRequest is a reference to the last PromotionRequest to reach a -terminal phase. It is absent for a Stage that governs no Targets. - -Kargo Enterprise only: This field is ignored in Kargo OSS. +terminal phase. It is absent for a Stage that governs no Targets, and only +ever moves forward, so it outlives the PromotionRequest it refers to. +optional */ lastPromotionRequest?: PromotionRequestReference; diff --git a/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json index 8066279679..e3d3672586 100644 --- a/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json +++ b/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json @@ -1223,7 +1223,7 @@ "type": "object" }, "currentPromotionRequest": { - "description": "CurrentPromotionRequest is a reference to the PromotionRequest currently\nfanning Freight out to this Stage's Targets. It is absent for a Stage that\ngoverns no Targets.\n\nKargo Enterprise only: This field is ignored in Kargo OSS.", + "description": "CurrentPromotionRequest is a reference to the PromotionRequest currently\nfanning Freight out to this Stage's Targets. It is absent for a Stage that\ngoverns no Targets.\n\nFanning Freight out to Targets is a Kargo Enterprise-only feature. Kargo\nOSS maintains this field all the same, but the PromotionRequest it refers\nto never gets further than being marked Errored for that reason.", "properties": { "finishedAt": { "description": "FinishedAt is the time at which the PromotionRequest completed.", @@ -2281,7 +2281,7 @@ "type": "object" }, "lastPromotionRequest": { - "description": "LastPromotionRequest is a reference to the last PromotionRequest to reach a\nterminal phase. It is absent for a Stage that governs no Targets.\n\nKargo Enterprise only: This field is ignored in Kargo OSS.", + "description": "LastPromotionRequest is a reference to the last PromotionRequest to reach a\nterminal phase. It is absent for a Stage that governs no Targets, and only\never moves forward, so it outlives the PromotionRequest it refers to.", "properties": { "finishedAt": { "description": "FinishedAt is the time at which the PromotionRequest completed.",