Skip to content

Commit 3b4600d

Browse files
krancourclaude
andcommitted
feat: consolidate Promotion construction in defaulting webhook
API server endpoints and the Stage controller now submit minimal Promotions (Stage + Freight); the defaulting webhook unconditionally populates the name, steps, and vars from the referenced Stage's PromotionTemplate. As a result, kubectl-driven Promotion creation now behaves the same as creation via the UI or CLI. Closes #5126. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0075217 commit 3b4600d

13 files changed

Lines changed: 1287 additions & 1545 deletions

File tree

pkg/api/promotion.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package api
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"strings"
78
"time"
89

10+
"github.com/oklog/ulid/v2"
911
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1012
"k8s.io/apimachinery/pkg/types"
1113
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -14,6 +16,77 @@ import (
1416
"github.com/akuity/kargo/pkg/server/user"
1517
)
1618

19+
// PromotionAliasSeparator is the separator used in the alias of an inflated
20+
// PromotionTask step to separate the task alias from the step alias.
21+
const PromotionAliasSeparator = "::"
22+
23+
const (
24+
// promotionNameSeparator separates components of a Promotion name.
25+
promotionNameSeparator = "."
26+
27+
// promotionShortHashLength is the length of the short Freight hash
28+
// embedded in a generated Promotion name.
29+
promotionShortHashLength = 7
30+
31+
// maxStageNamePrefixForPromotionName is the maximum length of the Stage
32+
// name used as the prefix of a generated Promotion name before it would
33+
// exceed the Kubernetes resource name limit of 253.
34+
maxStageNamePrefixForPromotionName = 253 -
35+
len(promotionNameSeparator) - ulid.EncodedSize -
36+
len(promotionNameSeparator) - promotionShortHashLength
37+
)
38+
39+
// NewMinimalPromotion constructs a Promotion containing only the fields that
40+
// callers (API server endpoints, the Stage controller's auto-promote loop) are
41+
// responsible for setting. The Promotion defaulting webhook fills in the rest:
42+
// name, steps copied from the Stage's PromotionTemplate, etc.
43+
func NewMinimalPromotion(
44+
stage *kargoapi.Stage,
45+
freightName string,
46+
) *kargoapi.Promotion {
47+
return &kargoapi.Promotion{
48+
ObjectMeta: metav1.ObjectMeta{
49+
Namespace: stage.Namespace,
50+
// The defaulting webhook overwrites this. We set it here only so that the
51+
// Kubernetes API server has a name to work with before admission runs.
52+
GenerateName: "promo-",
53+
},
54+
Spec: kargoapi.PromotionSpec{
55+
Stage: stage.Name,
56+
Freight: freightName,
57+
},
58+
}
59+
}
60+
61+
// GeneratePromotionName generates a name for a Promotion by combining the
62+
// Stage name, a ULID, and a short hash of the Freight.
63+
//
64+
// The name has the format of:
65+
//
66+
// <stage-name>.<ulid>.<short-hash>
67+
//
68+
// Promotion sorting and comparison logic elsewhere in Kargo relies on names
69+
// in this format -- the embedded ULID makes lex order match creation order.
70+
// Callers that need a Promotion name should always use this function.
71+
func GeneratePromotionName(stageName, freight string) string {
72+
if stageName == "" || freight == "" {
73+
return ""
74+
}
75+
76+
shortHash := freight
77+
if len(shortHash) > promotionShortHashLength {
78+
shortHash = shortHash[0:promotionShortHashLength]
79+
}
80+
81+
shortStageName := stageName
82+
if len(stageName) > maxStageNamePrefixForPromotionName {
83+
shortStageName = shortStageName[0:maxStageNamePrefixForPromotionName]
84+
}
85+
86+
parts := []string{shortStageName, ulid.Make().String(), shortHash}
87+
return strings.ToLower(strings.Join(parts, promotionNameSeparator))
88+
}
89+
1790
// GetPromotion returns a pointer to the Promotion resource specified by the
1891
// namespacedName argument. If no such resource is found, nil is returned
1992
// instead.
@@ -164,3 +237,175 @@ func IsCurrentStepRunning(promo *kargoapi.Promotion) bool {
164237
int64(len(promo.Status.StepExecutionMetadata)) == promo.Status.CurrentStep+1 &&
165238
promo.Status.StepExecutionMetadata[promo.Status.CurrentStep].Status == kargoapi.PromotionStepStatusRunning
166239
}
240+
241+
// InflateSteps inflates the given Promotion's steps in place by resolving any
242+
// references to (Cluster)PromotionTasks and expanding them into their
243+
// individual steps.
244+
func InflateSteps(
245+
ctx context.Context,
246+
c client.Client,
247+
promo *kargoapi.Promotion,
248+
) error {
249+
steps := make([]kargoapi.PromotionStep, 0, len(promo.Spec.Steps))
250+
for i, step := range promo.Spec.Steps {
251+
switch {
252+
case step.Task != nil:
253+
alias := step.GetAlias(i)
254+
taskSteps, err := inflateTaskSteps(
255+
ctx,
256+
c,
257+
promo.Namespace,
258+
alias,
259+
promo.Spec.Vars,
260+
step,
261+
)
262+
if err != nil {
263+
return fmt.Errorf(
264+
"inflate tasks steps for task %q (%q): %w",
265+
step.Task.Name, alias, err,
266+
)
267+
}
268+
steps = append(steps, taskSteps...)
269+
default:
270+
step.As = step.GetAlias(i)
271+
steps = append(steps, step)
272+
}
273+
}
274+
promo.Spec.Steps = steps
275+
return nil
276+
}
277+
278+
// inflateTaskSteps inflates the PromotionSteps for the given PromotionStep
279+
// that references a (Cluster)PromotionTask. The task is retrieved and its
280+
// steps are inflated with the given task inputs.
281+
func inflateTaskSteps(
282+
ctx context.Context,
283+
c client.Client,
284+
project, taskAlias string,
285+
promoVars []kargoapi.ExpressionVariable,
286+
taskStep kargoapi.PromotionStep,
287+
) ([]kargoapi.PromotionStep, error) {
288+
task, err := getPromotionTaskSpec(ctx, c, project, taskStep.Task)
289+
if err != nil {
290+
return nil, err
291+
}
292+
293+
vars, err := promotionTaskVarsToStepVars(task.Vars, promoVars, taskStep.Vars)
294+
if err != nil {
295+
return nil, err
296+
}
297+
298+
var steps []kargoapi.PromotionStep
299+
for i := range task.Steps {
300+
// Copy the step as-is.
301+
step := &task.Steps[i]
302+
303+
// Ensures we have a unique alias for each step within the context of
304+
// the Promotion.
305+
step.As = generatePromotionTaskStepAlias(taskAlias, step.GetAlias(i))
306+
307+
// With the variables validated and mapped, they are now available to
308+
// the Config of the step during the Promotion execution.
309+
step.Vars = append(vars, step.Vars...)
310+
311+
// Append the inflated step to the list of steps.
312+
steps = append(steps, *step)
313+
}
314+
return steps, nil
315+
}
316+
317+
// getPromotionTaskSpec retrieves the PromotionTaskSpec for the given
318+
// PromotionTaskReference.
319+
func getPromotionTaskSpec(
320+
ctx context.Context,
321+
c client.Client,
322+
project string,
323+
ref *kargoapi.PromotionTaskReference,
324+
) (*kargoapi.PromotionTaskSpec, error) {
325+
var spec kargoapi.PromotionTaskSpec
326+
327+
if ref == nil {
328+
return nil, errors.New("missing task reference")
329+
}
330+
331+
switch ref.Kind {
332+
case "PromotionTask", "":
333+
task := &kargoapi.PromotionTask{}
334+
if err := c.Get(ctx, client.ObjectKey{Namespace: project, Name: ref.Name}, task); err != nil {
335+
return nil, err
336+
}
337+
spec = task.Spec
338+
case "ClusterPromotionTask":
339+
task := &kargoapi.ClusterPromotionTask{}
340+
if err := c.Get(ctx, client.ObjectKey{Name: ref.Name}, task); err != nil {
341+
return nil, err
342+
}
343+
spec = task.Spec
344+
default:
345+
return nil, fmt.Errorf("unknown task reference kind %q", ref.Kind)
346+
}
347+
348+
return &spec, nil
349+
}
350+
351+
// generatePromotionTaskStepAlias generates an alias for a PromotionTask step
352+
// by combining the task alias and the step alias.
353+
func generatePromotionTaskStepAlias(taskAlias, stepAlias string) string {
354+
return fmt.Sprintf("%s%s%s", taskAlias, PromotionAliasSeparator, stepAlias)
355+
}
356+
357+
// promotionTaskVarsToStepVars validates the presence of the PromotionTask
358+
// variables and maps them to variables which can be used by the inflated
359+
// PromotionStep.
360+
func promotionTaskVarsToStepVars(
361+
taskVars, promoVars, stepVars []kargoapi.ExpressionVariable,
362+
) ([]kargoapi.ExpressionVariable, error) {
363+
// Promotion variables can be used to set (or override) the variables
364+
// required by the PromotionTask, but they are not inflated into the
365+
// variables for the step. This map is used to check if a variable is
366+
// set on the Promotion, to avoid overriding it with the default value
367+
// and to validate that the variable is set.
368+
promoVarsMap := make(map[string]struct{}, len(promoVars))
369+
for _, v := range promoVars {
370+
if v.Value != "" {
371+
promoVarsMap[v.Name] = struct{}{}
372+
}
373+
}
374+
375+
// Step variables are inflated into the variables for the step. This map
376+
// is used to ensure all variables required by the PromotionTask without
377+
// a default value are set.
378+
stepVarsMap := make(map[string]struct{}, len(stepVars))
379+
for _, v := range stepVars {
380+
if v.Value != "" {
381+
stepVarsMap[v.Name] = struct{}{}
382+
}
383+
}
384+
385+
var vars []kargoapi.ExpressionVariable
386+
387+
// Set the PromotionTask variable default values, but only if the variable
388+
// is not set on the Promotion.
389+
for _, v := range taskVars {
390+
// Variable is set on the Promotion, we do not need to set the default.
391+
if _, ok := promoVarsMap[v.Name]; ok {
392+
continue
393+
}
394+
395+
// Set the variable if it has a default value.
396+
if v.Value != "" {
397+
vars = append(vars, v)
398+
continue
399+
}
400+
401+
// If not, the variable must be set in the step variables.
402+
if _, ok := stepVarsMap[v.Name]; !ok {
403+
return nil, fmt.Errorf("missing value for variable %q", v.Name)
404+
}
405+
}
406+
407+
// Set the step variables.
408+
vars = append(vars, stepVars...)
409+
410+
return vars, nil
411+
}

0 commit comments

Comments
 (0)