diff --git a/docs/docs/35-references/10-promotion-steps.md b/docs/docs/35-references/10-promotion-steps.md index 5ba6807e9..47a101568 100644 --- a/docs/docs/35-references/10-promotion-steps.md +++ b/docs/docs/35-references/10-promotion-steps.md @@ -39,11 +39,12 @@ multiple working trees. | `repoURL` | `string` | Y | The URL of a remote Git repository to clone. | | `insecureSkipTLSVerify` | `boolean` | N | Whether to bypass TLS certificate verification when cloning (and for all subsequent operations involving this clone). Setting this to `true` is highly discouraged in production. | | `checkout` | `[]object` | Y | The commits, branches, or tags to check out from the repository and the paths where they should be checked out. At least one must be specified. | -| `checkout[].branch` | `string` | N | A branch to check out. Mutually exclusive with `tag` and `fromFreight=true`. If none of these is specified, the default branch will be checked out. | +| `checkout[].branch` | `string` | N | A branch to check out. Mutually exclusive with `commit`, `tag`, and `fromFreight=true`. If none of these is specified, the default branch will be checked out. | | `checkout[].create` | `boolean` | N | In the event `branch` does not already exist on the remote, whether a new, empty, orphaned branch should be created. Default is `false`, but should commonly be set to `true` for Stage-specific branches, which may not exist yet at the time of a Stage's first promotion. | -| `checkout[].tag` | `string` | N | A tag to check out. Mutually exclusive with `branch` and `fromFreight=true`. If none of these is specified, the default branch will be checked out. | -| `checkout[].fromFreight` | `boolean` | N | Whether a commit to check out should be obtained from the Freight being promoted. A value of `true` is mutually exclusive with `branch` and `tag`. If none of these is specified, the default branch will be checked out. Default is `false`, but is often set to `true`. | -| `checkout[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). | +| `checkout[].commit` | `string` | N | A specific commit to check out. Mutually exclusive with `branch`, `tag`, and `fromFreight=true`. If none of these is specified, the default branch will be checked out. | +| `checkout[].tag` | `string` | N | A tag to check out. Mutually exclusive with `branch`, `commit`, and `fromFreight=true`. If none of these is specified, the default branch will be checked out. | +| `checkout[].fromFreight` | `boolean` | N | Whether a commit to check out should be obtained from the Freight being promoted. A value of `true` is mutually exclusive with `branch`, `commit`, and `tag`. If none of these is specified, the default branch will be checked out. Default is `false`, but is often set to `true`.

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

__Deprecated: Use `commit` with an expression instead. Will be removed in v1.2.0.__ | | `checkout[].path` | `string` | Y | The path for a working tree that will be created from the checked out revision. This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. | ### `git-clone` Examples @@ -58,12 +59,15 @@ likely to perform actions that revise the contents of the Stage-specific branch using the commit from the Freight as input. ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo) }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -84,20 +88,17 @@ with the help of a [`copy`](#copy) step. For this case, a `git-clone` step may b configured similarly to the following: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true - fromOrigin: - kind: Warehouse - name: base + - commit: ${{ commitFrom(vars.gitRepo, warehouse("base")).ID }} path: ./src - - fromFreight: true - fromOrigin: - kind: Warehouse - name: ${{ ctx.stage }}-overlay + - commit: ${{ commitFrom(vars.gitRepo, warehouse(ctx.stage + "-overlay")).ID }} path: ./overlay - branch: stage/${{ ctx.stage }} create: true @@ -177,20 +178,17 @@ Kustomize overlay. Rendering the manifests intended for such a Stage will require combining the base and overlay configurations: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true - fromOrigin: - kind: Warehouse - name: base + - commit: ${{ commitFrom(vars.gitRepo, warehouse("base")).ID }} path: ./src - - fromFreight: true - fromOrigin: - kind: Warehouse - name: ${{ ctx.stage }}-overlay + - commit: ${{ commitFrom(vars.gitRepo, warehouse(ctx.stage + "-overlay")).ID }} path: ./overlay - branch: stage/${{ ctx.stage }} create: true @@ -218,10 +216,12 @@ to executing `kustomize edit set image`. This step is commonly followed by a |------|------|----------|-------------| | `path` | `string` | Y | Path to a directory containing a `kustomization.yaml` file. 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 `kustomization.yaml` file. At least one must be specified. | -| `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[].image` | `string` | Y | Name/URL of the image being updated. | +| `images[].tag` | `string` | N | A tag naming a specific revision of `image`. Mutually exclusive with `digest` and `useDigest=true`. If none of these are specified, the tag specified by a piece of Freight referencing `image` will be used as the value of this field. | +| `images[].digest` | `string` | N | A digest naming a specific revision of `image`. Mutually exclusive with `tag` and `useDigest=true`. If none of these are specified, the tag specified by a piece of Freight referencing `image` will be used as the value of `tag`. | +| `images[].useDigest` | `boolean` | N | Whether to update the `kustomization.yaml` file using the container image's digest instead of its tag. Mutually exclusive with `digest` and `tag`. If none of these are specified, the tag specified by a piece of Freight referencing `image` will be used as the value of `tag`.

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

__Deprecated: Use `digest` or `tag` with an expression instead. Will be removed in v1.2.0.__ | | `images[].newName` | `string` | N | A substitution for the name/URL of the image being updated. This is useful when different Stages have access to different container image repositories (assuming those different repositories contain equivalent images that are tagged identically). This may be a frequent consideration for users of Amazon's Elastic Container Registry. | -| `images[].useDigest` | `boolean` | N | Whether to update the `kustomization.yaml` file using the container image's digest instead of its tag. | ### `kustomize-set-image` Examples @@ -230,15 +230,17 @@ to executing `kustomize edit set image`. This step is commonly followed by a ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git +- name: imageRepo + value: my/image steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true - fromOrigin: - kind: Warehouse - name: base + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -250,7 +252,8 @@ steps: config: path: ./src/base images: - - image: my/image + - image: ${{ vars.imageRepo }} + tag: ${{ imageFrom(vars.imageRepo).tag }} # Render manifests to ./out, commit, push, etc... ``` @@ -267,15 +270,15 @@ region, it will be necessary to make a substitution when updating the `kustomization.yaml` file. This can be accomplished like so: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true - fromOrigin: - kind: Warehouse - name: base + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -327,12 +330,15 @@ preceded by a [`git-clear`](#git-clear) step and followed by ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -352,12 +358,15 @@ steps: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -391,20 +400,23 @@ step. |------|------|----------|-------------| | `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` | 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[].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[].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. Possible values for this field are limited to:
  • `ImageAndTag`: Replaces the value of `key` with a string in form `:`
  • `Tag`: Replaces the value of `key` with the image's tag
  • `ImageAndDigest`: Replaces the value of `key` with a string in form `@`
  • `Digest`: Replaces the value of `key` with the image's digest
| +| `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:
  • `ImageAndTag`: Replaces the value of `key` with a string in form `:`
  • `Tag`: Replaces the value of `key` with the image's tag
  • `ImageAndDigest`: Replaces the value of `key` with a string in form `@`
  • `Digest`: Replaces the value of `key` with the image's digest
When `image` is empty, use an expression in this field to describe the new value. | ### `helm-update-image` Example ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -416,9 +428,8 @@ steps: config: path: ./src/charts/my-chart/values.yaml images: - - image: my/image - key: image.tag - value: Tag + - key: image.tag + value: ${{ imageFrom("my/image").tag }} # Render manifests to ./out, commit, push, etc... ``` @@ -444,7 +455,8 @@ referenced by the Freight being promoted. This step is commonly followed by a | `charts` | `[]string` | Y | The details of dependency (subschart) updates to be applied to the chart's `Chart.yaml` file. | | `charts[].repository` | `string` | Y | The URL of the Helm chart repository in the `dependencies` entry whose `version` field is to be updated. Must _exactly_ match the `repository` field of that entry. | | `charts[].name` | `string` | Y | The name of the chart in in the `dependencies` entry whose `version` field is to be updated. Must exactly match the `name` field of that entry. | -| `charts[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins) | +| `charts[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins).

__Deprecated: Use `version` with an expression instead. Will be removed in v1.2.0.__ | +| `charts[].version` | `string` | N | The version to which the dependency should be updated. If left unspecified, the version specified by a piece of Freight referencing this chart will be used. | ### `helm-update-chart` Examples @@ -470,12 +482,17 @@ The `dependencies` can be updated to reflect the version of `some-chart` referenced by the Freight being promoted like so: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git +- name: chartRepo + value: https://example-chart-repo steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -487,8 +504,9 @@ steps: config: path: ./src/charts/my-chart charts: - - repository: https://example-chart-repo + - repository: ${{ chartRepo }} name: some-chart + version: ${{ chartFrom(chartRepo).Version }} # Render manifests to ./out, commit, push, etc... ``` @@ -570,12 +588,17 @@ The `dependencies` can be updated to reflect the version of promoted like so: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git +- name: chartReg + value: oci://example-chart-registry steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -587,8 +610,9 @@ steps: config: path: ./src/charts/my-chart charts: - - repository: oci://example-chart-registry + - repository: ${{ chartReg }} name: some-chart + version: ${{ chartFrom(chartReg + "/some-chart").Version }} # Render manifests to ./out, commit, push, etc... ``` @@ -630,12 +654,15 @@ commonly preceded by a [`git-clear`](#git-clear) step and followed by ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -657,12 +684,15 @@ steps: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -703,12 +733,15 @@ desired state and is commonly followed by a [`git-push`](#git-push) step. ### `git-commit` Example ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: git-clone config: - repoURL: https://github.com/example/repo.git + repoURL: ${{ vars.gitRepo }} checkout: - - fromFreight: true + - commit: ${{ commitFrom(vars.gitRepo).ID }} path: ./src - branch: stage/${{ ctx.stage }} create: true @@ -911,26 +944,28 @@ promotion process. | `apps[].sources` | `[]object` | N | Describes Argo CD `ApplicationSource`s to update and how to update them. | | `apps[].sources[].repoURL` | `string` | Y | The value of the target `ApplicationSource`'s own `repoURL` field. This must match exactly. | | `apps[].sources[].chart` | `string` | N | Applicable only when the target `ApplicationSource` references a Helm chart repository, the value of the target `ApplicationSource`'s own `chart` field. This must match exactly. | -| `apps[].sources[].desiredCommit` | `string` | N | Applicable only when `repoURL` references a Git repository, this field specifies a `commit` to use as the desired revision for the source. This field is mutually exclusive with `desiredCommitFromStep`. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's `targetRevision` will not be updated to this commit unless `updateTargetRevision=true` is set. The utility of this field is to ensure that health checks on Argo CD `ApplicationSource`s can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows. | -| `apps[].sources[].desiredCommitFromStep` | `string` | N | Applicable only when `repoURL` references a Git repository, this field references the `commit` output from a previous step and uses it as the desired revision for the source. This field is mutually exclusive with `desiredCommitFromStep`. If both left undefined, the desired revision will be determined by Freight (if possible). Note that the source's `targetRevision` will not be updated to this commit unless `updateTargetRevision=true` is set. The utility of this field is to ensure that health checks on Argo CD `ApplicationSource`s can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.

__Deprecated: Use `desiredCommit` with an expression instead. Will be removed in v1.2.0.__ | -| `apps[].sources[].updateTargetRevision` | `boolean` | Y | Indicates whether the target `ApplicationSource` should be updated such that its `targetRevision` field points at the most recently Git commit (if `repoURL` references a Git repository) or chart version (if `repoURL` references a chart repository). | +| `apps[].sources[].desiredRevision` | `string` | N | Specifies the desired revision for the source. This field is mutually exclusive with `desiredCommitFromStep`. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's `targetRevision` will not be updated to this revision unless `updateTargetRevision=true` is also set. The utility of this field, on its own, is to specify the revision that the `ApplicationSource` should be observably synced to during a health check. | +| `apps[].sources[].desiredCommitFromStep` | `string` | N | Applicable only when `repoURL` references a Git repository, this field references the `commit` output from a previous step and uses it as the desired revision for the source. This field is mutually exclusive with `desiredRevisionFromStep`. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's `targetRevision` will not be updated to this commit unless `updateTargetRevision=true` is also set. The utility of this field, on its own, is to specify the revision that the `ApplicationSource` should be observably synced to during a health check.

__Deprecated: Use `desiredRevision` with an expression instead. Will be removed in v1.2.0.__ | +| `apps[].sources[].updateTargetRevision` | `boolean` | Y | Indicates whether the target `ApplicationSource` should be updated such that its `targetRevision` field points directly at the desired revision. | | `apps[].sources[].kustomize` | `object` | N | Describes updates to an Argo CD `ApplicationSource`'s Kustomize-specific properties. | | `apps[].sources[].kustomize.images` | `[]object` | Y | Describes how to update an Argo CD `ApplicationSource`'s Kustomize-specific properties to reference specific versions of container images. | -| `apps[].sources[].kustomize.images[].repoURL` | `string` | Y | URL of the image being updated. The Freight being promoted must contain a reference to a revision of this image. | -| `apps[].sources[].kustomize.images[].newName` | `string` | N | A substitution for the name/URL of the image being updated. This is useful when different Stages have access to different container image repositories (assuming those different repositories contain equivalent images that are tagged identically). This may be a frequent consideration for users of Amazon's Elastic Container Registry. | +| `apps[].sources[].kustomize.images[].repoURL` | `string` | Y | URL of the image being updated. | +| `apps[].sources[].kustomize.images[].tag` | `string` | N | A tag naming a specific revision of the image specified by `repoURL`. Mutually exclusive with `digest` and `useDigest=true`. One of `digest`, `tag`, or `useDigest=true` must be specified. | +| `apps[].sources[].kustomize.images[].digest` | `string` | N | A digest naming a specific revision of the image specified by `repoURL`. Mutually exclusive with `tag` and `useDigest=true`. One of `digest`, `tag`, or `useDigest=true` must be specified. | | `apps[].sources[].kustomize.images[].useDigest` | `boolean` | N | Whether to use the container image's digest instead of its tag. | -| `apps[].sources[].kustomize.images[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].sources[].kustomize.fromOrigin`. | -| `apps[].sources[].kustomize.fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].sources[].fromOrigin`. | +| `apps[].sources[].kustomize.images[].newName` | `string` | N | A substitution for the name/URL of the image being updated. This is useful when different Stages have access to different container image repositories (assuming those different repositories contain equivalent images that are tagged identically). This may be a frequent consideration for users of Amazon's Elastic Container Registry. | +| `apps[].sources[].kustomize.images[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].sources[].kustomize.fromOrigin`.

__Deprecated: Use `digest` or `tag` with an expression instead. Will be removed in v1.2.0.__ | +| `apps[].sources[].kustomize.fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].sources[].fromOrigin`.

__Deprecated: Will be removed in v1.2.0.__ | | `apps[].sources[].helm` | `object` | N | Describes updates to an Argo CD `ApplicationSource`'s Helm parameters. | | `apps[].sources[].helm.images` | `[]object` | Y | Describes how to update an Argo CD `ApplicationSource`'s Helm parameters to reference specific versions of container images. | -| `apps[].sources[].helm.images[].repoURL` | `string` | Y | URL of the image being updated. The Freight being promoted must contain a reference to a revision of this image. | +| `apps[].sources[].helm.images[].repoURL` | `string` | N | URL of the image being updated. __Deprecated: Use `value` with an expression instead. Will be removed in v1.2.0.__ | | `apps[].sources[].helm.images[].key` | `string` | Y | The key to update within the target `ApplicationSource`'s `helm.parameters` map. 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. | -| `apps[].sources[].helm.images[].value` | `string` | Y | Specifies how the value of `key` is to be updated. Possible values for this field are limited to:
  • `ImageAndTag`: Replaces the value of `key` with a string in form `:`
  • `Tag`: Replaces the value of `key` with the image's tag
  • `ImageAndDigest`: Replaces the value of `key` with a string in form `@`
  • `Digest`: Replaces the value of `key` with the image's digest
| -| `apps[].sources[].helm.images[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].sources[].helm.fromOrigin` | -| `apps[].sources[].helm.fromOrigin` | `object` | N | See [specifying origins].(#specifying-origins). If not specified, may inherit a value from `apps[].sources[]`. | -| `apps[].sources[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].fromOrigin`. | -| `apps[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `fromOrigin`. | -| `fromOrigin` | `object` | N | See [specifying origins](#specifying-origins) | +| `apps[].sources[].helm.images[].value` | `string` | Y | Specifies how the value of `key` is to be updated. When `repoURL` is non-empty, possible values for this field are limited to:
  • `ImageAndTag`: Replaces the value of `key` with a string in form `:`
  • `Tag`: Replaces the value of `key` with the image's tag
  • `ImageAndDigest`: Replaces the value of `key` with a string in form `@`
  • `Digest`: Replaces the value of `key` with the image's digest
When `repoURL` is empty, use an expression in this field to describe the new value. | +| `apps[].sources[].helm.images[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].sources[].helm.fromOrigin`.

__Deprecated: Use `value` with an expression instead. Will be removed in v1.2.0.__ | +| `apps[].sources[].helm.fromOrigin` | `object` | N | See [specifying origins].(#specifying-origins). If not specified, may inherit a value from `apps[].sources[]`.

__Deprecated: Will be removed in v1.2.0.__ | +| `apps[].sources[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `apps[].fromOrigin`.

__Deprecated: Will be removed in v1.2.0.__ | +| `apps[].fromOrigin` | `object` | N | See [specifying origins](#specifying-origins). If not specified, may inherit a value from `fromOrigin`.

__Deprecated: Will be removed in v1.2.0.__ | +| `fromOrigin` | `object` | N | See [specifying origins](#specifying-origins).

__Deprecated: Will be removed in v1.2.0.__ | ### `argocd-update` Examples @@ -976,15 +1011,18 @@ be no remaining record of its desired state. ::: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: argocd-update config: apps: - name: my-app sources: - - repoURL: https://example-chart-repo + - repoURL: ${{ chartRepo }} chart: my-chart - updateTargetRevision: true + targetRevision: ${{ chartFrom(chartRepo, "my-chart").Version }} ```
@@ -1003,6 +1041,9 @@ no remaining record of its desired state. ::: ```yaml +vars: +- name: gitRepo + value: https://github.com/example/repo.git steps: - uses: argocd-update config: @@ -1012,7 +1053,8 @@ steps: - repoURL: https://github.com/example/repo.git kustomize: images: - - repoURL: my/image + - repoURL: ${{ vars.imageRepo }} + tag: ${{ imageFrom(vars.imageRepo).Tag }} ```
@@ -1039,9 +1081,8 @@ steps: - repoURL: https://github.com/example/repo.git helm: images: - - repoURL: my/image - key: image.tag - value: Tag + - key: image.tag + value: ${{ imageFrom("my/image").Tag }} ```
@@ -1063,88 +1104,3 @@ Although the `argocd-update` step is the only promotion step to currently utilize this health check framework, we anticipate that future built-in and third-party promotion steps will take advantage of it as well. ::: - -## Specifying Origins - -Many promotion steps, or parts of those steps, will (whether optionally or -unconditionally) attempt to learn the desired revision(s) of some artifact(s) by -consulting the revisions of those artifact(s) references by the Freight being -promoted. - -By way of example, this `kustomize-set-image` step will consult the Freight -being promoted to learn the desired revision of the `my/image` container image: - -```yaml -- uses: kustomize-set-image - config: - path: ./src/base - images: - - image: my/image -``` - -In some _advanced_ uses cases, Stages may request Freight from multiple origins -(Warehouses). In such scenarios, it is possible (although somewhat rare) that -the multiple Freight being promoted may collectively reference multiple distinct -revisions of the same artifact. In such as case, it can become ambiguous which -revision of an artifact referenced by a promotion step should be used. - -To permit disambiguation in cases such as those described above, all promotion -steps that have the potential to reference Freight from multiple origins support -a `fromOrigin` option that can be used to clarify which piece of Freight's -reference to the artifact should be used by identifying the origin (Warehouse) -from which the Freight should have originated. - -The best way to illustrate this involves a complex example wherein a Stage -requests Freight from two Warehouses. Both Warehouses subscribe to the same Git -repository, with one watching for changes to a Kustomize "base" configuration -and the other watching for changes to a Stage-specific Kustomize overlay. -Rendering the manifests intended for such a Stage will require combining the -base and overlay configurations with the help of a [`copy`](#copy) step. For -this case, a `git-clone` step may be configured similarly to the following: - -```yaml -steps: -- uses: git-clone - config: - repoURL: https://github.com/example/repo.git - checkout: - - fromFreight: true - fromOrigin: - kind: Warehouse - name: base - path: ./src - - fromFreight: true - fromOrigin: - kind: Warehouse - name: ${{ ctx.stage }}-overlay - path: ./overlay - - branch: stage/${{ ctx.stage }} - create: true - path: ./out -- uses: git-clear - config: - path: ./out -- uses: copy - config: - inPath: ./overlay/stages/${{ ctx.stage }}/kustomization.yaml - outPath: ./src/stages/${{ ctx.stage }}/kustomization.yaml -- uses: kustomize-build - config: - path: ./src/stages/${{ ctx.stage }} - outPath: ./out -# Commit, push, etc... -``` - -Note that when checking out specific revisions of the -`https://github.com/example/repo.git` repository to different working trees, the -`git-clone` step has twice utilized `fromOrigin` to clarify which of the Freight -being promoted should be used to determine the revision to check out. - -:::info -`fromOrigin` never needs to be specified in the majority of use cases wherein -there is no inherent ambiguity. Kargo will automatically select the correct -revision of an artifact when there is only one possibility. When Kargo detects -that there may be multiple possibilities, it will fail and raise an error -indicating that the user must disambiguate by specifying `fromOrigin` in -applicable steps. -::: diff --git a/docs/docs/35-references/20-expression-language.md b/docs/docs/35-references/20-expression-language.md index fde49b957..47999a6c0 100644 --- a/docs/docs/35-references/20-expression-language.md +++ b/docs/docs/35-references/20-expression-language.md @@ -187,3 +187,102 @@ At present, such re-use can be achieved only through manual copy/paste, but support for a new, top-level `PromotionTemplate` resource type is planned for an upcoming release. ::: + +## Functions + +Several functions are built-in to Kargo's expression language. This section +describes each of them. + +### `quote()` + +The `quote()` function takes a single argument of any type and returns a string +representation. This is useful for scenarios where an expression evaluates to a +non-`string` JSON type, but you wish to treat it as a `string` regardless. + +Example: + +```yaml +config: + numField: ${{ 40 + 2 }} # Will be treated as a number + strField: ${{ quote(40 + 2) }} # Will be treated as a string +``` + +### `warehouse()` + +The `warehouse()` function takes a single argument of type `string`, which is the +name of a `Warehouse` resource in the same `Project` as the `Promotion` being +executed. It returns a `FreightOrigin` object representing that `Warehouse`. + +The `FreightOrigin` object can be used as an optional argument to the +`commitFrom()`, `imageFrom()`, or `chartFrom()` functions to disambiguate the +desired source of an artifact when necessary. + +See the next sections for examples. + +### `commitFrom()` + +The `commitFrom()` function takes the URL of a Git repository as its first +argument and returns a corresponding `GitCommit` object from the `Promotion`'s +`FreightCollection`. + +In the event that a `Stage` requests `Freight` from multiple origins +(`Warehouse`s) and more than one of those can provide a `GitCommit` object from +the specified repository, a `FreightOrigin` may be used as a second argument to +disambiguate the desired source. + +Example: + +```yaml +config: + commitID: ${{ commitFrom("https://github.com/example/repo.git", warehouse("my-warehouse")).ID }} +``` + +### `imageFrom()` + +The `imageFrom()` function takes the URL of a container image repository as its +first argument and returns a corresponding `Image` object from the `Promotion`'s +`FreightCollection`. + +In the event that a `Stage` requests `Freight` from multiple origins +(`Warehouse`s) and more than one of those can provide an `Image` object from the +specified repository, a `FreightOrigin` may be used as a second argument to +disambiguate the desired source. + +Example: + +```yaml +config: + imageTag: ${{ imageFrom("public.ecr.aws/nginx/nginx", warehouse("my-warehouse")).Tag }} +``` + +### `chartFrom()` + +The `chartFrom()` function takes the URL of a Helm chart repository as its first +argument and returns a corresponding `Chart` object from the `Promotion`'s +`FreightCollection`. + +For Helm charts stored in OCI registries, the URL should be the full path to the +repository within that registry. + +For Helm charts stored in classic (http/s) repositories, which can store +multiple different charts within a single repository, a second argument should +be used to specify the name of the chart within the repository. + +In the event that a `Stage` requests `Freight` from multiple origins +(`Warehouse`s) and more than one of those can provide a `Chart` object from the +specified repository, a `FreightOrigin` may be used as a final argument to +disambiguate the desired source. + +OCI registry example: + +```yaml +config: + chartVersion: ${{ chartFrom("oci://example.com/my-chart", warehouse("my-warehouse")).Version }} +``` + +Classic repository example: + +```yaml +config: + chartVersion: ${{ chartFrom("https://example.com/charts", "my-chart", warehouse("my-warehouse")).Version }} +``` diff --git a/internal/directives/argocd_revisions.go b/internal/directives/argocd_revisions.go index 1b8599199..acaf966ce 100644 --- a/internal/directives/argocd_revisions.go +++ b/internal/directives/argocd_revisions.go @@ -41,7 +41,7 @@ func (a *argocdUpdater) getDesiredRevisions( // specific about a previous step whose output should be used as the desired // revision. if sourceUpdate != nil { - revisions[i] = sourceUpdate.DesiredCommit + revisions[i] = sourceUpdate.DesiredRevision if revisions[i] == "" { var err error if revisions[i], err = getCommitFromStep( diff --git a/internal/directives/argocd_updater.go b/internal/directives/argocd_updater.go index aee7c2070..540e46036 100644 --- a/internal/directives/argocd_updater.go +++ b/internal/directives/argocd_updater.go @@ -815,28 +815,41 @@ func (a *argocdUpdater) buildKustomizeImagesForAppSource( kustomizeImages := make(argocd.KustomizeImages, 0, len(update.Images)) for i := range update.Images { imageUpdate := &update.Images[i] - desiredOrigin := getDesiredOrigin(stepCfg, imageUpdate) - image, err := freight.FindImage( - ctx, - stepCtx.KargoClient, - stepCtx.Project, - stepCtx.FreightRequests, - desiredOrigin, - stepCtx.Freight.References(), - imageUpdate.RepoURL, - ) - if err != nil { - return nil, - fmt.Errorf("error finding image from repo %q: %w", imageUpdate.RepoURL, err) + var digest, tag string + switch { + case imageUpdate.Digest != "": + digest = imageUpdate.Digest + case imageUpdate.Tag != "": + tag = imageUpdate.Tag + default: + desiredOrigin := getDesiredOrigin(stepCfg, imageUpdate) + image, err := freight.FindImage( + ctx, + stepCtx.KargoClient, + stepCtx.Project, + stepCtx.FreightRequests, + desiredOrigin, + stepCtx.Freight.References(), + imageUpdate.RepoURL, + ) + if err != nil { + return nil, + fmt.Errorf("error finding image from repo %q: %w", imageUpdate.RepoURL, err) + } + if imageUpdate.UseDigest { + digest = image.Digest + } else { + tag = image.Tag + } } kustomizeImageStr := imageUpdate.RepoURL if imageUpdate.NewName != "" { kustomizeImageStr = fmt.Sprintf("%s=%s", kustomizeImageStr, imageUpdate.NewName) } - if imageUpdate.UseDigest { - kustomizeImageStr = fmt.Sprintf("%s@%s", kustomizeImageStr, image.Digest) + if digest != "" { + kustomizeImageStr = fmt.Sprintf("%s@%s", kustomizeImageStr, digest) } else { - kustomizeImageStr = fmt.Sprintf("%s:%s", kustomizeImageStr, image.Tag) + kustomizeImageStr = fmt.Sprintf("%s:%s", kustomizeImageStr, tag) } kustomizeImages = append( kustomizeImages, @@ -857,33 +870,33 @@ func (a *argocdUpdater) buildHelmParamChangesForAppSource( imageUpdate := &update.Images[i] switch imageUpdate.Value { case ImageAndTag, Tag, ImageAndDigest, Digest: + // TODO(krancour): Remove this for v1.2.0 + desiredOrigin := getDesiredOrigin(stepCfg, imageUpdate) + image, err := freight.FindImage( + ctx, + stepCtx.KargoClient, + stepCtx.Project, + stepCtx.FreightRequests, + desiredOrigin, + stepCtx.Freight.References(), + imageUpdate.RepoURL, + ) + if err != nil { + return nil, + fmt.Errorf("error finding image from repo %q: %w", imageUpdate.RepoURL, err) + } + switch imageUpdate.Value { + case ImageAndTag: + changes[imageUpdate.Key] = fmt.Sprintf("%s:%s", imageUpdate.RepoURL, image.Tag) + case Tag: + changes[imageUpdate.Key] = image.Tag + case ImageAndDigest: + changes[imageUpdate.Key] = fmt.Sprintf("%s@%s", imageUpdate.RepoURL, image.Digest) + case Digest: + changes[imageUpdate.Key] = image.Digest + } default: - // This really shouldn't happen, so we'll ignore it. - continue - } - desiredOrigin := getDesiredOrigin(stepCfg, imageUpdate) - image, err := freight.FindImage( - ctx, - stepCtx.KargoClient, - stepCtx.Project, - stepCtx.FreightRequests, - desiredOrigin, - stepCtx.Freight.References(), - imageUpdate.RepoURL, - ) - if err != nil { - return nil, - fmt.Errorf("error finding image from repo %q: %w", imageUpdate.RepoURL, err) - } - switch imageUpdate.Value { - case ImageAndTag: - changes[imageUpdate.Key] = fmt.Sprintf("%s:%s", imageUpdate.RepoURL, image.Tag) - case Tag: - changes[imageUpdate.Key] = image.Tag - case ImageAndDigest: - changes[imageUpdate.Key] = fmt.Sprintf("%s@%s", imageUpdate.RepoURL, image.Digest) - case Digest: - changes[imageUpdate.Key] = image.Digest + changes[imageUpdate.Key] = imageUpdate.Value } } return changes, nil diff --git a/internal/directives/argocd_updater_test.go b/internal/directives/argocd_updater_test.go index 2f2636d8c..24c04b7e0 100644 --- a/internal/directives/argocd_updater_test.go +++ b/internal/directives/argocd_updater_test.go @@ -121,6 +121,21 @@ func Test_argoCDUpdater_validate(t *testing.T) { "apps.0.sources.0.repoURL: String length must be greater than or equal to 1", }, }, + { + name: "desiredCommitFromStep and desiredRevision are both specified", + // These are meant to be mutually exclusive. + config: Config{ + "apps": []Config{{ + "sources": []Config{{ + "desiredCommitFromStep": "fake-step", + "desiredRevision": "fake-commit", + }}, + }}, + }, + expectedProblems: []string{ + "apps.0.sources.0: Must validate one and only one schema", + }, + }, { name: "helm images is empty array", config: Config{ @@ -169,7 +184,7 @@ func Test_argoCDUpdater_validate(t *testing.T) { }, }, { - name: "helm images update repoURL is not specified", + name: "helm images update value is not specified", config: Config{ "apps": []Config{{ "sources": []Config{{ @@ -180,137 +195,150 @@ func Test_argoCDUpdater_validate(t *testing.T) { }}, }, expectedProblems: []string{ - "apps.0.sources.0.helm.images.0: repoURL is required", + "apps.0.sources.0.helm.images.0: value is required", }, }, { - name: "helm images update repoURL is empty string", + name: "helm images repoURL specified with invalid update value", + // The value is more constrained when the repoURL is specified. config: Config{ "apps": []Config{{ "sources": []Config{{ "helm": Config{ "images": []Config{{ - "repoURL": "", + "repoURL": "fake-image", + "value": "bogus", }}, }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0.helm.images.0.repoURL: String length must be greater than or equal to 1", + "apps.0.sources.0.helm.images.0.value: Does not match pattern", }, }, { - name: "helm images update value is not specified", + name: "kustomize images is empty array", config: Config{ "apps": []Config{{ "sources": []Config{{ - "helm": Config{ - "images": []Config{{}}, + "kustomize": Config{ + "images": []Config{}, }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0.helm.images.0: value is required", + "apps.0.sources.0.kustomize.images: Array must have at least 1 items", }, }, { - name: "helm images update value is invalid", + name: "kustomize images update newName is empty string", config: Config{ "apps": []Config{{ "sources": []Config{{ - "helm": Config{ + "kustomize": Config{ "images": []Config{{ - "value": "bogus", + "newName": "", }}, }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0.helm.images.0.value must be one of the following", + "apps.0.sources.0.kustomize.images.0.newName: String length must be greater than or equal to 1", }, }, { - name: "kustomize images is empty array", + name: "kustomize images update repoURL is not specified", config: Config{ "apps": []Config{{ "sources": []Config{{ "kustomize": Config{ - "images": []Config{}, + "images": []Config{{}}, }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0.kustomize.images: Array must have at least 1 items", + "apps.0.sources.0.kustomize.images.0: repoURL is required", }, }, { - name: "kustomize images update newName is empty string", + name: "kustomize images update repoURL is empty string", config: Config{ "apps": []Config{{ "sources": []Config{{ "kustomize": Config{ "images": []Config{{ - "newName": "", + "repoURL": "", }}, }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0.kustomize.images.0.newName: String length must be greater than or equal to 1", + "apps.0.sources.0.kustomize.images.0.repoURL: String length must be greater than or equal to 1", }, }, { - name: "kustomize images update repoURL is not specified", + name: "kustomize images digest and tag are both specified", + // These are meant to be mutually exclusive. config: Config{ "apps": []Config{{ "sources": []Config{{ "kustomize": Config{ - "images": []Config{{}}, + "images": []Config{{ + "digest": "fake-digest", + "tag": "fake-tag", + }}, }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0.kustomize.images.0: repoURL is required", + "apps.0.sources.0.kustomize.images.0: Must validate one and only one schema (oneOf)", }, }, { - name: "kustomize images update repoURL is empty string", + name: "kustomize images digest and useDigest=true are both specified", + // These are meant to be mutually exclusive. config: Config{ "apps": []Config{{ "sources": []Config{{ "kustomize": Config{ "images": []Config{{ - "repoURL": "", + "digest": "fake-digest", + "useDigest": true, }}, }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0.kustomize.images.0.repoURL: String length must be greater than or equal to 1", + "apps.0.sources.0.kustomize.images.0: Must validate one and only one schema (oneOf)", }, }, { - name: "desiredCommit and desiredCommitFromStep are both specified", + name: "kustomize images tag and useDigest=true are both specified", // These are meant to be mutually exclusive. config: Config{ "apps": []Config{{ "sources": []Config{{ - "desiredCommit": "fake-commit", - "desiredCommitFromStep": "fake-step", + "kustomize": Config{ + "images": []Config{{ + "tag": "fake-tag", + "useDigest": true, + }}, + }, }}, }}, }, expectedProblems: []string{ - "apps.0.sources.0: Must validate one and only one schema", + "apps.0.sources.0.kustomize.images.0: Must validate one and only one schema (oneOf)", }, }, + { name: "valid kitchen sink", config: Config{ @@ -321,18 +349,53 @@ func Test_argoCDUpdater_validate(t *testing.T) { "repoURL": "fake-git-url", "updateTargetRevision": true, "helm": Config{ - "images": []Config{{ - "repoURL": "fake-image-url", - "key": "fake-key", - "value": Tag, - }}, + "images": []Config{ + { + "repoURL": "fake-image", + "key": "fake-key", + "value": Tag, + }, + { + "repoURL": "another-fake-image", + "key": "another-fake-key", + "value": Tag, + "fromOrigin": Config{ + "kind": Warehouse, + "name": "fake-warehouse", + }, + }, + { + "key": "another-fake-key", + "value": "fake-tag", + }, + }, }, "kustomize": Config{ - "images": []Config{{ - "repoURL": "fake-image-url", - "newName": "fake-new-name", - "useDigest": true, - }}, + "images": []Config{ + { + "repoURL": "fake-image", + "fromOrigin": Config{ + "kind": Warehouse, + "name": "fake-warehouse", + }, + }, + { + "repoURL": "another-fake-image", + "digest": "fake-digest", + }, + { + "repoURL": "yet-another-fake-image", + "tag": "fake-tag", + }, + { + "repoURL": "still-another-fake-image", + "useDigest": true, + "fromOrigin": Config{ + "kind": Warehouse, + "name": "fake-warehouse", + }, + }, + }, }, }}, }}, @@ -1921,6 +1984,14 @@ func Test_argoCDUpdater_buildKustomizeImagesForAppSource(t *testing.T) { RepoURL: "another-fake-url", UseDigest: true, }, + { + RepoURL: "yet-another-fake-url", + Digest: "fake-digest", + }, + { + RepoURL: "still-another-fake-url", + Tag: "fake-tag", + }, }, }, RepoURL: "https://github.com/universe/42", @@ -1943,6 +2014,8 @@ func Test_argoCDUpdater_buildKustomizeImagesForAppSource(t *testing.T) { argocd.KustomizeImages{ "fake-url:fake-tag", "another-fake-url@another-fake-digest", + "yet-another-fake-url@fake-digest", + "still-another-fake-url:fake-tag", }, result, ) @@ -2010,6 +2083,10 @@ func Test_argoCDUpdater_buildHelmParamChangesForAppSource(t *testing.T) { Key: "fourth-fake-key", Value: Digest, }, + { + Key: "fifth-fake-key", + Value: "fake-value", + }, }, }, }}, @@ -2033,6 +2110,7 @@ func Test_argoCDUpdater_buildHelmParamChangesForAppSource(t *testing.T) { "second-fake-key": "second-fake-tag", "third-fake-key": "third-fake-url@third-fake-digest", "fourth-fake-key": "fourth-fake-digest", + "fifth-fake-key": "fake-value", }, result, ) diff --git a/internal/directives/git_cloner.go b/internal/directives/git_cloner.go index a5718422e..4d66b527e 100644 --- a/internal/directives/git_cloner.go +++ b/internal/directives/git_cloner.go @@ -144,6 +144,11 @@ func (g *gitCloner) runPromotionStep( return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, fmt.Errorf("error ensuring existence of remote branch %s: %w", ref, err) } + case checkout.Commit != "": + ref = checkout.Commit + case checkout.Tag != "": + ref = checkout.Tag + // TODO(krancour): Remove for v1.2.0. case checkout.FromFreight: var desiredOrigin *kargoapi.FreightOrigin if checkout.FromOrigin != nil { @@ -166,8 +171,6 @@ func (g *gitCloner) runPromotionStep( fmt.Errorf("error finding commit from repo %s: %w", cfg.RepoURL, err) } ref = commit.ID - case checkout.Tag != "": - ref = checkout.Tag } path, err := securejoin.SecureJoin(stepCtx.WorkDir, checkout.Path) if err != nil { diff --git a/internal/directives/git_cloner_test.go b/internal/directives/git_cloner_test.go index f4e15ecdc..3b955f173 100644 --- a/internal/directives/git_cloner_test.go +++ b/internal/directives/git_cloner_test.go @@ -75,37 +75,30 @@ func Test_gitCloner_validate(t *testing.T) { }, }, { - name: "neither branch nor fromFreight nor tag specified", - // This is ok. The behavior should be to clone the default branch. - config: Config{ // Should be completely valid - "repoURL": "https://github.com/example/repo.git", + name: "branch and commit are both specified", + // These are meant to be mutually exclusive. + config: Config{ "checkout": []Config{{ - "path": "/fake/path", + "branch": "fake-branch", + "commit": "fake-commit", }}, }, - }, - { - name: "branch is empty string, fromFreight is explicitly false, and tag is empty string", - // This is ok. The behavior should be to clone the default branch. - config: Config{ // Should be completely valid - "repoURL": "https://github.com/example/repo.git", - "checkout": []Config{{ - "branch": "", - "fromFreight": false, - "tag": "", - "path": "/fake/path", - }}, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", }, }, { - name: "just branch is specified", - config: Config{ // Should be completely valid - "repoURL": "https://github.com/example/repo.git", + name: "branch and tag are both specified", + // These are meant to be mutually exclusive. + config: Config{ "checkout": []Config{{ "branch": "fake-branch", - "path": "/fake/path", + "tag": "fake-tag", }}, }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, }, { name: "branch is specified and fromFreight is true", @@ -134,11 +127,11 @@ func Test_gitCloner_validate(t *testing.T) { }, }, { - name: "branch and tag are both specified", + name: "commit and tag are both specified", // These are meant to be mutually exclusive. config: Config{ "checkout": []Config{{ - "branch": "fake-branch", + "commit": "fake-commit", "tag": "fake-tag", }}, }, @@ -147,36 +140,38 @@ func Test_gitCloner_validate(t *testing.T) { }, }, { - name: "just fromFreight is true", - config: Config{ // Should be completely valid - "repoURL": "https://github.com/example/repo.git", + name: "commit is specified and fromFreight is true", + // These are meant to be mutually exclusive. + config: Config{ "checkout": []Config{{ + "commit": "fake-commit", "fromFreight": true, - "path": "/fake/path", }}, }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, }, { - name: "fromFreight is true and fromOrigin is specified", - config: Config{ // Should be completely valid - "repoURL": "https://github.com/example/repo.git", + name: "commit and fromOrigin are both specified", + // These are not meant to be used together. + config: Config{ "checkout": []Config{{ - "fromFreight": true, - "fromOrigin": Config{ - "kind": "Warehouse", - "name": "fake-warehouse", - }, - "path": "/fake/path", + "commit": "fake-commit", + "fromOrigin": Config{}, }}, }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, }, { - name: "fromFreight is true and tag is specified", + name: "tag is specified and fromFreight is true", // These are meant to be mutually exclusive. config: Config{ "checkout": []Config{{ - "fromFreight": true, "tag": "fake-tag", + "fromFreight": true, }}, }, expectedProblems: []string{ @@ -184,10 +179,11 @@ func Test_gitCloner_validate(t *testing.T) { }, }, { - name: "just fromOrigin is specified", - // This is not meant to be used without fromFreight=true. + name: "tag and fromOrigin are both specified", + // These are not meant to be used together. config: Config{ "checkout": []Config{{ + "tag": "fake-tag", "fromOrigin": Config{}, }}, }, @@ -196,28 +192,17 @@ func Test_gitCloner_validate(t *testing.T) { }, }, { - name: "fromOrigin and tag are both specified", - // These are not meant to be used together. + name: "just fromOrigin is specified", + // This is not meant to be used without fromFreight=true. config: Config{ "checkout": []Config{{ "fromOrigin": Config{}, - "tag": "fake-tag", }}, }, expectedProblems: []string{ "checkout.0: Must validate one and only one schema", }, }, - { - name: "just tag is specified", - config: Config{ // Should be completely valid - "repoURL": "https://github.com/example/repo.git", - "checkout": []Config{{ - "tag": "fake-tag", - "path": "/fake/path", - }}, - }, - }, { name: "valid kitchen sink", config: Config{ @@ -226,13 +211,56 @@ func Test_gitCloner_validate(t *testing.T) { { "path": "/fake/path/0", }, + { + "branch": "", + "commit": "", + "tag": "", + "fromFreight": false, + "path": "/fake/path/1", + }, { "branch": "fake-branch", - "path": "/fake/path/1", + "path": "/fake/path/2", + }, + { + "branch": "fake-branch", + "commit": "", + "tag": "", + "fromFreight": false, + "path": "/fake/path/3", + }, + { + "commit": "fake-commit", + "path": "/fake/path/4", + }, + { + "branch": "", + "commit": "fake-commit", + "tag": "", + "fromFreight": false, + "path": "/fake/path/5", + }, + { + "tag": "fake-tag", + "path": "/fake/path/6", + }, + { + "branch": "", + "commit": "", + "tag": "fake-tag", + "fromFreight": false, + "path": "/fake/path/7", }, { "fromFreight": true, - "path": "/fake/path/2", + "path": "/fake/path/8", + }, + { + "branch": "", + "commit": "", + "tag": "", + "fromFreight": true, + "path": "/fake/path/9", }, { "fromFreight": true, @@ -240,11 +268,7 @@ func Test_gitCloner_validate(t *testing.T) { "kind": "Warehouse", "name": "fake-warehouse", }, - "path": "/fake/path/3", - }, - { - "tag": "fake-tag", - "path": "/fake/path/4", + "path": "/fake/path/10", }, }, }, @@ -295,6 +319,9 @@ func Test_gitCloner_runPromotionStep(t *testing.T) { err = repo.Push(nil) require.NoError(t, err) + commitID, err := repo.LastCommitID() + require.NoError(t, err) + // Now we can proceed to test gitCloner... r := newGitCloner() @@ -313,29 +340,27 @@ func Test_gitCloner_runPromotionStep(t *testing.T) { RepoURL: fmt.Sprintf("%s/test.git", server.URL), Checkout: []Checkout{ { - // "master" is still the default branch name for a new repository - // unless you configure it otherwise. - Branch: "master", - Path: "master", + Commit: commitID, + Path: "src", }, { Branch: "stage/dev", - Path: "dev", + Path: "out", }, }, }, ) require.NoError(t, err) require.Equal(t, kargoapi.PromotionPhaseSucceeded, res.Status) - require.DirExists(t, filepath.Join(stepCtx.WorkDir, "master")) + require.DirExists(t, filepath.Join(stepCtx.WorkDir, "src")) // The checked out master branch should have the content we know is in the // test remote's master branch. - require.FileExists(t, filepath.Join(stepCtx.WorkDir, "master", "test.txt")) - require.DirExists(t, filepath.Join(stepCtx.WorkDir, "dev")) + require.FileExists(t, filepath.Join(stepCtx.WorkDir, "src", "test.txt")) + require.DirExists(t, filepath.Join(stepCtx.WorkDir, "out")) // The stage/dev branch is a new orphan branch with a single empty commit. // It should lack any content. - dirEntries, err := os.ReadDir(filepath.Join(stepCtx.WorkDir, "dev")) + dirEntries, err := os.ReadDir(filepath.Join(stepCtx.WorkDir, "out")) require.NoError(t, err) require.Len(t, dirEntries, 1) // Just the .git file - require.FileExists(t, filepath.Join(stepCtx.WorkDir, "dev", ".git")) + require.FileExists(t, filepath.Join(stepCtx.WorkDir, "out", ".git")) } diff --git a/internal/directives/helm_chart_updater.go b/internal/directives/helm_chart_updater.go index 311b121ed..f9e8d6f69 100644 --- a/internal/directives/helm_chart_updater.go +++ b/internal/directives/helm_chart_updater.go @@ -64,12 +64,7 @@ func (h *helmChartUpdater) RunPromotionStep( ) (PromotionStepResult, error) { failure := PromotionStepResult{Status: kargoapi.PromotionPhaseErrored} - // Validate the configuration against the JSON Schema - if err := validate( - h.schemaLoader, - gojsonschema.NewGoLoader(stepCtx.Config), - h.Name(), - ); err != nil { + if err := h.validate(stepCtx.Config); err != nil { return failure, err } @@ -82,6 +77,11 @@ func (h *helmChartUpdater) RunPromotionStep( return h.runPromotionStep(ctx, stepCtx, cfg) } +// validate validates helmChartUpdater configuration against a JSON schema. +func (h *helmChartUpdater) validate(cfg Config) error { + return validate(h.schemaLoader, gojsonschema.NewGoLoader(cfg), h.Name()) +} + func (h *helmChartUpdater) runPromotionStep( ctx context.Context, stepCtx *PromotionStepContext, @@ -141,34 +141,37 @@ func (h *helmChartUpdater) processChartUpdates( ) (map[string]string, error) { changes := make(map[string]string) for _, update := range cfg.Charts { - repoURL, chartName := normalizeChartReference(update.Repository, update.Name) - - var desiredOrigin *kargoapi.FreightOrigin - if update.FromOrigin != nil { - desiredOrigin = &kargoapi.FreightOrigin{ - Kind: kargoapi.FreightOriginKind(update.FromOrigin.Kind), - Name: update.FromOrigin.Name, + version := update.Version + if update.Version == "" { + // TODO(krancour): Remove this for v1.2.0 + repoURL, chartName := normalizeChartReference(update.Repository, update.Name) + var desiredOrigin *kargoapi.FreightOrigin + if update.FromOrigin != nil { + desiredOrigin = &kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKind(update.FromOrigin.Kind), + Name: update.FromOrigin.Name, + } } - } - - chart, err := freight.FindChart( - ctx, - stepCtx.KargoClient, - stepCtx.Project, - stepCtx.FreightRequests, - desiredOrigin, - stepCtx.Freight.References(), - repoURL, - chartName, - ) - if err != nil { - return nil, fmt.Errorf("failed to find chart: %w", err) + chart, err := freight.FindChart( + ctx, + stepCtx.KargoClient, + stepCtx.Project, + stepCtx.FreightRequests, + desiredOrigin, + stepCtx.Freight.References(), + repoURL, + chartName, + ) + if err != nil { + return nil, fmt.Errorf("failed to find chart: %w", err) + } + version = chart.Version } var updateUsed bool for i, dep := range chartDependencies { if dep.Repository == update.Repository && dep.Name == update.Name { - changes[fmt.Sprintf("dependencies.%d.version", i)] = chart.Version + changes[fmt.Sprintf("dependencies.%d.version", i)] = version updateUsed = true break } diff --git a/internal/directives/helm_chart_updater_test.go b/internal/directives/helm_chart_updater_test.go index f1d3d64b5..7b03f8fed 100644 --- a/internal/directives/helm_chart_updater_test.go +++ b/internal/directives/helm_chart_updater_test.go @@ -28,6 +28,143 @@ import ( "github.com/akuity/kargo/internal/helm" ) +func Test_helmChartUpdater_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: "charts is null", + config: Config{}, + expectedProblems: []string{ + "(root): charts is required", + }, + }, + { + name: "charts is empty", + config: Config{ + "charts": []Config{}, + }, + expectedProblems: []string{ + "charts: Array must have at least 1 items", + }, + }, + { + name: "repository not specified", + config: Config{ + "charts": []Config{{}}, + }, + expectedProblems: []string{ + "charts.0: repository is required", + }, + }, + { + name: "repository is empty", + config: Config{ + "charts": []Config{{ + "repository": "", + }}, + }, + expectedProblems: []string{ + "charts.0.repository: String length must be greater than or equal to 1", + }, + }, + { + name: "name not specified", + config: Config{ + "charts": []Config{{}}, + }, + expectedProblems: []string{ + "charts.0: name is required", + }, + }, + { + name: "name is empty", + config: Config{ + "charts": []Config{{ + "name": "", + }}, + }, + expectedProblems: []string{ + "charts.0.name: String length must be greater than or equal to 1", + }, + }, + { + name: "valid kitchen sink", + config: Config{ + "path": "fake-path", + "charts": []Config{ + { + "repository": "fake-repository", + "name": "fake-chart-0", + }, + { + "repository": "fake-repository", + "name": "fake-chart-1", + "version": "", + }, + { + "repository": "fake-repository", + "name": "fake-chart-2", + "fromOrigin": Config{ + "kind": Warehouse, + "name": "fake-warehouse", + }, + }, + { + "repository": "fake-repository", + "name": "fake-chart-3", + "version": "", + "fromOrigin": Config{ + "kind": Warehouse, + "name": "fake-warehouse", + }, + }, + { + "repository": "fake-repository", + "name": "fake-chart-4", + "version": "fake-version", + }, + }, + }, + }, + } + + r := newHelmChartUpdater() + runner, ok := r.(*helmChartUpdater) + 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_helmChartUpdater_runPromotionStep(t *testing.T) { tests := []struct { name string @@ -352,6 +489,28 @@ func Test_helmChartUpdater_processChartUpdates(t *testing.T) { assert.Equal(t, map[string]string{"dependencies.0.version": "2.0.0"}, changes) }, }, + { + name: "chart with version specified", + context: &PromotionStepContext{ + Project: "test-project", + }, + cfg: HelmUpdateChartConfig{ + Charts: []Chart{ + { + Repository: "https://charts.example.com", + Name: "origin-chart", + Version: "fake-version", + }, + }, + }, + chartDependencies: []chartDependency{ + {Repository: "https://charts.example.com", Name: "origin-chart"}, + }, + assertions: func(t *testing.T, changes map[string]string, err error) { + assert.NoError(t, err) + assert.Equal(t, map[string]string{"dependencies.0.version": "fake-version"}, changes) + }, + }, } runner := &helmChartUpdater{} diff --git a/internal/directives/helm_common.go b/internal/directives/helm_common.go new file mode 100644 index 000000000..79cfc2f8c --- /dev/null +++ b/internal/directives/helm_common.go @@ -0,0 +1,9 @@ +package directives + +// TODO(krancour): Remove this for v1.2.0 +const ( + Digest = "Digest" + ImageAndDigest = "ImageAndDigest" + ImageAndTag = "ImageAndTag" + Tag = "Tag" +) diff --git a/internal/directives/helm_image_updater.go b/internal/directives/helm_image_updater.go index eaad3d6f0..0dcc5f068 100644 --- a/internal/directives/helm_image_updater.go +++ b/internal/directives/helm_image_updater.go @@ -3,6 +3,7 @@ package directives import ( "context" "fmt" + "slices" "strings" securejoin "github.com/cyphar/filepath-securejoin" @@ -48,12 +49,7 @@ func (h *helmImageUpdater) RunPromotionStep( ) (PromotionStepResult, error) { failure := PromotionStepResult{Status: kargoapi.PromotionPhaseErrored} - // Validate the configuration against the JSON Schema - if err := validate( - h.schemaLoader, - gojsonschema.NewGoLoader(stepCtx.Config), - h.Name(), - ); err != nil { + if err := h.validate(stepCtx.Config); err != nil { return failure, err } @@ -66,12 +62,17 @@ func (h *helmImageUpdater) RunPromotionStep( return h.runPromotionStep(ctx, stepCtx, cfg) } +// validate validates helmImageUpdater configuration against a JSON schema. +func (h *helmImageUpdater) validate(cfg Config) error { + return validate(h.schemaLoader, gojsonschema.NewGoLoader(cfg), h.Name()) +} + func (h *helmImageUpdater) runPromotionStep( ctx context.Context, stepCtx *PromotionStepContext, cfg HelmUpdateImageConfig, ) (PromotionStepResult, error) { - updates, fullImageRefs, err := h.generateImageUpdates(ctx, stepCtx, cfg) + updates, err := h.generateImageUpdates(ctx, stepCtx, cfg) if err != nil { return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, fmt.Errorf("failed to generate image updates: %w", err) @@ -84,7 +85,7 @@ func (h *helmImageUpdater) runPromotionStep( fmt.Errorf("values file update failed: %w", err) } - if commitMsg := h.generateCommitMessage(cfg.Path, fullImageRefs); commitMsg != "" { + if commitMsg := h.generateCommitMessage(cfg.Path, updates); commitMsg != "" { result.Output = map[string]any{ "commitMessage": commitMsg, } @@ -97,35 +98,31 @@ func (h *helmImageUpdater) generateImageUpdates( ctx context.Context, stepCtx *PromotionStepContext, cfg HelmUpdateImageConfig, -) (map[string]string, []string, error) { +) (map[string]string, error) { updates := make(map[string]string, len(cfg.Images)) - fullImageRefs := make([]string, 0, len(cfg.Images)) - for _, image := range cfg.Images { - 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 + 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 } - - updates[image.Key] = value - fullImageRefs = append(fullImageRefs, imageRef) } - return updates, fullImageRefs, nil + return updates, nil } func (h *helmImageUpdater) getDesiredOrigin(fromOrigin *ChartFromOrigin) *kargoapi.FreightOrigin { @@ -138,20 +135,19 @@ func (h *helmImageUpdater) getDesiredOrigin(fromOrigin *ChartFromOrigin) *kargoa } } -func (h *helmImageUpdater) getImageValues(image *kargoapi.Image, valueType Value) (string, string, error) { - switch valueType { +// TODO(krancour): Remove this for v1.2.0 +func (h *helmImageUpdater) getValue(image *kargoapi.Image, value string) string { + switch value { case ImageAndTag: - imageRef := fmt.Sprintf("%s:%s", image.RepoURL, image.Tag) - return imageRef, imageRef, nil + return fmt.Sprintf("%s:%s", image.RepoURL, image.Tag) case Tag: - return image.Tag, fmt.Sprintf("%s:%s", image.RepoURL, image.Tag), nil + return image.Tag case ImageAndDigest: - imageRef := fmt.Sprintf("%s@%s", image.RepoURL, image.Digest) - return imageRef, imageRef, nil + return fmt.Sprintf("%s@%s", image.RepoURL, image.Digest) case Digest: - return image.Digest, fmt.Sprintf("%s@%s", image.RepoURL, image.Digest), nil + return image.Digest default: - return "", "", fmt.Errorf("unknown image value type %q", valueType) + return value } } @@ -166,20 +162,20 @@ func (h *helmImageUpdater) updateValuesFile(workDir string, path string, changes return nil } -func (h *helmImageUpdater) generateCommitMessage(path string, fullImageRefs []string) string { - if len(fullImageRefs) == 0 { +func (h *helmImageUpdater) generateCommitMessage(path string, updates map[string]string) string { + if len(updates) == 0 { return "" } var commitMsg strings.Builder - _, _ = commitMsg.WriteString(fmt.Sprintf("Updated %s to use new image", path)) - if len(fullImageRefs) > 1 { - _, _ = commitMsg.WriteString("s") + _, _ = commitMsg.WriteString(fmt.Sprintf("Updated %s\n", path)) + keys := make([]string, 0, len(updates)) + for key := range updates { + keys = append(keys, key) } - _, _ = commitMsg.WriteString("\n") - - for _, s := range fullImageRefs { - _, _ = commitMsg.WriteString(fmt.Sprintf("\n- %s", s)) + 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/helm_image_updater_test.go b/internal/directives/helm_image_updater_test.go index de44b124d..a7da7f638 100644 --- a/internal/directives/helm_image_updater_test.go +++ b/internal/directives/helm_image_updater_test.go @@ -18,6 +18,137 @@ import ( kargoapi "github.com/akuity/kargo/api/v1alpha1" ) +func Test_helmImageUpdater_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: "images is null", + config: Config{}, + expectedProblems: []string{ + "(root): images is required", + }, + }, + { + name: "images is empty", + config: Config{ + "images": []Config{}, + }, + expectedProblems: []string{ + "images: Array must have at least 1 items", + }, + }, + { + name: "key not specified", + config: Config{ + "images": []Config{{}}, + }, + expectedProblems: []string{ + "images.0: key is required", + }, + }, + { + name: "key is empty", + config: Config{ + "images": []Config{{ + "key": "", + }}, + }, + expectedProblems: []string{ + "images.0.key: String length must be greater than or equal to 1", + }, + }, + { + name: "value not specified", + config: Config{ + "images": []Config{{}}, + }, + expectedProblems: []string{ + "images.0: value is required", + }, + }, + { + 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", + config: Config{ + "path": "fake-path", + "images": []Config{ + { + "image": "fake-image", + "key": "fake-key-0", + "value": "ImageAndTag", + }, + { + "image": "fake-image", + "key": "fake-key-1", + "value": "ImageAndTag", + "fromOrigin": Config{ + "kind": Warehouse, + "name": "fake-name", + }, + }, + { + "key": "fake-key-2", + "value": "fake-value", + }, + { + "image": "", + "key": "fake-key-3", + "value": "fake-value", + }, + }, + }, + }, + } + + r := newHelmImageUpdater() + runner, ok := r.(*helmImageUpdater) + 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_helmImageUpdater_runPromotionStep(t *testing.T) { tests := []struct { name string @@ -78,7 +209,7 @@ func Test_helmImageUpdater_runPromotionStep(t *testing.T) { assert.Equal(t, PromotionStepResult{ Status: kargoapi.PromotionPhaseSucceeded, Output: map[string]any{ - "commitMessage": "Updated values.yaml to use new image\n\n- docker.io/library/nginx:1.19.0", + "commitMessage": "Updated values.yaml\n\n- image.tag: \"1.19.0\"", }, }, result) content, err := os.ReadFile(path.Join(workDir, "values.yaml")) @@ -225,7 +356,7 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { objects []client.Object stepCtx *PromotionStepContext cfg HelmUpdateImageConfig - assertions func(*testing.T, map[string]string, []string, error) + assertions func(*testing.T, map[string]string, error) }{ { name: "finds image update", @@ -269,10 +400,9 @@ 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, summary []string, err error) { + assertions: func(t *testing.T, changes map[string]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) }, }, { @@ -287,7 +417,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, _ []string, err error) { + assertions: func(t *testing.T, _ map[string]string, err error) { assert.ErrorContains(t, err, "not found in referenced Freight") }, }, @@ -334,7 +464,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, _ []string, err error) { + assertions: func(t *testing.T, _ map[string]string, err error) { assert.ErrorContains(t, err, "not found in referenced Freight") }, }, @@ -385,10 +515,25 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { }, }, }, - assertions: func(t *testing.T, changes map[string]string, summary []string, err error) { + assertions: func(t *testing.T, changes map[string]string, err error) { assert.NoError(t, err) assert.Equal(t, map[string]string{"image.tag": "2.0.0"}, changes) - assert.Equal(t, []string{"docker.io/library/origin-image:2.0.0"}, summary) + }, + }, + { + 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) }, }, } @@ -403,8 +548,8 @@ func Test_helmImageUpdater_generateImageUpdates(t *testing.T) { stepCtx := tt.stepCtx stepCtx.KargoClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.objects...).Build() - changes, summary, err := runner.generateImageUpdates(context.Background(), stepCtx, tt.cfg) - tt.assertions(t, changes, summary, err) + changes, err := runner.generateImageUpdates(context.Background(), stepCtx, tt.cfg) + tt.assertions(t, changes, err) }) } } @@ -446,12 +591,12 @@ func Test_helmImageUpdater_getDesiredOrigin(t *testing.T) { } } -func Test_helmImageUpdater_getImageValues(t *testing.T) { +func Test_helmImageUpdater_getValue(t *testing.T) { tests := []struct { - name string - image *kargoapi.Image - valueType Value - assertions func(*testing.T, string, string, error) + name string + image *kargoapi.Image + inValue string + expected string }{ { name: "image and tag", @@ -459,12 +604,8 @@ func Test_helmImageUpdater_getImageValues(t *testing.T) { RepoURL: "docker.io/library/nginx", Tag: "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) - }, + inValue: ImageAndTag, + expected: "docker.io/library/nginx:1.19", }, { name: "tag only", @@ -472,12 +613,8 @@ func Test_helmImageUpdater_getImageValues(t *testing.T) { RepoURL: "docker.io/library/nginx", Tag: "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) - }, + inValue: Tag, + expected: "1.19", }, { name: "image and digest", @@ -485,12 +622,8 @@ func Test_helmImageUpdater_getImageValues(t *testing.T) { RepoURL: "docker.io/library/nginx", Digest: "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) - }, + inValue: ImageAndDigest, + expected: "docker.io/library/nginx@sha256:abcdef1234567890", }, { name: "digest only", @@ -498,22 +631,14 @@ func Test_helmImageUpdater_getImageValues(t *testing.T) { RepoURL: "docker.io/library/nginx", Digest: "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) - }, + inValue: Digest, + expected: "sha256:abcdef1234567890", }, { - 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) - }, + name: "any other value", + image: &kargoapi.Image{}, + inValue: "fake-value", + expected: "fake-value", }, } @@ -521,8 +646,7 @@ func Test_helmImageUpdater_getImageValues(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - value, ref, err := runner.getImageValues(tt.image, tt.valueType) - tt.assertions(t, value, ref, err) + require.Equal(t, tt.expected, runner.getValue(tt.image, tt.inValue)) }) } } @@ -590,38 +714,40 @@ func Test_helmImageUpdater_updateValuesFile(t *testing.T) { func Test_helmImageUpdater_generateCommitMessage(t *testing.T) { tests := []struct { - name string - path string - fullImageRefs []string - assertions func(*testing.T, string) + name string + path string + changes map[string]string + assertions func(*testing.T, string) }{ { - name: "no image references", - path: "values.yaml", - fullImageRefs: []string{}, + name: "no changes", + path: "values.yaml", assertions: func(t *testing.T, result string) { assert.Empty(t, result) }, }, { - name: "single image reference", - path: "values.yaml", - fullImageRefs: []string{"repo/image:tag1"}, + 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 to use new image + assert.Equal(t, `Updated values.yaml -- repo/image:tag1`, result) +- image: "repo/image:tag1"`, result) }, }, { - name: "multiple image references", - path: "chart/values.yaml", - fullImageRefs: []string{"repo1/image1:tag1", "repo2/image2:tag2"}, + 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 to use new images + assert.Equal(t, `Updated chart/values.yaml -- repo1/image1:tag1 -- repo2/image2:tag2`, result) +- image1: "repo1/image1:tag1" +- image2: "repo2/image2:tag2"`, result) }, }, } @@ -630,7 +756,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.fullImageRefs) + result := runner.generateCommitMessage(tt.path, tt.changes) tt.assertions(t, result) }) } diff --git a/internal/directives/kustomize_image_setter.go b/internal/directives/kustomize_image_setter.go index 821ed209e..c3a821546 100644 --- a/internal/directives/kustomize_image_setter.go +++ b/internal/directives/kustomize_image_setter.go @@ -59,21 +59,26 @@ func (k *kustomizeImageSetter) RunPromotionStep( ctx context.Context, stepCtx *PromotionStepContext, ) (PromotionStepResult, error) { - // Validate the configuration against the JSON Schema. - if err := validate(k.schemaLoader, gojsonschema.NewGoLoader(stepCtx.Config), k.Name()); err != nil { - return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, err + failure := PromotionStepResult{Status: kargoapi.PromotionPhaseErrored} + + if err := k.validate(stepCtx.Config); err != nil { + return failure, err } // Convert the configuration into a typed object. cfg, err := ConfigToStruct[KustomizeSetImageConfig](stepCtx.Config) if err != nil { - return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, - fmt.Errorf("could not convert config into kustomize-set-image config: %w", err) + return failure, fmt.Errorf("could not convert config into kustomize-set-image config: %w", err) } return k.runPromotionStep(ctx, stepCtx, cfg) } +// validate validates kustomizeImageSetter configuration against a JSON schema. +func (k *kustomizeImageSetter) validate(cfg Config) error { + return validate(k.schemaLoader, gojsonschema.NewGoLoader(cfg), k.Name()) +} + func (k *kustomizeImageSetter) runPromotionStep( ctx context.Context, stepCtx *PromotionStepContext, @@ -112,44 +117,45 @@ func (k *kustomizeImageSetter) buildTargetImages( images []KustomizeSetImageConfigImage, ) (map[string]kustypes.Image, error) { targetImages := make(map[string]kustypes.Image, len(images)) - for _, img := range images { - var desiredOrigin *kargoapi.FreightOrigin - if img.FromOrigin != nil { - desiredOrigin = &kargoapi.FreightOrigin{ - Kind: kargoapi.FreightOriginKind(img.FromOrigin.Kind), - Name: img.FromOrigin.Name, - } - } - - discoveredImage, err := freight.FindImage( - ctx, - stepCtx.KargoClient, - stepCtx.Project, - stepCtx.FreightRequests, - desiredOrigin, - stepCtx.Freight.References(), - img.Image, - ) - if err != nil { - return nil, fmt.Errorf("unable to discover image for %q: %w", img.Image, err) - } - targetImage := kustypes.Image{ Name: img.Image, NewName: img.NewName, - NewTag: discoveredImage.Tag, } if img.Name != "" { targetImage.Name = img.Name } - if img.UseDigest { - targetImage.Digest = discoveredImage.Digest + if img.Digest != "" { + targetImage.Digest = img.Digest + } else if img.Tag != "" { + targetImage.NewTag = img.Tag + } else { // TODO(krancour): Remove this for v1.2.0 + var desiredOrigin *kargoapi.FreightOrigin + if img.FromOrigin != nil { + desiredOrigin = &kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKind(img.FromOrigin.Kind), + Name: img.FromOrigin.Name, + } + } + discoveredImage, err := freight.FindImage( + ctx, + stepCtx.KargoClient, + stepCtx.Project, + stepCtx.FreightRequests, + desiredOrigin, + stepCtx.Freight.References(), + img.Image, + ) + if err != nil { + return nil, fmt.Errorf("unable to discover image for %q: %w", img.Image, err) + } + targetImage.NewTag = discoveredImage.Tag + if img.UseDigest { + targetImage.Digest = discoveredImage.Digest + } } - targetImages[targetImage.Name] = targetImage } - return targetImages, nil } diff --git a/internal/directives/kustomize_image_setter_test.go b/internal/directives/kustomize_image_setter_test.go index 212bc9bed..3f0b7fd56 100644 --- a/internal/directives/kustomize_image_setter_test.go +++ b/internal/directives/kustomize_image_setter_test.go @@ -18,6 +18,178 @@ import ( kargoapi "github.com/akuity/kargo/api/v1alpha1" ) +func Test_kustomizeImageSetter_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: "images is null", + config: Config{}, + expectedProblems: []string{ + "(root): images is required", + }, + }, + { + name: "images is empty", + config: Config{ + "images": []Config{}, + }, + expectedProblems: []string{ + "images: Array must have at least 1 items", + }, + }, + { + name: "image not specified", + config: Config{ + "images": []Config{{}}, + }, + expectedProblems: []string{ + "images.0: image is required", + }, + }, + { + name: "image is empty", + config: Config{ + "images": []Config{{ + "image": "", + }}, + }, + expectedProblems: []string{ + "images.0.image: String length must be greater than or equal to 1", + }, + }, + { + name: "digest and tag are both specified", + // These should be mutually exclusive. + config: Config{ + "images": []Config{{ + "digest": "fake-digest", + "tag": "fake-tag", + }}, + }, + expectedProblems: []string{ + "images.0: Must validate one and only one schema (oneOf)", + }, + }, + { + name: "digest and useDigest are both specified", + // These should be mutually exclusive. + config: Config{ + "images": []Config{{ + "digest": "fake-digest", + "useDigest": true, + }}, + }, + expectedProblems: []string{ + "images.0: Must validate one and only one schema (oneOf)", + }, + }, + { + name: "tag and useDigest are both specified", + // These should be mutually exclusive. + config: Config{ + "images": []Config{{ + "tag": "fake-tag", + "useDigest": true, + }}, + }, + expectedProblems: []string{ + "images.0: Must validate one and only one schema (oneOf)", + }, + }, + { + name: "valid kitchen sink", + config: Config{ + "path": "fake-path", + "images": []Config{ + { + "image": "fake-image-0", + }, + { + "image": "fake-image-1", + "digest": "", + "tag": "", + "useDigest": false, + }, + { + "image": "fake-image-2", + "digest": "fake-digest", + }, + { + "image": "fake-image-3", + "digest": "fake-digest", + "tag": "", + "useDigest": false, + }, + { + "image": "fake-image-4", + "tag": "fake-tag", + }, + { + "image": "fake-image-5", + "digest": "", + "tag": "fake-tag", + "useDigest": false, + }, + { + "image": "fake-image-6", + "useDigest": true, + }, + { + "image": "fake-image-7", + "digest": "", + "tag": "", + "useDigest": true, + }, + { + "image": "fake-image-8", + "useDigest": true, + "fromOrigin": Config{ + "kind": Warehouse, + "name": "fake-warehouse", + }, + }, + }, + }, + }, + } + + r := newKustomizeImageSetter() + runner, ok := r.(*kustomizeImageSetter) + 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_kustomizeImageSetter_runPromotionStep(t *testing.T) { const testNamespace = "test-project-run" @@ -180,6 +352,26 @@ func Test_kustomizeImageSetter_buildTargetImages(t *testing.T) { freightReferences map[string]kargoapi.FreightReference assertions func(*testing.T, map[string]kustypes.Image, error) }{ + { + name: "digest or tag specified", + images: []KustomizeSetImageConfigImage{ + { + Image: "nginx", + Tag: "fake-tag", + }, + { + Image: "redis", + Digest: "fake-digest", + }, + }, + assertions: func(t *testing.T, result map[string]kustypes.Image, err error) { + require.NoError(t, err) + assert.Equal(t, map[string]kustypes.Image{ + "nginx": {Name: "nginx", NewTag: "fake-tag"}, + "redis": {Name: "redis", Digest: "fake-digest"}, + }, result) + }, + }, { name: "discovers origins and builds target images", images: []KustomizeSetImageConfigImage{ diff --git a/internal/directives/promotions.go b/internal/directives/promotions.go index b7ade6c25..789d56b60 100644 --- a/internal/directives/promotions.go +++ b/internal/directives/promotions.go @@ -4,10 +4,12 @@ import ( "context" "fmt" + "github.com/expr-lang/expr" "sigs.k8s.io/controller-runtime/pkg/client" yaml "sigs.k8s.io/yaml/goyaml.v3" kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/internal/controller/freight" "github.com/akuity/kargo/internal/credentials" "github.com/akuity/kargo/internal/expressions" ) @@ -85,6 +87,8 @@ type PromotionStep struct { // expressions are evaluated in the context of the provided arguments // prior to unmarshaling. func (s *PromotionStep) GetConfig( + ctx context.Context, + cl client.Client, promoCtx PromotionContext, state State, ) (Config, error) { @@ -109,6 +113,27 @@ func (s *PromotionStep) GetConfig( "secrets": promoCtx.Secrets, "outputs": state, }, + expr.Function("warehouse", warehouseFunc, new(func(string) kargoapi.FreightOrigin)), + expr.Function( + "commitFrom", + getCommitFunc(ctx, cl, promoCtx), + new(func(repoURL string) kargoapi.GitCommit), + new(func(repoURL string, origin kargoapi.FreightOrigin) kargoapi.GitCommit), + ), + expr.Function( + "imageFrom", + getImageFunc(ctx, cl, promoCtx), + new(func(repoURL string) kargoapi.Image), + new(func(repoURL string, origin kargoapi.FreightOrigin) kargoapi.Image), + ), + expr.Function( + "chartFrom", + getChartFunc(ctx, cl, promoCtx), + new(func(repoURL string) kargoapi.Chart), + new(func(repoURL string, chartName string) kargoapi.Chart), + new(func(repoURL string, origin kargoapi.FreightOrigin) kargoapi.Chart), + new(func(repoURL string, chartName string, origin kargoapi.FreightOrigin) kargoapi.Chart), + ), ) if err != nil { return nil, err @@ -254,3 +279,92 @@ type PromotionStepResult struct { // configuration can later be used as input to health check processes. HealthCheckStep *HealthCheckStep } + +func warehouseFunc(name ...any) (any, error) { // nolint: unparam + return kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: name[0].(string), // nolint: forcetypeassert + }, nil +} + +func getCommitFunc( + ctx context.Context, + cl client.Client, + promoCtx PromotionContext, +) func(a ...any) (any, error) { + return func(a ...any) (any, error) { + repoURL := a[0].(string) // nolint: forcetypeassert + var desiredOriginPtr *kargoapi.FreightOrigin + if len(a) == 2 { + desiredOrigin := a[1].(kargoapi.FreightOrigin) // nolint: forcetypeassert + desiredOriginPtr = &desiredOrigin + } + return freight.FindCommit( + ctx, + cl, + promoCtx.Project, + promoCtx.FreightRequests, + desiredOriginPtr, + promoCtx.Freight.References(), + repoURL, + ) + } +} + +func getImageFunc( + ctx context.Context, + cl client.Client, + promoCtx PromotionContext, +) func(a ...any) (any, error) { + return func(a ...any) (any, error) { + repoURL := a[0].(string) // nolint: forcetypeassert + var desiredOriginPtr *kargoapi.FreightOrigin + if len(a) == 2 { + desiredOrigin := a[1].(kargoapi.FreightOrigin) // nolint: forcetypeassert + desiredOriginPtr = &desiredOrigin + } + return freight.FindImage( + ctx, + cl, + promoCtx.Project, + promoCtx.FreightRequests, + desiredOriginPtr, + promoCtx.Freight.References(), + repoURL, + ) + } +} + +func getChartFunc( + ctx context.Context, + cl client.Client, + promoCtx PromotionContext, +) func(a ...any) (any, error) { + return func(a ...any) (any, error) { + repoURL := a[0].(string) // nolint: forcetypeassert + var chartName string + var desiredOriginPtr *kargoapi.FreightOrigin + if len(a) == 2 { + var ok bool + if chartName, ok = a[1].(string); !ok { + desiredOrigin := a[1].(kargoapi.FreightOrigin) // nolint: forcetypeassert + desiredOriginPtr = &desiredOrigin + } + } + if len(a) == 3 { + chartName = a[1].(string) // nolint: forcetypeassert + desiredOrigin := a[2].(kargoapi.FreightOrigin) // nolint: forcetypeassert + desiredOriginPtr = &desiredOrigin + } + return freight.FindChart( + ctx, + cl, + promoCtx.Project, + promoCtx.FreightRequests, + desiredOriginPtr, + promoCtx.Freight.References(), + repoURL, + chartName, + ) + } +} diff --git a/internal/directives/promotions_test.go b/internal/directives/promotions_test.go index c536ea5d2..cd86d3a8c 100644 --- a/internal/directives/promotions_test.go +++ b/internal/directives/promotions_test.go @@ -1,6 +1,7 @@ package directives import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -41,6 +42,40 @@ func TestPromotionStep_GetConfig(t *testing.T) { Value: "${{ quote(vars.numVar + 1) }}", }, }, + FreightRequests: []kargoapi.FreightRequest{ + { + Origin: kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "fake-warehouse", + }, + Sources: kargoapi.FreightSources{ + Direct: true, + }, + }, + }, + Freight: kargoapi.FreightCollection{ + Freight: map[string]kargoapi.FreightReference{ + "Warehouse/fake-warehouse": { + Origin: kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKindWarehouse, + Name: "fake-warehouse", + }, + Commits: []kargoapi.GitCommit{{ + RepoURL: "https://fake-git-repo", + ID: "fake-commit-id", + }}, + Images: []kargoapi.Image{{ + RepoURL: "fake-image-repo", + Tag: "fake-image-tag", + }}, + Charts: []kargoapi.Chart{{ + RepoURL: "https://fake-chart-repo", + Name: "fake-chart", + Version: "fake-chart-version", + }}, + }, + }, + }, Secrets: map[string]map[string]string{ "secret1": { "key1": "value1", @@ -58,6 +93,7 @@ func TestPromotionStep_GetConfig(t *testing.T) { "numOutput": 42, } promoStep := PromotionStep{ + // nolint: lll Config: []byte(`{ "project": "${{ ctx.project }}", "stage": "${{ ctx.stage }}", @@ -71,6 +107,9 @@ func TestPromotionStep_GetConfig(t *testing.T) { "boolStrVar": "${{ quote(vars.boolStrVar) }}", "numVar": "${{ vars.numVar }}", "numStrVar": "${{ quote(vars.numStrVar) }}", + "commitID": "${{ commitFrom(\"https://fake-git-repo\", warehouse(\"fake-warehouse\")).ID }}", + "imageTag": "${{ imageFrom(\"fake-image-repo\", warehouse(\"fake-warehouse\")).Tag }}", + "chartVersion": "${{ chartFrom(\"https://fake-chart-repo\", \"fake-chart\", warehouse(\"fake-warehouse\")).Version }}", "secret1-1": "${{ secrets.secret1.key1 }}", "secret1-2": "${{ secrets.secret1.key2 }}", "secret2-3": "${{ secrets.secret2.key3 }}", @@ -83,7 +122,12 @@ func TestPromotionStep_GetConfig(t *testing.T) { "numStrOutput": "${{ quote(outputs.numOutput + 1) }}" }`), } - stepCfg, err := promoStep.GetConfig(promoCtx, promoState) + stepCfg, err := promoStep.GetConfig( + context.Background(), + nil, // We can get away with a nil Kubernetes client because we're specifying origins + promoCtx, + promoState, + ) require.NoError(t, err) require.Equal( t, @@ -100,6 +144,9 @@ func TestPromotionStep_GetConfig(t *testing.T) { "boolStrVar": "false", "numVar": 42, "numStrVar": "43", + "commitID": "fake-commit-id", + "imageTag": "fake-image-tag", + "chartVersion": "fake-chart-version", "secret1-1": "value1", "secret1-2": "value2", "secret2-3": "value3", diff --git a/internal/directives/schemas/argocd-update-config.json b/internal/directives/schemas/argocd-update-config.json index b63e339c5..83ee387e3 100644 --- a/internal/directives/schemas/argocd-update-config.json +++ b/internal/directives/schemas/argocd-update-config.json @@ -43,14 +43,14 @@ "description": "If applicable, identifies a specific chart within the Helm chart repository specified by the 'repoURL' field. When the source to be updated references a Helm chart repository, the values of the 'repoURL' and 'chart' fields should exactly match the values of the same fields in the source. i.e. Do not match the values of these two fields to your Warehouse; match them to the Application source you wish to update.", "minLength": 1 }, - "desiredCommit": { + "desiredRevision": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "desiredCommitFromStep": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredRevision'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "fromOrigin": { @@ -69,26 +69,28 @@ }, "updateTargetRevision": { "type": "boolean", - "description": "Indicates whether the source should be updated such that its TargetRevision field points at the most recently git commit (if 'repoURL' references a Git repository) or chart version (if 'repoURL' references a chart repository)." + "description": "Indicates whether the source should be updated such that its 'targetRevision' field points directly at the desired revision." } }, "oneOf": [ { "properties": { - "desiredCommit": { "enum": ["", null] }, - "desiredCommitFromStep": { "enum": ["", null] } + "desiredCommitFromStep": { "enum": ["", null] }, + "desiredRevision": { "enum": ["", null] } } }, { - "required": ["desiredCommit"], + "required": ["desiredCommitFromStep"], "properties": { - "desiredCommitFromStep": { "enum": ["", null] } + "desiredCommitFromStep": { "minLength": 1 }, + "desiredRevision": { "enum": [0, null] } } }, { - "required": ["desiredCommitFromStep"], + "required": ["desiredRevision"], "properties": { - "desiredCommit": { "enum": [0, null] } + "desiredCommitFromStep": { "enum": ["", null] }, + "desiredRevision": { "minLength": 1 } } } ] @@ -117,7 +119,7 @@ "type": "object", "description": "Describes how to update a Helm parameter to reference a specific version of a container image.", "additionalProperties": false, - "required": ["key", "repoURL", "value"], + "required": ["key", "value"], "properties": { "fromOrigin": { "$ref": "#/definitions/origin" @@ -134,10 +136,23 @@ }, "value": { "type": "string", - "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field.", - "enum": ["Digest", "ImageAndDigest", "ImageAndTag", "Tag"] + "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field." } - } + }, + "oneOf": [ + { + "required": ["repoURL"], + "properties": { + "repoURL": { "minLength": 1 }, + "value": { "pattern": "^(Digest|ImageAndDigest|ImageAndTag|Tag)$" } + } + }, + { + "properties": { + "repoURL": { "enum": ["", null] } + } + } + ] }, "argoCDKustomizeImageUpdates": { @@ -164,6 +179,10 @@ "additionalProperties": false, "required": ["repoURL"], "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "fromOrigin": { "$ref": "#/definitions/origin" }, @@ -177,11 +196,48 @@ "description": "The URL of a container image repository.", "minLength": 1 }, + "tag": { + "type": "string", + "description": "Tag of the image to set. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", - "description": "Specifies whether the image's digest should be used instead of its tag." + "description": "Specifies whether the image's digest should be used instead of its tag. Mutually exclusive with 'digest' and 'tag'." } - } + }, + "oneOf": [ + { + "properties": { + "digest": { "enum": [null, ""] }, + "tag": { "enum": [null, ""] }, + "useDigest": { "enum": [null, false] } + } + }, + { + "required": ["digest"], + "properties": { + "digest": { "minLength": 1 }, + "tag": { "enum": [null, ""] }, + "useDigest": { "enum": [null, false] } + } + }, + { + "required": ["tag"], + "properties": { + "digest": { "enum": [null, ""] }, + "tag": { "minLength": 1 }, + "useDigest": { "enum": [null, false] } + } + }, + { + "required": ["useDigest"], + "properties": { + "digest": { "enum": [null, ""] }, + "tag": { "enum": [null, ""] }, + "useDigest": { "const": true } + } + } + ] }, "origin": { diff --git a/internal/directives/schemas/git-clone-config.json b/internal/directives/schemas/git-clone-config.json index d411255aa..6962bdaf7 100644 --- a/internal/directives/schemas/git-clone-config.json +++ b/internal/directives/schemas/git-clone-config.json @@ -25,7 +25,11 @@ "properties": { "branch": { "type": "string", - "description": "The branch to checkout. Mutually exclusive with 'tag' and 'fromFreight=true'. If none of these is specified, the default branch is checked out." + "description": "The branch to checkout. Mutually exclusive with 'commit', 'tag', and 'fromFreight=true'. If none of these are specified, the default branch is checked out." + }, + "commit": { + "type": "string", + "description": "The commit to checkout. Mutually exclusive with 'branch', 'tag', and 'fromFreight=true'. If none of these are specified, the default branch is checked out." }, "create": { "type": "boolean", @@ -33,7 +37,7 @@ }, "fromFreight": { "type": "boolean", - "description": "Indicates whether the ID of a commit to check out may be obtained from Freight. A value of 'true' is mutually exclusive with 'branch' and 'tag'. If none of these is specified, the default branch is checked out." + "description": "Indicates whether the ID of a commit to check out may be obtained from Freight. A value of 'true' is mutually exclusive with 'branch', 'commit', and 'tag'. If none of these are specified, the default branch is checked out." }, "fromOrigin": { "$ref": "./common.json#/definitions/origin" @@ -45,13 +49,14 @@ }, "tag": { "type": "string", - "description": "The tag to checkout. Mutually exclusive with 'branch' and 'fromFreight=true'. If none of these is specified, the default branch is checked out." + "description": "The tag to checkout. Mutually exclusive with 'branch', 'commit', and 'fromFreight=true'. If none of these are specified, the default branch is checked out." } }, "oneOf": [ { "properties": { "branch": { "enum": [null, ""] }, + "commit": { "enum": [null, ""] }, "fromFreight": { "enum": [null, false] }, "fromOrigin": { "enum": [null] }, "tag": { "enum": [null, ""] } @@ -61,6 +66,17 @@ "required": ["branch"], "properties": { "branch": { "minLength": 1 }, + "commit": { "enum": [null, ""] }, + "fromFreight": { "enum": [null, false] }, + "fromOrigin": { "enum": [null] }, + "tag": { "enum": [null, ""] } + } + }, + { + "required": ["commit"], + "properties": { + "branch": { "enum": [null, ""] }, + "commit": { "minLength": 1 }, "fromFreight": { "enum": [null, false] }, "fromOrigin": { "enum": [null] }, "tag": { "enum": [null, ""] } @@ -70,6 +86,7 @@ "required": ["fromFreight"], "properties": { "branch": { "enum": [null, ""] }, + "commit": { "enum": [null, ""] }, "fromFreight": { "const": true }, "fromOrigin": { "oneOf": [{ "type": "object" }, { "enum": [null] }] }, "tag": { "enum": [null, ""] } @@ -79,6 +96,7 @@ "required": ["tag"], "properties": { "branch": { "enum": [null, ""] }, + "commit": { "enum": [null, ""] }, "fromFreight": { "enum": [null, false] }, "fromOrigin": { "enum": [null] }, "tag": { "minLength": 1 } diff --git a/internal/directives/schemas/helm-update-chart-config.json b/internal/directives/schemas/helm-update-chart-config.json index 049c57910..e7aef5fcc 100644 --- a/internal/directives/schemas/helm-update-chart-config.json +++ b/internal/directives/schemas/helm-update-chart-config.json @@ -13,6 +13,7 @@ "charts": { "type": "array", "description": "A list of chart dependencies which should receive updates.", + "minItems": 1, "items": { "type": "object", "additionalProperties": false, @@ -24,10 +25,15 @@ }, "name": { "type": "string", - "description": "The name of the subchart, as defined in `Chart.yaml`." + "description": "The name of the subchart, as defined in `Chart.yaml`.", + "minLength": 1 }, "fromOrigin": { - "$ref": "./common.json#/definitions/origin" + "$ref": "./common.json#/definitions/origin" + }, + "version": { + "type": "string", + "description": "The version of the subchart to update to. If not specified, a version referenced by the Freight is used." } }, "required": ["repository", "name"] diff --git a/internal/directives/schemas/helm-update-image-config.json b/internal/directives/schemas/helm-update-image-config.json index 8b4d22258..488c30283 100644 --- a/internal/directives/schemas/helm-update-image-config.json +++ b/internal/directives/schemas/helm-update-image-config.json @@ -13,14 +13,14 @@ "images": { "type": "array", "description": "A list of images which should receive updates.", + "minItems": 1, "items": { "type": "object", "additionalProperties": false, "properties": { "image": { "type": "string", - "description": "The container image (without tag) at which the update is targeted.", - "minLength": 1 + "description": "The container image (without tag) at which the update is targeted." }, "fromOrigin": { "$ref": "./common.json#/definitions/origin" @@ -32,11 +32,24 @@ }, "value": { "type": "string", - "enum": ["ImageAndTag", "Tag", "ImageAndDigest", "Digest"], "description": "Specifies the new value for the specified key in the Helm values file." } }, - "required": ["image", "key", "value"] + "required": ["key", "value"], + "oneOf": [ + { + "required": ["image"], + "properties": { + "image": { "minLength": 1 }, + "value": { "pattern": "^(Digest|ImageAndDigest|ImageAndTag|Tag)$" } + } + }, + { + "properties": { + "image": { "enum": ["", null] } + } + } + ] } } } diff --git a/internal/directives/schemas/kustomize-set-image-config.json b/internal/directives/schemas/kustomize-set-image-config.json index 4d22e2d42..aa2a44ba3 100644 --- a/internal/directives/schemas/kustomize-set-image-config.json +++ b/internal/directives/schemas/kustomize-set-image-config.json @@ -19,8 +19,13 @@ "additionalProperties": false, "required": ["image"], "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set in the Kustomization file. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "image": { "type": "string", + "minLength": 1, "description": "Image name of the repository from which to pick the version. This is the image name Kargo is subscribed to, and produces Freight for." }, "fromOrigin": { @@ -34,11 +39,48 @@ "type": "string", "description": "NewName for the image. This can be used to rename the container image name in the manifests." }, + "tag": { + "type": "string", + "description": "Tag of the image to set in the Kustomization file. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", "description": "UseDigest specifies whether to use the digest of the image instead of the tag." } - } + }, + "oneOf": [ + { + "properties": { + "digest": { "enum": ["", null] }, + "tag": { "enum": ["", null] }, + "useDigest": { "enum": [null, false] } + } + }, + { + "required": ["digest"], + "properties": { + "digest": { "minLength": 1 }, + "tag": { "enum": ["", null] }, + "useDigest": { "enum": [null, false] } + } + }, + { + "required": ["tag"], + "properties": { + "digest": { "enum": ["", null] }, + "tag": { "minLength": 1 }, + "useDigest": { "enum": [null, false] } + } + }, + { + "required": ["useDigest"], + "properties": { + "digest": { "enum": ["", null] }, + "tag": { "enum": ["", null] }, + "useDigest": { "const": true } + } + } + ] } } } diff --git a/internal/directives/simple_engine.go b/internal/directives/simple_engine.go index 57bb3d308..d504c91ea 100644 --- a/internal/directives/simple_engine.go +++ b/internal/directives/simple_engine.go @@ -118,7 +118,7 @@ func (e *SimpleEngine) Promote( }, fmt.Errorf("step alias %q is forbidden", step.Alias) } - stepCfg, err := step.GetConfig(promoCtx, stateCopy) + stepCfg, err := step.GetConfig(ctx, e.kargoClient, promoCtx, stateCopy) if err != nil { return PromotionResult{ Status: kargoapi.PromotionPhaseErrored, diff --git a/internal/directives/zz_config_types.go b/internal/directives/zz_config_types.go index 35de1a4e2..a2db4d755 100644 --- a/internal/directives/zz_config_types.go +++ b/internal/directives/zz_config_types.go @@ -34,37 +34,32 @@ type ArgoCDAppSourceUpdate struct { // same fields in the source. i.e. Do not match the values of these two fields to your // Warehouse; match them to the Application source you wish to update. Chart string `json:"chart,omitempty"` - // Applicable only when 'repoURL' references a Git repository, this field specifies the - // desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both - // are left undefined, the desired revision will be determined by Freight (if possible). - // Note that the source's 'targetRevision' will not be updated to this commit unless - // 'updateTargetRevision=true' is set. The utility of this field is to ensure that health - // checks on Argo CD ApplicationSources can account for scenarios where the desired revision - // differs from what may be found in Freight, likely due to the use of rendered branches - // and/or PR-based promotion workflows. - DesiredCommit string `json:"desiredCommit,omitempty"` // Applicable only when 'repoURL' references a Git repository, this field references the // 'commit' output from a previous step and uses it as the desired revision for the source. - // Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired + // Mutually exclusive with 'desiredRevision'. If both are left undefined, the desired // revision will be determined by Freight (if possible). Note that the source's // 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is - // set. The utility of this field is to ensure that health checks on Argo CD - // ApplicationSources can account for scenarios where the desired revision differs from what - // may be found in Freight, likely due to the use of rendered branches and/or PR-based - // promotion workflows. - DesiredCommitFromStep string `json:"desiredCommitFromStep,omitempty"` - FromOrigin *AppFromOrigin `json:"fromOrigin,omitempty"` - Helm *ArgoCDHelmParameterUpdates `json:"helm,omitempty"` - Kustomize *ArgoCDKustomizeImageUpdates `json:"kustomize,omitempty"` + // set. The utility of this field, on its own, is to specify the revision that the source + // should be observably synced to during a health check. + DesiredCommitFromStep string `json:"desiredCommitFromStep,omitempty"` + // Specifies the desired revision for the source. Mutually exclusive with + // 'desiredCommitFromStep'. If both are left undefined, the desired revision will be + // determined by Freight (if possible). Note that the source's 'targetRevision' will not be + // updated to this commit unless 'updateTargetRevision=true' is set. The utility of this + // field, on its own, is to specify the revision that the source should be observably synced + // to during a health check. + DesiredRevision string `json:"desiredRevision,omitempty"` + FromOrigin *AppFromOrigin `json:"fromOrigin,omitempty"` + Helm *ArgoCDHelmParameterUpdates `json:"helm,omitempty"` + Kustomize *ArgoCDKustomizeImageUpdates `json:"kustomize,omitempty"` // With possible help from the 'chart' field, identifies which of an Argo CD Application's // sources is to be updated. When the source to be updated references a Helm chart // repository, the values of the 'repoURL' and 'chart' fields should exactly match the // values of the same fields in the source. i.e. Do not match the values of these two fields // to your Warehouse; match them to the Application source you wish to update. RepoURL string `json:"repoURL"` - // Indicates whether the source should be updated such that its TargetRevision field points - // at the most recently git commit (if 'repoURL' references a Git repository) or chart - // version (if 'repoURL' references a chart repository). + // Indicates whether the source should be updated such that its 'targetRevision' field + // points directly at the desired revision. UpdateTargetRevision bool `json:"updateTargetRevision,omitempty"` } @@ -82,10 +77,10 @@ type ArgoCDHelmImageUpdate struct { // updated. Key string `json:"key"` // The URL of a container image repository. - RepoURL string `json:"repoURL"` + RepoURL string `json:"repoURL,omitempty"` // Specifies a new value for the setting within an Argo CD Application source's Helm // parameters identified by the 'key' field. - Value Value `json:"value"` + Value string `json:"value"` } // Describes updates to an Argo CD Application source's Kustomize images. @@ -97,12 +92,17 @@ type ArgoCDKustomizeImageUpdates struct { // Describes how to update a Kustomize image to reference a specific version of a container // image. type ArgoCDKustomizeImageUpdate struct { + // Digest of the image to set. Mutually exclusive with 'tag' and 'useDigest=true'. + Digest string `json:"digest,omitempty"` FromOrigin *AppFromOrigin `json:"fromOrigin,omitempty"` // Specifies a container image name override. NewName string `json:"newName,omitempty"` // The URL of a container image repository. RepoURL string `json:"repoURL"` - // Specifies whether the image's digest should be used instead of its tag. + // Tag of the image to set. Mutually exclusive with 'digest' and 'useDigest=true'. + Tag string `json:"tag,omitempty"` + // Specifies whether the image's digest should be used instead of its tag. Mutually + // exclusive with 'digest' and 'tag'. UseDigest bool `json:"useDigest,omitempty"` } @@ -133,21 +133,24 @@ type GitCloneConfig struct { } type Checkout struct { - // The branch to checkout. Mutually exclusive with 'tag' and 'fromFreight=true'. If none of - // these is specified, the default branch is checked out. + // The branch to checkout. Mutually exclusive with 'commit', 'tag', and 'fromFreight=true'. + // If none of these are specified, the default branch is checked out. Branch string `json:"branch,omitempty"` + // The commit to checkout. Mutually exclusive with 'branch', 'tag', and 'fromFreight=true'. + // If none of these are specified, the default branch is checked out. + Commit string `json:"commit,omitempty"` // Indicates whether a new, empty orphan branch should be created if the branch does not // already exist. Default is false. Create bool `json:"create,omitempty"` // Indicates whether the ID of a commit to check out may be obtained from Freight. A value - // of 'true' is mutually exclusive with 'branch' and 'tag'. If none of these is specified, - // the default branch is checked out. + // of 'true' is mutually exclusive with 'branch', 'commit', and 'tag'. If none of these are + // specified, the default branch is checked out. FromFreight bool `json:"fromFreight,omitempty"` FromOrigin *CheckoutFromOrigin `json:"fromOrigin,omitempty"` // The path where the repository should be checked out. Path string `json:"path"` - // The tag to checkout. Mutually exclusive with 'branch' and 'fromFreight=true'. If none of - // these is specified, the default branch is checked out. + // The tag to checkout. Mutually exclusive with 'branch', 'commit', and 'fromFreight=true'. + // If none of these are specified, the default branch is checked out. Tag string `json:"tag,omitempty"` } @@ -270,6 +273,9 @@ type Chart struct { // The repository of the subchart, as defined in `Chart.yaml`. It also supports OCI charts // using `oci://`. Repository string `json:"repository"` + // The version of the subchart to update to. If not specified, a version referenced by the + // Freight is used. + Version string `json:"version,omitempty"` } type ChartFromOrigin struct { @@ -289,12 +295,12 @@ 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"` + Image string `json:"image,omitempty"` // 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"` // Specifies the new value for the specified key in the Helm values file. - Value Value `json:"value"` + Value string `json:"value"` } type KustomizeBuildConfig struct { @@ -330,6 +336,9 @@ type KustomizeSetImageConfig struct { } type KustomizeSetImageConfigImage struct { + // Digest of the image to set in the Kustomization file. Mutually exclusive with 'tag' and + // 'useDigest=true'. + Digest string `json:"digest,omitempty"` FromOrigin *ChartFromOrigin `json:"fromOrigin,omitempty"` // Image name of the repository from which to pick the version. This is the image name Kargo // is subscribed to, and produces Freight for. @@ -339,6 +348,9 @@ type KustomizeSetImageConfigImage struct { // NewName for the image. This can be used to rename the container image name in the // manifests. NewName string `json:"newName,omitempty"` + // Tag of the image to set in the Kustomization file. Mutually exclusive with 'digest' and + // 'useDigest=true'. + Tag string `json:"tag,omitempty"` // UseDigest specifies whether to use the digest of the image instead of the tag. UseDigest bool `json:"useDigest,omitempty"` } @@ -350,19 +362,6 @@ const ( Warehouse Kind = "Warehouse" ) -// Specifies a new value for the setting within an Argo CD Application source's Helm -// parameters identified by the 'key' field. -// -// Specifies the new value for the specified key in the Helm values file. -type Value string - -const ( - Digest Value = "Digest" - ImageAndDigest Value = "ImageAndDigest" - ImageAndTag Value = "ImageAndTag" - Tag Value = "Tag" -) - // The name of the Git provider to use. Currently only 'github' and 'gitlab' are supported. // Kargo will try to infer the provider if it is not explicitly specified. type Provider string diff --git a/internal/expressions/json_templates.go b/internal/expressions/json_templates.go index 72a417d39..97f318f7e 100644 --- a/internal/expressions/json_templates.go +++ b/internal/expressions/json_templates.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "maps" "strconv" "strings" @@ -34,7 +33,7 @@ import ( // expression evaluates to a string enclosed in quotes. e.g. ${{ true }} will // evaluated as a bool, but ${{ quote(true) }} will be evaluated as a string. // This behavior should be intuitive to anyone familiar with YAML. -func EvaluateJSONTemplate(jsonBytes []byte, env map[string]any) ([]byte, error) { +func EvaluateJSONTemplate(jsonBytes []byte, env map[string]any, exprOpts ...expr.Option) ([]byte, error) { if _, ok := env["quote"]; ok { return nil, fmt.Errorf( `"quote" is a forbidden key in the environment map; it is reserved for internal use`, @@ -45,7 +44,7 @@ func EvaluateJSONTemplate(jsonBytes []byte, env map[string]any) ([]byte, error) return nil, fmt.Errorf("input is not valid JSON; are all expressions enclosed in quotes? %w", err) } - if err := evaluateExpressions(parsed, env); err != nil { + if err := evaluateExpressions(parsed, env, exprOpts...); err != nil { return nil, err } return json.Marshal(parsed) @@ -55,22 +54,22 @@ func EvaluateJSONTemplate(jsonBytes []byte, env map[string]any) ([]byte, error) // elements of a map[string]any or []any, updating those elements in place. // Passing any other type to this function will have no effect. Expressions are // evaluated using the provided environment map as context. -func evaluateExpressions(collection any, env map[string]any) error { +func evaluateExpressions(collection any, env map[string]any, exprOpts ...expr.Option) error { switch col := collection.(type) { case map[string]any: for key, val := range col { switch v := val.(type) { case map[string]any: - if err := evaluateExpressions(v, env); err != nil { + if err := evaluateExpressions(v, env, exprOpts...); err != nil { return err } case []any: - if err := evaluateExpressions(v, env); err != nil { + if err := evaluateExpressions(v, env, exprOpts...); err != nil { return err } case string: var err error - if col[key], err = EvaluateTemplate(v, env); err != nil { + if col[key], err = EvaluateTemplate(v, env, exprOpts...); err != nil { return err } } @@ -79,16 +78,16 @@ func evaluateExpressions(collection any, env map[string]any) error { for i, val := range col { switch v := val.(type) { case map[string]any: - if err := evaluateExpressions(v, env); err != nil { + if err := evaluateExpressions(v, env, exprOpts...); err != nil { return err } case []any: - if err := evaluateExpressions(v, env); err != nil { + if err := evaluateExpressions(v, env, exprOpts...); err != nil { return err } case string: var err error - if col[i], err = EvaluateTemplate(v, env); err != nil { + if col[i], err = EvaluateTemplate(v, env, exprOpts...); err != nil { return err } } @@ -100,12 +99,21 @@ func evaluateExpressions(collection any, env map[string]any) error { // EvaluateTemplate evaluates a single template string with the provided // environment. Note that a single template string can contain multiple // expressions. -func EvaluateTemplate(template string, env map[string]any) (any, error) { - env = maps.Clone(env) // We don't want to add the quote function to the user's map. - env["quote"] = func(a any) string { return fmt.Sprintf(`"%v"`, a) } - t := fasttemplate.New(template, "${{", "}}") +func EvaluateTemplate(template string, env map[string]any, exprOpts ...expr.Option) (any, error) { + if exprOpts == nil { + exprOpts = make([]expr.Option, 0, 1) + } + exprOpts = append(exprOpts, expr.Function( + "quote", + func(a ...any) (any, error) { return fmt.Sprintf(`"%v"`, a[0]), nil }, + new(func(any) string), + )) + t, err := fasttemplate.NewTemplate(template, "${{", "}}") + if err != nil { + return nil, fmt.Errorf("error parsing template: %w", err) + } out := &bytes.Buffer{} - if _, err := t.ExecuteFunc(out, getExpressionEvaluator(env)); err != nil { + if _, err := t.ExecuteFunc(out, getExpressionEvaluator(env, exprOpts...)); err != nil { return nil, err } result := out.String() @@ -142,9 +150,9 @@ func EvaluateTemplate(template string, env map[string]any) (any, error) { // getExpressionEvaluator returns a fasttemplate.TagFunc that evaluates input // as a single expr-lang expression with the provided map as the environment. -func getExpressionEvaluator(env map[string]any) fasttemplate.TagFunc { +func getExpressionEvaluator(env map[string]any, exprOpts ...expr.Option) fasttemplate.TagFunc { return func(out io.Writer, expression string) (int, error) { - program, err := expr.Compile(expression, expr.Env(env)) + program, err := expr.Compile(expression, exprOpts...) if err != nil { return 0, err } diff --git a/ui/src/gen/directives/argocd-update-config.json b/ui/src/gen/directives/argocd-update-config.json index 5467f8457..449f39ba2 100644 --- a/ui/src/gen/directives/argocd-update-config.json +++ b/ui/src/gen/directives/argocd-update-config.json @@ -46,14 +46,14 @@ "description": "If applicable, identifies a specific chart within the Helm chart repository specified by the 'repoURL' field. When the source to be updated references a Helm chart repository, the values of the 'repoURL' and 'chart' fields should exactly match the values of the same fields in the source. i.e. Do not match the values of these two fields to your Warehouse; match them to the Application source you wish to update.", "minLength": 1 }, - "desiredCommit": { + "desiredRevision": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "desiredCommitFromStep": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredRevision'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "fromOrigin": { @@ -134,13 +134,7 @@ }, "value": { "type": "string", - "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field.", - "enum": [ - "Digest", - "ImageAndDigest", - "ImageAndTag", - "Tag" - ] + "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field." } } } @@ -177,6 +171,10 @@ "description": "Describes how to update a Kustomize image to reference a specific version of a container image.", "additionalProperties": false, "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "fromOrigin": { "type": "object", "additionalProperties": false, @@ -205,9 +203,13 @@ "description": "The URL of a container image repository.", "minLength": 1 }, + "tag": { + "type": "string", + "description": "Tag of the image to set. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", - "description": "Specifies whether the image's digest should be used instead of its tag." + "description": "Specifies whether the image's digest should be used instead of its tag. Mutually exclusive with 'digest' and 'tag'." } } } @@ -221,7 +223,7 @@ }, "updateTargetRevision": { "type": "boolean", - "description": "Indicates whether the source should be updated such that its TargetRevision field points at the most recently git commit (if 'repoURL' references a Git repository) or chart version (if 'repoURL' references a chart repository)." + "description": "Indicates whether the source should be updated such that its 'targetRevision' field points directly at the desired revision." } } } @@ -237,14 +239,14 @@ "description": "If applicable, identifies a specific chart within the Helm chart repository specified by the 'repoURL' field. When the source to be updated references a Helm chart repository, the values of the 'repoURL' and 'chart' fields should exactly match the values of the same fields in the source. i.e. Do not match the values of these two fields to your Warehouse; match them to the Application source you wish to update.", "minLength": 1 }, - "desiredCommit": { + "desiredRevision": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "desiredCommitFromStep": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredRevision'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "fromOrigin": { @@ -325,13 +327,7 @@ }, "value": { "type": "string", - "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field.", - "enum": [ - "Digest", - "ImageAndDigest", - "ImageAndTag", - "Tag" - ] + "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field." } } } @@ -368,6 +364,10 @@ "description": "Describes how to update a Kustomize image to reference a specific version of a container image.", "additionalProperties": false, "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "fromOrigin": { "type": "object", "additionalProperties": false, @@ -396,9 +396,13 @@ "description": "The URL of a container image repository.", "minLength": 1 }, + "tag": { + "type": "string", + "description": "Tag of the image to set. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", - "description": "Specifies whether the image's digest should be used instead of its tag." + "description": "Specifies whether the image's digest should be used instead of its tag. Mutually exclusive with 'digest' and 'tag'." } } } @@ -412,7 +416,7 @@ }, "updateTargetRevision": { "type": "boolean", - "description": "Indicates whether the source should be updated such that its TargetRevision field points at the most recently git commit (if 'repoURL' references a Git repository) or chart version (if 'repoURL' references a chart repository)." + "description": "Indicates whether the source should be updated such that its 'targetRevision' field points directly at the desired revision." } } }, @@ -476,13 +480,7 @@ }, "value": { "type": "string", - "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field.", - "enum": [ - "Digest", - "ImageAndDigest", - "ImageAndTag", - "Tag" - ] + "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field." } } } @@ -524,13 +522,7 @@ }, "value": { "type": "string", - "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field.", - "enum": [ - "Digest", - "ImageAndDigest", - "ImageAndTag", - "Tag" - ] + "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field." } } }, @@ -564,6 +556,10 @@ "description": "Describes how to update a Kustomize image to reference a specific version of a container image.", "additionalProperties": false, "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "fromOrigin": { "type": "object", "additionalProperties": false, @@ -592,9 +588,13 @@ "description": "The URL of a container image repository.", "minLength": 1 }, + "tag": { + "type": "string", + "description": "Tag of the image to set. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", - "description": "Specifies whether the image's digest should be used instead of its tag." + "description": "Specifies whether the image's digest should be used instead of its tag. Mutually exclusive with 'digest' and 'tag'." } } } @@ -606,6 +606,10 @@ "description": "Describes how to update a Kustomize image to reference a specific version of a container image.", "additionalProperties": false, "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "fromOrigin": { "type": "object", "additionalProperties": false, @@ -634,9 +638,13 @@ "description": "The URL of a container image repository.", "minLength": 1 }, + "tag": { + "type": "string", + "description": "Tag of the image to set. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", - "description": "Specifies whether the image's digest should be used instead of its tag." + "description": "Specifies whether the image's digest should be used instead of its tag. Mutually exclusive with 'digest' and 'tag'." } } }, @@ -708,14 +716,14 @@ "description": "If applicable, identifies a specific chart within the Helm chart repository specified by the 'repoURL' field. When the source to be updated references a Helm chart repository, the values of the 'repoURL' and 'chart' fields should exactly match the values of the same fields in the source. i.e. Do not match the values of these two fields to your Warehouse; match them to the Application source you wish to update.", "minLength": 1 }, - "desiredCommit": { + "desiredRevision": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Specifies the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "desiredCommitFromStep": { "type": "string", - "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredCommitFromStep'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field is to ensure that health checks on Argo CD ApplicationSources can account for scenarios where the desired revision differs from what may be found in Freight, likely due to the use of rendered branches and/or PR-based promotion workflows.", + "description": "Applicable only when 'repoURL' references a Git repository, this field references the 'commit' output from a previous step and uses it as the desired revision for the source. Mutually exclusive with 'desiredRevision'. If both are left undefined, the desired revision will be determined by Freight (if possible). Note that the source's 'targetRevision' will not be updated to this commit unless 'updateTargetRevision=true' is set. The utility of this field, on its own, is to specify the revision that the source should be observably synced to during a health check.", "minLength": 1 }, "fromOrigin": { @@ -796,13 +804,7 @@ }, "value": { "type": "string", - "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field.", - "enum": [ - "Digest", - "ImageAndDigest", - "ImageAndTag", - "Tag" - ] + "description": "Specifies a new value for the setting within an Argo CD Application source's Helm parameters identified by the 'key' field." } } } @@ -839,6 +841,10 @@ "description": "Describes how to update a Kustomize image to reference a specific version of a container image.", "additionalProperties": false, "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "fromOrigin": { "type": "object", "additionalProperties": false, @@ -867,9 +873,13 @@ "description": "The URL of a container image repository.", "minLength": 1 }, + "tag": { + "type": "string", + "description": "Tag of the image to set. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", - "description": "Specifies whether the image's digest should be used instead of its tag." + "description": "Specifies whether the image's digest should be used instead of its tag. Mutually exclusive with 'digest' and 'tag'." } } } @@ -883,7 +893,7 @@ }, "updateTargetRevision": { "type": "boolean", - "description": "Indicates whether the source should be updated such that its TargetRevision field points at the most recently git commit (if 'repoURL' references a Git repository) or chart version (if 'repoURL' references a chart repository)." + "description": "Indicates whether the source should be updated such that its 'targetRevision' field points directly at the desired revision." } } } diff --git a/ui/src/gen/directives/git-clone-config.json b/ui/src/gen/directives/git-clone-config.json index c348c9bc8..b4332b5fb 100644 --- a/ui/src/gen/directives/git-clone-config.json +++ b/ui/src/gen/directives/git-clone-config.json @@ -22,7 +22,11 @@ "properties": { "branch": { "type": "string", - "description": "The branch to checkout. Mutually exclusive with 'tag' and 'fromFreight=true'. If none of these is specified, the default branch is checked out." + "description": "The branch to checkout. Mutually exclusive with 'commit', 'tag', and 'fromFreight=true'. If none of these are specified, the default branch is checked out." + }, + "commit": { + "type": "string", + "description": "The commit to checkout. Mutually exclusive with 'branch', 'tag', and 'fromFreight=true'. If none of these are specified, the default branch is checked out." }, "create": { "type": "boolean", @@ -30,7 +34,7 @@ }, "fromFreight": { "type": "boolean", - "description": "Indicates whether the ID of a commit to check out may be obtained from Freight. A value of 'true' is mutually exclusive with 'branch' and 'tag'. If none of these is specified, the default branch is checked out." + "description": "Indicates whether the ID of a commit to check out may be obtained from Freight. A value of 'true' is mutually exclusive with 'branch', 'commit', and 'tag'. If none of these are specified, the default branch is checked out." }, "fromOrigin": { "type": "object", @@ -57,7 +61,7 @@ }, "tag": { "type": "string", - "description": "The tag to checkout. Mutually exclusive with 'branch' and 'fromFreight=true'. If none of these is specified, the default branch is checked out." + "description": "The tag to checkout. Mutually exclusive with 'branch', 'commit', and 'fromFreight=true'. If none of these are specified, the default branch is checked out." } } } diff --git a/ui/src/gen/directives/helm-update-chart-config.json b/ui/src/gen/directives/helm-update-chart-config.json index 21735c0b7..5bb49e36c 100644 --- a/ui/src/gen/directives/helm-update-chart-config.json +++ b/ui/src/gen/directives/helm-update-chart-config.json @@ -23,7 +23,8 @@ }, "name": { "type": "string", - "description": "The name of the subchart, as defined in `Chart.yaml`." + "description": "The name of the subchart, as defined in `Chart.yaml`.", + "minLength": 1 }, "fromOrigin": { "type": "object", @@ -42,6 +43,10 @@ "minLength": 1 } } + }, + "version": { + "type": "string", + "description": "The version of the subchart to update to. If not specified, a version referenced by the Freight is used." } } } diff --git a/ui/src/gen/directives/helm-update-image-config.json b/ui/src/gen/directives/helm-update-image-config.json index d02da84d1..2f23b7776 100644 --- a/ui/src/gen/directives/helm-update-image-config.json +++ b/ui/src/gen/directives/helm-update-image-config.json @@ -18,8 +18,7 @@ "properties": { "image": { "type": "string", - "description": "The container image (without tag) at which the update is targeted.", - "minLength": 1 + "description": "The container image (without tag) at which the update is targeted." }, "fromOrigin": { "type": "object", @@ -46,12 +45,6 @@ }, "value": { "type": "string", - "enum": [ - "ImageAndTag", - "Tag", - "ImageAndDigest", - "Digest" - ], "description": "Specifies the new value for the specified key in the Helm values file." } } diff --git a/ui/src/gen/directives/kustomize-set-image-config.json b/ui/src/gen/directives/kustomize-set-image-config.json index 677f96e0e..8146ef8d0 100644 --- a/ui/src/gen/directives/kustomize-set-image-config.json +++ b/ui/src/gen/directives/kustomize-set-image-config.json @@ -16,8 +16,13 @@ "type": "object", "additionalProperties": false, "properties": { + "digest": { + "type": "string", + "description": "Digest of the image to set in the Kustomization file. Mutually exclusive with 'tag' and 'useDigest=true'." + }, "image": { "type": "string", + "minLength": 1, "description": "Image name of the repository from which to pick the version. This is the image name Kargo is subscribed to, and produces Freight for." }, "fromOrigin": { @@ -46,6 +51,10 @@ "type": "string", "description": "NewName for the image. This can be used to rename the container image name in the manifests." }, + "tag": { + "type": "string", + "description": "Tag of the image to set in the Kustomization file. Mutually exclusive with 'digest' and 'useDigest=true'." + }, "useDigest": { "type": "boolean", "description": "UseDigest specifies whether to use the digest of the image instead of the tag."