diff --git a/api/v1alpha1/promotion_types.go b/api/v1alpha1/promotion_types.go index 1b36d5091..ac72f9fcb 100644 --- a/api/v1alpha1/promotion_types.go +++ b/api/v1alpha1/promotion_types.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + "fmt" "time" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -224,6 +225,21 @@ type PromotionStep struct { Config *apiextensionsv1.JSON `json:"config,omitempty" protobuf:"bytes,3,opt,name=config"` } +// GetAlias returns the As field, or a default value in the form of "step-" +// or "task-" if the As field is empty. The index i is provided as an +// argument to this method and should be the index of the PromotionStep in the +// list it belongs to. +func (s *PromotionStep) GetAlias(i int) string { + switch { + case s.As != "": + return s.As + case s.Task != nil: + return fmt.Sprintf("task-%d", i) + default: + return fmt.Sprintf("step-%d", i) + } +} + // PromotionStatus describes the current state of the transition represented by // a Promotion. type PromotionStatus struct { diff --git a/internal/api/promote_downstream_v1alpha1.go b/internal/api/promote_downstream_v1alpha1.go index 888194f95..ccf616acb 100644 --- a/internal/api/promote_downstream_v1alpha1.go +++ b/internal/api/promote_downstream_v1alpha1.go @@ -137,19 +137,23 @@ func (s *server) PromoteDownstream( promoteErrs := make([]error, 0, len(downstreams)) createdPromos := make([]*kargoapi.Promotion, 0, len(downstreams)) for _, downstream := range downstreams { - newPromo := kargo.NewPromotion(ctx, downstream, freight.Name) if downstream.Spec.PromotionTemplate != nil && len(downstream.Spec.PromotionTemplate.Spec.Steps) == 0 { // Avoid creating a Promotion if the downstream Stage has no promotion // steps and is therefore a "control flow" Stage. continue } - if err := s.createPromotionFn(ctx, &newPromo); err != nil { + newPromo, err := kargo.NewPromotionBuilder(s.client).Build(ctx, downstream, freight.Name) + if err != nil { promoteErrs = append(promoteErrs, err) continue } - s.recordPromotionCreatedEvent(ctx, &newPromo, freight) - createdPromos = append(createdPromos, &newPromo) + if err = s.createPromotionFn(ctx, newPromo); err != nil { + promoteErrs = append(promoteErrs, err) + continue + } + s.recordPromotionCreatedEvent(ctx, newPromo, freight) + createdPromos = append(createdPromos, newPromo) } res := connect.NewResponse(&svcv1alpha1.PromoteDownstreamResponse{ diff --git a/internal/api/promote_to_stage_v1alpha1.go b/internal/api/promote_to_stage_v1alpha1.go index 310d9cd05..d6b7330ab 100644 --- a/internal/api/promote_to_stage_v1alpha1.go +++ b/internal/api/promote_to_stage_v1alpha1.go @@ -98,7 +98,7 @@ func (s *server) PromoteToStage( ) } - if err := s.authorizeFn( + if err = s.authorizeFn( ctx, "promote", schema.GroupVersionResource{ @@ -115,13 +115,16 @@ func (s *server) PromoteToStage( return nil, err } - promotion := kargo.NewPromotion(ctx, *stage, freight.Name) - if err := s.createPromotionFn(ctx, &promotion); err != nil { + promotion, err := kargo.NewPromotionBuilder(s.client).Build(ctx, *stage, freight.Name) + if err != nil { + return nil, fmt.Errorf("build promotion: %w", err) + } + if err := s.createPromotionFn(ctx, promotion); err != nil { return nil, fmt.Errorf("create promotion: %w", err) } - s.recordPromotionCreatedEvent(ctx, &promotion, freight) + s.recordPromotionCreatedEvent(ctx, promotion, freight) return connect.NewResponse(&svcv1alpha1.PromoteToStageResponse{ - Promotion: &promotion, + Promotion: promotion, }), nil } diff --git a/internal/controller/stages/regular_stages.go b/internal/controller/stages/regular_stages.go index 1213cae4d..acd81b050 100644 --- a/internal/controller/stages/regular_stages.go +++ b/internal/controller/stages/regular_stages.go @@ -1573,19 +1573,25 @@ func (r *RegularStageReconciler) autoPromoteFreight( } // Auto promote the latest available Freight and record an event. - promotion := kargo.NewPromotion(ctx, *stage, latestFreight.Name) - if err := r.client.Create(ctx, &promotion); err != nil { + promotion, err := kargo.NewPromotionBuilder(r.client).Build(ctx, *stage, latestFreight.Name) + if err != nil { + return newStatus, fmt.Errorf( + "error building Promotion for Freight %q in namespace %q: %w", + latestFreight.Name, stage.Namespace, err, + ) + } + if err = r.client.Create(ctx, promotion); err != nil { return newStatus, fmt.Errorf( "error creating Promotion for Freight %q in namespace %q: %w", latestFreight.Name, stage.Namespace, err, ) } r.eventRecorder.AnnotatedEventf( - &promotion, + promotion, kargoEvent.NewPromotionAnnotations( ctx, kargoapi.FormatEventControllerActor(r.cfg.Name()), - &promotion, + promotion, &latestFreight, ), corev1.EventTypeNormal, diff --git a/internal/kargo/kargo.go b/internal/kargo/kargo.go index d3c1ad7d2..148bd14c6 100644 --- a/internal/kargo/kargo.go +++ b/internal/kargo/kargo.go @@ -1,71 +1,13 @@ package kargo import ( - "context" - "fmt" - "strings" - - "github.com/oklog/ulid/v2" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" kargoapi "github.com/akuity/kargo/api/v1alpha1" - "github.com/akuity/kargo/internal/api/user" "github.com/akuity/kargo/internal/logging" ) -const ( - // maximum length of the stage name used in the promotion name prefix before it exceeds - // kubernetes resource name limit of 253 - // 253 - 1 (.) - 26 (ulid) - 1 (.) - 7 (sha) = 218 - maxStageNamePrefixLength = 218 -) - -// NewPromotion returns a new Promotion from a given stage and freight with our -// naming convention. -func NewPromotion( - ctx context.Context, - stage kargoapi.Stage, - freight string, -) kargoapi.Promotion { - shortHash := freight - if len(shortHash) > 7 { - shortHash = freight[0:7] - } - shortStageName := stage.Name - if len(stage.Name) > maxStageNamePrefixLength { - shortStageName = shortStageName[0:maxStageNamePrefixLength] - } - - annotations := make(map[string]string, 1) - // Put actor information to track on the controller side - if u, ok := user.InfoFromContext(ctx); ok { - annotations[kargoapi.AnnotationKeyCreateActor] = kargoapi.FormatEventUserActor(u) - } - - // ulid.Make() is pseudo-random, not crypto-random, but we don't care. - // We just want a unique ID that can be sorted lexicographically - promoName := strings.ToLower(fmt.Sprintf("%s.%s.%s", shortStageName, ulid.Make(), shortHash)) - - promotion := kargoapi.Promotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: promoName, - Namespace: stage.Namespace, - Annotations: annotations, - }, - Spec: kargoapi.PromotionSpec{ - Stage: stage.Name, - Freight: freight, - }, - } - if stage.Spec.PromotionTemplate != nil { - promotion.Spec.Vars = stage.Spec.PromotionTemplate.Spec.Vars - promotion.Spec.Steps = stage.Spec.PromotionTemplate.Spec.Steps - } - return promotion -} - func NewPromoWentTerminalPredicate(logger *logging.Logger) PromoWentTerminal[*kargoapi.Promotion] { return PromoWentTerminal[*kargoapi.Promotion]{ logger: logger, diff --git a/internal/kargo/kargo_test.go b/internal/kargo/kargo_test.go index c71d9f929..da5abc9f7 100644 --- a/internal/kargo/kargo_test.go +++ b/internal/kargo/kargo_test.go @@ -37,6 +37,17 @@ func TestNewPromotion(t *testing.T) { Name: "test", Namespace: "kargo-demo", }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Steps: []kargoapi.PromotionStep{ + { + Uses: "fake-step", + }, + }, + }, + }, + }, }, freight: testFreight, assertions: func(t *testing.T, _ kargoapi.Stage, promo kargoapi.Promotion) { @@ -53,6 +64,17 @@ func TestNewPromotion(t *testing.T) { Name: veryLongResourceName, Namespace: "kargo-demo", }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Steps: []kargoapi.PromotionStep{ + { + Uses: "fake-step", + }, + }, + }, + }, + }, }, freight: testFreight, assertions: func(t *testing.T, _ kargoapi.Stage, promo kargoapi.Promotion) { @@ -65,12 +87,13 @@ func TestNewPromotion(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - promo := NewPromotion(context.TODO(), tc.stage, tc.freight) + promo, err := NewPromotionBuilder(nil).Build(context.TODO(), tc.stage, tc.freight) + require.NoError(t, err) require.Equal(t, tc.freight, promo.Spec.Freight) require.Equal(t, tc.stage.Name, promo.Spec.Stage) require.Equal(t, tc.freight, promo.Spec.Freight) require.LessOrEqual(t, len(promo.Name), 253) - tc.assertions(t, tc.stage, promo) + tc.assertions(t, tc.stage, *promo) }) } } diff --git a/internal/kargo/promotion_builder.go b/internal/kargo/promotion_builder.go new file mode 100644 index 000000000..74f743067 --- /dev/null +++ b/internal/kargo/promotion_builder.go @@ -0,0 +1,253 @@ +package kargo + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/oklog/ulid/v2" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/internal/api/user" +) + +const ( + // nameSeparator is the separator used in the Promotion name. + nameSeparator = "." + + // aliasSeparator is the separator used in the Promotion step alias + // to separate the task alias from the step alias. + aliasSeparator = "::" + + // ulidLength is the length of the ULID string. + ulidLength = ulid.EncodedSize + + // shortHashLength is the length of the short hash. + shortHashLength = 7 + + // maxStageNamePrefixLength is the maximum length of the Stage name + // used in the Promotion name prefix before it exceeds the Kubernetes + // resource name limit of 253. + maxStageNamePrefixLength = 253 - len(nameSeparator) - ulidLength - len(nameSeparator) - shortHashLength +) + +type PromotionBuilder struct { + client client.Client +} + +// NewPromotionBuilder creates a new PromotionBuilder with the given client. +func NewPromotionBuilder(c client.Client) *PromotionBuilder { + return &PromotionBuilder{ + client: c, + } +} + +// Build creates a new Promotion for the Freight based on the PromotionTemplate +// of the given Stage. +func (b *PromotionBuilder) Build( + ctx context.Context, + stage kargoapi.Stage, + freight string, +) (*kargoapi.Promotion, error) { + if stage.Name == "" { + return nil, fmt.Errorf("stage is required") + } + + if stage.Spec.PromotionTemplate == nil { + return nil, fmt.Errorf("stage %q has no promotion template", stage.Name) + } + + if freight == "" { + return nil, fmt.Errorf("freight is required") + } + + // Build metadata + annotations := make(map[string]string) + if u, ok := user.InfoFromContext(ctx); ok { + annotations[kargoapi.AnnotationKeyCreateActor] = kargoapi.FormatEventUserActor(u) + } + + // Build steps + steps, err := b.buildSteps(ctx, stage) + if err != nil { + return nil, fmt.Errorf("failed to build promotion steps: %w", err) + } + + promotion := kargoapi.Promotion{ + ObjectMeta: metav1.ObjectMeta{ + Name: generatePromotionName(stage.Name, freight), + Namespace: stage.Namespace, + Annotations: annotations, + }, + Spec: kargoapi.PromotionSpec{ + Stage: stage.Name, + Freight: freight, + Vars: stage.Spec.PromotionTemplate.Spec.Vars, + Steps: steps, + }, + } + return &promotion, nil +} + +// buildSteps processes the Promotion steps from the PromotionTemplate of the +// given Stage. If a PromotionStep references a PromotionTask, the task is +// retrieved and its steps are inflated with the given task inputs. +func (b *PromotionBuilder) buildSteps(ctx context.Context, stage kargoapi.Stage) ([]kargoapi.PromotionStep, error) { + steps := make([]kargoapi.PromotionStep, 0, len(stage.Spec.PromotionTemplate.Spec.Steps)) + for i, step := range stage.Spec.PromotionTemplate.Spec.Steps { + switch { + case step.Task != nil: + alias := step.GetAlias(i) + taskSteps, err := b.inflateTaskSteps(ctx, stage.Namespace, alias, step) + if err != nil { + return nil, fmt.Errorf("inflate tasks steps for task %q (%q): %w", step.Task.Name, alias, err) + } + steps = append(steps, taskSteps...) + default: + steps = append(steps, step) + } + } + return steps, nil +} + +// inflateTaskSteps inflates the PromotionSteps for the given PromotionStep +// that references a (Cluster)PromotionTask. The task is retrieved and its +// steps are inflated with the given task inputs. +func (b *PromotionBuilder) inflateTaskSteps( + ctx context.Context, + project, taskAlias string, + taskStep kargoapi.PromotionStep, +) ([]kargoapi.PromotionStep, error) { + task, err := b.getTaskSpec(ctx, project, taskStep.Task) + if err != nil { + return nil, err + } + + inputs, err := validateAndMapTaskInputs(task.Inputs, taskStep.Config) + if err != nil { + return nil, err + } + + var steps []kargoapi.PromotionStep + for i := range task.Steps { + // Copy the step as-is. + step := &task.Steps[i] + + // Ensures we have a unique alias for each step within the context of + // the Promotion. + step.As = generatePromotionTaskStepAlias(taskAlias, step.GetAlias(i)) + + // With the inputs validated and mapped, they are now available to + // the Config of the step during the Promotion execution. + step.Inputs = inputs + + // Append the inflated step to the list of steps. + steps = append(steps, *step) + } + return steps, nil +} + +// getTaskSpec retrieves the PromotionTaskSpec for the given PromotionTaskReference. +func (b *PromotionBuilder) getTaskSpec( + ctx context.Context, + project string, + ref *kargoapi.PromotionTaskReference, +) (*kargoapi.PromotionTaskSpec, error) { + var spec kargoapi.PromotionTaskSpec + + if ref == nil { + return nil, errors.New("missing task reference") + } + + switch ref.Kind { + case "PromotionTask", "": + task := &kargoapi.PromotionTask{} + if err := b.client.Get(ctx, client.ObjectKey{Namespace: project, Name: ref.Name}, task); err != nil { + return nil, err + } + spec = task.Spec + case "ClusterPromotionTask": + task := &kargoapi.ClusterPromotionTask{} + if err := b.client.Get(ctx, client.ObjectKey{Name: ref.Name}, task); err != nil { + return nil, err + } + spec = task.Spec + default: + return nil, fmt.Errorf("unknown task reference kind %q", ref.Kind) + } + + return &spec, nil +} + +// generatePromotionName generates a name for the Promotion by combining the +// Stage name, a ULID, and a short hash of the Freight. +// +// The name has the format of: +// +// .. +func generatePromotionName(stageName, freight string) string { + if stageName == "" || freight == "" { + return "" + } + + shortHash := freight + if len(shortHash) > shortHashLength { + shortHash = shortHash[0:shortHashLength] + } + + shortStageName := stageName + if len(stageName) > maxStageNamePrefixLength { + shortStageName = shortStageName[0:maxStageNamePrefixLength] + } + + parts := []string{shortStageName, ulid.Make().String(), shortHash} + return strings.ToLower(strings.Join(parts, nameSeparator)) +} + +// generatePromotionTaskStepAlias generates an alias for a PromotionTask step +// by combining the task alias and the step alias. +func generatePromotionTaskStepAlias(taskAlias, stepAlias string) string { + return fmt.Sprintf("%s%s%s", taskAlias, aliasSeparator, stepAlias) +} + +// validateAndMapTaskInputs validates the task step config against the task +// inputs, and maps the config to inputs for the inflated steps. +func validateAndMapTaskInputs( + taskInputs []kargoapi.PromotionTaskInput, + stepConfig *apiextensionsv1.JSON, +) (map[string]string, error) { + if len(taskInputs) == 0 { + return nil, nil + } + + if stepConfig == nil { + return nil, errors.New("missing step config") + } + + config := make(map[string]any, len(taskInputs)) + if err := yaml.Unmarshal(stepConfig.Raw, &config); err != nil { + return nil, fmt.Errorf("unmarshal step config: %w", err) + } + + inputs := make(map[string]string, len(taskInputs)) + for _, input := range taskInputs { + iv := input.Default + if cv, exists := config[input.Name]; exists { + strVal, ok := cv.(string) + if !ok { + return nil, fmt.Errorf("input %q must be a string", input.Name) + } + iv = strVal + } + if iv == "" { + return nil, fmt.Errorf("missing required input %q", input.Name) + } + inputs[input.Name] = iv + } + return inputs, nil +} diff --git a/internal/kargo/promotion_builder_test.go b/internal/kargo/promotion_builder_test.go new file mode 100644 index 000000000..3a6b1732f --- /dev/null +++ b/internal/kargo/promotion_builder_test.go @@ -0,0 +1,1093 @@ +package kargo + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/yaml" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/internal/api/user" +) + +func TestPromotionBuilder_Build(t *testing.T) { + s := runtime.NewScheme() + require.NoError(t, kargoapi.AddToScheme(s)) + + tests := []struct { + name string + stage kargoapi.Stage + freight string + userInfo user.Info + objects []client.Object + assertions func(*testing.T, *kargoapi.Promotion, error) + }{ + { + name: "empty stage name returns error", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-project", + }, + }, + freight: "abc123", + assertions: func(t *testing.T, promotion *kargoapi.Promotion, err error) { + assert.ErrorContains(t, err, "stage is required") + assert.Nil(t, promotion) + }, + }, + { + name: "empty freight returns error", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{}, + }, + }, + freight: "", + assertions: func(t *testing.T, promotion *kargoapi.Promotion, err error) { + assert.ErrorContains(t, err, "freight is required") + assert.Nil(t, promotion) + }, + }, + { + name: "missing promotion template returns error", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + }, + freight: "abc123", + assertions: func(t *testing.T, promotion *kargoapi.Promotion, err error) { + assert.ErrorContains(t, err, "has no promotion template") + assert.Nil(t, promotion) + }, + }, + { + name: "successful build with direct steps", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Vars: []kargoapi.PromotionVariable{ + {Name: "key1", Value: "value1"}, + }, + Steps: []kargoapi.PromotionStep{ + { + As: "step1", + Uses: "fake-step", + }, + }, + }, + }, + }, + }, + freight: "abc123", + assertions: func(t *testing.T, promotion *kargoapi.Promotion, err error) { + require.NoError(t, err) + require.NotNil(t, promotion) + + // Check basic metadata + assert.Equal(t, "test-project", promotion.Namespace) + assert.Equal(t, "test-stage", promotion.Spec.Stage) + assert.Equal(t, "abc123", promotion.Spec.Freight) + + // Check vars + assert.Equal(t, []kargoapi.PromotionVariable{ + { + Name: "key1", + Value: "value1", + }, + }, promotion.Spec.Vars) + + // Check steps + require.Len(t, promotion.Spec.Steps, 1) + assert.Equal(t, "step1", promotion.Spec.Steps[0].As) + assert.Equal(t, "fake-step", promotion.Spec.Steps[0].Uses) + + // Check name format + assert.Contains(t, promotion.Name, "test-stage") + assert.Contains(t, promotion.Name, "abc123"[:6]) + }, + }, + { + name: "successful build with task steps and user info", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Steps: []kargoapi.PromotionStep{ + { + As: "task-step", + Task: &kargoapi.PromotionTaskReference{ + Name: "test-task", + }, + Config: makeJSONObj(t, map[string]any{ + "input1": "value1", + }), + }, + }, + }, + }, + }, + }, + freight: "abc123", + userInfo: user.Info{ + IsAdmin: true, + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + Steps: []kargoapi.PromotionStep{ + { + As: "sub-step", + Uses: "other-fake-step", + }, + }, + }, + }, + }, + assertions: func(t *testing.T, promotion *kargoapi.Promotion, err error) { + require.NoError(t, err) + require.NotNil(t, promotion) + + // Check metadata including user annotation + assert.Equal(t, kargoapi.EventActorAdmin, promotion.Annotations[kargoapi.AnnotationKeyCreateActor]) + + // Check steps + require.Len(t, promotion.Spec.Steps, 1) + assert.Equal(t, "task-step::sub-step", promotion.Spec.Steps[0].As) + assert.Equal(t, "other-fake-step", promotion.Spec.Steps[0].Uses) + assert.Equal(t, map[string]string{"input1": "value1"}, promotion.Spec.Steps[0].Inputs) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := user.ContextWithInfo(context.Background(), tt.userInfo) + + c := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(tt.objects...). + Build() + + b := NewPromotionBuilder(c) + promotion, err := b.Build(ctx, tt.stage, tt.freight) + tt.assertions(t, promotion, err) + }) + } +} + +func TestPromotionBuilder_buildSteps(t *testing.T) { + s := runtime.NewScheme() + require.NoError(t, kargoapi.AddToScheme(s)) + + tests := []struct { + name string + stage kargoapi.Stage + objects []client.Object + assertions func(*testing.T, []kargoapi.PromotionStep, error) + }{ + { + name: "task not found returns error", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Steps: []kargoapi.PromotionStep{ + { + Task: &kargoapi.PromotionTaskReference{ + Name: "missing-task", + }, + }, + }, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + assert.ErrorContains(t, err, "not found") + assert.Nil(t, steps) + }, + }, + { + name: "single direct step", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Steps: []kargoapi.PromotionStep{ + { + As: "direct-step", + Uses: "fake-step", + }, + }, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + require.NoError(t, err) + require.Len(t, steps, 1) + + assert.Equal(t, "direct-step", steps[0].As) + assert.Equal(t, "fake-step", steps[0].Uses) + }, + }, + { + name: "mix of direct and task steps", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Steps: []kargoapi.PromotionStep{ + { + As: "direct-step", + Uses: "fake-step", + }, + { + As: "task-step", + Task: &kargoapi.PromotionTaskReference{ + Name: "test-task", + }, + Config: makeJSONObj(t, map[string]any{ + "input1": "value1", + }), + }, + }, + }, + }, + }, + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + Steps: []kargoapi.PromotionStep{ + { + As: "sub-step", + Uses: "other-fake-step", + }, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + require.NoError(t, err) + require.Len(t, steps, 2) + + // Check direct step + assert.Equal(t, "direct-step", steps[0].As) + assert.Equal(t, "fake-step", steps[0].Uses) + + // Check inflated task step + assert.Equal(t, "task-step::sub-step", steps[1].As) + assert.Equal(t, "other-fake-step", steps[1].Uses) + assert.Equal(t, map[string]string{"input1": "value1"}, steps[1].Inputs) + }, + }, + { + name: "multiple task steps", + stage: kargoapi.Stage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stage", + Namespace: "test-project", + }, + Spec: kargoapi.StageSpec{ + PromotionTemplate: &kargoapi.PromotionTemplate{ + Spec: kargoapi.PromotionTemplateSpec{ + Steps: []kargoapi.PromotionStep{ + { + As: "task1", + Task: &kargoapi.PromotionTaskReference{ + Name: "test-task-1", + }, + Config: makeJSONObj(t, map[string]any{ + "input1": "value1", + }), + }, + { + As: "task2", + Task: &kargoapi.PromotionTaskReference{ + Kind: "ClusterPromotionTask", + Name: "test-task-2", + }, + Config: makeJSONObj(t, map[string]any{ + "input2": "value2", + }), + }, + }, + }, + }, + }, + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task-1", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + Steps: []kargoapi.PromotionStep{ + { + As: "step1", + Uses: "fake-step", + }, + }, + }, + }, + &kargoapi.ClusterPromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task-2", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input2"}, + }, + Steps: []kargoapi.PromotionStep{ + { + As: "step2", + Uses: "other-fake-step", + }, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + require.NoError(t, err) + require.Len(t, steps, 2) + + assert.Equal(t, "task1::step1", steps[0].As) + assert.Equal(t, "fake-step", steps[0].Uses) + assert.Equal(t, map[string]string{"input1": "value1"}, steps[0].Inputs) + + assert.Equal(t, "task2::step2", steps[1].As) + assert.Equal(t, "other-fake-step", steps[1].Uses) + assert.Equal(t, map[string]string{"input2": "value2"}, steps[1].Inputs) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(tt.objects...). + Build() + + b := NewPromotionBuilder(c) + steps, err := b.buildSteps(context.Background(), tt.stage) + tt.assertions(t, steps, err) + }) + } +} + +func TestPromotionBuilder_inflateTaskSteps(t *testing.T) { + s := runtime.NewScheme() + require.NoError(t, kargoapi.AddToScheme(s)) + + tests := []struct { + name string + project string + taskAlias string + taskStep kargoapi.PromotionStep + objects []client.Object + assertions func(*testing.T, []kargoapi.PromotionStep, error) + }{ + { + name: "task not found", + project: "test-project", + taskAlias: "deploy", + taskStep: kargoapi.PromotionStep{ + Task: &kargoapi.PromotionTaskReference{ + Name: "missing-task", + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + assert.True(t, apierrors.IsNotFound(err)) + assert.Nil(t, steps) + }, + }, + { + name: "invalid config for task inputs", + project: "test-project", + + taskStep: kargoapi.PromotionStep{ + Task: &kargoapi.PromotionTaskReference{ + Name: "test-task", + }, + Config: &apiextensionsv1.JSON{Raw: []byte(`{invalid json`)}, + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + assert.ErrorContains(t, err, "unmarshal step config") + assert.Nil(t, steps) + }, + }, + { + name: "successful task step inflation", + project: "test-project", + taskAlias: "task-1", + taskStep: kargoapi.PromotionStep{ + Task: &kargoapi.PromotionTaskReference{ + Name: "test-task", + }, + Config: makeJSONObj(t, map[string]any{ + "input1": "value1", + "input2": "value2", + }), + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + {Name: "input2", Default: "default2"}, + }, + Steps: []kargoapi.PromotionStep{ + { + As: "step1", + Uses: "fake-step", + }, + { + As: "step2", + Uses: "other-fake-step", + }, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + require.NoError(t, err) + require.Len(t, steps, 2) + + assert.Equal(t, "task-1::step1", steps[0].As) + assert.Equal(t, "fake-step", steps[0].Uses) + assert.Equal(t, map[string]string{ + "input1": "value1", + "input2": "value2", + }, steps[0].Inputs) + + assert.Equal(t, "task-1::step2", steps[1].As) + assert.Equal(t, "other-fake-step", steps[1].Uses) + assert.Equal(t, map[string]string{ + "input1": "value1", + "input2": "value2", + }, steps[1].Inputs) + }, + }, + { + name: "task steps with default alias", + project: "test-project", + taskAlias: "custom-alias", + taskStep: kargoapi.PromotionStep{ + Task: &kargoapi.PromotionTaskReference{ + Name: "test-task", + }, + Config: makeJSONObj(t, map[string]any{ + "input1": "value1", + }), + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + Steps: []kargoapi.PromotionStep{ + { + Uses: "fake-step", + }, + { + Uses: "other-fake-step", + }, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + require.NoError(t, err) + require.Len(t, steps, 2) + + assert.Equal(t, "custom-alias::step-0", steps[0].As) + assert.Equal(t, "custom-alias::step-1", steps[1].As) + }, + }, + { + name: "cluster task with steps", + project: "test-project", + taskAlias: "task-0", + taskStep: kargoapi.PromotionStep{ + Task: &kargoapi.PromotionTaskReference{ + Kind: "ClusterPromotionTask", + Name: "test-cluster-task", + }, + Config: makeJSONObj(t, map[string]any{ + "input1": "value1", + }), + }, + objects: []client.Object{ + &kargoapi.ClusterPromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-task", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + Steps: []kargoapi.PromotionStep{ + { + As: "custom-alias", + Uses: "fake-step", + }, + }, + }, + }, + }, + assertions: func(t *testing.T, steps []kargoapi.PromotionStep, err error) { + require.NoError(t, err) + require.Len(t, steps, 1) + assert.Equal(t, "task-0::custom-alias", steps[0].As) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(tt.objects...). + Build() + + b := NewPromotionBuilder(c) + steps, err := b.inflateTaskSteps(context.Background(), tt.project, tt.taskAlias, tt.taskStep) + tt.assertions(t, steps, err) + }) + } +} + +func TestPromotionBuilder_getTaskSpec(t *testing.T) { + s := runtime.NewScheme() + require.NoError(t, kargoapi.AddToScheme(s)) + + tests := []struct { + name string + project string + ref *kargoapi.PromotionTaskReference + objects []client.Object + interceptor interceptor.Funcs + assertions func(*testing.T, *kargoapi.PromotionTaskSpec, error) + }{ + { + name: "nil reference returns error", + project: "test-project", + ref: nil, + assertions: func(t *testing.T, result *kargoapi.PromotionTaskSpec, err error) { + assert.ErrorContains(t, err, "missing task reference") + assert.Nil(t, result) + }, + }, + { + name: "unknown task kind returns error", + project: "test-project", + ref: &kargoapi.PromotionTaskReference{ + Kind: "UnknownKind", + Name: "test-task", + }, + assertions: func(t *testing.T, result *kargoapi.PromotionTaskSpec, err error) { + assert.ErrorContains(t, err, "unknown task reference kind") + assert.Nil(t, result) + }, + }, + { + name: "PromotionTask not found returns error", + project: "test-project", + ref: &kargoapi.PromotionTaskReference{ + Kind: "PromotionTask", + Name: "missing-task", + }, + interceptor: interceptor.Funcs{ + Get: func( + context.Context, + client.WithWatch, + client.ObjectKey, + client.Object, + ...client.GetOption, + ) error { + return errors.New("something went wrong") + }, + }, + assertions: func(t *testing.T, result *kargoapi.PromotionTaskSpec, err error) { + assert.ErrorContains(t, err, "something went wrong") + assert.Nil(t, result) + }, + }, + { + name: "ClusterPromotionTask not found returns error", + project: "test-project", + ref: &kargoapi.PromotionTaskReference{ + Kind: "ClusterPromotionTask", + Name: "missing-cluster-task", + }, + interceptor: interceptor.Funcs{ + Get: func( + context.Context, + client.WithWatch, + client.ObjectKey, + client.Object, + ...client.GetOption, + ) error { + return errors.New("something went wrong") + }, + }, + assertions: func(t *testing.T, result *kargoapi.PromotionTaskSpec, err error) { + assert.ErrorContains(t, err, "something went wrong") + assert.Nil(t, result) + }, + }, + { + name: "successfully retrieves PromotionTask", + project: "test-project", + ref: &kargoapi.PromotionTaskReference{ + Kind: "PromotionTask", + Name: "test-task", + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1", Default: "value1"}, + }, + }, + }, + }, + assertions: func(t *testing.T, result *kargoapi.PromotionTaskSpec, err error) { + require.NoError(t, err) + require.NotNil(t, result) + + assert.Len(t, result.Inputs, 1) + assert.Equal(t, "input1", result.Inputs[0].Name) + assert.Equal(t, "value1", result.Inputs[0].Default) + }, + }, + { + name: "successfully retrieves ClusterPromotionTask", + project: "test-project", + ref: &kargoapi.PromotionTaskReference{ + Kind: "ClusterPromotionTask", + Name: "test-cluster-task", + }, + objects: []client.Object{ + &kargoapi.ClusterPromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-task", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1", Default: "value1"}, + }, + }, + }, + }, + assertions: func(t *testing.T, result *kargoapi.PromotionTaskSpec, err error) { + require.NoError(t, err) + require.NotNil(t, result) + + assert.Len(t, result.Inputs, 1) + assert.Equal(t, "input1", result.Inputs[0].Name) + assert.Equal(t, "value1", result.Inputs[0].Default) + }, + }, + { + name: "empty kind defaults to PromotionTask", + project: "test-project", + ref: &kargoapi.PromotionTaskReference{ + Kind: "", + Name: "test-task", + }, + objects: []client.Object{ + &kargoapi.PromotionTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-task", + Namespace: "test-project", + }, + Spec: kargoapi.PromotionTaskSpec{ + Inputs: []kargoapi.PromotionTaskInput{ + {Name: "input1", Default: "value1"}, + }, + }, + }, + }, + assertions: func(t *testing.T, result *kargoapi.PromotionTaskSpec, err error) { + require.NoError(t, err) + require.NotNil(t, result) + + assert.Len(t, result.Inputs, 1) + assert.Equal(t, "input1", result.Inputs[0].Name) + assert.Equal(t, "value1", result.Inputs[0].Default) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(tt.objects...). + WithInterceptorFuncs(tt.interceptor). + Build() + + b := NewPromotionBuilder(c) + result, err := b.getTaskSpec(context.Background(), tt.project, tt.ref) + tt.assertions(t, result, err) + }) + } +} + +func Test_generatePromotionName(t *testing.T) { + tests := []struct { + name string + stageName string + freight string + assertions func(t *testing.T, result string) + }{ + { + name: "standard input lengths", + stageName: "dev", + freight: "abc123def456", + assertions: func(t *testing.T, result string) { + components := strings.Split(result, ".") + assert.Len(t, components, 3) + assert.Equal(t, "dev", components[0]) + assert.Len(t, components[1], ulidLength) + assert.Equal(t, "abc123d", components[2]) + }, + }, + { + name: "short freight", + stageName: "prod", + freight: "abc", + assertions: func(t *testing.T, result string) { + components := strings.Split(result, ".") + assert.Len(t, components, 3) + assert.Equal(t, "prod", components[0]) + assert.Len(t, components[1], ulidLength) + assert.Equal(t, "abc", components[2]) + }, + }, + { + name: "long stage name gets truncated", + // nolint:lll + stageName: "this-is-a-very-long-stage-name-that-exceeds-the-maximum-allowed-length-for-kubernetes-resources-and-should-be-truncated-to-fit-within-the-limits-set-by-the-api-server-which-is-253-characters-including-the-generated-suffix", + freight: "abc123def456", + assertions: func(t *testing.T, result string) { + assert.Equal(t, len(result), 253) // Kubernetes resource name limit + assert.Equal(t, maxStageNamePrefixLength, len(result[:strings.Index(result, ".")])) + }, + }, + { + name: "long freight gets truncated", + stageName: "stage", + freight: "this-is-a-very-long-freight-hash-that-should-be-truncated", + assertions: func(t *testing.T, result string) { + shortHash := result[strings.LastIndex(result, ".")+1:] + assert.Equal(t, shortHashLength, len(shortHash)) + }, + }, + { + name: "all lowercase conversion", + stageName: "DEV-STAGE", + freight: "ABC123DEF456", + assertions: func(t *testing.T, result string) { + assert.Equal(t, "dev-stage", result[:len("dev-stage")]) + assert.Equal(t, "abc123d", result[len(result)-7:]) + }, + }, + { + name: "empty inputs", + stageName: "", + freight: "", + assertions: func(t *testing.T, result string) { + assert.Empty(t, result) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generatePromotionName(tt.stageName, tt.freight) + tt.assertions(t, result) + }) + } +} + +func Test_generatePromotionTaskStepName(t *testing.T) { + tests := []struct { + name string + taskAlias string + stepAlias string + assertions func(t *testing.T, result string) + }{ + { + name: "standard aliases", + taskAlias: "deploy", + stepAlias: "apply", + assertions: func(t *testing.T, result string) { + assert.Equal(t, "deploy::apply", result) + }, + }, + { + name: "empty task alias", + taskAlias: "", + stepAlias: "apply", + assertions: func(t *testing.T, result string) { + assert.Equal(t, "::apply", result) + }, + }, + { + name: "empty step alias", + taskAlias: "deploy", + stepAlias: "", + assertions: func(t *testing.T, result string) { + assert.Equal(t, "deploy::", result) + }, + }, + { + name: "both aliases empty", + taskAlias: "", + stepAlias: "", + assertions: func(t *testing.T, result string) { + assert.Equal(t, "::", result) + }, + }, + { + name: "aliases with special characters", + taskAlias: "deploy-task", + stepAlias: "apply_config", + assertions: func(t *testing.T, result string) { + assert.Equal(t, "deploy-task::apply_config", result) + }, + }, + { + name: "aliases containing separator", + taskAlias: "deploy::task", + stepAlias: "apply::config", + assertions: func(t *testing.T, result string) { + assert.Equal(t, "deploy::task::apply::config", result) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generatePromotionTaskStepAlias(tt.taskAlias, tt.stepAlias) + tt.assertions(t, result) + }) + } +} + +func Test_validateAndMapTaskInputs(t *testing.T) { + tests := []struct { + name string + taskInputs []kargoapi.PromotionTaskInput + config map[string]any + assertions func(t *testing.T, result map[string]string, err error) + }{ + { + name: "nil inputs returns nil map and no error", + taskInputs: nil, + config: nil, + assertions: func(t *testing.T, result map[string]string, err error) { + require.NoError(t, err) + assert.Nil(t, result) + }, + }, + { + name: "empty inputs returns nil map and no error", + taskInputs: []kargoapi.PromotionTaskInput{}, + config: nil, + assertions: func(t *testing.T, result map[string]string, err error) { + require.NoError(t, err) + assert.Nil(t, result) + }, + }, + { + name: "missing config when inputs required returns error", + taskInputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + config: nil, + assertions: func(t *testing.T, result map[string]string, err error) { + assert.ErrorContains(t, err, "missing step config") + assert.Nil(t, result) + }, + }, + { + name: "non-string input value returns error", + taskInputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + config: map[string]any{ + "input1": 123, // number instead of string + }, + assertions: func(t *testing.T, result map[string]string, err error) { + assert.ErrorContains(t, err, "input \"input1\" must be a string") + assert.Nil(t, result) + }, + }, + { + name: "missing required input returns error", + taskInputs: []kargoapi.PromotionTaskInput{ + {Name: "input1"}, + }, + config: map[string]any{ + "input1": "", // empty string is not allowed without default + }, + assertions: func(t *testing.T, result map[string]string, err error) { + assert.ErrorContains(t, err, "missing required input \"input1\"") + assert.Nil(t, result) + }, + }, + { + name: "default value used when config value not provided", + taskInputs: []kargoapi.PromotionTaskInput{ + {Name: "input1", Default: "default1"}, + }, + config: map[string]any{}, + assertions: func(t *testing.T, result map[string]string, err error) { + require.NoError(t, err) + assert.Equal(t, map[string]string{"input1": "default1"}, result) + }, + }, + { + name: "config value overrides default value", + taskInputs: []kargoapi.PromotionTaskInput{ + {Name: "input1", Default: "default1"}, + }, + config: map[string]any{ + "input1": "override1", + }, + assertions: func(t *testing.T, result map[string]string, err error) { + require.NoError(t, err) + assert.Equal(t, map[string]string{"input1": "override1"}, result) + }, + }, + { + name: "multiple inputs processed correctly", + taskInputs: []kargoapi.PromotionTaskInput{ + {Name: "input1", Default: "default1"}, + {Name: "input2", Default: "default2"}, + {Name: "input3"}, + }, + config: map[string]any{ + "input1": "override1", + "input3": "value3", + }, + assertions: func(t *testing.T, result map[string]string, err error) { + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "input1": "override1", + "input2": "default2", + "input3": "value3", + }, result) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var configJSON *apiextensionsv1.JSON + if tt.config != nil { + configBytes, err := yaml.Marshal(tt.config) + require.NoError(t, err) + configJSON = &apiextensionsv1.JSON{Raw: configBytes} + } + + result, err := validateAndMapTaskInputs(tt.taskInputs, configJSON) + tt.assertions(t, result, err) + }) + } +} + +// makeJSONObj is a helper function to create an API extension JSON object from +// a map. +func makeJSONObj(t *testing.T, m map[string]any) *apiextensionsv1.JSON { + data, err := yaml.Marshal(m) + require.NoError(t, err) + return &apiextensionsv1.JSON{Raw: data} +}