Repository: akuity/kargo

Issue:
Reconciliation errors for Control-Flow Stage
# Checklist

* [x] I've searched the issue queue to verify this is not a duplicate bug report.
* [x] I've included steps to reproduce the bug.
* [x] I've pasted the output of `kargo version`.
* [x] I've pasted logs, if applicable.

# Description

The Controller Reconciliation outputs errors for the Control-Flow Stage.

# Steps to Reproduce

1. Create Control Flow Stage (my stages were created back in the Kargo 1.3.1)
2. Restart Controller Pod

# Version
1.5.0


# Logs

```
controller=control_flow_stage controllerGroup=kargo.akuity.io controllerKind=Stage error="failed to update Stage status: Stage.kargo.akuity.io \"prod\" is invalid: status.conditions[0].reason: Invalid value: \"\": conditions[0].reason in body should be at least 1 chars long" name=prod namespace=addon-namespace-controller reconcileID="\"9525db3c-0961-42e7-b0d7-fec4d06bf49a\""
```

# Gut Feelings
It looks like the `reason` field is missing in [this](https://github.com/akuity/kargo/blob/v1.5.0/internal/controller/stages/control_flow_stages.go#L317) condition.


Discussion:

Solution patch:
diff --git a/cmd/controlplane/controller.go b/cmd/controlplane/controller.go
index d015b43e..9c9f0031 100644
--- a/cmd/controlplane/controller.go
+++ b/cmd/controlplane/controller.go
@@ -337,7 +337,7 @@ func (o *controllerOptions) setupReconcilers(
 		ctx,
 		kargoMgr,
 		argocdMgr,
-		promotion.NewSimpleEngine(kargoMgr.GetClient()),
+		promotion.NewSimpleEngine(kargoMgr.GetClient(), promotion.DefaultExprDataCacheFn),
 		promotions.ReconcilerConfigFromEnv(),
 	); err != nil {
 		return fmt.Errorf("error setting up Promotions reconciler: %w", err)
diff --git a/internal/controller/stages/control_flow_stages.go b/internal/controller/stages/control_flow_stages.go
index 31858311..004c6655 100644
--- a/internal/controller/stages/control_flow_stages.go
+++ b/internal/controller/stages/control_flow_stages.go
@@ -315,6 +315,7 @@ func (r *ControlFlowStageReconciler) reconcile(
 	conditions.Set(&newStatus, &metav1.Condition{
 		Type:   kargoapi.ConditionTypeReady,
 		Status: metav1.ConditionTrue,
+		Reason: kargoapi.ConditionTypeReady,
 	})
 	conditions.Delete(&newStatus, kargoapi.ConditionTypeReconciling)
 
diff --git a/internal/controller/stages/regular_stages.go b/internal/controller/stages/regular_stages.go
index 5d50dbc2..0a333f05 100644
--- a/internal/controller/stages/regular_stages.go
+++ b/internal/controller/stages/regular_stages.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/kelseyhightower/envconfig"
+	gocache "github.com/patrickmn/go-cache"
 	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -1339,7 +1340,12 @@ func (r *RegularStageReconciler) startVerification(
 					stage.Spec.RequestedFreight,
 					freight.References(),
 				),
-				exprfn.DataOperations(ctx, r.client, stage.Namespace)...,
+				exprfn.DataOperations(
+					ctx,
+					r.client,
+					gocache.New(gocache.NoExpiration, gocache.NoExpiration),
+					stage.Namespace,
+				)...,
 			),
 			Vars: stage.Spec.Vars,
 		},
diff --git a/internal/expressions/function/functions.go b/internal/expressions/function/functions.go
index be0d9458..d59d28e6 100644
--- a/internal/expressions/function/functions.go
+++ b/internal/expressions/function/functions.go
@@ -3,9 +3,11 @@ package function
 import (
 	"context"
 	"fmt"
+	"maps"
 	"strings"
 
 	"github.com/expr-lang/expr"
+	gocache "github.com/patrickmn/go-cache"
 	corev1 "k8s.io/api/core/v1"
 	kubeerr "k8s.io/apimachinery/pkg/api/errors"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -41,10 +43,15 @@ func FreightOperations(
 
 // DataOperations returns a slice of expr.Option containing functions for
 // data operations, such as accessing ConfigMaps and Secrets.
-func DataOperations(ctx context.Context, c client.Client, project string) []expr.Option {
+//
+// When the cache parameter is set, the functions will cache the retrieved
+// ConfigMaps and Secrets to avoid repeated API calls. This can
+// improve performance when the same ConfigMaps and Secrets are accessed
+// multiple times within the same expression evaluation.
+func DataOperations(ctx context.Context, c client.Client, cache *gocache.Cache, project string) []expr.Option {
 	return []expr.Option{
-		ConfigMap(ctx, c, project),
-		Secret(ctx, c, project),
+		ConfigMap(ctx, c, cache, project),
+		Secret(ctx, c, cache, project),
 	}
 }
 
@@ -138,20 +145,20 @@ func ChartFrom(
 
 // ConfigMap returns an expr.Option that provides a `configMap()` function for
 // use in expressions.
-func ConfigMap(ctx context.Context, c client.Client, project string) expr.Option {
+func ConfigMap(ctx context.Context, c client.Client, cache *gocache.Cache, project string) expr.Option {
 	return expr.Function(
 		"configMap",
-		getConfigMap(ctx, c, project),
+		getConfigMap(ctx, c, cache, project),
 		new(func(name string) map[string]string),
 	)
 }
 
 // Secret returns an expr.Option that provides a `secret()` function for use in
 // expressions.
-func Secret(ctx context.Context, c client.Client, project string) expr.Option {
+func Secret(ctx context.Context, c client.Client, cache *gocache.Cache, project string) expr.Option {
 	return expr.Function(
 		"secret",
-		getSecret(ctx, c, project),
+		getSecret(ctx, c, cache, project),
 		new(func(name string) map[string]string),
 	)
 }
@@ -375,7 +382,16 @@ func getChart(
 	}
 }
 
-func getConfigMap(ctx context.Context, c client.Client, project string) exprFn {
+// getConfigMap returns a function that retrieves a ConfigMap by its name
+// within the specified project namespace. If the ConfigMap is not found,
+// it returns an empty map.
+//
+// If a cache is provided, it will be used to store the retrieved ConfigMap
+// data to avoid repeated API calls. The cache key is generated based on a
+// prefix, project name, and ConfigMap name. Because of this, the same cache
+// can be shared with other functions that accept a cache parameter (e.g.,
+// getSecret) without worrying about key collisions.
+func getConfigMap(ctx context.Context, c client.Client, cache *gocache.Cache, project string) exprFn {
 	return func(a ...any) (any, error) {
 		if len(a) != 1 {
 			return nil, fmt.Errorf("expected 1 argument, got %d", len(a))
@@ -386,6 +402,18 @@ func getConfigMap(ctx context.Context, c client.Client, project string) exprFn {
 			return nil, fmt.Errorf("argument must be string, got %T", a[0])
 		}
 
+		cacheKey := getCacheKey(cacheKeyPrefixConfigMap, project, name)
+		if cache != nil {
+			if cachedData, ok := cache.Get(cacheKey); ok {
+				if cachedData == nil {
+					return map[string]string{}, nil
+				}
+				if data, ok := cachedData.(map[string]string); ok {
+					return maps.Clone(data), nil
+				}
+			}
+		}
+
 		var cfgMap corev1.ConfigMap
 		if err := c.Get(
 			ctx,
@@ -401,11 +429,24 @@ func getConfigMap(ctx context.Context, c client.Client, project string) exprFn {
 			return nil, fmt.Errorf("failed to get configmap %s: %w", name, err)
 		}
 
+		if cache != nil {
+			cache.Set(cacheKey, maps.Clone(cfgMap.Data), gocache.NoExpiration)
+		}
+
 		return cfgMap.Data, nil
 	}
 }
 
-func getSecret(ctx context.Context, c client.Client, project string) exprFn {
+// getSecret returns a function that retrieves a Secret by its name within the
+// specified project namespace. If the Secret is not found, it returns an empty
+// map.
+//
+// If a cache is provided, it will be used to store the retrieved Secret data to
+// avoid repeated API calls. The cache key is generated based on a prefix,
+// project name, and Secret name. Because of this, the same cache can be shared
+// with other functions that accept a cache parameter (e.g., getConfigMap)
+// without worrying about key collisions.
+func getSecret(ctx context.Context, c client.Client, cache *gocache.Cache, project string) exprFn {
 	return func(a ...any) (any, error) {
 		if len(a) != 1 {
 			return nil, fmt.Errorf("expected 1 argument, got %d", len(a))
@@ -416,6 +457,19 @@ func getSecret(ctx context.Context, c client.Client, project string) exprFn {
 			return nil, fmt.Errorf("argument must be string, got %T", a[0])
 		}
 
+		cacheKey := getCacheKey(cacheKeyPrefixSecret, project, name)
+		if cache != nil {
+			cachedData, ok := cache.Get(cacheKey)
+			if ok {
+				if cachedData == nil {
+					return map[string]string{}, nil
+				}
+				if data, ok := cachedData.(map[string]string); ok {
+					return maps.Clone(data), nil
+				}
+			}
+		}
+
 		var secret corev1.Secret
 		if err := c.Get(
 			ctx,
@@ -431,11 +485,15 @@ func getSecret(ctx context.Context, c client.Client, project string) exprFn {
 			return nil, fmt.Errorf("failed to get secret %s: %w", name, err)
 		}
 
-		data := make(map[string]string, len(secret.Data))
+		data := make(map[string]string)
 		for k, v := range secret.Data {
 			data[k] = string(v)
 		}
 
+		if cache != nil {
+			cache.Set(cacheKey, maps.Clone(data), gocache.NoExpiration)
+		}
+
 		return data, nil
 	}
 }
@@ -485,3 +543,14 @@ func getStatus(
 		return "", nil
 	}
 }
+
+const (
+	cacheKeyPrefixConfigMap = "ConfigMap"
+	cacheKeyPrefixSecret    = "Secret"
+)
+
+// getCacheKey generates a cache key for the given prefix, project, and name.
+// The cache key is a string formatted as "<prefix>/<project>/<name>".
+func getCacheKey(prefix, project, name string) string {
+	return fmt.Sprintf("%s/%s/%s", prefix, project, name)
+}
diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go
index 0b157bae..5770aad0 100644
--- a/internal/indexer/indexer.go
+++ b/internal/indexer/indexer.go
@@ -181,7 +181,7 @@ func RunningPromotionsByArgoCDApplications(
 
 			// As step-level variables are allowed to reference to output, we
 			// need to provide the state.
-			vars, err := dirStep.GetVars(ctx, cl, promoCtx, promoCtx.State)
+			vars, err := dirStep.GetVars(ctx, cl, nil, promoCtx, promoCtx.State)
 			if err != nil {
 				logger.Error(
 					err,
diff --git a/internal/promotion/promotion.go b/internal/promotion/promotion.go
index 95d5b99a..5109c5d1 100644
--- a/internal/promotion/promotion.go
+++ b/internal/promotion/promotion.go
@@ -6,6 +6,7 @@ import (
 	"strings"
 	"time"
 
+	gocache "github.com/patrickmn/go-cache"
 	"k8s.io/utils/ptr"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	yaml "sigs.k8s.io/yaml/goyaml.v3"
@@ -206,6 +207,7 @@ func (s *Step) BuildEnv(promoCtx Context, opts ...StepEnvOption) map[string]any
 func (s *Step) Skip(
 	ctx context.Context,
 	cl client.Client,
+	cache *gocache.Cache,
 	promoCtx Context,
 	state promotion.State,
 ) (bool, error) {
@@ -216,7 +218,7 @@ func (s *Step) Skip(
 		return promoCtx.StepExecutionMetadata.HasFailures(), nil
 	}
 
-	vars, err := s.GetVars(ctx, cl, promoCtx, state)
+	vars, err := s.GetVars(ctx, cl, cache, promoCtx, state)
 	if err != nil {
 		return false, err
 	}
@@ -241,7 +243,7 @@ func (s *Step) Skip(
 					promoCtx.FreightRequests,
 					promoCtx.Freight.References(),
 				),
-				exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
+				exprfn.DataOperations(ctx, cl, cache, promoCtx.Project)...,
 			),
 			exprfn.StatusOperations(s.Alias, promoCtx.StepExecutionMetadata)...,
 		)...,
@@ -263,6 +265,7 @@ func (s *Step) Skip(
 func (s *Step) GetConfig(
 	ctx context.Context,
 	cl client.Client,
+	cache *gocache.Cache,
 	promoCtx Context,
 	state promotion.State,
 ) (promotion.Config, error) {
@@ -270,7 +273,7 @@ func (s *Step) GetConfig(
 		return nil, nil
 	}
 
-	vars, err := s.GetVars(ctx, cl, promoCtx, state)
+	vars, err := s.GetVars(ctx, cl, cache, promoCtx, state)
 	if err != nil {
 		return nil, err
 	}
@@ -295,7 +298,7 @@ func (s *Step) GetConfig(
 				promoCtx.FreightRequests,
 				promoCtx.Freight.References(),
 			),
-			exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
+			exprfn.DataOperations(ctx, cl, cache, promoCtx.Project)...,
 		)...,
 	)
 	if err != nil {
@@ -313,6 +316,7 @@ func (s *Step) GetConfig(
 func (s *Step) GetVars(
 	ctx context.Context,
 	cl client.Client,
+	cache *gocache.Cache,
 	promoCtx Context,
 	state promotion.State,
 ) (map[string]any, error) {
@@ -326,7 +330,7 @@ func (s *Step) GetVars(
 			s.BuildEnv(promoCtx, StepEnvWithVars(vars)),
 			append(
 				exprfn.FreightOperations(ctx, cl, promoCtx.Project, promoCtx.FreightRequests, promoCtx.Freight.References()),
-				exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
+				exprfn.DataOperations(ctx, cl, cache, promoCtx.Project)...,
 			)...,
 		)
 		if err != nil {
@@ -349,7 +353,7 @@ func (s *Step) GetVars(
 			),
 			append(
 				exprfn.FreightOperations(ctx, cl, promoCtx.Project, promoCtx.FreightRequests, promoCtx.Freight.References()),
-				exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
+				exprfn.DataOperations(ctx, cl, cache, promoCtx.Project)...,
 			)...,
 		)
 		if err != nil {
diff --git a/internal/promotion/simple_engine.go b/internal/promotion/simple_engine.go
index af8304bc..8edde699 100644
--- a/internal/promotion/simple_engine.go
+++ b/internal/promotion/simple_engine.go
@@ -7,6 +7,7 @@ import (
 	"os"
 	"regexp"
 
+	gocache "github.com/patrickmn/go-cache"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/utils/ptr"
@@ -28,19 +29,41 @@ const ComposeOutputStepKind = "compose-output"
 // are reserved for internal use.
 var ReservedStepAliasRegex = regexp.MustCompile(`^(step|task)-\d+$`)
 
+// ExprDataCacheFn is a function that returns a new cache to use in expression
+// functions that consult the Kubernetes API.
+//
+// A new cache is created for each step execution, so that the cache is
+// shared between all expression functions that are executed in the same step.
+// This is important for performance, as our Kubernetes API client does not
+// cache Secrets and ConfigMaps, but also for correctness, as the data may
+// change between calls.
+//
+// It is allowed for the cache to be nil, in which case the expression functions
+// will not cache their results.
+type ExprDataCacheFn func() *gocache.Cache
+
+// DefaultExprDataCacheFn returns a new gocache.Cache instance with
+// default expiration and cleanup intervals. This is used as the default
+// ExprDataCacheFn for the Engine.
+func DefaultExprDataCacheFn() *gocache.Cache {
+	return gocache.New(gocache.NoExpiration, gocache.NoExpiration)
+}
+
 // simpleEngine is a simple implementation of the Engine interface that uses
 // built-in StepRunners.
 type simpleEngine struct {
 	registry    stepRunnerRegistry
 	kargoClient client.Client
+	cacheFunc   ExprDataCacheFn
 }
 
 // NewSimpleEngine returns a simple implementation of the Engine interface that
 // uses built-in StepRunners.
-func NewSimpleEngine(kargoClient client.Client) Engine {
+func NewSimpleEngine(kargoClient client.Client, cacheFunc ExprDataCacheFn) Engine {
 	return &simpleEngine{
 		registry:    stepRunnerReg,
 		kargoClient: kargoClient,
+		cacheFunc:   cacheFunc,
 	}
 }
 
@@ -130,9 +153,18 @@ stepLoop:
 		default:
 		}
 
+		// Shared cache for expression functions that consult the Kubernetes API.
+		// By using a shared cache, we avoid repeated API calls for multiple
+		// expressions that require the same data (e.g. `secret('foo').bar` and
+		// `secret('foo').baz`).
+		var exprDataCache *gocache.Cache
+		if e.cacheFunc != nil {
+			exprDataCache = e.cacheFunc()
+		}
+
 		// Check if the step should be skipped.
 		var skip bool
-		if skip, err = step.Skip(ctx, e.kargoClient, promoCtx, state); err != nil {
+		if skip, err = step.Skip(ctx, e.kargoClient, exprDataCache, promoCtx, state); err != nil {
 			stepExecMeta.Status = kargoapi.PromotionStepStatusErrored
 			stepExecMeta.Message = fmt.Sprintf("error checking if step %q should be skipped: %s", step.Alias, err)
 			// Continue, because despite this failure, some steps' "if" conditions may
@@ -157,7 +189,7 @@ stepLoop:
 		if stepExecMeta.StartedAt == nil {
 			stepExecMeta.StartedAt = ptr.To(metav1.Now())
 		}
-		result, err := e.executeStep(ctx, promoCtx, step, runner, workDir, state)
+		result, err := e.executeStep(ctx, exprDataCache, promoCtx, step, runner, workDir, state)
 		stepExecMeta.Status = result.Status
 		stepExecMeta.Message = result.Message
 
@@ -343,13 +375,14 @@ func determinePromoPhase(
 // executeStep executes a single Step.
 func (e *simpleEngine) executeStep(
 	ctx context.Context,
+	cache *gocache.Cache,
 	promoCtx Context,
 	step Step,
 	runner promotion.StepRunner,
 	workDir string,
 	state promotion.State,
 ) (promotion.StepResult, error) {
-	stepCtx, err := e.prepareStepContext(ctx, promoCtx, step, workDir, state)
+	stepCtx, err := e.prepareStepContext(ctx, cache, promoCtx, step, workDir, state)
 	if err != nil {
 		return promotion.StepResult{
 			Status: kargoapi.PromotionStepStatusErrored,
@@ -366,6 +399,7 @@ func (e *simpleEngine) executeStep(
 // prepareStepContext prepares a StepContext corresponding to the provided Step.
 func (e *simpleEngine) prepareStepContext(
 	ctx context.Context,
+	cache *gocache.Cache,
 	promoCtx Context,
 	step Step,
 	workDir string,
@@ -373,7 +407,7 @@ func (e *simpleEngine) prepareStepContext(
 ) (*promotion.StepContext, error) {
 	stateCopy := state.DeepCopy()
 
-	stepCfg, err := step.GetConfig(ctx, e.kargoClient, promoCtx, stateCopy)
+	stepCfg, err := step.GetConfig(ctx, e.kargoClient, cache, promoCtx, stateCopy)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get step config: %w", err)
 	}
diff --git a/pkg/go.mod b/pkg/go.mod
index 30b61179..e932e774 100644
--- a/pkg/go.mod
+++ b/pkg/go.mod
@@ -7,7 +7,7 @@ replace github.com/akuity/kargo/api => ../api
 require (
 	github.com/akuity/kargo/api v0.0.0
 	github.com/stretchr/testify v1.10.0
-	k8s.io/apimachinery v0.33.0
+	k8s.io/apimachinery v0.33.1
 )
 
 require (
diff --git a/pkg/go.sum b/pkg/go.sum
index 86e15723..0d3a4dea 100644
--- a/pkg/go.sum
+++ b/pkg/go.sum
@@ -71,8 +71,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs=
 k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc=
-k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=
-k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
+k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4=
+k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
 k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
 k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
 k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97RvvF3a8J3fP/Lg=



Test patch:
diff --git a/internal/controller/stages/control_flow_stages_test.go b/internal/controller/stages/control_flow_stages_test.go
index 71f7db4a..5147022d 100644
--- a/internal/controller/stages/control_flow_stages_test.go
+++ b/internal/controller/stages/control_flow_stages_test.go
@@ -602,6 +602,7 @@ func TestControlFlowStageReconciler_reconcile(t *testing.T) {
 				assert.Len(t, status.Conditions, 1)
 				assert.Equal(t, kargoapi.ConditionTypeReady, status.Conditions[0].Type)
 				assert.Equal(t, metav1.ConditionTrue, status.Conditions[0].Status)
+				assert.Equal(t, kargoapi.ConditionTypeReady, status.Conditions[0].Reason)
 			},
 		},
 	}
diff --git a/internal/expressions/function/functions_test.go b/internal/expressions/function/functions_test.go
index 9a8d5ecd..13c2b110 100644
--- a/internal/expressions/function/functions_test.go
+++ b/internal/expressions/function/functions_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"testing"
 
+	"github.com/patrickmn/go-cache"
 	"github.com/stretchr/testify/assert"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -654,12 +655,13 @@ func Test_getConfigMap(t *testing.T) {
 		name       string
 		objects    []client.Object
 		args       []any
-		assertions func(t *testing.T, result any, err error)
+		cache      *cache.Cache
+		assertions func(t *testing.T, cache *cache.Cache, result any, err error)
 	}{
 		{
 			name: "no arguments",
 			args: []any{},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, _ *cache.Cache, result any, err error) {
 				assert.ErrorContains(t, err, "expected 1 argument")
 				assert.Nil(t, result)
 			},
@@ -667,7 +669,7 @@ func Test_getConfigMap(t *testing.T) {
 		{
 			name: "too many arguments",
 			args: []any{testConfigMap, "extra"},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, _ *cache.Cache, result any, err error) {
 				assert.ErrorContains(t, err, "expected 1 argument")
 				assert.Nil(t, result)
 			},
@@ -675,7 +677,7 @@ func Test_getConfigMap(t *testing.T) {
 		{
 			name: "invalid argument type",
 			args: []any{123},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, _ *cache.Cache, result any, err error) {
 				assert.ErrorContains(t, err, "argument must be string")
 				assert.Nil(t, result)
 			},
@@ -683,11 +685,12 @@ func Test_getConfigMap(t *testing.T) {
 		{
 			name: "ConfigMap not found",
 			args: []any{testConfigMap},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
 				assert.NoError(t, err)
 				assert.NotNil(t, result)
 				assert.IsType(t, map[string]string{}, result)
 				assert.Empty(t, result)
+				assert.Nil(t, cache)
 			},
 		},
 		{
@@ -702,9 +705,63 @@ func Test_getConfigMap(t *testing.T) {
 				},
 			},
 			args: []any{testConfigMap},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, testData, result)
+				assert.Nil(t, cache)
+			},
+		},
+		{
+			name: "success with cache",
+			objects: []client.Object{
+				&corev1.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testConfigMap,
+					},
+					Data: testData,
+				},
+			},
+			cache: cache.New(cache.NoExpiration, cache.NoExpiration),
+			args:  []any{testConfigMap},
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
 				assert.NoError(t, err)
 				assert.Equal(t, testData, result)
+
+				// Check if the item is in the cache
+				data, ok := cache.Get(getCacheKey(cacheKeyPrefixConfigMap, testProject, testConfigMap))
+				assert.True(t, ok)
+				assert.Equal(t, testData, data)
+			},
+		},
+		{
+			name: "success from cache",
+			cache: cache.NewFrom(cache.NoExpiration, cache.NoExpiration, map[string]cache.Item{
+				getCacheKey(cacheKeyPrefixConfigMap, testProject, testConfigMap): {
+					Object: testData,
+				},
+			}),
+			objects: []client.Object{
+				&corev1.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testConfigMap,
+					},
+					Data: map[string]string{
+						// This data should not be used
+						"foo": "baz",
+					},
+				},
+			},
+			args: []any{testConfigMap},
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, testData, result)
+
+				// Check if the item data did not change
+				data, ok := cache.Get(getCacheKey(cacheKeyPrefixConfigMap, testProject, testConfigMap))
+				assert.True(t, ok)
+				assert.Equal(t, testData, data)
 			},
 		},
 	}
@@ -718,10 +775,10 @@ func Test_getConfigMap(t *testing.T) {
 				WithObjects(tt.objects...).
 				Build()
 
-			fn := getConfigMap(ctx, c, testProject)
+			fn := getConfigMap(ctx, c, tt.cache, testProject)
 
 			result, err := fn(tt.args...)
-			tt.assertions(t, result, err)
+			tt.assertions(t, tt.cache, result, err)
 		})
 	}
 }
@@ -737,12 +794,13 @@ func Test_getSecret(t *testing.T) {
 		name       string
 		objects    []client.Object
 		args       []any
-		assertions func(t *testing.T, result any, err error)
+		cache      *cache.Cache
+		assertions func(t *testing.T, cache *cache.Cache, result any, err error)
 	}{
 		{
 			name: "no arguments",
 			args: []any{},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, _ *cache.Cache, result any, err error) {
 				assert.ErrorContains(t, err, "expected 1 argument")
 				assert.Nil(t, result)
 			},
@@ -750,7 +808,7 @@ func Test_getSecret(t *testing.T) {
 		{
 			name: "too many arguments",
 			args: []any{testSecret, "extra"},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, _ *cache.Cache, result any, err error) {
 				assert.ErrorContains(t, err, "expected 1 argument")
 				assert.Nil(t, result)
 			},
@@ -758,7 +816,7 @@ func Test_getSecret(t *testing.T) {
 		{
 			name: "invalid argument type",
 			args: []any{123},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, _ *cache.Cache, result any, err error) {
 				assert.ErrorContains(t, err, "argument must be string")
 				assert.Nil(t, result)
 			},
@@ -766,11 +824,12 @@ func Test_getSecret(t *testing.T) {
 		{
 			name: "Secret not found",
 			args: []any{testSecret},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
 				assert.NoError(t, err)
 				assert.NotNil(t, result)
 				assert.IsType(t, map[string]string{}, result)
 				assert.Empty(t, result)
+				assert.Nil(t, cache)
 			},
 		},
 		{
@@ -787,9 +846,65 @@ func Test_getSecret(t *testing.T) {
 				},
 			},
 			args: []any{testSecret},
-			assertions: func(t *testing.T, result any, err error) {
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
 				assert.NoError(t, err)
 				assert.Equal(t, map[string]string{"foo": "bar"}, result)
+				assert.Nil(t, cache)
+			},
+		},
+		{
+			name: "success with cache",
+			objects: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testSecret,
+					},
+					Data: map[string][]byte{
+						"foo": []byte("bar"),
+					},
+				},
+			},
+			cache: cache.New(cache.NoExpiration, cache.NoExpiration),
+			args:  []any{testSecret},
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, map[string]string{"foo": "bar"}, result)
+
+				// Check if the item is in the cache
+				data, ok := cache.Get(getCacheKey(cacheKeyPrefixSecret, testProject, testSecret))
+				assert.True(t, ok)
+				assert.Equal(t, map[string]string{"foo": "bar"}, data)
+			},
+		},
+		{
+			name: "success from cache",
+			cache: cache.NewFrom(cache.NoExpiration, cache.NoExpiration, map[string]cache.Item{
+				getCacheKey(cacheKeyPrefixSecret, testProject, testSecret): {
+					Object: map[string]string{"foo": "bar"},
+				},
+			}),
+			objects: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testSecret,
+					},
+					Data: map[string][]byte{
+						// This data should not be used
+						"foo": []byte("baz"),
+					},
+				},
+			},
+			args: []any{testSecret},
+			assertions: func(t *testing.T, cache *cache.Cache, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, map[string]string{"foo": "bar"}, result)
+
+				// Check if the item data did not change
+				data, ok := cache.Get(getCacheKey(cacheKeyPrefixSecret, testProject, testSecret))
+				assert.True(t, ok)
+				assert.Equal(t, map[string]string{"foo": "bar"}, data)
 			},
 		},
 	}
@@ -803,10 +918,83 @@ func Test_getSecret(t *testing.T) {
 				WithObjects(tt.objects...).
 				Build()
 
-			fn := getSecret(ctx, c, testProject)
+			fn := getSecret(ctx, c, tt.cache, testProject)
 
 			result, err := fn(tt.args...)
-			tt.assertions(t, result, err)
+			tt.assertions(t, tt.cache, result, err)
+		})
+	}
+}
+
+func Test_getConfigMap_getSecret_no_cache_key_collision(t *testing.T) {
+	const testProject = "fake-project"
+	const testIdenticalName = "fake-name"
+
+	scheme := runtime.NewScheme()
+	assert.NoError(t, corev1.AddToScheme(scheme))
+
+	tests := []struct {
+		name       string
+		objects    []client.Object
+		args       []any
+		cache      *cache.Cache
+		assertions func(t *testing.T, cache *cache.Cache)
+	}{
+		{
+			name: "ConfigMap and Secret with identical names",
+			objects: []client.Object{
+				&corev1.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testIdenticalName,
+					},
+					Data: map[string]string{
+						"type": "configmap",
+					},
+				},
+				&corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testIdenticalName,
+					},
+					Data: map[string][]byte{
+						"type": []byte("secret"),
+					},
+				},
+			},
+			cache: cache.New(cache.NoExpiration, cache.NoExpiration),
+			args:  []any{testIdenticalName},
+			assertions: func(t *testing.T, cache *cache.Cache) {
+				// Check if both items are in the cache with different keys
+				data, ok := cache.Get(getCacheKey(cacheKeyPrefixConfigMap, testProject, testIdenticalName))
+				assert.True(t, ok)
+				assert.Equal(t, map[string]string{"type": "configmap"}, data)
+
+				data, ok = cache.Get(getCacheKey(cacheKeyPrefixSecret, testProject, testIdenticalName))
+				assert.True(t, ok)
+				assert.Equal(t, map[string]string{"type": "secret"}, data)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			c := fake.NewClientBuilder().
+				WithScheme(scheme).
+				WithObjects(tt.objects...).
+				Build()
+
+			cfgFn := getConfigMap(ctx, c, tt.cache, testProject)
+			_, err := cfgFn(tt.args...)
+			assert.NoError(t, err)
+
+			secretFn := getSecret(ctx, c, tt.cache, testProject)
+			_, err = secretFn(tt.args...)
+			assert.NoError(t, err)
+
+			tt.assertions(t, tt.cache)
 		})
 	}
 }
diff --git a/internal/promotion/promotion_test.go b/internal/promotion/promotion_test.go
index 4a45112b..e7ab90e5 100644
--- a/internal/promotion/promotion_test.go
+++ b/internal/promotion/promotion_test.go
@@ -481,6 +481,7 @@ func TestStep_GetConfig(t *testing.T) {
 			stepCfg, err := promoStep.GetConfig(
 				context.Background(),
 				testClient,
+				nil,
 				testCase.promoCtx,
 				testCase.promoState,
 			)
@@ -852,6 +853,7 @@ func TestStep_GetVars(t *testing.T) {
 			vars, err := testCase.step.GetVars(
 				context.Background(),
 				testClient,
+				nil,
 				testCase.promoCtx,
 				testCase.promoState,
 			)
@@ -986,6 +988,7 @@ func TestStep_Skip(t *testing.T) {
 			got, err := tt.step.Skip(
 				context.Background(),
 				fake.NewClientBuilder().Build(),
+				nil,
 				tt.ctx,
 				tt.state,
 			)
diff --git a/internal/promotion/simple_engine_test.go b/internal/promotion/simple_engine_test.go
index 2c465789..7def8ffd 100644
--- a/internal/promotion/simple_engine_test.go
+++ b/internal/promotion/simple_engine_test.go
@@ -776,6 +776,7 @@ func TestSimpleEngine_executeStep(t *testing.T) {
 
 			result, err := engine.executeStep(
 				context.Background(),
+				nil,
 				tt.promoCtx,
 				tt.step,
 				tt.runner,
@@ -819,6 +820,7 @@ func TestSimpleEngine_prepareStepContext(t *testing.T) {
 
 			stepCtx, err := engine.prepareStepContext(
 				context.Background(),
+				nil,
 				tt.promoCtx,
 				tt.step,
 				t.TempDir(),