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..1ceb2de35 --- /dev/null +++ b/internal/kargo/promotion_builder.go @@ -0,0 +1,237 @@ +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 = "." + + // 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 = fmt.Sprintf("%s::%s", 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 { + 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)) +} + +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 +}