From c09fec4e07a380dcbf4644293a4200a442906570 Mon Sep 17 00:00:00 2001 From: Kent Rancourt Date: Sat, 16 Nov 2024 09:48:21 -0500 Subject: [PATCH] refactor: replace helm-update-image with yaml-update (#2941) Signed-off-by: Kent Rancourt --- docs/docs/35-references/10-promotion-steps.md | 60 +++- internal/directives/helm_image_updater.go | 86 ++--- .../directives/helm_image_updater_test.go | 155 ++++----- .../schemas/helm-update-image-config.json | 20 +- .../schemas/yaml-update-config.json | 44 +++ internal/directives/yaml_updater.go | 118 +++++++ internal/directives/yaml_updater_test.go | 299 ++++++++++++++++++ internal/directives/zz_config_types.go | 16 +- .../directives/helm-update-image-config.json | 4 +- ui/src/gen/directives/yaml-update-config.json | 49 +++ 10 files changed, 702 insertions(+), 149 deletions(-) create mode 100644 internal/directives/schemas/yaml-update-config.json create mode 100644 internal/directives/yaml_updater.go create mode 100644 internal/directives/yaml_updater_test.go create mode 100644 ui/src/gen/directives/yaml-update-config.json diff --git a/docs/docs/35-references/10-promotion-steps.md b/docs/docs/35-references/10-promotion-steps.md index 47a101568..c92adfea3 100644 --- a/docs/docs/35-references/10-promotion-steps.md +++ b/docs/docs/35-references/10-promotion-steps.md @@ -394,16 +394,18 @@ file with new version information which is referenced by the Freight being promoted. This step is commonly followed by a [`helm-template`](#helm-template) step. +__Deprecated: Use the generic `yaml-update` step instead. Will be removed in v1.2.0.__ + ### `helm-update-image` Configuration | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | `string` | Y | Path to Helm values file (e.g. `values.yaml`). This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. | | `images` | `[]object` | Y | The details of changes to be applied to the values file. At least one must be specified. | -| `images[].image` | `string` | N | Name/URL of the image being updated.

__Deprecated: Use `value` with an expression instead. Will be removed in v1.2.0.__ | -| `images[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins).

__Deprecated: Use `value` with an expression instead. Will be removed in v1.2.0.__ | +| `images[].image` | `string` | Y | Name/URL of the image being updated. The Freight being promoted presumably contains a reference to a revision of this image. | +| `images[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins) | | `images[].key` | `string` | Y | The key to update within the values file. See Helm documentation on the [format and limitations](https://helm.sh/docs/intro/using_helm/#the-format-and-limitations-of---set) of the notation used in this field. | -| `images[].value` | `string` | Y | Specifies how the value of `key` is to be updated. When `image` is non-empty, possible values for this field are limited to: When `image` is empty, use an expression in this field to describe the new value. | +| `images[].value` | `string` | Y | Specifies how the value of `key` is to be updated. Possible values for this field are limited to: | ### `helm-update-image` Example @@ -428,12 +430,62 @@ steps: config: path: ./src/charts/my-chart/values.yaml images: + - image: my/image + key: image.tag + value: Tag +# Render manifests to ./out, commit, push, etc... +``` + +### `helm-update-image` Output + +| Name | Type | Description | +|------|------|-------------| +| `commitMessage` | `string` | A description of the change(s) applied by this step. Typically, a subsequent [`git-commit`](#git-commit) step will reference this output and aggregate this commit message fragment with other like it to build a comprehensive commit message that describes all changes. | + +## `yaml-update` + +`yaml-update` updates the values of specified keys in any YAML file. This step +most often used to update image tags or digests in a Helm values and is commonly +followed by a [`helm-template`](#helm-template) step. + +### `yaml-update` Configuration + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | `string` | Y | Path to a YAML file. This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. | +| `updates` | `[]object` | Y | The details of changes to be applied to the file. At least one must be specified. | +| `updates[].key` | `string` | Y | The key to update within the file. For nested values, use a YAML dot notation path. | +| `updates[].value` | `string` | Y | The new value for the key. Typically specified using an expression. | + +### `yaml-update` Example + +```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git +steps: +- uses: git-clone + config: + repoURL: ${{ vars.gitRepo }} + checkout: + - commit: ${{ commitFrom(vars.gitRepo).ID }} + path: ./src + - branch: stage/${{ ctx.stage }} + create: true + path: ./out +- uses: git-clear + config: + path: ./out +- uses: yaml-update + config: + path: ./src/charts/my-chart/values.yaml + updates: - key: image.tag value: ${{ imageFrom("my/image").tag }} # Render manifests to ./out, commit, push, etc... ``` -### `helm-update-image` Output +### `yaml-update` Output | Name | Type | Description | |------|------|-------------| diff --git a/internal/directives/helm_image_updater.go b/internal/directives/helm_image_updater.go index 0dcc5f068..44fe188f8 100644 --- a/internal/directives/helm_image_updater.go +++ b/internal/directives/helm_image_updater.go @@ -3,7 +3,6 @@ package directives import ( "context" "fmt" - "slices" "strings" securejoin "github.com/cyphar/filepath-securejoin" @@ -72,7 +71,7 @@ func (h *helmImageUpdater) runPromotionStep( stepCtx *PromotionStepContext, cfg HelmUpdateImageConfig, ) (PromotionStepResult, error) { - updates, err := h.generateImageUpdates(ctx, stepCtx, cfg) + updates, fullImageRefs, err := h.generateImageUpdates(ctx, stepCtx, cfg) if err != nil { return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, fmt.Errorf("failed to generate image updates: %w", err) @@ -85,7 +84,7 @@ func (h *helmImageUpdater) runPromotionStep( fmt.Errorf("values file update failed: %w", err) } - if commitMsg := h.generateCommitMessage(cfg.Path, updates); commitMsg != "" { + if commitMsg := h.generateCommitMessage(cfg.Path, fullImageRefs); commitMsg != "" { result.Output = map[string]any{ "commitMessage": commitMsg, } @@ -98,31 +97,35 @@ func (h *helmImageUpdater) generateImageUpdates( ctx context.Context, stepCtx *PromotionStepContext, cfg HelmUpdateImageConfig, -) (map[string]string, error) { +) (map[string]string, []string, error) { updates := make(map[string]string, len(cfg.Images)) + fullImageRefs := make([]string, 0, len(cfg.Images)) + for _, image := range cfg.Images { - switch image.Value { - case ImageAndTag, Tag, ImageAndDigest, Digest: - // TODO(krancour): Remove this for v1.2.0 - desiredOrigin := h.getDesiredOrigin(image.FromOrigin) - targetImage, err := freight.FindImage( - ctx, - stepCtx.KargoClient, - stepCtx.Project, - stepCtx.FreightRequests, - desiredOrigin, - stepCtx.Freight.References(), - image.Image, - ) - if err != nil { - return nil, fmt.Errorf("failed to find image %s: %w", image.Image, err) - } - updates[image.Key] = h.getValue(targetImage, image.Value) - default: - updates[image.Key] = image.Value + desiredOrigin := h.getDesiredOrigin(image.FromOrigin) + + targetImage, err := freight.FindImage( + ctx, + stepCtx.KargoClient, + stepCtx.Project, + stepCtx.FreightRequests, + desiredOrigin, + stepCtx.Freight.References(), + image.Image, + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to find image %s: %w", image.Image, err) + } + + value, imageRef, err := h.getImageValues(targetImage, image.Value) + if err != nil { + return nil, nil, err } + + updates[image.Key] = value + fullImageRefs = append(fullImageRefs, imageRef) } - return updates, nil + return updates, fullImageRefs, nil } func (h *helmImageUpdater) getDesiredOrigin(fromOrigin *ChartFromOrigin) *kargoapi.FreightOrigin { @@ -135,19 +138,20 @@ func (h *helmImageUpdater) getDesiredOrigin(fromOrigin *ChartFromOrigin) *kargoa } } -// TODO(krancour): Remove this for v1.2.0 -func (h *helmImageUpdater) getValue(image *kargoapi.Image, value string) string { - switch value { +func (h *helmImageUpdater) getImageValues(image *kargoapi.Image, valueType string) (string, string, error) { + switch valueType { case ImageAndTag: - return fmt.Sprintf("%s:%s", image.RepoURL, image.Tag) + imageRef := fmt.Sprintf("%s:%s", image.RepoURL, image.Tag) + return imageRef, imageRef, nil case Tag: - return image.Tag + return image.Tag, fmt.Sprintf("%s:%s", image.RepoURL, image.Tag), nil case ImageAndDigest: - return fmt.Sprintf("%s@%s", image.RepoURL, image.Digest) + imageRef := fmt.Sprintf("%s@%s", image.RepoURL, image.Digest) + return imageRef, imageRef, nil case Digest: - return image.Digest + return image.Digest, fmt.Sprintf("%s@%s", image.RepoURL, image.Digest), nil default: - return value + return "", "", fmt.Errorf("unknown image value type %q", valueType) } } @@ -162,20 +166,20 @@ func (h *helmImageUpdater) updateValuesFile(workDir string, path string, changes return nil } -func (h *helmImageUpdater) generateCommitMessage(path string, updates map[string]string) string { - if len(updates) == 0 { +func (h *helmImageUpdater) generateCommitMessage(path string, fullImageRefs []string) string { + if len(fullImageRefs) == 0 { return "" } var commitMsg strings.Builder - _, _ = commitMsg.WriteString(fmt.Sprintf("Updated %s\n", path)) - keys := make([]string, 0, len(updates)) - for key := range updates { - keys = append(keys, key) + _, _ = commitMsg.WriteString(fmt.Sprintf("Updated %s to use new image", path)) + if len(fullImageRefs) > 1 { + _, _ = commitMsg.WriteString("s") } - slices.Sort(keys) - for _, key := range keys { - _, _ = commitMsg.WriteString(fmt.Sprintf("\n- %s: %q", key, updates[key])) + _, _ = commitMsg.WriteString("\n") + + for _, s := range fullImageRefs { + _, _ = commitMsg.WriteString(fmt.Sprintf("\n- %s", s)) } return commitMsg.String() diff --git a/internal/directives/helm_image_updater_test.go b/internal/directives/helm_image_updater_test.go index a7da7f638..e269555f0 100644 --- a/internal/directives/helm_image_updater_test.go +++ b/internal/directives/helm_image_updater_test.go @@ -86,20 +86,7 @@ func Test_helmImageUpdater_validate(t *testing.T) { }, }, { - name: "image and value both specified", - config: Config{ - "images": []Config{{ - "image": "fake-image", - "key": "fake-key", - "value": "fake-value", - }}, - }, - expectedProblems: []string{ - "images.0: Must validate one and only one schema", - }, - }, - { - name: "valid kitchen sink", + name: "valid", config: Config{ "path": "fake-path", "images": []Config{ @@ -117,15 +104,6 @@ func Test_helmImageUpdater_validate(t *testing.T) { "name": "fake-name", }, }, - { - "key": "fake-key-2", - "value": "fake-value", - }, - { - "image": "", - "key": "fake-key-3", - "value": "fake-value", - }, }, }, }, @@ -209,7 +187,7 @@ func Test_helmImageUpdater_runPromotionStep(t *testing.T) { assert.Equal(t, PromotionStepResult{ Status: kargoapi.PromotionPhaseSucceeded, Output: map[string]any{ - "commitMessage": "Updated values.yaml\n\n- image.tag: \"1.19.0\"", + "commitMessage": "Updated values.yaml to use new image\n\n- docker.io/library/nginx:1.19.0", }, }, result) content, err := os.ReadFile(path.Join(workDir, "values.yaml")) @@ -356,7 +334,7 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { objects []client.Object stepCtx *PromotionStepContext cfg HelmUpdateImageConfig - assertions func(*testing.T, map[string]string, error) + assertions func(*testing.T, map[string]string, []string, error) }{ { name: "finds image update", @@ -400,9 +378,10 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { {Key: "image.tag", Image: "docker.io/library/nginx", Value: Tag}, }, }, - assertions: func(t *testing.T, changes map[string]string, err error) { + assertions: func(t *testing.T, changes map[string]string, summary []string, err error) { assert.NoError(t, err) assert.Equal(t, map[string]string{"image.tag": "1.19.0"}, changes) + assert.Equal(t, []string{"docker.io/library/nginx:1.19.0"}, summary) }, }, { @@ -417,7 +396,7 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { {Key: "image.tag", Image: "docker.io/library/non-existent", Value: Tag}, }, }, - assertions: func(t *testing.T, _ map[string]string, err error) { + assertions: func(t *testing.T, _ map[string]string, _ []string, err error) { assert.ErrorContains(t, err, "not found in referenced Freight") }, }, @@ -464,7 +443,7 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { {Key: "image2.tag", Image: "docker.io/library/non-existent", Value: Tag}, }, }, - assertions: func(t *testing.T, _ map[string]string, err error) { + assertions: func(t *testing.T, _ map[string]string, _ []string, err error) { assert.ErrorContains(t, err, "not found in referenced Freight") }, }, @@ -515,25 +494,10 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { }, }, }, - assertions: func(t *testing.T, changes map[string]string, err error) { + assertions: func(t *testing.T, changes map[string]string, summary []string, err error) { assert.NoError(t, err) assert.Equal(t, map[string]string{"image.tag": "2.0.0"}, changes) - }, - }, - { - name: "value specified directly", - stepCtx: &PromotionStepContext{ - Project: "test-project", - }, - cfg: HelmUpdateImageConfig{ - Images: []HelmUpdateImageConfigImage{{ - Key: "image.tag", - Value: "fake-tag", - }}, - }, - assertions: func(t *testing.T, changes map[string]string, err error) { - assert.NoError(t, err) - assert.Equal(t, map[string]string{"image.tag": "fake-tag"}, changes) + assert.Equal(t, []string{"docker.io/library/origin-image:2.0.0"}, summary) }, }, } @@ -548,8 +512,8 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { stepCtx := tt.stepCtx stepCtx.KargoClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.objects...).Build() - changes, err := runner.generateImageUpdates(context.Background(), stepCtx, tt.cfg) - tt.assertions(t, changes, err) + changes, summary, err := runner.generateImageUpdates(context.Background(), stepCtx, tt.cfg) + tt.assertions(t, changes, summary, err) }) } } @@ -591,12 +555,12 @@ func Test_helmImageUpdater_getDesiredOrigin(t *testing.T) { } } -func Test_helmImageUpdater_getValue(t *testing.T) { +func Test_helmImageUpdater_getImageValues(t *testing.T) { tests := []struct { - name string - image *kargoapi.Image - inValue string - expected string + name string + image *kargoapi.Image + valueType string + assertions func(*testing.T, string, string, error) }{ { name: "image and tag", @@ -604,8 +568,12 @@ func Test_helmImageUpdater_getValue(t *testing.T) { RepoURL: "docker.io/library/nginx", Tag: "1.19", }, - inValue: ImageAndTag, - expected: "docker.io/library/nginx:1.19", + valueType: ImageAndTag, + assertions: func(t *testing.T, value, ref string, err error) { + assert.NoError(t, err) + assert.Equal(t, "docker.io/library/nginx:1.19", value) + assert.Equal(t, "docker.io/library/nginx:1.19", ref) + }, }, { name: "tag only", @@ -613,8 +581,12 @@ func Test_helmImageUpdater_getValue(t *testing.T) { RepoURL: "docker.io/library/nginx", Tag: "1.19", }, - inValue: Tag, - expected: "1.19", + valueType: Tag, + assertions: func(t *testing.T, value, ref string, err error) { + assert.NoError(t, err) + assert.Equal(t, "1.19", value) + assert.Equal(t, "docker.io/library/nginx:1.19", ref) + }, }, { name: "image and digest", @@ -622,8 +594,12 @@ func Test_helmImageUpdater_getValue(t *testing.T) { RepoURL: "docker.io/library/nginx", Digest: "sha256:abcdef1234567890", }, - inValue: ImageAndDigest, - expected: "docker.io/library/nginx@sha256:abcdef1234567890", + valueType: ImageAndDigest, + assertions: func(t *testing.T, value, ref string, err error) { + assert.NoError(t, err) + assert.Equal(t, "docker.io/library/nginx@sha256:abcdef1234567890", value) + assert.Equal(t, "docker.io/library/nginx@sha256:abcdef1234567890", ref) + }, }, { name: "digest only", @@ -631,14 +607,22 @@ func Test_helmImageUpdater_getValue(t *testing.T) { RepoURL: "docker.io/library/nginx", Digest: "sha256:abcdef1234567890", }, - inValue: Digest, - expected: "sha256:abcdef1234567890", + valueType: Digest, + assertions: func(t *testing.T, value, ref string, err error) { + assert.NoError(t, err) + assert.Equal(t, "sha256:abcdef1234567890", value) + assert.Equal(t, "docker.io/library/nginx@sha256:abcdef1234567890", ref) + }, }, { - name: "any other value", - image: &kargoapi.Image{}, - inValue: "fake-value", - expected: "fake-value", + name: "unknown value type", + image: &kargoapi.Image{}, + valueType: "unknown", + assertions: func(t *testing.T, value, ref string, err error) { + assert.Error(t, err) + assert.Empty(t, value) + assert.Empty(t, ref) + }, }, } @@ -646,7 +630,8 @@ func Test_helmImageUpdater_getValue(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.expected, runner.getValue(tt.image, tt.inValue)) + value, ref, err := runner.getImageValues(tt.image, tt.valueType) + tt.assertions(t, value, ref, err) }) } } @@ -714,40 +699,38 @@ func Test_helmImageUpdater_updateValuesFile(t *testing.T) { func Test_helmImageUpdater_generateCommitMessage(t *testing.T) { tests := []struct { - name string - path string - changes map[string]string - assertions func(*testing.T, string) + name string + path string + fullImageRefs []string + assertions func(*testing.T, string) }{ { - name: "no changes", - path: "values.yaml", + name: "no image references", + path: "values.yaml", + fullImageRefs: []string{}, assertions: func(t *testing.T, result string) { assert.Empty(t, result) }, }, { - name: "single change", - path: "values.yaml", - changes: map[string]string{"image": "repo/image:tag1"}, + name: "single image reference", + path: "values.yaml", + fullImageRefs: []string{"repo/image:tag1"}, assertions: func(t *testing.T, result string) { - assert.Equal(t, `Updated values.yaml + assert.Equal(t, `Updated values.yaml to use new image -- image: "repo/image:tag1"`, result) +- repo/image:tag1`, result) }, }, { - name: "multiple changes", - path: "chart/values.yaml", - changes: map[string]string{ - "image1": "repo1/image1:tag1", - "image2": "repo2/image2:tag2", - }, + name: "multiple image references", + path: "chart/values.yaml", + fullImageRefs: []string{"repo1/image1:tag1", "repo2/image2:tag2"}, assertions: func(t *testing.T, result string) { - assert.Equal(t, `Updated chart/values.yaml + assert.Equal(t, `Updated chart/values.yaml to use new images -- image1: "repo1/image1:tag1" -- image2: "repo2/image2:tag2"`, result) +- repo1/image1:tag1 +- repo2/image2:tag2`, result) }, }, } @@ -756,7 +739,7 @@ func Test_helmImageUpdater_generateCommitMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := runner.generateCommitMessage(tt.path, tt.changes) + result := runner.generateCommitMessage(tt.path, tt.fullImageRefs) tt.assertions(t, result) }) } diff --git a/internal/directives/schemas/helm-update-image-config.json b/internal/directives/schemas/helm-update-image-config.json index 488c30283..d59e3b75b 100644 --- a/internal/directives/schemas/helm-update-image-config.json +++ b/internal/directives/schemas/helm-update-image-config.json @@ -32,24 +32,12 @@ }, "value": { "type": "string", - "description": "Specifies the new value for the specified key in the Helm values file." + "description": "Specifies the new value for the specified key in the Helm values file.", + "minLength": 1, + "pattern": "^(Digest|ImageAndDigest|ImageAndTag|Tag)$" } }, - "required": ["key", "value"], - "oneOf": [ - { - "required": ["image"], - "properties": { - "image": { "minLength": 1 }, - "value": { "pattern": "^(Digest|ImageAndDigest|ImageAndTag|Tag)$" } - } - }, - { - "properties": { - "image": { "enum": ["", null] } - } - } - ] + "required": ["image", "key", "value"] } } } diff --git a/internal/directives/schemas/yaml-update-config.json b/internal/directives/schemas/yaml-update-config.json new file mode 100644 index 000000000..a61fdeb0c --- /dev/null +++ b/internal/directives/schemas/yaml-update-config.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YAMLUpdateConfig", + + "definitions": { + + "yamlUpdate": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The key whose value needs to be updated. For nested values, use a YAML dot notation path.", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "The new value for the specified key." + } + }, + "required": ["key", "value"] + } + + }, + + "type": "object", + "required": ["path", "updates"], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "The path to a YAML file.", + "minLength": 1 + }, + "updates": { + "type": "array", + "description": "A list of updates to apply to the YAML file.", + "minItems": 1, + "items": { + "$ref": "#/definitions/yamlUpdate" + } + } + } +} diff --git a/internal/directives/yaml_updater.go b/internal/directives/yaml_updater.go new file mode 100644 index 000000000..64bd86e92 --- /dev/null +++ b/internal/directives/yaml_updater.go @@ -0,0 +1,118 @@ +package directives + +import ( + "context" + "fmt" + "slices" + "strings" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/xeipuuv/gojsonschema" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + libYAML "github.com/akuity/kargo/internal/yaml" +) + +func init() { + builtins.RegisterPromotionStepRunner(newYAMLUpdater(), nil) +} + +// yamlUpdater is an implementation of the PromotionStepRunner interface that +// updates the values of specified keys in a YAML file. +type yamlUpdater struct { + schemaLoader gojsonschema.JSONLoader +} + +// newYAMLUpdater returns an implementation of the PromotionStepRunner interface +// that updates the values of specified keys in a YAML file. +func newYAMLUpdater() PromotionStepRunner { + r := &yamlUpdater{} + r.schemaLoader = getConfigSchemaLoader(r.Name()) + return r +} + +// Name implements the PromotionStepRunner interface. +func (y *yamlUpdater) Name() string { + return "yaml-update" +} + +// RunPromotionStep implements the PromotionStepRunner interface. +func (y *yamlUpdater) RunPromotionStep( + ctx context.Context, + stepCtx *PromotionStepContext, +) (PromotionStepResult, error) { + failure := PromotionStepResult{Status: kargoapi.PromotionPhaseErrored} + + if err := y.validate(stepCtx.Config); err != nil { + return failure, err + } + + // Convert the configuration into a typed struct + cfg, err := ConfigToStruct[YAMLUpdateConfig](stepCtx.Config) + if err != nil { + return failure, fmt.Errorf("could not convert config into %s config: %w", y.Name(), err) + } + + return y.runPromotionStep(ctx, stepCtx, cfg) +} + +// validate validates yamlImageUpdater configuration against a JSON schema. +func (y *yamlUpdater) validate(cfg Config) error { + return validate(y.schemaLoader, gojsonschema.NewGoLoader(cfg), y.Name()) +} + +func (y *yamlUpdater) runPromotionStep( + _ context.Context, + stepCtx *PromotionStepContext, + cfg YAMLUpdateConfig, +) (PromotionStepResult, error) { + updates := make(map[string]string, len(cfg.Updates)) + for _, image := range cfg.Updates { + updates[image.Key] = image.Value + } + + result := PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded} + if len(updates) > 0 { + if err := y.updateFile(stepCtx.WorkDir, cfg.Path, updates); err != nil { + return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, + fmt.Errorf("values file update failed: %w", err) + } + + if commitMsg := y.generateCommitMessage(cfg.Path, updates); commitMsg != "" { + result.Output = map[string]any{ + "commitMessage": commitMsg, + } + } + } + return result, nil +} + +func (y *yamlUpdater) updateFile(workDir string, path string, changes map[string]string) error { + absValuesFile, err := securejoin.SecureJoin(workDir, path) + if err != nil { + return fmt.Errorf("error joining path %q: %w", path, err) + } + if err := libYAML.SetStringsInFile(absValuesFile, changes); err != nil { + return fmt.Errorf("error updating image references in values file %q: %w", path, err) + } + return nil +} + +func (y *yamlUpdater) generateCommitMessage(path string, updates map[string]string) string { + if len(updates) == 0 { + return "" + } + + var commitMsg strings.Builder + _, _ = commitMsg.WriteString(fmt.Sprintf("Updated %s\n", path)) + keys := make([]string, 0, len(updates)) + for key := range updates { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + _, _ = commitMsg.WriteString(fmt.Sprintf("\n- %s: %q", key, updates[key])) + } + + return commitMsg.String() +} diff --git a/internal/directives/yaml_updater_test.go b/internal/directives/yaml_updater_test.go new file mode 100644 index 000000000..f7fef0a64 --- /dev/null +++ b/internal/directives/yaml_updater_test.go @@ -0,0 +1,299 @@ +package directives + +import ( + "context" + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" +) + +func Test_yamlUpdater_validate(t *testing.T) { + testCases := []struct { + name string + config Config + expectedProblems []string + }{ + { + name: "path is not specified", + config: Config{}, + expectedProblems: []string{ + "(root): path is required", + }, + }, + { + name: "path is empty", + config: Config{ + "path": "", + }, + expectedProblems: []string{ + "path: String length must be greater than or equal to 1", + }, + }, + { + name: "updates is null", + config: Config{}, + expectedProblems: []string{ + "(root): updates is required", + }, + }, + { + name: "updates is empty", + config: Config{ + "updates": []Config{}, + }, + expectedProblems: []string{ + "updates: Array must have at least 1 items", + }, + }, + { + name: "key not specified", + config: Config{ + "updates": []Config{{}}, + }, + expectedProblems: []string{ + "updates.0: key is required", + }, + }, + { + name: "key is empty", + config: Config{ + "updates": []Config{{ + "key": "", + }}, + }, + expectedProblems: []string{ + "updates.0.key: String length must be greater than or equal to 1", + }, + }, + { + name: "value not specified", + config: Config{ + "updates": []Config{{}}, + }, + expectedProblems: []string{ + "updates.0: value is required", + }, + }, + { + name: "valid config", + config: Config{ + "path": "fake-path", + "updates": []Config{ + { + "key": "fake-key", + "value": "fake-value", + }, + { + "key": "another-fake-key", + "value": "another-fake-value", + }, + }, + }, + }, + } + + r := newYAMLUpdater() + runner, ok := r.(*yamlUpdater) + require.True(t, ok) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + err := runner.validate(testCase.config) + if len(testCase.expectedProblems) == 0 { + require.NoError(t, err) + } else { + for _, problem := range testCase.expectedProblems { + require.ErrorContains(t, err, problem) + } + } + }) + } +} + +func Test_yamlUpdater_runPromotionStep(t *testing.T) { + tests := []struct { + name string + stepCtx *PromotionStepContext + cfg YAMLUpdateConfig + files map[string]string + assertions func(*testing.T, string, PromotionStepResult, error) + }{ + { + name: "successful run with updates", + stepCtx: &PromotionStepContext{ + Project: "test-project", + }, + cfg: YAMLUpdateConfig{ + Path: "values.yaml", + Updates: []YAMLUpdate{ + {Key: "image.tag", Value: "fake-tag"}, + }, + }, + files: map[string]string{ + "values.yaml": "image:\n tag: oldtag\n", + }, + assertions: func(t *testing.T, workDir string, result PromotionStepResult, err error) { + assert.NoError(t, err) + assert.Equal(t, PromotionStepResult{ + Status: kargoapi.PromotionPhaseSucceeded, + Output: map[string]any{ + "commitMessage": "Updated values.yaml\n\n- image.tag: \"fake-tag\"", + }, + }, result) + content, err := os.ReadFile(path.Join(workDir, "values.yaml")) + require.NoError(t, err) + assert.Contains(t, string(content), "tag: fake-tag") + }, + }, + { + name: "failed to update file", + stepCtx: &PromotionStepContext{ + Project: "test-project", + }, + cfg: YAMLUpdateConfig{ + Path: "non-existent/values.yaml", + Updates: []YAMLUpdate{ + {Key: "image.tag", Value: Tag}, + }, + }, + assertions: func(t *testing.T, _ string, result PromotionStepResult, err error) { + assert.Error(t, err) + assert.Equal(t, PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, result) + assert.Contains(t, err.Error(), "values file update failed") + }, + }, + } + + runner := &yamlUpdater{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stepCtx := tt.stepCtx + + stepCtx.WorkDir = t.TempDir() + for p, c := range tt.files { + require.NoError(t, os.MkdirAll(path.Join(stepCtx.WorkDir, path.Dir(p)), 0o700)) + require.NoError(t, os.WriteFile(path.Join(stepCtx.WorkDir, p), []byte(c), 0o600)) + } + + result, err := runner.runPromotionStep(context.Background(), stepCtx, tt.cfg) + tt.assertions(t, stepCtx.WorkDir, result, err) + }) + } +} + +func Test_yamlUpdater_updateValuesFile(t *testing.T) { + tests := []struct { + name string + valuesContent string + changes map[string]string + assertions func(*testing.T, string, error) + }{ + { + name: "successful update", + valuesContent: "key: value\n", + changes: map[string]string{"key": "newvalue"}, + assertions: func(t *testing.T, valuesFilePath string, err error) { + require.NoError(t, err) + + require.FileExists(t, valuesFilePath) + content, err := os.ReadFile(valuesFilePath) + require.NoError(t, err) + assert.Contains(t, string(content), "key: newvalue") + }, + }, + { + name: "file does not exist", + valuesContent: "", + changes: map[string]string{"key": "value"}, + assertions: func(t *testing.T, valuesFilePath string, err error) { + require.ErrorContains(t, err, "no such file or directory") + require.NoFileExists(t, valuesFilePath) + }, + }, + { + name: "empty changes", + valuesContent: "key: value\n", + changes: map[string]string{}, + assertions: func(t *testing.T, valuesFilePath string, err error) { + require.NoError(t, err) + require.FileExists(t, valuesFilePath) + content, err := os.ReadFile(valuesFilePath) + require.NoError(t, err) + assert.Equal(t, "key: value\n", string(content)) + }, + }, + } + + runner := &yamlUpdater{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workDir := t.TempDir() + valuesFile := path.Join(workDir, "values.yaml") + + if tt.valuesContent != "" { + err := os.WriteFile(valuesFile, []byte(tt.valuesContent), 0o600) + require.NoError(t, err) + } + + err := runner.updateFile(workDir, path.Base(valuesFile), tt.changes) + tt.assertions(t, valuesFile, err) + }) + } +} + +func Test_yamlUpdater_generateCommitMessage(t *testing.T) { + tests := []struct { + name string + path string + changes map[string]string + assertions func(*testing.T, string) + }{ + { + name: "no changes", + path: "values.yaml", + assertions: func(t *testing.T, result string) { + assert.Empty(t, result) + }, + }, + { + name: "single change", + path: "values.yaml", + changes: map[string]string{"image": "repo/image:tag1"}, + assertions: func(t *testing.T, result string) { + assert.Equal(t, `Updated values.yaml + +- image: "repo/image:tag1"`, result) + }, + }, + { + name: "multiple changes", + path: "chart/values.yaml", + changes: map[string]string{ + "image1": "repo1/image1:tag1", + "image2": "repo2/image2:tag2", + }, + assertions: func(t *testing.T, result string) { + assert.Equal(t, `Updated chart/values.yaml + +- image1: "repo1/image1:tag1" +- image2: "repo2/image2:tag2"`, result) + }, + }, + } + + runner := &yamlUpdater{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runner.generateCommitMessage(tt.path, tt.changes) + tt.assertions(t, result) + }) + } +} diff --git a/internal/directives/zz_config_types.go b/internal/directives/zz_config_types.go index a2db4d755..bd2262453 100644 --- a/internal/directives/zz_config_types.go +++ b/internal/directives/zz_config_types.go @@ -295,7 +295,7 @@ type HelmUpdateImageConfig struct { type HelmUpdateImageConfigImage struct { FromOrigin *ChartFromOrigin `json:"fromOrigin,omitempty"` // The container image (without tag) at which the update is targeted. - Image string `json:"image,omitempty"` + Image string `json:"image"` // The key in the Helm values file of which the value needs to be updated. For nested // values, it takes a YAML dot notation path. Key string `json:"key"` @@ -355,6 +355,20 @@ type KustomizeSetImageConfigImage struct { UseDigest bool `json:"useDigest,omitempty"` } +type YAMLUpdateConfig struct { + // The path to a YAML file. + Path string `json:"path"` + // A list of updates to apply to the YAML file. + Updates []YAMLUpdate `json:"updates"` +} + +type YAMLUpdate struct { + // The key whose value needs to be updated. For nested values, use a YAML dot notation path. + Key string `json:"key"` + // The new value for the specified key. + Value string `json:"value"` +} + // The kind of origin. Currently only 'Warehouse' is supported. Required. type Kind string diff --git a/ui/src/gen/directives/helm-update-image-config.json b/ui/src/gen/directives/helm-update-image-config.json index 2f23b7776..10f6a311c 100644 --- a/ui/src/gen/directives/helm-update-image-config.json +++ b/ui/src/gen/directives/helm-update-image-config.json @@ -45,7 +45,9 @@ }, "value": { "type": "string", - "description": "Specifies the new value for the specified key in the Helm values file." + "description": "Specifies the new value for the specified key in the Helm values file.", + "minLength": 1, + "pattern": "^(Digest|ImageAndDigest|ImageAndTag|Tag)$" } } } diff --git a/ui/src/gen/directives/yaml-update-config.json b/ui/src/gen/directives/yaml-update-config.json new file mode 100644 index 000000000..18ef87e0d --- /dev/null +++ b/ui/src/gen/directives/yaml-update-config.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YAMLUpdateConfig", + "definitions": { + "yamlUpdate": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The key whose value needs to be updated. For nested values, use a YAML dot notation path.", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "The new value for the specified key." + } + } + } + }, + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "The path to a YAML file.", + "minLength": 1 + }, + "updates": { + "type": "array", + "description": "A list of updates to apply to the YAML file.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The key whose value needs to be updated. For nested values, use a YAML dot notation path.", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "The new value for the specified key." + } + } + } + } + } +} \ No newline at end of file