diff --git a/pkg/promotion/gate/builtin/availability.go b/pkg/promotion/gate/builtin/availability.go new file mode 100644 index 0000000000..758a45e332 --- /dev/null +++ b/pkg/promotion/gate/builtin/availability.go @@ -0,0 +1,113 @@ +package builtin + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +// AvailabilityGateName is the name of the Promotion creation gate that enforces +// that Freight is available to the target Stage via approval, a direct source, +// or verification in the required upstream Stage(s). +const AvailabilityGateName = "availability" + +type availabilityGate struct{} + +// NewAvailabilityGate returns a PromotionGate that allows Freight that is +// approved for the target Stage, sourced directly, or verified in the upstream +// Stage(s) required by the applicable FreightRequest. It does not evaluate +// dynamic policies such as soak time. +// +// It expects the Freight's origin to be requested by the Stage; compose it +// after NewRequestedOriginGate, which reports an unrequested origin with a more +// specific message. +func NewAvailabilityGate() types.PromotionGate { + return &availabilityGate{} +} + +func (g *availabilityGate) Name() string { + return AvailabilityGateName +} + +func (g *availabilityGate) Evaluate( + _ context.Context, + input types.PromotionInput, +) (*types.Decision, error) { + if input.Stage == nil { + return nil, errors.New("stage is nil") + } + if input.Freight == nil { + return nil, errors.New("freight is nil") + } + stage, freight := input.Stage, input.Freight + request := input.FreightRequest() + if request == nil { + // The Stage does not request Freight from this origin, so the Freight + // cannot be available regardless of approval. The requested-origin gate + // reports this case with a more specific message. + return types.NewDenyDecision().WithMessage(fmt.Sprintf( + "Freight %q is not available to Stage %q", + freight.Name, + stage.Name, + )), nil + } + if freight.IsApprovedFor(stage.Name) { + return types.NewAllowDecision(), nil + } + if request.Sources.Direct { + return types.NewAllowDecision(), nil + } + if isVerifiedUpstream(freight, request.Sources) { + return types.NewAllowDecision(), nil + } + message := unavailableMessage(freight.Name, stage.Name, request.Sources) + return types.NewDenyDecision().WithMessage(message), errors.New(message) +} + +// isVerifiedUpstream reports whether the Freight satisfies the upstream +// verification requirement of the sources, honoring the availability strategy. +func isVerifiedUpstream( + freight *kargoapi.Freight, + sources kargoapi.FreightSources, +) bool { + if sources.AvailabilityStrategy == kargoapi.FreightAvailabilityStrategyAll { + // Freight must be verified in every upstream Stage. + for _, upstream := range sources.Stages { + if !freight.IsVerifiedIn(upstream) { + return false + } + } + return true + } + // Freight must be verified in at least one upstream Stage. + return slices.ContainsFunc(sources.Stages, freight.IsVerifiedIn) +} + +func unavailableMessage( + freightName string, + stageName string, + sources kargoapi.FreightSources, +) string { + stages := strings.Join(sources.Stages, ", ") + if sources.AvailabilityStrategy == kargoapi.FreightAvailabilityStrategyAll { + return fmt.Sprintf( + "Freight %q must be verified in all upstream Stages (%s) "+ + "to be available to Stage %q", + freightName, + stages, + stageName, + ) + } + return fmt.Sprintf( + "Freight %q must be verified in at least one upstream Stage (%s) "+ + "to be available to Stage %q", + freightName, + stages, + stageName, + ) +} diff --git a/pkg/promotion/gate/builtin/namespace.go b/pkg/promotion/gate/builtin/namespace.go new file mode 100644 index 0000000000..df2f8eeeb4 --- /dev/null +++ b/pkg/promotion/gate/builtin/namespace.go @@ -0,0 +1,47 @@ +package builtin + +import ( + "context" + "errors" + "fmt" + + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +// NamespaceGateName is the name of the Promotion creation gate that enforces +// that Freight and its target Stage reside in the same namespace (Project). +const NamespaceGateName = "namespace" + +type namespaceGate struct{} + +// NewNamespaceGate returns a PromotionGate that denies Freight whose namespace +// differs from the target Stage's namespace. +func NewNamespaceGate() types.PromotionGate { + return &namespaceGate{} +} + +func (g *namespaceGate) Name() string { + return NamespaceGateName +} + +func (g *namespaceGate) Evaluate( + _ context.Context, + input types.PromotionInput, +) (*types.Decision, error) { + if input.Stage == nil { + return nil, errors.New("stage is nil") + } + if input.Freight == nil { + return nil, errors.New("freight is nil") + } + if input.Stage.Namespace != input.Freight.Namespace { + return types.NewDenyDecision().WithMessage(fmt.Sprintf( + "Freight %q is in namespace %q, but Stage %q is in namespace %q", + input.Freight.Name, + input.Freight.Namespace, + input.Stage.Name, + input.Stage.Namespace, + )), nil + } + return types.NewAllowDecision(), nil +} diff --git a/pkg/promotion/gate/builtin/requested_origin.go b/pkg/promotion/gate/builtin/requested_origin.go new file mode 100644 index 0000000000..ebc41e6c09 --- /dev/null +++ b/pkg/promotion/gate/builtin/requested_origin.go @@ -0,0 +1,46 @@ +package builtin + +import ( + "context" + "errors" + "fmt" + + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +// RequestedOriginGateName is the name of the Promotion creation gate that +// enforces that the target Stage requests Freight from the Freight's origin. +const RequestedOriginGateName = "requested-origin" + +type requestedOriginGate struct{} + +// NewRequestedOriginGate returns a PromotionGate that denies Freight whose +// origin is not requested by the target Stage. +func NewRequestedOriginGate() types.PromotionGate { + return &requestedOriginGate{} +} + +func (g *requestedOriginGate) Name() string { + return RequestedOriginGateName +} + +func (g *requestedOriginGate) Evaluate( + _ context.Context, + input types.PromotionInput, +) (*types.Decision, error) { + if input.Stage == nil { + return nil, errors.New("stage is nil") + } + if input.Freight == nil { + return nil, errors.New("freight is nil") + } + if input.FreightRequest() == nil { + return types.NewDenyDecision().WithMessage(fmt.Sprintf( + "Stage %q does not request Freight from %s %q", + input.Stage.Name, + input.Freight.Origin.Kind, + input.Freight.Origin.Name, + )), nil + } + return types.NewAllowDecision(), nil +} diff --git a/pkg/promotion/gate/builtin/soak_time.go b/pkg/promotion/gate/builtin/soak_time.go new file mode 100644 index 0000000000..9d85e593e4 --- /dev/null +++ b/pkg/promotion/gate/builtin/soak_time.go @@ -0,0 +1,194 @@ +package builtin + +import ( + "context" + "errors" + "fmt" + "time" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +// SoakTimeGateName is the name of the Promotion creation gate that enforces +// Freight soak requirements. +const SoakTimeGateName = "soak-time" + +type soakTimeGate struct { + nowFn func() time.Time +} + +// NewSoakTimeGate returns a PromotionGate that enforces the soak requirement in +// a FreightRequest. +func NewSoakTimeGate() types.PromotionGate { + return &soakTimeGate{nowFn: time.Now} +} + +func (s *soakTimeGate) Name() string { + return SoakTimeGateName +} + +func (s *soakTimeGate) Evaluate( + _ context.Context, + input types.PromotionInput, +) (*types.Decision, error) { + if input.Stage == nil { + return types.NewDenyDecision(), errors.New("stage is nil") + } + if input.Freight == nil { + return types.NewDenyDecision(), errors.New("freight is nil") + } + + // Approval is a manual override that bypasses the soak requirement, mirroring + // how the availability gate treats approved Freight. + if input.Freight.IsApprovedFor(input.Stage.Name) { + return types.NewAllowDecision(), nil + } + + request := input.FreightRequest() + if request == nil { + // The Stage does not request Freight from this origin, so there is no + // soak requirement to enforce here. Static eligibility (including the + // requested-origin check) is enforced by the eligibility gate. + return types.NewAllowDecision(), nil + } + + sources := request.Sources + requiredSoakTime := sources.RequiredSoakTime + hasRequiredSoakTime := requiredSoakTime != nil && requiredSoakTime.Duration > 0 + if sources.Direct || !hasRequiredSoakTime { + return types.NewAllowDecision(), nil + } + if len(sources.Stages) == 0 { + message := "FreightRequest has a soak requirement but no upstream Stages" + return types.NewDenyDecision().WithMessage(message), errors.New(message) + } + + var ( + allowed bool + requeueAfter *time.Duration + ) + switch sources.AvailabilityStrategy { + case "", kargoapi.FreightAvailabilityStrategyOneOf: + allowed, requeueAfter = s.evaluateOneOf( + input.Freight, + sources.Stages, + requiredSoakTime.Duration, + ) + case kargoapi.FreightAvailabilityStrategyAll: + allowed, requeueAfter = s.evaluateAll( + input.Freight, + sources.Stages, + requiredSoakTime.Duration, + ) + default: + message := fmt.Sprintf( + "unsupported Freight availability strategy %q", + sources.AvailabilityStrategy, + ) + return types.NewDenyDecision().WithMessage(message), errors.New(message) + } + + if allowed { + return types.NewAllowDecision(), nil + } + + message := fmt.Sprintf( + "Freight %q has not met the %s soak requirement for Stage %q", + input.Freight.Name, + requiredSoakTime.Duration, + input.Stage.Name, + ) + + decision := types.NewDenyDecision(). + WithMessage(message). + WithRequeueAfter(requeueAfter) + + return decision, nil +} + +func (s *soakTimeGate) evaluateOneOf( + freight *kargoapi.Freight, + stages []string, + required time.Duration, +) (bool, *time.Duration) { + now := s.nowFn() + var shortestRemaining *time.Duration + for _, stage := range stages { + status := getSoakStatus(freight, stage, now) + if status.longest >= required { + return true, nil + } + if !status.current { + continue + } + remaining := required - status.currentDuration + if shortestRemaining == nil || remaining < *shortestRemaining { + shortestRemaining = &remaining + } + } + return false, shortestRemaining +} + +func (s *soakTimeGate) evaluateAll( + freight *kargoapi.Freight, + stages []string, + required time.Duration, +) (bool, *time.Duration) { + now := s.nowFn() + var ( + longestRemaining *time.Duration + hasStoppedTimer bool + ) + for _, stage := range stages { + status := getSoakStatus(freight, stage, now) + if status.longest >= required { + continue + } + if !status.current { + hasStoppedTimer = true + continue + } + remaining := required - status.currentDuration + if longestRemaining == nil || remaining > *longestRemaining { + longestRemaining = &remaining + } + } + if longestRemaining == nil && !hasStoppedTimer { + return true, nil + } + if hasStoppedTimer { + return false, nil + } + return false, longestRemaining +} + +type soakStatus struct { + longest time.Duration + current bool + currentDuration time.Duration +} + +func getSoakStatus( + freight *kargoapi.Freight, + stage string, + now time.Time, +) soakStatus { + verifiedStage, verified := freight.Status.VerifiedIn[stage] + if !verified { + return soakStatus{} + } + + status := soakStatus{} + if verifiedStage.LongestCompletedSoak != nil { + status.longest = verifiedStage.LongestCompletedSoak.Duration + } + currentStage, current := freight.Status.CurrentlyIn[stage] + if !current || currentStage.Since == nil { + return status + } + status.current = true + status.currentDuration = max(now.Sub(currentStage.Since.Time), 0) + status.longest = max(status.longest, status.currentDuration) + return status +} diff --git a/pkg/promotion/gate/builtin/soak_time_test.go b/pkg/promotion/gate/builtin/soak_time_test.go new file mode 100644 index 0000000000..23ac1ac186 --- /dev/null +++ b/pkg/promotion/gate/builtin/soak_time_test.go @@ -0,0 +1,465 @@ +package builtin + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +func TestSoakTimeGate(t *testing.T) { + t.Parallel() + + soakGate := NewSoakTimeGate() + require.Equal(t, SoakTimeGateName, soakGate.Name()) +} + +func TestSoakTimeGate_Evaluate(t *testing.T) { + t.Parallel() + + const ( + targetStage = "target" + freightName = "freight" + ) + now := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC) + requiredSoakTime := &metav1.Duration{Duration: time.Hour} + denyDecision := &types.Decision{ + Message: `Freight "freight" has not met the 1h0m0s soak requirement for Stage "target"`, + } + // newInput assembles a PromotionInput where the Stage requests Freight from + // the Freight's origin with the given request. The soak gate resolves the + // applicable request via PromotionInput.FreightRequest, so the origins must + // line up. + newInput := func( + stageName string, + freight *kargoapi.Freight, + request kargoapi.FreightRequest, + ) types.PromotionInput { + freight.Origin = testOrigin + request.Origin = testOrigin + return types.PromotionInput{ + Stage: &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{Name: stageName}, + Spec: kargoapi.StageSpec{ + RequestedFreight: []kargoapi.FreightRequest{request}, + }, + }, + Freight: freight, + } + } + testCases := []struct { + name string + input types.PromotionInput + expectedDecision *types.Decision + expectedError string + }{ + { + name: "nil Stage", + input: types.PromotionInput{ + Freight: &kargoapi.Freight{}, + }, + expectedError: "stage is nil", + }, + { + name: "nil Freight", + input: types.PromotionInput{ + Stage: &kargoapi.Stage{}, + }, + expectedError: "freight is nil", + }, + { + name: "no soak required", + input: types.PromotionInput{ + Stage: &kargoapi.Stage{}, + Freight: &kargoapi.Freight{}, + }, + expectedDecision: types.NewAllowDecision(), + }, + { + name: "approval bypasses soak", + input: newInput( + targetStage, + &kargoapi.Freight{ + Status: kargoapi.FreightStatus{ + ApprovedFor: map[string]kargoapi.ApprovedStage{ + targetStage: {}, + }, + }, + }, + freightRequest([]string{"upstream"}, requiredSoakTime, ""), + ), + expectedDecision: types.NewAllowDecision(), + }, + { + name: "direct source bypasses soak", + input: newInput( + "", + &kargoapi.Freight{}, + kargoapi.FreightRequest{ + Sources: kargoapi.FreightSources{ + Direct: true, + RequiredSoakTime: requiredSoakTime, + }, + }, + ), + expectedDecision: types.NewAllowDecision(), + }, + { + name: "required soak has no upstream Stages", + input: newInput( + "", + &kargoapi.Freight{}, + freightRequest(nil, requiredSoakTime, ""), + ), + expectedError: "FreightRequest has a soak requirement but no upstream Stages", + }, + { + name: "one-of allows completed soak", + input: newInput( + "", + freightWithSoaks( + "", + now, + map[string]soakFixture{ + "upstream": { + verified: true, + completed: duration(time.Hour), + }, + }, + ), + freightRequest([]string{"upstream"}, requiredSoakTime, ""), + ), + expectedDecision: types.NewAllowDecision(), + }, + { + name: "one-of uses shortest active timer", + input: newInput( + targetStage, + freightWithSoaks( + freightName, + now, + map[string]soakFixture{ + "upstream-a": { + verified: true, + current: duration(20 * time.Minute), + }, + "upstream-b": { + verified: true, + current: duration(50 * time.Minute), + }, + }, + ), + freightRequest([]string{"upstream-a", "upstream-b"}, requiredSoakTime, ""), + ), + expectedDecision: decisionWithRequeue( + denyDecision, + 10*time.Minute, + ), + }, + { + name: "one-of active timer is based on current soak", + input: newInput( + targetStage, + freightWithSoaks( + freightName, + now, + map[string]soakFixture{ + "upstream": { + verified: true, + completed: duration(50 * time.Minute), + current: duration(10 * time.Minute), + }, + }, + ), + freightRequest([]string{"upstream"}, requiredSoakTime, ""), + ), + expectedDecision: decisionWithRequeue( + denyDecision, + 50*time.Minute, + ), + }, + { + name: "one-of does not requeue without an active verified soak", + input: newInput( + targetStage, + freightWithSoaks( + freightName, + now, + map[string]soakFixture{"upstream": {}}, + ), + freightRequest([]string{"upstream"}, requiredSoakTime, ""), + ), + expectedDecision: denyDecision, + }, + { + // Regression test for + // https://github.com/akuity/kargo/issues/4586: with an active + // (CurrentlyIn) soak timer that has already met the requirement, + // auto-promotion must proceed instead of stalling. + name: "one-of allows an active timer that has met the soak requirement (issue #4586)", + input: newInput( + targetStage, + freightWithSoaks( + freightName, + now, + map[string]soakFixture{ + "upstream-a": { + verified: true, + current: duration(90 * time.Minute), + }, + "upstream-b": { + verified: true, + current: duration(20 * time.Minute), + }, + }, + ), + freightRequest( + []string{"upstream-a", "upstream-b"}, + requiredSoakTime, + kargoapi.FreightAvailabilityStrategyOneOf, + ), + ), + expectedDecision: types.NewAllowDecision(), + }, + { + name: "all requires every upstream soak", + input: newInput( + targetStage, + freightWithSoaks( + freightName, + now, + map[string]soakFixture{ + "upstream-a": { + verified: true, + current: duration(20 * time.Minute), + }, + "upstream-b": { + verified: true, + current: duration(50 * time.Minute), + }, + }, + ), + freightRequest( + []string{"upstream-a", "upstream-b"}, + requiredSoakTime, + kargoapi.FreightAvailabilityStrategyAll, + ), + ), + expectedDecision: decisionWithRequeue( + denyDecision, + 40*time.Minute, + ), + }, + { + name: "all does not requeue when an unmet timer is inactive", + input: newInput( + targetStage, + freightWithSoaks( + freightName, + now, + map[string]soakFixture{ + "upstream-a": { + verified: true, + completed: duration(time.Hour), + }, + "upstream-b": { + verified: true, + }, + }, + ), + freightRequest( + []string{"upstream-a", "upstream-b"}, + requiredSoakTime, + kargoapi.FreightAvailabilityStrategyAll, + ), + ), + expectedDecision: denyDecision, + }, + { + name: "all allows every completed upstream soak", + input: newInput( + "", + freightWithSoaks( + "", + now, + map[string]soakFixture{ + "upstream-a": { + verified: true, + completed: duration(time.Hour), + }, + "upstream-b": { + verified: true, + completed: duration(time.Hour), + }, + }, + ), + freightRequest( + []string{"upstream-a", "upstream-b"}, + requiredSoakTime, + kargoapi.FreightAvailabilityStrategyAll, + ), + ), + expectedDecision: types.NewAllowDecision(), + }, + { + // Regression test for + // https://github.com/akuity/kargo/issues/4586: with All, once + // every active (CurrentlyIn) soak timer has met the requirement, + // auto-promotion must proceed instead of being blocked + // indefinitely. + name: "all allows active timers that have met the soak requirement (issue #4586)", + input: newInput( + "", + freightWithSoaks( + "", + now, + map[string]soakFixture{ + "upstream-a": { + verified: true, + current: duration(90 * time.Minute), + }, + "upstream-b": { + verified: true, + current: duration(2 * time.Hour), + }, + }, + ), + freightRequest( + []string{"upstream-a", "upstream-b"}, + requiredSoakTime, + kargoapi.FreightAvailabilityStrategyAll, + ), + ), + expectedDecision: types.NewAllowDecision(), + }, + { + // Regression test for + // https://github.com/akuity/kargo/issues/4586: a deny for an + // in-progress soak must carry a RequeueAfter so the controller + // re-evaluates when the soak completes, rather than relying on the + // periodic reconcile interval (which left auto-promotion stuck). + name: "all requeues an in-progress soak so auto-promotion is not stuck (issue #4586)", + input: newInput( + targetStage, + freightWithSoaks( + freightName, + now, + map[string]soakFixture{ + "upstream-a": { + verified: true, + current: duration(55 * time.Minute), + }, + "upstream-b": { + verified: true, + current: duration(59 * time.Minute), + }, + }, + ), + freightRequest( + []string{"upstream-a", "upstream-b"}, + requiredSoakTime, + kargoapi.FreightAvailabilityStrategyAll, + ), + ), + expectedDecision: decisionWithRequeue( + denyDecision, + 5*time.Minute, + ), + }, + { + name: "unsupported availability strategy", + input: newInput( + "", + &kargoapi.Freight{}, + freightRequest([]string{"upstream"}, requiredSoakTime, "unsupported"), + ), + expectedError: `unsupported Freight availability strategy "unsupported"`, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + soakGate := &soakTimeGate{ + nowFn: func() time.Time { return now }, + } + decision, err := soakGate.Evaluate(t.Context(), testCase.input) + if testCase.expectedError != "" { + require.EqualError(t, err, testCase.expectedError) + require.NotNil(t, decision) + return + } + require.NoError(t, err) + require.Equal(t, testCase.expectedDecision, decision) + }) + } +} + +type soakFixture struct { + verified bool + completed *time.Duration + current *time.Duration +} + +func freightWithSoaks( + name string, + now time.Time, + fixtures map[string]soakFixture, +) *kargoapi.Freight { + freight := &kargoapi.Freight{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: kargoapi.FreightStatus{ + VerifiedIn: map[string]kargoapi.VerifiedStage{}, + CurrentlyIn: map[string]kargoapi.CurrentStage{}, + }, + } + for stage, fixture := range fixtures { + if fixture.verified { + verifiedStage := kargoapi.VerifiedStage{} + if fixture.completed != nil { + verifiedStage.LongestCompletedSoak = &metav1.Duration{ + Duration: *fixture.completed, + } + } + freight.Status.VerifiedIn[stage] = verifiedStage + } + if fixture.current != nil { + freight.Status.CurrentlyIn[stage] = kargoapi.CurrentStage{ + Since: &metav1.Time{Time: now.Add(-*fixture.current)}, + } + } + } + return freight +} + +func freightRequest( + stages []string, + requiredSoakTime *metav1.Duration, + strategy kargoapi.FreightAvailabilityStrategy, +) kargoapi.FreightRequest { + return kargoapi.FreightRequest{ + Sources: kargoapi.FreightSources{ + Stages: stages, + RequiredSoakTime: requiredSoakTime, + AvailabilityStrategy: strategy, + }, + } +} + +func decisionWithRequeue( + decision *types.Decision, + requeueAfter time.Duration, +) *types.Decision { + d := *decision + d.RequeueAfter = &requeueAfter + return &d +} + +func duration(value time.Duration) *time.Duration { + return &value +} diff --git a/pkg/promotion/gate/builtin/static_test.go b/pkg/promotion/gate/builtin/static_test.go new file mode 100644 index 0000000000..02059557f1 --- /dev/null +++ b/pkg/promotion/gate/builtin/static_test.go @@ -0,0 +1,303 @@ +package builtin + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +const ( + testNamespace = "test-project" + testStageName = "test-stage" +) + +var testOrigin = kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "test-warehouse", +} + +func TestNamespaceGate_Evaluate(t *testing.T) { + t.Parallel() + + g := NewNamespaceGate() + require.Equal(t, NamespaceGateName, g.Name()) + + testCases := []struct { + name string + input types.PromotionInput + expectedDecision *types.Decision + expectedError string + }{ + { + name: "nil Stage", + input: types.PromotionInput{Freight: freight(testNamespace, testOrigin, nil)}, + expectedError: "stage is nil", + }, + { + name: "nil Freight", + input: types.PromotionInput{Stage: stage(oneOfRequest())}, + expectedError: "freight is nil", + }, + { + name: "different namespaces are denied", + input: types.PromotionInput{ + Stage: stage(oneOfRequest()), + Freight: freight("other-project", testOrigin, nil), + }, + expectedDecision: types.NewDenyDecision().WithMessage( + `Freight "test-freight" is in namespace "other-project", ` + + `but Stage "test-stage" is in namespace "test-project"`, + ), + }, + { + name: "matching namespace is allowed", + input: types.PromotionInput{ + Stage: stage(oneOfRequest()), + Freight: freight(testNamespace, testOrigin, nil), + }, + expectedDecision: types.NewAllowDecision(), + }, + } + runGateCases(t, g, testCases) +} + +func TestRequestedOriginGate_Evaluate(t *testing.T) { + t.Parallel() + + g := NewRequestedOriginGate() + require.Equal(t, RequestedOriginGateName, g.Name()) + + testCases := []struct { + name string + input types.PromotionInput + expectedDecision *types.Decision + expectedError string + }{ + { + name: "nil Stage", + input: types.PromotionInput{Freight: freight(testNamespace, testOrigin, nil)}, + expectedError: "stage is nil", + }, + { + name: "nil Freight", + input: types.PromotionInput{Stage: stage(oneOfRequest())}, + expectedError: "freight is nil", + }, + { + name: "unrequested origin is denied", + input: types.PromotionInput{ + Stage: stage(kargoapi.FreightRequest{ + Origin: kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "other-warehouse", + }, + Sources: kargoapi.FreightSources{Direct: true}, + }), + Freight: freight(testNamespace, testOrigin, nil), + }, + expectedDecision: types.NewDenyDecision().WithMessage( + `Stage "test-stage" does not request Freight from ` + + `Warehouse "test-warehouse"`, + ), + }, + { + name: "requested origin is allowed", + input: types.PromotionInput{ + Stage: stage(oneOfRequest()), + Freight: freight(testNamespace, testOrigin, nil), + }, + expectedDecision: types.NewAllowDecision(), + }, + } + runGateCases(t, g, testCases) +} + +func TestAvailabilityGate_Evaluate(t *testing.T) { + t.Parallel() + + g := NewAvailabilityGate() + require.Equal(t, AvailabilityGateName, g.Name()) + + approvedStatus := &kargoapi.FreightStatus{ + ApprovedFor: map[string]kargoapi.ApprovedStage{testStageName: {}}, + } + + testCases := []struct { + name string + input types.PromotionInput + expectedDecision *types.Decision + expectedError string + }{ + { + name: "nil Stage", + input: types.PromotionInput{Freight: freight(testNamespace, testOrigin, nil)}, + expectedError: "stage is nil", + }, + { + name: "nil Freight", + input: types.PromotionInput{Stage: stage(oneOfRequest())}, + expectedError: "freight is nil", + }, + { + name: "unrequested origin is denied even when approved", + input: types.PromotionInput{ + Stage: stage(kargoapi.FreightRequest{ + Origin: kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "other-warehouse", + }, + Sources: kargoapi.FreightSources{Stages: []string{"upstream"}}, + }), + Freight: freight(testNamespace, testOrigin, approvedStatus), + }, + expectedDecision: types.NewDenyDecision().WithMessage( + `Freight "test-freight" is not available to Stage "test-stage"`, + ), + }, + { + name: "approval satisfies availability", + input: types.PromotionInput{ + Stage: stage(oneOfRequest()), + Freight: freight(testNamespace, testOrigin, approvedStatus), + }, + expectedDecision: types.NewAllowDecision(), + }, + { + name: "direct source satisfies availability", + input: types.PromotionInput{ + Stage: stage(kargoapi.FreightRequest{ + Origin: testOrigin, + Sources: kargoapi.FreightSources{Direct: true}, + }), + Freight: freight(testNamespace, testOrigin, nil), + }, + expectedDecision: types.NewAllowDecision(), + }, + { + name: "one-of requires upstream verification", + input: types.PromotionInput{ + Stage: stage(oneOfRequest()), + Freight: freight(testNamespace, testOrigin, nil), + }, + expectedDecision: types.NewDenyDecision().WithMessage( + `Freight "test-freight" must be verified in at least one ` + + `upstream Stage (upstream) to be available to Stage "test-stage"`, + ), + }, + { + name: "one-of upstream verification satisfies availability", + input: types.PromotionInput{ + Stage: stage(oneOfRequest()), + Freight: freight(testNamespace, testOrigin, &kargoapi.FreightStatus{ + VerifiedIn: map[string]kargoapi.VerifiedStage{"upstream": {}}, + }), + }, + expectedDecision: types.NewAllowDecision(), + }, + { + name: "all requires every upstream verification", + input: types.PromotionInput{ + Stage: stage(allRequest("upstream-a", "upstream-b")), + Freight: freight(testNamespace, testOrigin, &kargoapi.FreightStatus{ + VerifiedIn: map[string]kargoapi.VerifiedStage{"upstream-a": {}}, + }), + }, + expectedDecision: types.NewDenyDecision().WithMessage( + `Freight "test-freight" must be verified in all upstream ` + + `Stages (upstream-a, upstream-b) to be available to ` + + `Stage "test-stage"`, + ), + }, + { + name: "all upstream verifications satisfy availability", + input: types.PromotionInput{ + Stage: stage(allRequest("upstream-a", "upstream-b")), + Freight: freight(testNamespace, testOrigin, &kargoapi.FreightStatus{ + VerifiedIn: map[string]kargoapi.VerifiedStage{ + "upstream-a": {}, + "upstream-b": {}, + }, + }), + }, + expectedDecision: types.NewAllowDecision(), + }, + } + runGateCases(t, g, testCases) +} + +func runGateCases( + t *testing.T, + g types.PromotionGate, + testCases []struct { + name string + input types.PromotionInput + expectedDecision *types.Decision + expectedError string + }, +) { + t.Helper() + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + decision, err := g.Evaluate(t.Context(), testCase.input) + if testCase.expectedError != "" { + require.EqualError(t, err, testCase.expectedError) + require.Nil(t, decision) + return + } + require.NoError(t, err) + require.Equal(t, testCase.expectedDecision, decision) + }) + } +} + +func oneOfRequest() kargoapi.FreightRequest { + return kargoapi.FreightRequest{ + Origin: testOrigin, + Sources: kargoapi.FreightSources{Stages: []string{"upstream"}}, + } +} + +func allRequest(stages ...string) kargoapi.FreightRequest { + return kargoapi.FreightRequest{ + Origin: testOrigin, + Sources: kargoapi.FreightSources{ + Stages: stages, + AvailabilityStrategy: kargoapi.FreightAvailabilityStrategyAll, + }, + } +} + +func stage(request kargoapi.FreightRequest) *kargoapi.Stage { + return &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testNamespace, + Name: testStageName, + }, + Spec: kargoapi.StageSpec{ + RequestedFreight: []kargoapi.FreightRequest{request}, + }, + } +} + +func freight( + namespace string, + origin kargoapi.FreightOrigin, + status *kargoapi.FreightStatus, +) *kargoapi.Freight { + f := &kargoapi.Freight{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "test-freight", + }, + Origin: origin, + } + if status != nil { + f.Status = *status + } + return f +} diff --git a/pkg/promotion/gate/evaluate.go b/pkg/promotion/gate/evaluate.go new file mode 100644 index 0000000000..8f18955439 --- /dev/null +++ b/pkg/promotion/gate/evaluate.go @@ -0,0 +1,35 @@ +package gate + +import ( + "context" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/promotion/gate/builtin" + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +// Default composes the standard gate set: static eligibility (namespace, +// requested origin, availability) followed by dynamic soak time. +func Default() types.PromotionGate { + return NewSet( + builtin.NewNamespaceGate(), + builtin.NewRequestedOriginGate(), + builtin.NewAvailabilityGate(), + builtin.NewSoakTimeGate(), + ) +} + +// DefaultEvaluate runs the default gate for a Stage/Freight. +func DefaultEvaluate( + ctx context.Context, + stage *kargoapi.Stage, + freight *kargoapi.Freight, +) (*types.Decision, error) { + return Default().Evaluate( + ctx, + types.PromotionInput{ + Stage: stage, + Freight: freight, + }, + ) +} diff --git a/pkg/promotion/gate/evaluate_test.go b/pkg/promotion/gate/evaluate_test.go new file mode 100644 index 0000000000..83e38f9816 --- /dev/null +++ b/pkg/promotion/gate/evaluate_test.go @@ -0,0 +1,93 @@ +package gate + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" +) + +func TestDefaultEvaluate(t *testing.T) { + t.Parallel() + + origin := kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "test-warehouse", + } + directStage := &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-project", + Name: "test-stage", + }, + Spec: kargoapi.StageSpec{ + RequestedFreight: []kargoapi.FreightRequest{{ + Origin: origin, + Sources: kargoapi.FreightSources{Direct: true}, + }}, + }, + } + freight := func(namespace string) *kargoapi.Freight { + return &kargoapi.Freight{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "test-freight", + }, + Origin: origin, + } + } + + t.Run("eligible direct Freight is allowed", func(t *testing.T) { + t.Parallel() + decision, err := DefaultEvaluate( + t.Context(), + directStage, + freight("test-project"), + ) + require.NoError(t, err) + require.NotNil(t, decision) + require.True(t, decision.Allow) + }) + + t.Run("Freight in another namespace is denied", func(t *testing.T) { + t.Parallel() + decision, err := DefaultEvaluate( + t.Context(), + directStage, + freight("other-project"), + ) + require.NoError(t, err) + require.NotNil(t, decision) + require.False(t, decision.Allow) + }) + + t.Run("unrequested origin is denied", func(t *testing.T) { + t.Parallel() + stage := &kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-project", + Name: "test-stage", + }, + Spec: kargoapi.StageSpec{ + RequestedFreight: []kargoapi.FreightRequest{{ + Origin: kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "other-warehouse", + }, + Sources: kargoapi.FreightSources{Direct: true}, + }}, + }, + } + decision, err := DefaultEvaluate(t.Context(), stage, freight("test-project")) + require.NoError(t, err) + require.NotNil(t, decision) + require.False(t, decision.Allow) + }) + + t.Run("nil Stage yields an error", func(t *testing.T) { + t.Parallel() + _, err := DefaultEvaluate(t.Context(), nil, freight("test-project")) + require.Error(t, err) + }) +} diff --git a/pkg/promotion/gate/set.go b/pkg/promotion/gate/set.go new file mode 100644 index 0000000000..4826d5231e --- /dev/null +++ b/pkg/promotion/gate/set.go @@ -0,0 +1,70 @@ +package gate + +import ( + "context" + "errors" + "fmt" + + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +const setName = "set" + +// Set is an ordered collection of Promotion creation gates. +type Set struct { + gates []types.PromotionGate +} + +// NewSet returns a PromotionGate that evaluates the provided gates in +// order. +func NewSet(gates ...types.PromotionGate) types.PromotionGate { + return &Set{ + gates: append([]types.PromotionGate(nil), gates...), + } +} + +func (s *Set) Name() string { + return setName +} + +func (s *Set) Evaluate( + ctx context.Context, + input types.PromotionInput, +) (*types.Decision, error) { + if s == nil { + return types.NewAllowDecision(), nil + } + + if input.Stage == nil { + return types.NewDenyDecision(), errors.New("stage is nil") + } + + if input.Freight == nil { + return types.NewDenyDecision(), errors.New("freight is nil") + } + + for i, promotionGate := range s.gates { + if promotionGate == nil { + return nil, fmt.Errorf( + "promotion creation gate at index %d is nil", + i, + ) + } + decision, err := promotionGate.Evaluate(ctx, input) + if err != nil { + return nil, fmt.Errorf( + "error evaluating Promotion creation gate %q: %w", + promotionGate.Name(), + err, + ) + } + if !decision.Allow { + return decision, err + } + + if decision == nil { + return types.NewDenyDecision(), errors.New("decision is nil") + } + } + return types.NewAllowDecision(), nil +} diff --git a/pkg/promotion/gate/set_test.go b/pkg/promotion/gate/set_test.go new file mode 100644 index 0000000000..eee7ced3e1 --- /dev/null +++ b/pkg/promotion/gate/set_test.go @@ -0,0 +1,154 @@ +package gate + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/promotion/gate/types" +) + +func TestNewSet(t *testing.T) { + t.Parallel() + + require.Equal(t, setName, NewSet().Name()) +} + +func TestSetEvaluate(t *testing.T) { + t.Parallel() + + testStage := &kargoapi.Stage{} + testFreight := &kargoapi.Freight{} + testInput := types.PromotionInput{ + Stage: testStage, + Freight: testFreight, + } + denial := types.NewDenyDecision().WithMessage("denied by test gate") + testCases := []struct { + name string + evaluations []fakeGateEvaluation + nilGateAt int + expectedDecision *types.Decision + expectedError string + expectedCalls []string + }{ + { + name: "empty set allows", + nilGateAt: -1, + expectedDecision: types.NewAllowDecision(), + }, + { + name: "all gates allow", + evaluations: []fakeGateEvaluation{ + {decision: types.NewAllowDecision()}, + {decision: types.NewAllowDecision()}, + }, + nilGateAt: -1, + expectedDecision: types.NewAllowDecision(), + expectedCalls: []string{"gate-0", "gate-1"}, + }, + { + name: "first denial short-circuits", + evaluations: []fakeGateEvaluation{ + {decision: types.NewAllowDecision()}, + {decision: denial}, + {err: errors.New("must not be called")}, + }, + nilGateAt: -1, + expectedDecision: denial, + expectedCalls: []string{"gate-0", "gate-1"}, + }, + { + name: "first error short-circuits", + evaluations: []fakeGateEvaluation{ + {decision: types.NewAllowDecision()}, + {err: errors.New("boom")}, + {err: errors.New("must not be called")}, + }, + nilGateAt: -1, + expectedError: `error evaluating Promotion creation gate "gate-1": boom`, + expectedCalls: []string{"gate-0", "gate-1"}, + }, + { + name: "nil gate is rejected", + evaluations: []fakeGateEvaluation{ + {decision: types.NewAllowDecision()}, + {decision: types.NewAllowDecision()}, + }, + nilGateAt: 1, + expectedError: "promotion creation gate at index 1 is nil", + expectedCalls: []string{"gate-0"}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + var calls []string + gates := make( + []types.PromotionGate, + len(testCase.evaluations), + ) + for i, evaluation := range testCase.evaluations { + if i == testCase.nilGateAt { + continue + } + gateName := fmt.Sprintf("gate-%d", i) + gates[i] = &fakeGate{ + name: gateName, + evaluateFn: func( + _ context.Context, + input types.PromotionInput, + ) (*types.Decision, error) { + require.Same(t, testStage, input.Stage) + require.Same(t, testFreight, input.Freight) + calls = append(calls, gateName) + return evaluation.decision, evaluation.err + }, + } + } + + decision, err := NewSet(gates...).Evaluate( + t.Context(), + testInput, + ) + if testCase.expectedError != "" { + require.EqualError(t, err, testCase.expectedError) + require.Nil(t, decision) + } else { + require.NoError(t, err) + require.Equal(t, testCase.expectedDecision, decision) + } + require.Equal(t, testCase.expectedCalls, calls) + }) + } +} + +type fakeGateEvaluation struct { + decision *types.Decision + err error +} + +type fakeGate struct { + name string + evaluateFn func( + context.Context, + types.PromotionInput, + ) (*types.Decision, error) +} + +func (f *fakeGate) Name() string { + return f.name +} + +func (f *fakeGate) Evaluate( + ctx context.Context, + input types.PromotionInput, +) (*types.Decision, error) { + return f.evaluateFn(ctx, input) +} diff --git a/pkg/promotion/gate/types/decision.go b/pkg/promotion/gate/types/decision.go new file mode 100644 index 0000000000..fe18c8d056 --- /dev/null +++ b/pkg/promotion/gate/types/decision.go @@ -0,0 +1,36 @@ +package types + +import "time" + +func NewDenyDecision() *Decision { + return &Decision{Allow: false} +} + +func NewAllowDecision() *Decision { + return &Decision{Allow: true} +} + +// Decision is the uniform result of every gate contributor. +// Contributors are predicates: they never promote, only allow, deny, or skip. +type Decision struct { + Allow bool + // Message is a human-readable explanation of the decision. + Message string + // RequeueAfter makes a gate a "when", not just a "whether". + RequeueAfter *time.Duration +} + +func (d *Decision) WithAllow(allow bool) *Decision { + d.Allow = allow + return d +} + +func (d *Decision) WithRequeueAfter(requeueAfter *time.Duration) *Decision { + d.RequeueAfter = requeueAfter + return d +} + +func (d *Decision) WithMessage(message string) *Decision { + d.Message = message + return d +} diff --git a/pkg/promotion/gate/types/types.go b/pkg/promotion/gate/types/types.go new file mode 100644 index 0000000000..11cb3dd40f --- /dev/null +++ b/pkg/promotion/gate/types/types.go @@ -0,0 +1,38 @@ +package types + +import ( + "context" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" +) + +type PromotionGate interface { + Name() string + Evaluate(context.Context, PromotionInput) (*Decision, error) +} + +// PromotionInput contains the information a PromotionGate uses to decide +// whether a Promotion may be created. +type PromotionInput struct { + Stage *kargoapi.Stage + Freight *kargoapi.Freight +} + +// FreightRequest returns the Stage's requested-freight entry that applies to +// this input's Freight, matched by origin, or nil if the Stage does not +// request Freight from that origin. The applicable request carries the Stage's +// policy for the Freight (direct sources, upstream Stages, availability +// strategy, and soak requirement); gates read it from here rather than +// re-deriving the origin match themselves. +func (i PromotionInput) FreightRequest() *kargoapi.FreightRequest { + if i.Stage == nil || i.Freight == nil { + return nil + } + for idx := range i.Stage.Spec.RequestedFreight { + request := &i.Stage.Spec.RequestedFreight[idx] + if request.Origin.Equals(&i.Freight.Origin) { + return request + } + } + return nil +} diff --git a/pkg/promotion/gate/types/types_test.go b/pkg/promotion/gate/types/types_test.go new file mode 100644 index 0000000000..f51f247c66 --- /dev/null +++ b/pkg/promotion/gate/types/types_test.go @@ -0,0 +1,89 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" +) + +func TestPromotionInput_FreightRequest(t *testing.T) { + t.Parallel() + + testOrigin := kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "test-warehouse", + } + otherOrigin := kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "other-warehouse", + } + matchingRequest := kargoapi.FreightRequest{ + Origin: testOrigin, + Sources: kargoapi.FreightSources{Stages: []string{"upstream"}}, + } + + stageWith := func(requests ...kargoapi.FreightRequest) *kargoapi.Stage { + return &kargoapi.Stage{ + Spec: kargoapi.StageSpec{RequestedFreight: requests}, + } + } + freightWith := func(origin kargoapi.FreightOrigin) *kargoapi.Freight { + return &kargoapi.Freight{Origin: origin} + } + + testCases := []struct { + name string + input PromotionInput + expected *kargoapi.FreightRequest + }{ + { + name: "nil Stage", + input: PromotionInput{Freight: freightWith(testOrigin)}, + }, + { + name: "nil Freight", + input: PromotionInput{Stage: stageWith(matchingRequest)}, + }, + { + name: "no matching origin", + input: PromotionInput{ + Stage: stageWith(kargoapi.FreightRequest{Origin: otherOrigin}), + Freight: freightWith(testOrigin), + }, + }, + { + name: "matching origin", + input: PromotionInput{ + Stage: stageWith(matchingRequest), + Freight: freightWith(testOrigin), + }, + expected: &matchingRequest, + }, + { + name: "returns first matching origin", + input: PromotionInput{ + Stage: stageWith( + kargoapi.FreightRequest{Origin: otherOrigin}, + matchingRequest, + ), + Freight: freightWith(testOrigin), + }, + expected: &matchingRequest, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + got := testCase.input.FreightRequest() + if testCase.expected == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *testCase.expected, *got) + }) + } +}