diff --git a/internal/directives/promotions.go b/internal/directives/promotions.go index fda35fe12..12d9b0ce1 100644 --- a/internal/directives/promotions.go +++ b/internal/directives/promotions.go @@ -106,6 +106,26 @@ type PromotionStep struct { Config []byte } +// PromotionStepEnvOption is a functional option for customizing the +// environment of a PromotionStep built by BuildEnv. +type PromotionStepEnvOption func(map[string]any) + +// StepEnvWithVars returns a PromotionStepEnvOption that adds the provided vars to +// the environment of the PromotionStep. +func StepEnvWithVars(vars map[string]any) PromotionStepEnvOption { + return func(env map[string]any) { + env["vars"] = vars + } +} + +// StepEnvWithSecrets returns a PromotionStepEnvOption that adds the provided secrets +// to the environment of the PromotionStep. +func StepEnvWithSecrets(secrets map[string]map[string]string) PromotionStepEnvOption { + return func(env map[string]any) { + env["secrets"] = secrets + } +} + // GetTimeout returns the maximum interval the provided runner may spend // attempting to execute the step before retries are abandoned and the entire // Promotion is marked as failed. If the runner is a RetryableStepRunner, its @@ -131,32 +151,23 @@ func (s *PromotionStep) GetErrorThreshold(runner any) uint32 { return s.Retry.GetErrorThreshold(fallback) } -// GetConfig returns the Config unmarshalled into a map. Any expr-lang -// expressions are evaluated in the context of the provided arguments -// prior to unmarshaling. -func (s *PromotionStep) GetConfig( - ctx context.Context, - cl client.Client, +// BuildEnv returns the environment for the PromotionStep. The environment +// includes the context of the Promotion, the outputs of the previous steps, +// and any additional options provided. +// +// The environment is a (nested) map of string keys to any values. The keys +// are used as variables in the PromotionStep configuration. +func (s *PromotionStep) BuildEnv( promoCtx PromotionContext, state State, -) (Config, error) { - if s.Config == nil { - return nil, nil - } - - vars, err := s.GetVars(promoCtx, state) - if err != nil { - return nil, err - } - + opts ...PromotionStepEnvOption, +) map[string]any { env := map[string]any{ "ctx": map[string]any{ "project": promoCtx.Project, "promotion": promoCtx.Promotion, "stage": promoCtx.Stage, }, - "vars": vars, - "secrets": promoCtx.Secrets, "outputs": state, } @@ -170,6 +181,34 @@ func (s *PromotionStep) GetConfig( } } + // Apply all provided options + for _, opt := range opts { + opt(env) + } + + return env +} + +// GetConfig returns the Config unmarshalled into a map. Any expr-lang +// expressions are evaluated in the context of the provided arguments +// prior to unmarshaling. +func (s *PromotionStep) GetConfig( + ctx context.Context, + cl client.Client, + promoCtx PromotionContext, + state State, +) (Config, error) { + if s.Config == nil { + return nil, nil + } + + vars, err := s.GetVars(promoCtx, state) + if err != nil { + return nil, err + } + + env := s.BuildEnv(promoCtx, state, StepEnvWithVars(vars), StepEnvWithSecrets(promoCtx.Secrets)) + evaledCfgJSON, err := expressions.EvaluateJSONTemplate( s.Config, env, @@ -219,25 +258,9 @@ func (s *PromotionStep) GetVars( rawVars[v.Name] = v.Value } - taskOutput := s.getTaskOutputs(state) - vars := make(map[string]any, len(rawVars)) for k, v := range rawVars { - env := map[string]any{ - "ctx": map[string]any{ - "project": promoCtx.Project, - "promotion": promoCtx.Promotion, - "stage": promoCtx.Stage, - }, - "vars": vars, - "outputs": state, - } - - if taskOutput != nil { - env["task"] = map[string]any{ - "outputs": taskOutput, - } - } + env := s.BuildEnv(promoCtx, state) newVar, err := expressions.EvaluateTemplate(v, env) if err != nil { diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 364b753e9..ec2e9fcbd 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -140,6 +140,15 @@ func RunningPromotionsByArgoCDApplications( return nil } + // Build just enough context to extract the relevant config from the + // argocd-update promotion step. + promoCtx := directives.PromotionContext{ + Project: promo.Namespace, + Stage: promo.Spec.Stage, + Promotion: promo.Name, + Vars: promo.Spec.Vars, + } + // Extract the Argo CD Applications from the promotion steps. // // TODO(hidde): This is not ideal as it requires parsing the directives and @@ -155,20 +164,14 @@ func RunningPromotionsByArgoCDApplications( if step.Uses != "argocd-update" || step.Config == nil { continue } + dirStep := directives.PromotionStep{ Kind: step.Uses, Alias: step.As, - Vars: step.Vars, + Vars: step.Vars, Config: step.Config.Raw, } - // Build just enough context to extract the relevant config from the - // argocd-update promotion step. - promoCtx := directives.PromotionContext{ - Project: promo.Namespace, - Stage: promo.Spec.Stage, - Promotion: promo.Name, - Vars: promo.Spec.Vars, - } + // As we are not evaluating expressions in the entire config, we do not // pass any state. vars, err := dirStep.GetVars(promoCtx, promo.Status.GetState()) @@ -215,14 +218,12 @@ func RunningPromotionsByArgoCDApplications( for _, app := range appsList { if app, ok := app.(map[string]any); ok { if nameTemplate, ok := app["name"].(string); ok { - env := map[string]any{ - "ctx": map[string]any{ - "project": promoCtx.Project, - "promotion": promoCtx.Promotion, - "stage": promoCtx.Stage, - }, - "vars": vars, - } + env := dirStep.BuildEnv( + promoCtx, + promo.Status.GetState(), + directives.StepEnvWithVars(vars), + ) + var namespace any = libargocd.Namespace() if namespaceTemplate, ok := app["namespace"].(string); ok { if namespace, err = expressions.EvaluateTemplate(namespaceTemplate, env); err != nil { diff --git a/internal/indexer/indexer_test.go b/internal/indexer/indexer_test.go index c14ab823a..39cdfbd4b 100644 --- a/internal/indexer/indexer_test.go +++ b/internal/indexer/indexer_test.go @@ -329,6 +329,12 @@ func TestRunningPromotionsByArgoCDApplications(t *testing.T) { }, Spec: kargoapi.PromotionSpec{ Stage: "fake-stage", + Vars: []kargoapi.PromotionVariable{ + { + Name: "app", + Value: "fake-app-from-var", + }, + }, Steps: []kargoapi.PromotionStep{ { Uses: "argocd-update", @@ -346,16 +352,74 @@ func TestRunningPromotionsByArgoCDApplications(t *testing.T) { Raw: []byte(`{"apps":[{"name":"fake-app-${{ ctx.stage }}"}]}`), }, }, + { + Uses: "argocd-update", + Config: &apiextensionsv1.JSON{ + // Note that this uses a variable within the expression + Raw: []byte(`{"apps":[{"name":"${{ vars.app }}"}]}`), + }, + }, + { + Uses: "argocd-update", + Vars: []kargoapi.PromotionVariable{ + { + Name: "app", + Value: "fake-app-from-step-var", + }, + }, + Config: &apiextensionsv1.JSON{ + // Note that this uses a step-level variable within the expression + Raw: []byte(`{"apps":[{"name":"${{ vars.app }}"}]}`), + }, + }, + { + Uses: "argocd-update", + Config: &apiextensionsv1.JSON{ + // Note that this uses output from a (fake) previous step within the expression + Raw: []byte(`{"apps":[{"name":"fake-app-${{ outputs.push.branch }}"}]}`), + }, + }, + { + Uses: "argocd-update", + Vars: []kargoapi.PromotionVariable{ + { + Name: "input", + Value: "${{ outputs.composition.name }}", + }, + }, + Config: &apiextensionsv1.JSON{ + // Note that this uses output from a previous step through a variable + Raw: []byte(`{"apps":[{"name":"fake-app-${{ vars.input }}"}]}`), + }, + }, + { + Uses: "argocd-update", + As: "task-1::update", + Config: &apiextensionsv1.JSON{ + // Note that this uses output from a "task" step within the expression + Raw: []byte(`{"apps":[{"name":"fake-app-${{ task.outputs.fake.name }}"}]}`), + }, + }, }, }, Status: kargoapi.PromotionStatus{ - Phase: kargoapi.PromotionPhaseRunning, - CurrentStep: 2, // Ensure all steps above are considered + Phase: kargoapi.PromotionPhaseRunning, + State: &apiextensionsv1.JSON{ + // Mock the output of the previous steps + // nolint:lll + Raw: []byte(`{"push":{"branch":"from-branch"},"composition":{"name":"from-composition"},"task-1::fake":{"name":"from-task"}}`), + }, + CurrentStep: 7, // Ensure all steps above are considered }, }, expected: []string{ "fake-namespace:fake-app", fmt.Sprintf("%s:%s", argocd.Namespace(), "fake-app-fake-stage"), + fmt.Sprintf("%s:%s", argocd.Namespace(), "fake-app-from-var"), + fmt.Sprintf("%s:%s", argocd.Namespace(), "fake-app-from-step-var"), + fmt.Sprintf("%s:%s", argocd.Namespace(), "fake-app-from-branch"), + fmt.Sprintf("%s:%s", argocd.Namespace(), "fake-app-from-composition"), + fmt.Sprintf("%s:%s", argocd.Namespace(), "fake-app-from-task"), }, }, {