Repository: akuity/kargo

Issue:
Reference ConfigMaps for DRY configuration
# Proposed Feature

We should support the ability to reference a project level var, or possibly even global/system level.

# Motivation

Stages and/or PromotionTasks often have to repeat definitions like:

```yaml
  vars:
  - name: repoURL
    value: https://github.com/jessesuen/kargo-rendered-branches.git
  - name: image
    value: ghcr.io/jessesuen/guestbook
  - name: branch
    value: main
```

To keep things DRY, it would be nice to define these in one shared place (e.g. a configmap), and reference it in a new expr object.

# Suggested Implementation

We already have an established pattern for referencing secrets. e.g. `secrets.slack.token`. I think we do the same but with ConfigMap:

```yaml
  - uses: git-clone
    config:
      repoURL: ${{ config.myproj.repoURL }}
      checkout:
      - branch: ${{ config.myproj.branch }}
        path: ./main
```

Hints from issue comments:
@krancour: This is pretty easily done.@hiddeco: I have started to wonder if it wouldn't be better to introduce functions (e.g. `secret("name")` and `configMap("name")`) instead of injecting all Secrets / ConfigMaps into the context. The function can still maintain some sort of cache for the lifetime of the step execution, but it would be lazy.@jessesuen: I like the idea for functions because:
* for the reason you mentioned about not injecting all config/secrets into the context
* In the future, we might support referencing secrets or config across namespaces and might support an optional 2nd argument for namespace `secret("name", "other-ns")`@krancour: The other thing that would be nice about finding secrets or config maps lazily...

Those are things we _don't_ have our k8s client configured to cache due to their high potential to be very large, so getting all the project's secrets or all its config maps up-front is somewhat expensive and totally not worth it in cases where the promotion process doesn't use all, or even any, of them.@krancour: I've upped the size on this to medium due to the complexities of implementing this as a function, going back and doing the same for secrets, deprecating the existing method of accessing secrets (for removal at a later time), etc.

Solution patch:
diff --git a/cmd/controlplane/controller.go b/cmd/controlplane/controller.go
index f479e802..1807b040 100644
--- a/cmd/controlplane/controller.go
+++ b/cmd/controlplane/controller.go
@@ -209,12 +209,18 @@ func (o *controllerOptions) setupKargoManager(
 			PprofBindAddress: o.PprofBindAddress,
 			Client: client.Options{
 				Cache: &client.CacheOptions{
-					// The controller does not have cluster-wide permissions, to
-					// get/list/watch Secrets. Its access to Secrets grows and shrinks
-					// dynamically as Projects are created and deleted. We disable caching
-					// here since the underlying informer will not be able to watch
-					// Secrets in all namespaces.
-					DisableFor: []client.Object{&corev1.Secret{}},
+					DisableFor: []client.Object{
+						// The controller does not have cluster-wide permissions, to
+						// get/list/watch Secrets. Its access to Secrets grows and shrinks
+						// dynamically as Projects are created and deleted. We disable
+						// caching here since the underlying informer will not be able to
+						// watch Secrets in all namespaces.
+						&corev1.Secret{},
+						// The controller has cluster-wide permissions to get/list/watch
+						// ConfigMaps, but ConfigMaps have the potential to be quite large,
+						// so we prefer to not cache them.
+						&corev1.ConfigMap{},
+					},
 				},
 			},
 			Cache: cache.Options{
diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/compose-output.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/compose-output.md
index 11c4e2fa..6ae15f57 100644
--- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/compose-output.md
+++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/compose-output.md
@@ -55,7 +55,7 @@ steps:
     url: https://slack.com/api/chat.postMessage
     headers:
     - name: Authorization
-      value: Bearer ${{ secrets.slack.token }}
+      value: Bearer ${{ secret('slack').token }}
     - name: Content-Type
       value: application/json
     body: |
diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/http.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/http.md
index 67275ace..dcd613c6 100644
--- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/http.md
+++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/http.md
@@ -172,7 +172,7 @@ steps:
     url: https://slack.com/api/chat.postMessage
     headers:
     - name: Authorization
-      value: Bearer ${{ secrets.slack.token }}
+      value: Bearer ${{ secret('slack').token }}
     - name: Content-Type
       value: application/json
     body: |
diff --git a/docs/docs/50-user-guide/60-reference-docs/40-expressions.md b/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
index 49decb79..3fd6614c 100644
--- a/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
+++ b/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
@@ -145,7 +145,7 @@ Expect other useful variables to be added in the future.
 |------|------|-------------|
 | `ctx` | `object` | Contains contextual information about the promotion. See detailed structure below. |
 | `outputs` | `object` | A map of output from previous promotion steps indexed by step aliases. |
-| `secrets` | `object` | A map of maps indexed by the names of all Kubernetes `Secret`s in the `Promotion`'s `Project` and the keys within the `Data` block of each. |
+| `secrets` | `object` | A map of maps indexed by the names of all Kubernetes `Secret`s in the `Promotion`'s `Project` and the keys within the `Data` block of each.<br/><br/>__Deprecated: Use the [`secret()` function](#secretname) instead. Will be removed in v1.7.0.__ |
 | `vars` | `object` | A user-defined map of variable names to static values of any type. The map is derived from a `Promotion`'s `spec.promotionTemplate.spec.vars` field. Variable names must observe standard Go variable-naming rules. Variables values may, themselves, be defined using an expression. `vars` (contains previously defined variables) and `ctx` are available to expressions defining the values of variables, however, `outputs` and `secrets` are not. |
 | `task` | `object` | A map containing output from previous steps within the same PromotionTask under the `outputs` field, indexed by step aliases. Only available within `(Cluster)PromotionTask` steps. |
 
@@ -269,6 +269,34 @@ config:
   strField: ${{ quote(40 + 2) }} # Will be treated as a string
 ```
 
+### `configMap(name)`
+
+The `configMap()` function returns the `Data` field (a `map[string]string`) of a
+Kubernetes `ConfigMap` with the specified name from the `Project`'s namespace.
+If no such `ConfigMap` exists, an empty map is returned.
+
+Example:
+
+```yaml
+config:
+  repoURL: ${{ configMap('my-config').repoURL }}
+```
+
+### `secret(name)`
+
+The `secret()` function returns the `Data` field of a Kubernetes `Secret` with
+the specified name from the `Project`'s namespace decoded into a
+`map[string]string`. If no such `Secret` exists, an empty map is returned.
+
+Examples:
+
+```yaml
+config:
+  headers:
+  - name: Authorization
+    value: Bearer ${{ secret('slack').token }}
+```
+
 ### `warehouse(name)`
 
 The `warehouse()` function returns a `FreightOrigin` object representing a
diff --git a/internal/controller/promotions/promotions.go b/internal/controller/promotions/promotions.go
index 97230d45..733e6021 100644
--- a/internal/controller/promotions/promotions.go
+++ b/internal/controller/promotions/promotions.go
@@ -416,10 +416,8 @@ func (r *reconciler) Reconcile(
 
 	// If the promotion is still running, we'll need to periodically check on
 	// it.
-	//
-	// TODO: Make this configurable
 	if newStatus.Phase == kargoapi.PromotionPhaseRunning {
-		return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
+		return ctrl.Result{RequeueAfter: calculateRequeueInterval(promo)}, nil
 	}
 	return ctrl.Result{}, nil
 }
@@ -665,3 +663,20 @@ func parseCreateActorAnnotation(promo *kargoapi.Promotion) string {
 	}
 	return creator
 }
+
+func calculateRequeueInterval(p *kargoapi.Promotion) time.Duration {
+	defaultRequeueInterval := 5 * time.Minute
+	step := p.Spec.Steps[p.Status.CurrentStep]
+	runner := promotion.GetStepRunner(step.Uses)
+
+	timeout := (&promotion.Step{
+		Retry: step.Retry,
+	}).GetTimeout(runner)
+
+	md := p.Status.StepExecutionMetadata[p.Status.CurrentStep]
+	targetTimeout := md.StartedAt.Time.Add(*timeout)
+	if targetTimeout.Before(time.Now().Add(defaultRequeueInterval)) {
+		return time.Until(targetTimeout)
+	}
+	return defaultRequeueInterval
+}
diff --git a/internal/controller/stages/regular_stages.go b/internal/controller/stages/regular_stages.go
index 70596f8c..b4b0c369 100644
--- a/internal/controller/stages/regular_stages.go
+++ b/internal/controller/stages/regular_stages.go
@@ -1330,12 +1330,15 @@ func (r *RegularStageReconciler) startVerification(
 					"stage":   stage.Name,
 				},
 			},
-			Options: exprfn.FreightOperations(
-				ctx,
-				r.client,
-				stage.Namespace,
-				stage.Spec.RequestedFreight,
-				freight.References(),
+			Options: append(
+				exprfn.FreightOperations(
+					ctx,
+					r.client,
+					stage.Namespace,
+					stage.Spec.RequestedFreight,
+					freight.References(),
+				),
+				exprfn.DataOperations(ctx, r.client, stage.Namespace)...,
 			),
 		},
 	}
diff --git a/internal/expressions/function/functions.go b/internal/expressions/function/functions.go
index 2f79a55d..28293776 100644
--- a/internal/expressions/function/functions.go
+++ b/internal/expressions/function/functions.go
@@ -5,6 +5,8 @@ import (
 	"fmt"
 
 	"github.com/expr-lang/expr"
+	corev1 "k8s.io/api/core/v1"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
@@ -35,6 +37,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 {
+	return []expr.Option{
+		ConfigMap(ctx, c, project),
+		Secret(ctx, c, project),
+	}
+}
+
 // Warehouse returns an expr.Option that provides a `warehouse()` function
 // for use in expressions.
 //
@@ -109,6 +120,26 @@ 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 {
+	return expr.Function(
+		"configMap",
+		getConfigMap(ctx, c, 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 {
+	return expr.Function(
+		"secret",
+		getSecret(ctx, c, project),
+		new(func(name string) map[string]string),
+	)
+}
+
 // warehouse creates a FreightOrigin of kind Warehouse with the specified name.
 //
 // It returns an error if the argument count is incorrect or if the name is not
@@ -277,3 +308,68 @@ func getChart(
 		)
 	}
 }
+
+func getConfigMap(ctx context.Context, c client.Client, project string) exprFn {
+	return func(a ...any) (any, error) {
+		if len(a) != 1 {
+			return nil, fmt.Errorf("expected 1 argument, got %d", len(a))
+		}
+
+		name, ok := a[0].(string)
+		if !ok {
+			return nil, fmt.Errorf("argument must be string, got %T", a[0])
+		}
+
+		var cfgMap corev1.ConfigMap
+		if err := c.Get(
+			ctx,
+			client.ObjectKey{
+				Namespace: project,
+				Name:      name,
+			},
+			&cfgMap,
+		); err != nil {
+			if kubeerr.IsNotFound(err) {
+				return map[string]string{}, nil
+			}
+			return nil, fmt.Errorf("failed to get configmap %s: %w", name, err)
+		}
+
+		return cfgMap.Data, nil
+	}
+}
+
+func getSecret(ctx context.Context, c client.Client, project string) exprFn {
+	return func(a ...any) (any, error) {
+		if len(a) != 1 {
+			return nil, fmt.Errorf("expected 1 argument, got %d", len(a))
+		}
+
+		name, ok := a[0].(string)
+		if !ok {
+			return nil, fmt.Errorf("argument must be string, got %T", a[0])
+		}
+
+		var secret corev1.Secret
+		if err := c.Get(
+			ctx,
+			client.ObjectKey{
+				Namespace: project,
+				Name:      name,
+			},
+			&secret,
+		); err != nil {
+			if kubeerr.IsNotFound(err) {
+				return map[string]string{}, nil
+			}
+			return nil, fmt.Errorf("failed to get secret %s: %w", name, err)
+		}
+
+		data := make(map[string]string, len(secret.Data))
+		for k, v := range secret.Data {
+			data[k] = string(v)
+		}
+
+		return data, nil
+	}
+}
diff --git a/internal/promotion/promotion.go b/internal/promotion/promotion.go
index c19163ef..7acd8922 100644
--- a/internal/promotion/promotion.go
+++ b/internal/promotion/promotion.go
@@ -208,13 +208,20 @@ func (s *Step) Skip(
 		StepEnvWithVars(vars),
 	)
 
-	v, err := expressions.EvaluateTemplate(s.If, env, exprfn.FreightOperations(
-		ctx,
-		cl,
-		promoCtx.Project,
-		promoCtx.FreightRequests,
-		promoCtx.Freight.References(),
-	)...)
+	v, err := expressions.EvaluateTemplate(
+		s.If,
+		env,
+		append(
+			exprfn.FreightOperations(
+				ctx,
+				cl,
+				promoCtx.Project,
+				promoCtx.FreightRequests,
+				promoCtx.Freight.References(),
+			),
+			exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
+		)...,
+	)
 	if err != nil {
 		return false, err
 	}
@@ -255,12 +262,15 @@ func (s *Step) GetConfig(
 	evaledCfgJSON, err := expressions.EvaluateJSONTemplate(
 		s.Config,
 		env,
-		exprfn.FreightOperations(
-			ctx,
-			cl,
-			promoCtx.Project,
-			promoCtx.FreightRequests,
-			promoCtx.Freight.References(),
+		append(
+			exprfn.FreightOperations(
+				ctx,
+				cl,
+				promoCtx.Project,
+				promoCtx.FreightRequests,
+				promoCtx.Freight.References(),
+			),
+			exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
 		)...,
 	)
 	if err != nil {
@@ -289,7 +299,10 @@ func (s *Step) GetVars(
 		newVar, err := expressions.EvaluateTemplate(
 			v.Value,
 			s.BuildEnv(promoCtx, StepEnvWithVars(vars)),
-			exprfn.FreightOperations(ctx, cl, promoCtx.Project, promoCtx.FreightRequests, promoCtx.Freight.References())...,
+			append(
+				exprfn.FreightOperations(ctx, cl, promoCtx.Project, promoCtx.FreightRequests, promoCtx.Freight.References()),
+				exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
+			)...,
 		)
 		if err != nil {
 			return nil, fmt.Errorf("error pre-processing promotion variable %q: %w", v.Name, err)
@@ -308,7 +321,10 @@ func (s *Step) GetVars(
 				StepEnvWithTaskOutputs(s.Alias, state),
 				StepEnvWithVars(vars),
 			),
-			exprfn.FreightOperations(ctx, cl, promoCtx.Project, promoCtx.FreightRequests, promoCtx.Freight.References())...,
+			append(
+				exprfn.FreightOperations(ctx, cl, promoCtx.Project, promoCtx.FreightRequests, promoCtx.Freight.References()),
+				exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
+			)...,
 		)
 		if err != nil {
 			return nil, fmt.Errorf("error pre-processing promotion variable %q: %w", v.Name, err)
diff --git a/internal/promotion/registry.go b/internal/promotion/registry.go
index d1217d21..1b22e148 100644
--- a/internal/promotion/registry.go
+++ b/internal/promotion/registry.go
@@ -1,6 +1,8 @@
 package promotion
 
-import "github.com/akuity/kargo/pkg/promotion"
+import (
+	"github.com/akuity/kargo/pkg/promotion"
+)
 
 // stepRunnerRegistry is a registry of StepRunners.
 type stepRunnerRegistry map[string]promotion.StepRunner
@@ -24,3 +26,9 @@ var stepRunnerReg = stepRunnerRegistry{}
 func RegisterStepRunner(runner promotion.StepRunner) {
 	stepRunnerReg.register(runner)
 }
+
+// GetStepRunner returns a StepRunner from the package's internal registry. If
+// no StepRunner is registered with the given name, nil is returned instead.
+func GetStepRunner(name string) promotion.StepRunner {
+	return stepRunnerReg.getStepRunner(name)
+}



Test patch:
diff --git a/internal/controller/promotions/promotions_test.go b/internal/controller/promotions/promotions_test.go
index 09aded66..b2fc49e7 100644
--- a/internal/controller/promotions/promotions_test.go
+++ b/internal/controller/promotions/promotions_test.go
@@ -531,6 +531,118 @@ func Test_parseCreateActorAnnotation(t *testing.T) {
 	}
 }
 
+func Test_calculateRequeueInterval(t *testing.T) {
+	defaultTimeout := 5 * time.Minute
+	for _, test := range []struct {
+		name       string
+		promo      *kargoapi.Promotion
+		setup      func(*testing.T)
+		assertions func(*testing.T, time.Duration)
+	}{
+		{
+			name: "should return default requeue interval",
+			promo: &kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					CreationTimestamp: now,
+					Name:              "fake-name",
+					Namespace:         "fake-namespace",
+				},
+				Spec: kargoapi.PromotionSpec{
+					Stage: "fake-stage",
+					Steps: []kargoapi.PromotionStep{
+						{
+							Uses: "slow-step",
+							Retry: &kargoapi.PromotionStepRetry{
+								Timeout: &metav1.Duration{
+									// exceeds default threshold
+									Duration: 10 * time.Minute,
+								},
+							},
+						},
+					},
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase:       kargoapi.PromotionPhasePending,
+					CurrentStep: 0,
+					StepExecutionMetadata: []kargoapi.StepExecutionMetadata{
+						{StartedAt: &now},
+					},
+				},
+			},
+			setup: func(tt *testing.T) {
+				tt.Helper()
+				var (
+					// exceeds than the 5 minute default
+					stepRunnerTimeout = 10 * time.Minute
+					numRetries        = uint32(1)
+					runner            = promotion.NewMockRetryableStepRunner(
+						"slow-step",
+						&stepRunnerTimeout,
+						numRetries,
+					)
+				)
+				promotion.RegisterStepRunner(runner)
+			},
+			assertions: func(tt *testing.T, d time.Duration) {
+				require.Equal(tt, d, defaultTimeout)
+			},
+		},
+		{
+			name: "requeue interval should return requeue interval less than the default",
+			promo: &kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					CreationTimestamp: now,
+					Name:              "fake-name",
+					Namespace:         "fake-namespace",
+				},
+				Spec: kargoapi.PromotionSpec{
+					Stage: "fake-stage",
+					Steps: []kargoapi.PromotionStep{
+						{
+							Uses: "fast-step",
+							Retry: &kargoapi.PromotionStepRetry{
+								Timeout: &metav1.Duration{
+									// faster than default threshold
+									Duration: 3 * time.Minute,
+								},
+							},
+						},
+					},
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase:       kargoapi.PromotionPhasePending,
+					CurrentStep: 0,
+					StepExecutionMetadata: []kargoapi.StepExecutionMetadata{
+						{StartedAt: &now},
+					},
+				},
+			},
+			setup: func(tt *testing.T) {
+				tt.Helper()
+				var (
+					// lower than the 5 minute default
+					stepRunnerTimeout = 3 * time.Minute
+					numRetries        = uint32(1)
+					runner            = promotion.NewMockRetryableStepRunner(
+						"fast-step",
+						&stepRunnerTimeout,
+						numRetries,
+					)
+				)
+				promotion.RegisterStepRunner(runner)
+			},
+			assertions: func(tt *testing.T, d time.Duration) {
+				require.Less(tt, d, defaultTimeout)
+			},
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			test.setup(t)
+			test.assertions(t, calculateRequeueInterval(test.promo))
+		})
+	}
+}
+
 // nolint: unparam
 func newPromo(namespace, name, stage string,
 	phase kargoapi.PromotionPhase,
diff --git a/internal/expressions/function/functions_test.go b/internal/expressions/function/functions_test.go
index 73b4f9a2..cc399f7a 100644
--- a/internal/expressions/function/functions_test.go
+++ b/internal/expressions/function/functions_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -637,3 +638,175 @@ func Test_getChart(t *testing.T) {
 		})
 	}
 }
+
+func Test_getConfigMap(t *testing.T) {
+	const testProject = "fake-project"
+	const testConfigMap = "fake-configmap"
+
+	testData := map[string]string{
+		"foo": "bar",
+	}
+
+	scheme := runtime.NewScheme()
+	assert.NoError(t, corev1.AddToScheme(scheme))
+
+	tests := []struct {
+		name       string
+		objects    []client.Object
+		args       []any
+		assertions func(t *testing.T, result any, err error)
+	}{
+		{
+			name: "no arguments",
+			args: []any{},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "expected 1 argument")
+				assert.Nil(t, result)
+			},
+		},
+		{
+			name: "too many arguments",
+			args: []any{testConfigMap, "extra"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "expected 1 argument")
+				assert.Nil(t, result)
+			},
+		},
+		{
+			name: "invalid argument type",
+			args: []any{123},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "argument must be string")
+				assert.Nil(t, result)
+			},
+		},
+		{
+			name: "ConfigMap not found",
+			args: []any{testConfigMap},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, result)
+				assert.IsType(t, map[string]string{}, result)
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name: "success",
+			objects: []client.Object{
+				&corev1.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testConfigMap,
+					},
+					Data: testData,
+				},
+			},
+			args: []any{testConfigMap},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, testData, result)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			c := fake.NewClientBuilder().
+				WithScheme(scheme).
+				WithObjects(tt.objects...).
+				Build()
+
+			fn := getConfigMap(ctx, c, testProject)
+
+			result, err := fn(tt.args...)
+			tt.assertions(t, result, err)
+		})
+	}
+}
+
+func Test_getSecret(t *testing.T) {
+	const testProject = "fake-project"
+	const testSecret = "fake-secret"
+
+	scheme := runtime.NewScheme()
+	assert.NoError(t, corev1.AddToScheme(scheme))
+
+	tests := []struct {
+		name       string
+		objects    []client.Object
+		args       []any
+		assertions func(t *testing.T, result any, err error)
+	}{
+		{
+			name: "no arguments",
+			args: []any{},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "expected 1 argument")
+				assert.Nil(t, result)
+			},
+		},
+		{
+			name: "too many arguments",
+			args: []any{testSecret, "extra"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "expected 1 argument")
+				assert.Nil(t, result)
+			},
+		},
+		{
+			name: "invalid argument type",
+			args: []any{123},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "argument must be string")
+				assert.Nil(t, result)
+			},
+		},
+		{
+			name: "Secret not found",
+			args: []any{testSecret},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, result)
+				assert.IsType(t, map[string]string{}, result)
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name: "success",
+			objects: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: testProject,
+						Name:      testSecret,
+					},
+					Data: map[string][]byte{
+						"foo": []byte("bar"),
+					},
+				},
+			},
+			args: []any{testSecret},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, map[string]string{"foo": "bar"}, result)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			c := fake.NewClientBuilder().
+				WithScheme(scheme).
+				WithObjects(tt.objects...).
+				Build()
+
+			fn := getSecret(ctx, c, testProject)
+
+			result, err := fn(tt.args...)
+			tt.assertions(t, result, err)
+		})
+	}
+}
diff --git a/internal/promotion/mock_step_runner_test.go b/internal/promotion/mock_step_runner.go
similarity index 63%
rename from internal/promotion/mock_step_runner_test.go
rename to internal/promotion/mock_step_runner.go
index ae8cd745..6edcfd0f 100644
--- a/internal/promotion/mock_step_runner_test.go
+++ b/internal/promotion/mock_step_runner.go
@@ -2,6 +2,7 @@ package promotion
 
 import (
 	"context"
+	"time"
 
 	"github.com/akuity/kargo/pkg/promotion"
 )
@@ -37,3 +38,29 @@ func (m *mockStepRunner) Run(
 	}
 	return m.runResult, m.runErr
 }
+
+type MockRetryableStepRunner struct {
+	*mockStepRunner
+	defaultTimeout        *time.Duration
+	defaultErrorThreshold uint32
+}
+
+func NewMockRetryableStepRunner(
+	name string,
+	defaultTimeout *time.Duration,
+	defaultErrThreshold uint32,
+) MockRetryableStepRunner {
+	return MockRetryableStepRunner{
+		mockStepRunner:        &mockStepRunner{name: name},
+		defaultTimeout:        defaultTimeout,
+		defaultErrorThreshold: defaultErrThreshold,
+	}
+}
+
+func (m MockRetryableStepRunner) DefaultTimeout() *time.Duration {
+	return m.defaultTimeout
+}
+
+func (m MockRetryableStepRunner) DefaultErrorThreshold() uint32 {
+	return m.defaultErrorThreshold
+}
diff --git a/internal/promotion/promotion_test.go b/internal/promotion/promotion_test.go
index ff823e45..e32c7b65 100644
--- a/internal/promotion/promotion_test.go
+++ b/internal/promotion/promotion_test.go
@@ -17,20 +17,6 @@ import (
 	"github.com/akuity/kargo/pkg/promotion"
 )
 
-type mockRetryableStepRunner struct {
-	*mockStepRunner
-	defaultTimeout        *time.Duration
-	defaultErrorThreshold uint32
-}
-
-func (m mockRetryableStepRunner) DefaultTimeout() *time.Duration {
-	return m.defaultTimeout
-}
-
-func (m mockRetryableStepRunner) DefaultErrorThreshold() uint32 {
-	return m.defaultErrorThreshold
-}
-
 func TestStep_GetTimeout(t *testing.T) {
 	tests := []struct {
 		name       string
@@ -70,7 +56,7 @@ func TestStep_GetTimeout(t *testing.T) {
 					},
 				},
 			},
-			runner: mockRetryableStepRunner{defaultTimeout: ptr.To(time.Duration(3))},
+			runner: MockRetryableStepRunner{defaultTimeout: ptr.To(time.Duration(3))},
 			assertions: func(t *testing.T, result *time.Duration) {
 				assert.Equal(t, ptr.To(time.Duration(5)), result)
 			},
@@ -80,7 +66,7 @@ func TestStep_GetTimeout(t *testing.T) {
 			step: &Step{
 				Retry: &kargoapi.PromotionStepRetry{},
 			},
-			runner: mockRetryableStepRunner{defaultTimeout: ptr.To(time.Duration(3))},
+			runner: MockRetryableStepRunner{defaultTimeout: ptr.To(time.Duration(3))},
 			assertions: func(t *testing.T, result *time.Duration) {
 				assert.Equal(t, ptr.To(time.Duration(3)), result)
 			},