Skip to content

Commit

Permalink
feat: allow terminating Promotions (#2749)
Browse files Browse the repository at this point in the history
Signed-off-by: Hidde Beydals <[email protected]>
  • Loading branch information
hiddeco authored Oct 15, 2024
1 parent 5341deb commit 97ac7d7
Show file tree
Hide file tree
Showing 25 changed files with 2,996 additions and 1,580 deletions.
10 changes: 10 additions & 0 deletions api/service/v1alpha1/service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ service KargoService {
rpc WatchPromotions(WatchPromotionsRequest) returns (stream WatchPromotionsResponse);
rpc GetPromotion(GetPromotionRequest) returns (GetPromotionResponse);
rpc WatchPromotion(WatchPromotionRequest) returns (stream WatchPromotionResponse);
rpc AbortPromotion(AbortPromotionRequest) returns (AbortPromotionResponse);

/* Project APIs */

Expand Down Expand Up @@ -338,6 +339,15 @@ message WatchPromotionResponse {
string type = 2;
}

message AbortPromotionRequest {
string project = 1;
string name = 2;
}

message AbortPromotionResponse {
/* explicitly empty */
}

message DeleteProjectRequest {
string name = 1;
}
Expand Down
31 changes: 29 additions & 2 deletions api/v1alpha1/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ func ReverifyAnnotationValue(annotations map[string]string) (*VerificationReques
return &vr, ok
}

// AbortAnnotationValue returns the value of the AnnotationKeyAbort annotation
// AbortVerificationAnnotationValue returns the value of the AnnotationKeyAbort annotation
// which can be used to abort the verification of a Freight, and a boolean
// indicating whether the annotation was present.
//
// If the value of the annotation is a valid JSON object, it is unmarshalled
// into a VerificationRequest struct. Otherwise, the value is treated as the ID
// of the verification to be aborted and set as the ID field of the returned
// VerificationRequest.
func AbortAnnotationValue(annotations map[string]string) (*VerificationRequest, bool) {
func AbortVerificationAnnotationValue(annotations map[string]string) (*VerificationRequest, bool) {
requested, ok := annotations[AnnotationKeyAbort]
if !ok {
return nil, ok
Expand All @@ -104,3 +104,30 @@ func AbortAnnotationValue(annotations map[string]string) (*VerificationRequest,
}
return &vr, ok
}

// AbortPromotionAnnotationValue returns the value of the AnnotationKeyAbort
// annotation which can be used to abort the promotion of a Freight, and a
// boolean indicating whether the annotation was present.
//
// If the value of the annotation is a valid JSON object, it is unmarshalled
// into an AbortPromotionRequest struct. Otherwise, the value is treated as the
// action to be taken on the Promotion and set as the Action field of the
// returned AbortPromotionRequest.
func AbortPromotionAnnotationValue(annotations map[string]string) (*AbortPromotionRequest, bool) {
requested, ok := annotations[AnnotationKeyAbort]
if !ok {
return nil, ok
}
var apr AbortPromotionRequest
if b := []byte(requested); json.Valid(b) {
if err := json.Unmarshal(b, &apr); err != nil {
return nil, false
}
} else {
apr.Action = AbortAction(requested)
}
if apr.Action == "" {
return nil, false
}
return &apr, ok
}
43 changes: 38 additions & 5 deletions api/v1alpha1/annotations_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package v1alpha1

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -62,31 +63,63 @@ func TestReverifyAnnotationValue(t *testing.T) {
})
}

func TestAbortAnnotationValue(t *testing.T) {
func TestAbortVerificationAnnotationValue(t *testing.T) {
t.Run("has abort annotation with valid JSON", func(t *testing.T) {
result, ok := AbortAnnotationValue(map[string]string{
result, ok := AbortVerificationAnnotationValue(map[string]string{
AnnotationKeyAbort: `{"id":"foo"}`,
})
require.True(t, ok)
require.Equal(t, "foo", result.ID)
})

t.Run("has abort annotation with ID string", func(t *testing.T) {
result, ok := AbortAnnotationValue(map[string]string{
result, ok := AbortVerificationAnnotationValue(map[string]string{
AnnotationKeyAbort: "foo",
})
require.True(t, ok)
require.Equal(t, "foo", result.ID)
})

t.Run("does not have abort annotation", func(t *testing.T) {
result, ok := AbortAnnotationValue(nil)
result, ok := AbortVerificationAnnotationValue(nil)
require.False(t, ok)
require.Nil(t, result)
})

t.Run("has abort annotation with empty ID", func(t *testing.T) {
result, ok := AbortAnnotationValue(map[string]string{
result, ok := AbortVerificationAnnotationValue(map[string]string{
AnnotationKeyAbort: "",
})
require.False(t, ok)
require.Nil(t, result)
})
}

func TestAbortPromotionAnnotationValue(t *testing.T) {
t.Run("has abort annotation with valid JSON", func(t *testing.T) {
result, ok := AbortPromotionAnnotationValue(map[string]string{
AnnotationKeyAbort: fmt.Sprintf(`{"action":"%s"}`, AbortActionTerminate),
})
require.True(t, ok)
require.Equal(t, AbortActionTerminate, result.Action)
})

t.Run("has abort annotation with action string", func(t *testing.T) {
result, ok := AbortPromotionAnnotationValue(map[string]string{
AnnotationKeyAbort: string(AbortActionTerminate),
})
require.True(t, ok)
require.Equal(t, AbortActionTerminate, result.Action)
})

t.Run("does not have abort annotation", func(t *testing.T) {
result, ok := AbortPromotionAnnotationValue(nil)
require.False(t, ok)
require.Nil(t, result)
})

t.Run("has abort annotation with empty action", func(t *testing.T) {
result, ok := AbortPromotionAnnotationValue(map[string]string{
AnnotationKeyAbort: "",
})
require.False(t, ok)
Expand Down
1 change: 1 addition & 0 deletions api/v1alpha1/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
EventReasonPromotionSucceeded = "PromotionSucceeded"
EventReasonPromotionFailed = "PromotionFailed"
EventReasonPromotionErrored = "PromotionErrored"
EventReasonPromotionAborted = "PromotionAborted"
EventReasonFreightApproved = "FreightApproved"
EventReasonFreightVerificationSucceeded = "FreightVerificationSucceeded"
EventReasonFreightVerificationFailed = "FreightVerificationFailed"
Expand Down
92 changes: 92 additions & 0 deletions api/v1alpha1/promotion_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,70 @@ package v1alpha1

import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/akuity/kargo/internal/api/user"
)

// AbortAction is an action to take on a Promotion to abort it.
type AbortAction string

const (
// AbortActionTerminate is an action to terminate the Promotion.
// I.e. the Promotion will be marked as failed and the controller
// will stop processing it.
AbortActionTerminate AbortAction = "terminate"
)

// AbortPromotionRequest is a request payload with an optional actor field which
// can be used to annotate a Promotion using the AnnotationKeyAbort annotation.
//
// +protobuf=false
// +k8s:deepcopy-gen=false
// +k8s:openapi-gen=false
type AbortPromotionRequest struct {
// Action is the action to take on the Promotion to abort it.
Action AbortAction `json:"action,omitempty" protobuf:"bytes,1,opt,name=action"`
// Actor is the user who initiated the request.
Actor string `json:"actor,omitempty" protobuf:"bytes,2,opt,name=actor"`
// ControlPlane is a flag to indicate if the request has been initiated by
// a control plane.
ControlPlane bool `json:"controlPlane,omitempty" protobuf:"varint,3,opt,name=controlPlane"`
}

// Equals returns true if the AbortPromotionRequest is equal to the other
// AbortPromotionRequest, false otherwise. Two VerificationRequests are equal
// if their Action, Actor, and ControlPlane fields are equal.
func (r *AbortPromotionRequest) Equals(other *AbortPromotionRequest) bool {
if r == nil && other == nil {
return true
}
if r == nil || other == nil {
return false
}
return r.Action == other.Action && r.Actor == other.Actor && r.ControlPlane == other.ControlPlane
}

// String returns the JSON string representation of the AbortPromotionRequest,
// or an empty string if the AbortPromotionRequest is nil or has an empty Action.
func (r *AbortPromotionRequest) String() string {
if r == nil || r.Action == "" {
return ""
}
b, _ := json.Marshal(r)
if b == nil {
return ""
}
return string(b)
}

// GetPromotion returns a pointer to the Promotion resource specified by the
// namespacedName argument. If no such resource is found, nil is returned
// instead.
Expand Down Expand Up @@ -55,6 +110,43 @@ func RefreshPromotion(
return promo, nil
}

// AbortPromotion forces aborting the Promotion by setting an annotation on the
// object, causing the controller to abort the Promotion. The annotation value
// is the action to take on the Promotion to abort it.
func AbortPromotion(
ctx context.Context,
c client.Client,
namespacedName types.NamespacedName,
action AbortAction,
) error {
promotion, err := GetPromotion(ctx, c, namespacedName)
if err != nil || promotion == nil {
if promotion == nil {
err = fmt.Errorf(
"Promotion %q in namespace %q not found",
namespacedName.Name,
namespacedName.Namespace,
)
}
return err
}

if promotion.Status.Phase.IsTerminal() {
// The Promotion is already in a terminal phase, so we can skip the
// abort request.
return nil
}

ar := AbortPromotionRequest{
Action: action,
}
// Put actor information to track on the controller side
if u, ok := user.InfoFromContext(ctx); ok {
ar.Actor = FormatEventUserActor(u)
}
return patchAnnotation(ctx, c, promotion, AnnotationKeyAbort, ar.String())
}

// ComparePromotionByPhaseAndCreationTime compares two Promotions by their
// phase and creation timestamp. It returns a negative value if Promotion `a`
// should come before Promotion `b`, a positive value if Promotion `a` should
Expand Down
Loading

0 comments on commit 97ac7d7

Please sign in to comment.