Skip to content

Commit 18a0ed0

Browse files
committed
feat: pause auto-promotion after rollback
Signed-off-by: Jacob Boykin <jacob.boykin@akuity.io>
1 parent 9de13ee commit 18a0ed0

77 files changed

Lines changed: 12052 additions & 1134 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/v1alpha1/annotations.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ const (
66
// the resource.
77
AnnotationKeyCreateActor = "kargo.akuity.io/create-actor"
88

9+
// AnnotationKeyClearAutoPromotionHold is an annotation key set on a
10+
// user-directed Promotion when it should clear an existing auto-promotion
11+
// hold after it succeeds. The value identifies the FreightOrigin the hold
12+
// applies to.
13+
AnnotationKeyClearAutoPromotionHold = "kargo.akuity.io/clear-auto-promotion-hold"
14+
15+
// AnnotationKeyClearAutoPromotionHoldPromotion is an annotation key set
16+
// alongside AnnotationKeyClearAutoPromotionHold. The value identifies the
17+
// rollback Promotion recorded on the hold that should be cleared.
18+
AnnotationKeyClearAutoPromotionHoldPromotion = "kargo.akuity.io/clear-auto-promotion-hold-promotion"
19+
20+
// AnnotationKeyClearAutoPromotionHoldPromotionUID is an annotation key set
21+
// alongside AnnotationKeyClearAutoPromotionHold. The value identifies the
22+
// rollback Promotion UID recorded on the hold that should be cleared.
23+
AnnotationKeyClearAutoPromotionHoldPromotionUID = "kargo.akuity.io/clear-auto-promotion-hold-promotion-uid"
24+
25+
// AnnotationKeyClearAutoPromotionHoldCreatedAt is an annotation key set
26+
// alongside AnnotationKeyClearAutoPromotionHold. The value identifies the
27+
// hold creation time that should be cleared.
28+
AnnotationKeyClearAutoPromotionHoldCreatedAt = "kargo.akuity.io/clear-auto-promotion-hold-created-at"
29+
930
// AnnotationKeyRefresh is an annotation key that can be set on a resource
1031
// to trigger a refresh of the resource by the controller. The value of the
1132
// annotation is interpreted as a token, and any change to the value of the

api/v1alpha1/generated.pb.go

Lines changed: 1195 additions & 544 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1alpha1/generated.proto

Lines changed: 44 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1alpha1/promotion_types.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import (
99
"sigs.k8s.io/yaml"
1010
)
1111

12+
const (
13+
// PromotionSourceNonAuto denotes a Promotion created outside the Stage
14+
// controller's normal auto-promotion loop. It does not necessarily mean a
15+
// human created the Promotion.
16+
PromotionSourceNonAuto PromotionSource = "nonAuto"
17+
// PromotionSourceAuto denotes a Promotion created by Kargo's Stage
18+
// controller while processing normal auto-promotion.
19+
PromotionSourceAuto PromotionSource = "auto"
20+
)
21+
22+
// PromotionSource identifies the actor path that created a Promotion.
23+
// +kubebuilder:validation:Enum=nonAuto;auto
24+
type PromotionSource string
25+
1226
const (
1327
// PromotionPhasePending denotes a Promotion that has not been executed yet.
1428
// i.e. It is currently waiting in a queue. Queues are stage-specific and
@@ -178,6 +192,10 @@ type PromotionSpec struct {
178192
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
179193
// +akuity:test-kubebuilder-pattern=KubernetesName
180194
Freight string `json:"freight" protobuf:"bytes,2,opt,name=freight"`
195+
// Source describes the system path that created this Promotion. The value is
196+
// immutable and is used by controllers to distinguish normal auto-promotion
197+
// from user-directed promotion requests.
198+
Source PromotionSource `json:"source,omitempty" protobuf:"bytes,5,opt,name=source" swaggertype:"string"`
181199
// Vars is a list of variables that can be referenced by expressions in
182200
// promotion steps.
183201
Vars []ExpressionVariable `json:"vars,omitempty" protobuf:"bytes,4,rep,name=vars"`

api/v1alpha1/stage_types.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,27 @@ func (f *FreightOrigin) String() string {
262262
if f == nil {
263263
return ""
264264
}
265+
// This format is persisted in annotations and status keys. Do not change it
266+
// without a migration for existing resources.
265267
return fmt.Sprintf("%s/%s", f.Kind, f.Name)
266268
}
267269

270+
// ParseFreightOriginKey parses a canonical Freight origin key in "Kind/name"
271+
// form and rejects empty parts or unsupported origin kinds.
272+
func ParseFreightOriginKey(key string) (FreightOrigin, error) {
273+
kind, name, ok := strings.Cut(key, "/")
274+
if !ok || kind == "" || name == "" {
275+
return FreightOrigin{}, fmt.Errorf("invalid Freight origin key %q", key)
276+
}
277+
origin := FreightOrigin{Kind: FreightOriginKind(kind), Name: name}
278+
switch origin.Kind {
279+
case FreightOriginKindWarehouse:
280+
return origin, nil
281+
default:
282+
return FreightOrigin{}, fmt.Errorf("invalid Freight origin kind %q", kind)
283+
}
284+
}
285+
268286
func (f *FreightOrigin) Equals(other *FreightOrigin) bool {
269287
if f == nil && other == nil {
270288
return true
@@ -426,6 +444,65 @@ type StageStatus struct {
426444
// This is useful for storing additional information about the Stage
427445
// that can be shared across promotions, verifications, or other processes.
428446
Metadata map[string]apiextensionsv1.JSON `json:"metadata,omitempty" protobuf:"bytes,15,rep,name=metadata" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
447+
// AutoPromotionHolds pause auto-promotion for specific FreightOrigins on
448+
// this Stage after a user-directed promotion intentionally selects an older
449+
// piece of Freight. Each map entry pins a single origin keyed by the
450+
// canonical string representation of the FreightOrigin.
451+
AutoPromotionHolds map[string]AutoPromotionHold `json:"autoPromotionHolds,omitempty" protobuf:"bytes,16,rep,name=autoPromotionHolds" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
452+
}
453+
454+
// AutoPromotionHoldState represents the lifecycle state of an
455+
// AutoPromotionHold.
456+
// +kubebuilder:validation:Enum=Pending;Active
457+
type AutoPromotionHoldState string
458+
459+
const (
460+
// AutoPromotionHoldStatePending indicates that the hold is waiting for
461+
// its linked rollback Promotion to succeed.
462+
AutoPromotionHoldStatePending AutoPromotionHoldState = "Pending"
463+
// AutoPromotionHoldStateActive indicates that the hold is preserving a
464+
// successful rollback.
465+
AutoPromotionHoldStateActive AutoPromotionHoldState = "Active"
466+
)
467+
468+
// AutoPromotionHold pins a single FreightOrigin on a Stage, pausing
469+
// auto-promotion for that origin after a user-directed promotion intentionally
470+
// selects an older piece of Freight. Other origins continue to auto-promote
471+
// normally. The origin is identified by the enclosing map key.
472+
type AutoPromotionHold struct {
473+
// Freight is a reference to the Freight that was selected by the operator
474+
// when the hold was created.
475+
// +kubebuilder:validation:Required
476+
Freight FreightReference `json:"freight" protobuf:"bytes,1,opt,name=freight"`
477+
// State is the current lifecycle state of the hold.
478+
// +kubebuilder:validation:Required
479+
State AutoPromotionHoldState `json:"state" protobuf:"bytes,2,opt,name=state" swaggertype:"string"`
480+
// PromotionName is the name of the rollback Promotion associated
481+
// with this hold, when applicable.
482+
PromotionName string `json:"promotionName,omitempty" protobuf:"bytes,3,opt,name=promotionName"`
483+
// PromotionUID is the UID of the rollback Promotion. Used to
484+
// prevent an older failed rollback from clearing a newer hold.
485+
PromotionUID string `json:"promotionUID,omitempty" protobuf:"bytes,4,opt,name=promotionUID"`
486+
// Actor is an identifier for the user who caused the hold to be created.
487+
Actor string `json:"actor,omitempty" protobuf:"bytes,5,opt,name=actor"`
488+
// Reason is a free-form human-readable explanation of why the hold was
489+
// created.
490+
Reason string `json:"reason,omitempty" protobuf:"bytes,6,opt,name=reason"`
491+
// CreatedAt is the time at which the hold was created.
492+
CreatedAt *metav1.Time `json:"createdAt,omitempty" protobuf:"bytes,7,opt,name=createdAt"`
493+
}
494+
495+
// GetAutoPromotionHold returns the AutoPromotionHold for the given origin, if
496+
// one exists. The second return value indicates whether a hold was found.
497+
func (s *StageStatus) GetAutoPromotionHold(origin FreightOrigin) (AutoPromotionHold, bool) {
498+
if s == nil {
499+
return AutoPromotionHold{}, false
500+
}
501+
hold, ok := s.AutoPromotionHolds[origin.String()]
502+
if !ok {
503+
return AutoPromotionHold{}, false
504+
}
505+
return hold, true
429506
}
430507

431508
// GetConditions implements the conditions.Getter interface.

api/v1alpha1/stage_types_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,119 @@ func TestChartDeepEquals(t *testing.T) {
974974
}
975975
}
976976

977+
func TestFreightOrigin_String(t *testing.T) {
978+
origin := &FreightOrigin{
979+
Kind: FreightOriginKindWarehouse,
980+
Name: "fake-warehouse",
981+
}
982+
983+
require.Equal(t, "Warehouse/fake-warehouse", origin.String())
984+
require.Empty(t, (*FreightOrigin)(nil).String())
985+
}
986+
987+
func TestParseFreightOriginKey(t *testing.T) {
988+
testCases := []struct {
989+
name string
990+
key string
991+
expectedOrigin FreightOrigin
992+
expectErr bool
993+
}{
994+
{
995+
name: "valid warehouse origin",
996+
key: "Warehouse/fake-warehouse",
997+
expectedOrigin: FreightOrigin{
998+
Kind: FreightOriginKindWarehouse,
999+
Name: "fake-warehouse",
1000+
},
1001+
},
1002+
{
1003+
name: "missing slash",
1004+
key: "Warehouse",
1005+
expectErr: true,
1006+
},
1007+
{
1008+
name: "missing kind",
1009+
key: "/fake-warehouse",
1010+
expectErr: true,
1011+
},
1012+
{
1013+
name: "missing name",
1014+
key: "Warehouse/",
1015+
expectErr: true,
1016+
},
1017+
{
1018+
name: "unknown kind",
1019+
key: "Stage/fake-stage",
1020+
expectErr: true,
1021+
},
1022+
}
1023+
1024+
for _, testCase := range testCases {
1025+
t.Run(testCase.name, func(t *testing.T) {
1026+
origin, err := ParseFreightOriginKey(testCase.key)
1027+
if testCase.expectErr {
1028+
require.Error(t, err)
1029+
return
1030+
}
1031+
require.NoError(t, err)
1032+
require.Equal(t, testCase.expectedOrigin, origin)
1033+
})
1034+
}
1035+
}
1036+
1037+
func TestStageStatus_GetAutoPromotionHold(t *testing.T) {
1038+
origin := FreightOrigin{
1039+
Kind: FreightOriginKindWarehouse,
1040+
Name: "fake-warehouse",
1041+
}
1042+
hold := AutoPromotionHold{
1043+
Freight: FreightReference{Name: "fake-freight", Origin: origin},
1044+
State: AutoPromotionHoldStateActive,
1045+
}
1046+
1047+
testCases := []struct {
1048+
name string
1049+
status *StageStatus
1050+
assertions func(*testing.T, AutoPromotionHold, bool)
1051+
}{
1052+
{
1053+
name: "nil status",
1054+
status: nil,
1055+
assertions: func(t *testing.T, hold AutoPromotionHold, ok bool) {
1056+
require.False(t, ok)
1057+
require.Empty(t, hold)
1058+
},
1059+
},
1060+
{
1061+
name: "hold exists",
1062+
status: &StageStatus{
1063+
AutoPromotionHolds: map[string]AutoPromotionHold{
1064+
origin.String(): hold,
1065+
},
1066+
},
1067+
assertions: func(t *testing.T, actual AutoPromotionHold, ok bool) {
1068+
require.True(t, ok)
1069+
require.Equal(t, hold, actual)
1070+
},
1071+
},
1072+
{
1073+
name: "hold does not exist",
1074+
status: &StageStatus{},
1075+
assertions: func(t *testing.T, hold AutoPromotionHold, ok bool) {
1076+
require.False(t, ok)
1077+
require.Empty(t, hold)
1078+
},
1079+
},
1080+
}
1081+
1082+
for _, testCase := range testCases {
1083+
t.Run(testCase.name, func(t *testing.T) {
1084+
hold, ok := testCase.status.GetAutoPromotionHold(origin)
1085+
testCase.assertions(t, hold, ok)
1086+
})
1087+
}
1088+
}
1089+
9771090
func TestStageStatus_UpsertMetadata(t *testing.T) {
9781091
testCases := []struct {
9791092
name string

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)