Repository: akuity/kargo

Issue:
Image version is interpreted as number instead of string in Kargo 1.3.2
# 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

If the image version is a number (like '1' or '1.2'), the kustomize-set-image treats it as a number, leading to an error:
```
Promotion Errored
step execution failed: step 2 met error threshold of 1: failed to run step "kustomize-set-image": invalid kustomize-set-image config: images.0.tag: Invalid type. Expected: string, given: number images.1.tag: Invalid type. Expected: string, given: number
```

# Screenshots

<!-- If applicable, add screenshots to help explain the bug. -->

# Steps to Reproduce

* Have an image Warehouse with images with tags which are either plain integer numbers, or SemVer with two number fields (like 1.2)
* Attempt to promote a Freight with such images
* Promotion fails, as it tries to update a string field with a number

```
      - uses: kustomize-set-image
        as: update-image
        config:
          path: ./src/apps/application/dev/images-component
          images:
          - image: ${{ vars.imageRepoApi }}
            tag: ${{ imageFrom(vars.imageRepoApi).Tag }}
          - image: ${{ vars.imageRepoSpa }}
            tag: ${{ imageFrom(vars.imageRepoSpa).Tag }}
```

As a workaround, use 'Digest' instead of 'Tag':

```
      - uses: kustomize-set-image
        as: update-image
        config:
          path: ./src/apps/application/dev/images-component
          images:
          - image: ${{ vars.imageRepoApi }}
            digest: "${{ imageFrom(vars.imageRepoApi).Digest}}"
          - image: ${{ vars.imageRepoSpa }}
            digest: "${{ imageFrom(vars.imageRepoSpa).Digest}}"
```

We also tried these, but they didn't work
```
tag: ${{ quote(imageFrom(vars.imageRepoSpa).Tag) }}
tag: "${{ imageFrom(vars.imageRepoSpa).Tag }}"
```

# Version

```
$ kargo version
Client Version: v1.3.2
Server Version: v1.3.2
```

# Logs


Discussion:
@krancour: https://docs.kargo.io/user-guide/reference-docs/expressions#types@ToniA: We tried the quote function, and got YAML where the image tag was enclosed both in double and single quotes, like `"'42'"`

The actual git diff shows this for kustomization.yaml:
```
-   newTag: "1.4261"
+   newTag: '"1.4262"'
```
This didn't work on Flux...@krancour: 🤔 that's odd. I'm looking into it.@krancour: @ToniA can you show me what the step looked like when you tried it with the `quote()` function?@ToniA: We used to have Kargo 1.2.2 with this step
```
      - uses: kustomize-set-image
        as: update-image
        config:
          path: ./src/apps/application/dev/images-component
          images:
          - image: acr.azurecr.io/application/api
          - image: acr.azurecr.io/application/spa
```
In Kargo 1.3.2 we tried this, after we figured out that numbers need to be converted to strings
```
  promotionTemplate:
    spec:
      vars:
      - name: imageRepoApi
        value: acr.azurecr.io/application/api
      - name: imageRepoSpa
        value: acr.azurecr.io/application/spa
      - uses: kustomize-set-image
        as: update-image
        config:
          path: ./src/apps/application/dev/images-component
          images:
          - image: ${{ vars.imageRepoApi }}
            tag: ${{ quote(imageFrom(vars.imageRepoApi).Tag) }}
          - image: ${{ vars.imageRepoSpa }}
            tag: ${{ quote(imageFrom(vars.imageRepoSpa).Tag) }}
```
The result was a commit, which looked like this (previous state was from Kargo 1.2.2):
```
 - name: acr.azurecr.io/application/api
-  newTag: "1.4261"
+  newTag: '"1.4262"'
 - name: acr.azurecr.io/application/spa
-  newTag: "1.6988"
+  newTag: '"1.6988"'
```
We finally ended up with this. The digest is always a string, as it starts with `sha256:`. The downside is that it makes the manifest repo, deployments and pods harder to read, as image references are no longer tags.
```
      - uses: kustomize-set-image
        as: update-image
        config:
          path: ./src/apps/application/dev/images-component
          images:
          - image: ${{ vars.imageRepoApi }}
            digest: "${{ imageFrom(vars.imageRepoApi).Digest}}"
          - image: ${{ vars.imageRepoSpa }}
            digest: "${{ imageFrom(vars.imageRepoSpa).Digest}}"
```@krancour: Thanks @ToniA. What you're doing looks just right.

fwiw, expressions are evaluated _before_ the step's config is handed off to a step runner. I can point to unit tests that show quote() working correctly, so I wonder if the issue is in the step runner. @hiddeco might have some insight.

As soon as I have the chance, I'm going to take the config you graciously supplied and work it into an e2e test to get more insight into this.

Thanks! We'll get this sorted out.

Solution patch:
diff --git a/internal/expressions/json_templates.go b/internal/expressions/json_templates.go
index cfeebe7d..8e639be4 100644
--- a/internal/expressions/json_templates.go
+++ b/internal/expressions/json_templates.go
@@ -113,6 +113,9 @@ func EvaluateTemplate(template string, env map[string]any, exprOpts ...expr.Opti
 	exprOpts = append(exprOpts, expr.Function(
 		"quote",
 		func(a ...any) (any, error) {
+			if str, ok := a[0].(string); ok {
+				return fmt.Sprintf("%q", str), nil
+			}
 			jsonBytes, err := json.Marshal(a[0])
 			if err != nil {
 				return nil, fmt.Errorf("error applying quote() function: %w", err)



Test patch:
diff --git a/internal/expressions/json_templates_test.go b/internal/expressions/json_templates_test.go
index 74941658..ae3d9f21 100644
--- a/internal/expressions/json_templates_test.go
+++ b/internal/expressions/json_templates_test.go
@@ -220,6 +220,16 @@ func TestEvaluateJSONTemplate(t *testing.T) {
 				require.Equal(t, `{"foo":"bar"}`, parsed.AString)
 			},
 		},
+		{
+			name:         "quote function doesn't double quote string input",
+			jsonTemplate: `{ "AString": "${{ quote('foo') }}" }`,
+			assertions: func(t *testing.T, jsonOutput []byte, err error) {
+				require.NoError(t, err)
+				parsed := testStruct{}
+				require.NoError(t, json.Unmarshal(jsonOutput, &parsed))
+				require.Equal(t, "foo", parsed.AString)
+			},
+		},
 		{
 			name: "a variety of tricky cases dealing with YAML and quotes",
 			yamlTemplate: `