Repository: akuity/kargo
Pull request #3820: fix!: freight from funcs return nil instead of error when freight not found
Author: login: jessesuen
Labels: name: kind/refactor
description: Non-functional changes to implementation details
name: priority/normal
description: This is the priority for most work
name: area/controller
description: Affects the (main) controller
Created: 2025-04-08T20:43:29Z | Merged: 2025-04-09T19:30:28Z

Resolves https://github.com/akuity/kargo/issues/3647

The `imageFrom`, `chartFrom`, `commitFrom` expr functions will now return `nil` instead of returning error if a Freight could not be found. This supports the use case where a PromoTask could be re-used for multiple types of artifact promotions (image/commit/chart) using a conditional like:

```yaml
  - uses: yaml-update
    if: ${{ imageFrom( vars.image ) != nil }}
```



Diff:
diff --git a/docs/docs/50-user-guide/60-reference-docs/40-expressions.md b/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
index 507b58f2d6..49decb79a4 100644
--- a/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
+++ b/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
@@ -286,7 +286,14 @@ The returned `FreightOrigin` object has the following fields:
 
 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.
+desired source of an artifact when necessary. These functions return `nil` when
+relevant `Freight` is not found from the `FreightCollection`. 
+
+:::tip
+You can handle `nil` values gracefully in Expr using its
+[nil coalescing](https://expr-lang.org/docs/language-definition#nil-coalescing) and
+[optional chaining](https://expr-lang.org/docs/language-definition#optional-chaining) features.
+:::
 
 ### `commitFrom(repoURL, [freightOrigin])`
 
@@ -315,6 +322,8 @@ The optional `freightOrigin` argument should be used when a `Stage` requests
 `Freight` from multiple origins (`Warehouse`s) and more than one can provide a
 `GitCommit` object from the specified repository.
 
+If a commit is not found from the `FreightCollection`, returns `nil`.
+
 Examples:
 
 ```yaml
@@ -352,6 +361,8 @@ The optional `freightOrigin` argument should be used when a `Stage` requests
 `Freight` from multiple origins (`Warehouse`s) and more than one can provide a
 `Image` object from the specified repository.
 
+If an image is not found from the `FreightCollection`, returns `nil`.
+
 Examples:
 
 ```yaml
@@ -396,6 +407,8 @@ The optional `freightOrigin` argument should be used when a `Stage` requests
 `Freight` from multiple origins (`Warehouse`s) and more than one can provide a
 `Chart` object from the specified repository.
 
+If a chart is not found from the `FreightCollection`, returns `nil`.
+
 Examples:
 
 ```yaml
diff --git a/internal/controller/freight/finder.go b/internal/controller/freight/finder.go
index 2a4c012700..49b509b005 100644
--- a/internal/controller/freight/finder.go
+++ b/internal/controller/freight/finder.go
@@ -73,9 +73,7 @@ func FindCommit(
 		}
 	}
 	if desiredOrigin == nil {
-		return nil, NotFoundError{
-			msg: fmt.Sprintf("commit from repo %s not found in referenced Freight", repoURL),
-		}
+		return nil, nil
 	}
 	// We know exactly what we're after, so this should be easy
 	for i := range freight {
@@ -92,9 +90,7 @@ func FindCommit(
 	// If we get to here, we looked at all the FreightReferences and didn't find
 	// any that came from the desired origin. This could be because no Freight
 	// from the desired origin has been promoted yet.
-	return nil, NotFoundError{
-		msg: fmt.Sprintf("commit from repo %s not found in referenced Freight", repoURL),
-	}
+	return nil, nil
 }
 
 func FindImage(
@@ -147,9 +143,7 @@ func FindImage(
 	if desiredOrigin == nil {
 		// There is no chance of finding the commit we're looking for. Just return
 		// nil and let the caller decide what to do.
-		return nil, NotFoundError{
-			msg: fmt.Sprintf("image from repo %s not found in referenced Freight", repoURL),
-		}
+		return nil, nil
 	}
 	// We know exactly what we're after, so this should be easy
 	for _, f := range freight {
@@ -163,10 +157,9 @@ func FindImage(
 	}
 	// If we get to here, we looked at all the FreightReferences and didn't find
 	// any that came from the desired origin. This could be because no Freight
-	// from the desired origin has been promoted yet.
-	return nil, NotFoundError{
-		msg: fmt.Sprintf("image from repo %s not found in referenced Freight", repoURL),
-	}
+	// from the desired origin has been promoted yet. We return nil to indicate
+	// that none was found
+	return nil, nil
 }
 
 func HasAmbiguousImageRequest(
@@ -285,12 +278,5 @@ func FindChart(
 	// If we get to here, we looked at all the FreightReferences and didn't find
 	// any that came from the desired origin. This could be because no Freight
 	// from the desired origin has been promoted yet.
-	if chartName == "" {
-		return nil, NotFoundError{
-			msg: fmt.Sprintf("chart from repo %s not found in referenced Freight", repoURL),
-		}
-	}
-	return nil, NotFoundError{
-		msg: fmt.Sprintf("chart %q from repo %s not found in referenced Freight", chartName, repoURL),
-	}
+	return nil, nil
 }
diff --git a/internal/controller/freight/finder_test.go b/internal/controller/freight/finder_test.go
index 2df033e5dd..4ee9a25974 100644
--- a/internal/controller/freight/finder_test.go
+++ b/internal/controller/freight/finder_test.go
@@ -59,8 +59,10 @@ func TestFindCommit(t *testing.T) {
 					Commits: []kargoapi.GitCommit{testCommit2},
 				},
 			},
-			assertions: func(t *testing.T, _ *kargoapi.GitCommit, err error) {
-				require.ErrorContains(t, err, "not found in referenced Freight")
+			assertions: func(t *testing.T, commit *kargoapi.GitCommit, err error) {
+				require.NoError(t, err)
+				require.Nil(t, commit)
+
 			},
 		},
 		{
@@ -127,8 +129,9 @@ func TestFindCommit(t *testing.T) {
 					RequestedFreight: []kargoapi.FreightRequest{{Origin: testOrigin1}},
 				},
 			},
-			assertions: func(t *testing.T, _ *kargoapi.GitCommit, err error) {
-				require.ErrorContains(t, err, "not found in referenced Freight")
+			assertions: func(t *testing.T, commit *kargoapi.GitCommit, err error) {
+				require.NoError(t, err)
+				require.Nil(t, commit)
 			},
 		},
 		{
@@ -293,8 +296,9 @@ func TestFindImage(t *testing.T) {
 					Images: []kargoapi.Image{testImage2},
 				},
 			},
-			assertions: func(t *testing.T, _ *kargoapi.Image, err error) {
-				require.ErrorContains(t, err, "not found in referenced Freight")
+			assertions: func(t *testing.T, image *kargoapi.Image, err error) {
+				require.NoError(t, err)
+				require.Nil(t, image)
 			},
 		},
 		{
@@ -359,8 +363,9 @@ func TestFindImage(t *testing.T) {
 					RequestedFreight: []kargoapi.FreightRequest{{Origin: testOrigin1}},
 				},
 			},
-			assertions: func(t *testing.T, _ *kargoapi.Image, err error) {
-				require.ErrorContains(t, err, "not found in referenced Freight")
+			assertions: func(t *testing.T, image *kargoapi.Image, err error) {
+				require.NoError(t, err)
+				require.Nil(t, image)
 			},
 		},
 		{
@@ -656,8 +661,9 @@ func TestFindChart(t *testing.T) {
 					Charts: []kargoapi.Chart{testChart2},
 				},
 			},
-			assertions: func(t *testing.T, _ *kargoapi.Chart, err error) {
-				require.ErrorContains(t, err, "not found in referenced Freight")
+			assertions: func(t *testing.T, chart *kargoapi.Chart, err error) {
+				require.NoError(t, err)
+				require.Nil(t, chart)
 			},
 		},
 		{


Reviews:
user: login: hiddeco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T11:05:16Z
user: login: krancour
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:18:30Z
user: login: krancour
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T14:21:16Z
user: login: jessesuen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T17:03:58Z
user: login: hiddeco
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-09T18:45:56Z

Review comments:
diff_hunk: @@ -315,6 +315,8 @@ The optional `freightOrigin` argument should be used when a `Stage` requests
 `Freight` from multiple origins (`Warehouse`s) and more than one can provide a
path: docs/docs/50-user-guide/60-reference-docs/40-expressions.md
user: login: hiddeco
body: Probably an idea to add references to [optional chaining](https://expr-lang.org/docs/language-definition#optional-chaining) and [nil coalescing](https://expr-lang.org/docs/language-definition#nil-coalescing) to this document to help people supercharge the `nil` returns.
created_at: 2025-04-09T11:05:16Z
author_association: COLLABORATOR
diff_hunk: @@ -315,6 +315,8 @@ The optional `freightOrigin` argument should be used when a `Stage` requests
 `Freight` from multiple origins (`Warehouse`s) and more than one can provide a
path: docs/docs/50-user-guide/60-reference-docs/40-expressions.md
user: login: krancour
body: Agree with this, but preemptively LGTM'ing. Thanks @jessesuen!
created_at: 2025-04-09T14:18:30Z
author_association: MEMBER
diff_hunk: @@ -315,6 +315,8 @@ The optional `freightOrigin` argument should be used when a `Stage` requests
 `Freight` from multiple origins (`Warehouse`s) and more than one can provide a
path: docs/docs/50-user-guide/60-reference-docs/40-expressions.md
user: login: jessesuen
body: Will add a note in the docs
created_at: 2025-04-09T17:03:58Z
author_association: MEMBER

PR comments:
user: login: netlify[bot]
created_at: 2025-04-08T20:43:48Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *docs-kargo-io* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 206fbc45941b1ddebbaf5dd2d8a5afaa40b15b17 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-io/deploys/67f6ac8d6eed05000860992d |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-3820.docs.kargo.io](https://deploy-preview-3820.docs.kargo.io) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTM4MjAuZG9jcy5rYXJnby5pbyJ9.gXCLTI89o35UOoIGpXfnostkoGV28BPlOnhcR7xORM0)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [docs-kargo-io Preview](https://deploy-preview-3820.docs.kargo.io) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/docs-kargo-io/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-08T20:49:01Z
body: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/3820?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 52.24%. Comparing base [(`0286938`)](https://app.codecov.io/gh/akuity/kargo/commit/0286938fafe32e924344dab9f393f0d3fd2e7961?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) to head [(`206fbc4`)](https://app.codecov.io/gh/akuity/kargo/commit/206fbc45941b1ddebbaf5dd2d8a5afaa40b15b17?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).
> Report is 17 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3820      +/-   ##
==========================================
- Coverage   52.25%   52.24%   -0.01%     
==========================================
  Files         309      309              
  Lines       27598    27589       -9     
==========================================
- Hits        14420    14413       -7     
+ Misses      12398    12396       -2     
  Partials      780      780              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/3820?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: hiddeco
created_at: 2025-04-08T21:39:07Z
body: I was wondering today if returning an empty object wouldn't be more convenient as it would prevent you from having to do `&&` type of checks.
author_association: COLLABORATOR
user: login: jessesuen
created_at: 2025-04-09T00:23:18Z
body: > I was wondering today if returning an empty object wouldn't be more convenient

Hmmm, I'd like to understand more. What would be the equivalent of the following?

```yaml
  - uses: yaml-update
    if: ${{ imageFrom( vars.image ) != nil }}
```

author_association: MEMBER
user: login: jessesuen
created_at: 2025-04-09T00:27:46Z
body: One advantage I see with nil, is that you could use expr's [nil coalescing](https://expr-lang.org/docs/language-definition#nil-coalescing) syntax. e.g.:

```
commitFrom( vars.commit ) ?? "main"
```
author_association: MEMBER
user: login: hiddeco
created_at: 2025-04-09T11:03:14Z
body: My initial idea was that doing something like the following would be more straightforward in most cases than `nil` checking (and then possibly having to look at _other_ things as well):

```
${{ commitFrom('https://git.example.com/repo.git').ID != '' }}
```

But I had forgotten about the fact that [optional chaining](https://expr-lang.org/docs/language-definition#optional-chaining) exists in expression language, which makes this as easy when `nil` is returned:

```
${{ commitFrom('https://git.example.com/repo.git')?.ID != '' }}
```

Given this, I am now convinced your suggestion is the best way.
author_association: COLLABORATOR
user: login: krancour
created_at: 2025-04-09T14:20:18Z
body: @jessesuen, I had #3647 in v1.4.1 initially, but as this is a (very minor) breaking change, I am thinking this is more appropriately a v1.5 feature unless you have some reason otherwise. wdyt?
author_association: MEMBER
user: login: jessesuen
created_at: 2025-04-09T17:24:15Z
body: I'm okay with v1.5, since:
* So far I think I'm the only one hitting it
* It'll be in a v1.5 nightly
* It would need to go with https://github.com/akuity/kargo/pull/3817 to be more useful
author_association: MEMBER