From 454160c14c86eb3ed6c718118055bfd46cf95477 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Wed, 19 Aug 2026 15:14:40 -0400 Subject: [PATCH] feat(controller): record a Stage's current and last PromotionRequest The Stage reconciler gains a syncPromotionRequests step that lists the PromotionRequests promoting on the Stage's behalf and records two of them in status: the one the Stage is promoting through, and the newest to have reached a terminal phase. Selection matches syncPromotions exactly, because the situation is the same one. A Stage can have several PromotionRequests in flight -- auto-promotion creates one only when none exists in any phase, but the promote endpoints create one per call, so consecutive promotions queue up. So the requests are sorted with ComparePromotionRequestByPhaseAndCreationTime, the PromotionRequest counterpart of ComparePromotionByPhaseAndCreationTime -- Running first, then non-terminal by ULID ascending, then terminal by ULID descending -- and the Stage records the first as current, exactly as it takes the first sorted Promotion as its current Promotion. 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. (Child Promotion names carry a Target segment before the ULID; PromotionRequest names carry no such segment, so the divergence possible there cannot arise here.) The last PromotionRequest only ever moves forward. Terminal requests sort newest-first, so the first terminal request in the sorted list is the newest, and it is recorded only when it is newer than the one 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. Supporting pieces: a PromotionRequestsByStage index to list a Stage's requests, and a PromotionRequestPhaseChanged predicate so a phase change -- or the deletion of a non-terminal request, which must clear the current reference -- enqueues the Stage. Both mirror what already exists for Promotions. The two StageStatus fields are no longer documented as ignored in OSS, since this writer is in OSS: OSS maintains them, and the request they refer to simply never gets further than being marked Errored because fanning Freight out to Targets is an Enterprise-only feature. The stale reasoning in createAutoPromotionRequest about status not tracking PromotionRequests is updated too; its stricter guard is unchanged, and still justified. Signed-off-by: Justin Brooks Co-Authored-By: Claude Fable 5 --- api/v1alpha1/stage_types.go | 9 +- .../crds/kargo.akuity.io_stages.yaml | 9 +- pkg/api/promotion_request.go | 75 +++ pkg/api/promotion_request_test.go | 129 +++++ pkg/controller/stages/regular_stages.go | 160 ++++++- pkg/controller/stages/regular_stages_test.go | 450 ++++++++++++++++++ pkg/indexer/indexer.go | 11 + pkg/indexer/indexer_test.go | 13 + pkg/kargo/kargo.go | 61 +++ pkg/kargo/kargo_test.go | 122 +++++ pkg/x/client/generated/api/openapi.yaml | 9 +- pkg/x/client/generated/model_stage_status.go | 4 +- swagger.json | 4 +- ui/src/gen/api/v2/models/stageStatus.ts | 9 +- .../stages.kargo.akuity.io_v1alpha1.json | 4 +- 15 files changed, 1045 insertions(+), 24 deletions(-) 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.",