Repository: rancher/fleet

Issue:
[backport v2.13] Unable to use `lookup` in Fleet-managed Helm template
This is a backport issue for #1851, automatically created via [GitHub Actions workflow](https://github.com/rancher/fleet/actions/runs/19734378753) initiated by @0xavi0


Original issue body:

### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

If I create a Fleet Bundle with an embedded Helm chart, and one of the templates in that Helm chart uses Helm's `lookup` feature, it does not work.  The `lookup` function returns nothing, and the template does not render as expected.

### Expected Behavior

Fleet Bundles with Helm charts that use `lookup` should function as expected.

### Steps To Reproduce

Create a simple Helm bundle:
```
$ cat Chart.yaml
apiVersion: v2
name: lookup-test
version: 1.0.0

$ cat fleet.yaml
helm:
  chart: .

$ cat templates/config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: config-test
  namespace: kube-system
data:
  lookup-output: {{ len (lookup "v1" "Pods" "kube-system" "").items }}
```

When deployed to a cluster, the expected ConfigMap should have a number (the count of Pods in kube-system) as the value in `lookup-output`.  But instead we get an error from Fleet indicating that it was unable to parse the template:

```
    message: 'ErrApplied(10) [Bundle lookup-test-test: template: test/templates/config.yaml:7:20:
      executing "test/templates/config.yaml" at <len (lookup "v1" "Pods"
      "kube-system" "").items>: error calling len: len of nil pointer]'
```

The fact that it complains of a `nil pointer` is important -- that means that the `lookup` function either returned an empty dict, or a nil pointer itself.  If it had executed properly, even if there were no resources matching the query, `items` would have been an empty list, and `len` would have returned `0`.

### Environment

```markdown
- Architecture: amd64
- Fleet Version: 0.7.0
- Cluster:
  - Provider: RKE
  - Options:
  - Kubernetes Version: 1.26
```


### Logs

_No response_

### Anything else?

_No response_


Hints from issue comments:
@0xavi0: ## QA Template

The goal is to test that the `lookup` command in `Helm` works as expected, when getting information from the cluster.

You can use the folder attached, calling `fleet apply` for it or you can upload it to a git repository and then create a `GitRepo` pointing to it.

The Helm chart deploys the following _ConfigMap_ :
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: config-test
  namespace: kube-system
data:
  lookup-output: "{{- $pods := lookup "v1" "Pod" "kube-system" "" -}}{{ len $pods.items }}"
```

So, after the folder is applied (via `GitRepo` or `fleet apply`) you should see a _ConfigMap_ deployed having its data as `lookup-output: "NUMBER OF PODS IN THE kube-system NAMESPACE"`

Before the fix it is throwing an error. 

[test-helm-lookup.tar.gz](https://github.com/user-attachments/files/23854407/test-helm-lookup.tar.gz)@mmartin24: Verified on Rancher `v2.13-ac12817b491589f29a44a1b414139369d105057a-head` with Fleet `0.14.1-beta.2`.

---

Deployed gitrepo deploying described configmap from [here](https://github.com/mmartin24/test-fleet/tree/master/1851/fleet-lookup-test/).

Gitrepo in `active` state correctly deployed. No errors observed. 
Configmap data displays `7` aligned with the number of pods in the `kube-system` namespace (7, as well):

<img width="1894" height="927" alt="Image" src="https://github.com/user-attachments/assets/2054f7b3-e747-4cd4-b45d-6e004b183056" />



Solution patch:
diff --git a/internal/helmdeployer/install.go b/internal/helmdeployer/install.go
index f82c2c38..9251c166 100644
--- a/internal/helmdeployer/install.go
+++ b/internal/helmdeployer/install.go
@@ -22,6 +22,11 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/log"
 )
 
+type dryRunConfig struct {
+	DryRun       bool
+	DryRunOption string
+}
+
 // Deploy deploys an unpacked content resource with helm. bundleID is the name of the bundledeployment.
 func (h *Helm) Deploy(ctx context.Context, bundleID string, manifest *manifest.Manifest, options fleet.BundleDeploymentOptions) (*release.Release, error) {
 	if options.Helm == nil {
@@ -53,18 +58,18 @@ func (h *Helm) Deploy(ctx context.Context, bundleID string, manifest *manifest.M
 		chart.Metadata.Annotations[CommitAnnotation] = manifest.Commit
 	}
 
-	if release, err := h.install(ctx, bundleID, manifest, chart, options, true); err != nil {
+	if release, err := h.install(ctx, bundleID, manifest, chart, options, getDryRunConfig(chart, true)); err != nil {
 		return nil, err
 	} else if h.template {
 		return release, nil
 	}
 
-	return h.install(ctx, bundleID, manifest, chart, options, false)
+	return h.install(ctx, bundleID, manifest, chart, options, getDryRunConfig(chart, false))
 }
 
 // install runs helm install or upgrade and supports dry running the action. Will run helm rollback in case of a failed upgrade.
-func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.Manifest, chart *chart.Chart, options fleet.BundleDeploymentOptions, dryRun bool) (*release.Release, error) {
-	logger := log.FromContext(ctx).WithName("helm-deployer").WithName("install").WithValues("commit", manifest.Commit, "dryRun", dryRun)
+func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.Manifest, chart *chart.Chart, options fleet.BundleDeploymentOptions, dryRunCfg dryRunConfig) (*release.Release, error) {
+	logger := log.FromContext(ctx).WithName("helm-deployer").WithName("install").WithValues("commit", manifest.Commit, "dryRun", dryRunCfg.DryRun)
 	timeout, defaultNamespace, releaseName := h.getOpts(bundleID, options)
 
 	values, err := h.getValues(ctx, options, defaultNamespace)
@@ -84,10 +89,10 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
 
 	if uninstall {
 		logger.Info("Uninstalling helm release first")
-		if err := h.delete(ctx, bundleID, options, dryRun); err != nil {
+		if err := h.delete(ctx, bundleID, options, dryRunCfg.DryRun); err != nil {
 			return nil, err
 		}
-		if dryRun {
+		if dryRunCfg.DryRun {
 			return nil, nil
 		}
 	}
@@ -116,7 +121,7 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
 
 	if install {
 		u := action.NewInstall(&cfg)
-		u.ClientOnly = h.template || dryRun
+		u.ClientOnly = h.template || (dryRunCfg.DryRun && dryRunCfg.DryRunOption == "")
 		if cfg.Capabilities != nil {
 			if cfg.Capabilities.KubeVersion.Version != "" {
 				u.KubeVersion = &cfg.Capabilities.KubeVersion
@@ -133,14 +138,15 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
 		u.CreateNamespace = true
 		u.Namespace = defaultNamespace
 		u.Timeout = timeout
-		u.DryRun = dryRun
+		u.DryRun = dryRunCfg.DryRun
+		u.DryRunOption = dryRunCfg.DryRunOption
 		u.SkipSchemaValidation = options.Helm.SkipSchemaValidation
 		u.PostRenderer = pr
 		u.WaitForJobs = options.Helm.WaitForJobs
 		if u.Timeout > 0 {
 			u.Wait = true
 		}
-		if !dryRun {
+		if !dryRunCfg.DryRun {
 			logger.Info("Installing helm release")
 		}
 		return u.Run(chart, values)
@@ -160,15 +166,16 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
 	}
 	u.Namespace = defaultNamespace
 	u.Timeout = timeout
-	u.DryRun = dryRun
+	u.DryRun = dryRunCfg.DryRun
+	u.DryRunOption = dryRunCfg.DryRunOption
 	u.SkipSchemaValidation = options.Helm.SkipSchemaValidation
-	u.DisableOpenAPIValidation = h.template || dryRun
+	u.DisableOpenAPIValidation = h.template || dryRunCfg.DryRun
 	u.PostRenderer = pr
 	u.WaitForJobs = options.Helm.WaitForJobs
 	if u.Timeout > 0 {
 		u.Wait = true
 	}
-	if !dryRun {
+	if !dryRunCfg.DryRun {
 		logger.Info("Upgrading helm release")
 	}
 	rel, err := u.Run(releaseName, chart, values)
@@ -349,3 +356,17 @@ func mergeValues(dest, src map[string]interface{}) map[string]interface{} {
 	}
 	return dest
 }
+
+// getDryRunConfig determines the dry-run configuration based on whether the chart
+// uses the Helm "lookup" function.
+// If the chart contains the "lookup" function, DryRunOption is set to "server"
+// to allow the lookup function to interact with the Kubernetes API during a dry-run.
+// Otherwise, DryRunOption remains empty, implying a client-side dry-run.
+func getDryRunConfig(chart *chart.Chart, dryRun bool) dryRunConfig {
+	cfg := dryRunConfig{DryRun: dryRun}
+	if dryRun && hasLookupFunction(chart) {
+		cfg.DryRunOption = "server"
+	}
+
+	return cfg
+}
diff --git a/internal/helmdeployer/lookup.go b/internal/helmdeployer/lookup.go
new file mode 100644
index 00000000..f0edb8ee
--- /dev/null
+++ b/internal/helmdeployer/lookup.go
@@ -0,0 +1,158 @@
+package helmdeployer
+
+import (
+	"reflect"
+	"strings"
+	"text/template"
+	"text/template/parse"
+
+	"helm.sh/helm/v3/pkg/chart"
+)
+
+// hasLookupFunction checks if any template in the given Helm chart
+// calls the "lookup" function. It parses the templates to ensure it's a function
+// call and not just the word "lookup" in text or comments.
+func hasLookupFunction(ch *chart.Chart) bool {
+	for _, tpl := range ch.Templates {
+		// Parse the template into an AST.
+		t, err := template.New(
+			tpl.Name,
+		).Option(
+			"missingkey=zero",
+		).Funcs(
+			map[string]interface{}{"lookup": func() error { return nil }},
+		).Parse(string(tpl.Data))
+		if err != nil {
+			// Some templates might not parse correctly if they depend on values
+			// that aren't available. We can safely ignore these errors and continue,
+			// as a parse error means we couldn't definitively find a valid 'lookup' call.
+			continue
+		}
+
+		// Walk all parse trees in this template and look for lookup invocations.
+		if t.Tree != nil && t.Root != nil {
+			if containsLookup(t.Root) {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+// containsLookup recursively checks whether a parse.Node (and its children)
+// contains a call to the "lookup" function.
+func containsLookup(node parse.Node) bool { // nolint: gocyclo // recursive logic
+	if nodeIsNil(node) {
+		return false
+	}
+
+	// Quick textual pre-check. If the node's string representation does not
+	// contain the word "lookup", there's no need to traverse it deeply.
+	// This avoids unnecessary recursion for nodes that clearly don't reference
+	// the lookup function. If the textual representation contains
+	// "lookup", fall through to the detailed inspection below.
+	if !nodeExprContainsLookup(node) {
+		return false
+	}
+
+	switch node.Type() {
+	case parse.NodeAction:
+		if n, ok := node.(*parse.ActionNode); ok && n != nil {
+			return containsLookup(n.Pipe)
+		}
+		return false
+	case parse.NodeIf:
+		if n, ok := node.(*parse.IfNode); ok && n != nil {
+			// check if any of the sub-nodes contain lookup
+			return containsLookup(n.ElseList) || containsLookup(n.Pipe) || containsLookup(n.List)
+		}
+		return false
+	case parse.NodeList:
+		if n, ok := node.(*parse.ListNode); ok && n != nil {
+			for _, subNode := range n.Nodes {
+				if containsLookup(subNode) {
+					return true
+				}
+			}
+		}
+		return false
+	case parse.NodeRange:
+		if n, ok := node.(*parse.RangeNode); ok && n != nil {
+			// check if any of the sub-nodes contain lookup
+			return containsLookup(n.ElseList) || containsLookup(n.Pipe) || containsLookup(n.List)
+		}
+		return false
+	case parse.NodeTemplate:
+		if n, ok := node.(*parse.TemplateNode); ok && n != nil {
+			return containsLookup(n.Pipe)
+		}
+		return false
+	case parse.NodeWith:
+		if n, ok := node.(*parse.WithNode); ok && n != nil {
+			// check if any of the sub-nodes contain lookup
+			return containsLookup(n.Pipe) || containsLookup(n.List) || containsLookup(n.ElseList)
+		}
+		return false
+	case parse.NodePipe:
+		if n, ok := node.(*parse.PipeNode); ok && n != nil {
+			for _, cmd := range n.Cmds {
+				if containsLookup(cmd) {
+					return true
+				}
+			}
+		}
+		return false
+	case parse.NodeCommand:
+		if n, ok := node.(*parse.CommandNode); ok && n != nil {
+			for i, arg := range n.Args {
+				// The first argument of a command node is usually the function name.
+				if i == 0 {
+					ident, ok := arg.(*parse.IdentifierNode)
+					if ok && ident != nil && ident.Ident == "lookup" {
+						return true
+					}
+				}
+				// Recurse into arguments to find nested lookups, e.g., {{ template "foo" (lookup ...) }}
+				if containsLookup(arg) {
+					return true
+				}
+			}
+		}
+		return false
+	case parse.NodeChain:
+		if n, ok := node.(*parse.ChainNode); ok && n != nil {
+			// Covers cases like (lookup ...).items where the lookup is part of a chained expression.
+			if n.Node != nil {
+				return containsLookup(n.Node)
+			}
+		}
+		return false
+	default:
+		return false
+	}
+}
+
+// nodeExprContainsLookup returns true when the node has a textual
+// expression (via String()) and that textual representation contains the
+// substring "lookup". This is a cheap pre-check to avoid deep traversal for
+// nodes that don't reference the lookup function at all.
+func nodeExprContainsLookup(node parse.Node) bool {
+	if nodeIsNil(node) {
+		return false
+	}
+	s := node.String()
+
+	return strings.Contains(s, "lookup")
+}
+
+func nodeIsNil(node parse.Node) bool {
+	if node == nil {
+		return true
+	}
+	rv := reflect.ValueOf(node)
+	if rv.Kind() == reflect.Ptr && rv.IsNil() {
+		return true
+	}
+	return false
+}



Test patch:
diff --git a/internal/helmdeployer/lookup_test.go b/internal/helmdeployer/lookup_test.go
new file mode 100644
index 00000000..baa53e80
--- /dev/null
+++ b/internal/helmdeployer/lookup_test.go
@@ -0,0 +1,131 @@
+package helmdeployer
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"helm.sh/helm/v3/pkg/chart"
+)
+
+func TestHasLookupFunction(t *testing.T) {
+	testCases := []struct {
+		name           string
+		templates      []*chart.File
+		expectedResult bool
+	}{
+		{
+			name:           "Chart with no templates",
+			templates:      []*chart.File{},
+			expectedResult: false,
+		},
+		{
+			name: "Template without lookup",
+			templates: []*chart.File{
+				{Name: "templates/deployment.yaml", Data: []byte(`apiVersion: apps/v1\nkind: Deployment`)},
+			},
+			expectedResult: false,
+		},
+		{
+			name: "Template with simple lookup",
+			templates: []*chart.File{
+				{Name: "templates/service.yaml", Data: []byte(`{{ lookup "v1" "Service" "default" "kubernetes" }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with 'lookup' as text",
+			templates: []*chart.File{
+				{Name: "templates/configmap.yaml", Data: []byte(`data:\n  key: "some lookup value"`)},
+			},
+			expectedResult: false,
+		},
+		{
+			name: "Template with 'lookup' in a comment",
+			templates: []*chart.File{
+				{Name: "templates/configmap.yaml", Data: []byte(`{{- /* This is a lookup function */ -}}`)},
+			},
+			expectedResult: false,
+		},
+		{
+			name: "Template with lookup in an 'if' block",
+			templates: []*chart.File{
+				{Name: "templates/if.yaml", Data: []byte(`{{ if .Values.enabled }}{{ lookup "v1" "Pod" "default" "mypod" }}{{ end }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with lookup in an 'if' condition",
+			templates: []*chart.File{
+				{Name: "templates/if.yaml", Data: []byte(`{{ if lookup "v1" "ConfigMap" "default" "my-cm" }}found{{ end }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with lookup in a 'range' block",
+			templates: []*chart.File{
+				{Name: "templates/range.yaml", Data: []byte(`{{ range .Values.items }}{{ lookup "v1" "Secret" .Release.Namespace .Name }}{{ end }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with lookup in a 'with' block",
+			templates: []*chart.File{
+				{Name: "templates/with.yaml", Data: []byte(`{{ with .Values.service }}{{ lookup "v1" "Service" .Namespace .Name }}{{ end }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with nested lookup",
+			templates: []*chart.File{
+				{Name: "templates/nested.yaml", Data: []byte(`{{ $cm := lookup "v1" "ConfigMap" "default" "my-cm" }}{{ if $cm }}{{ lookup "v1" "Secret" "default" "my-secret" }}{{ end }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with lookup used in chained expression",
+			templates: []*chart.File{
+				{Name: "templates/chained.yaml", Data: []byte(`{{- range $index, $pod := (lookup "v1" "Pod" "kube-system" "").items }}- {{ $pod.metadata.name }}{{- end }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with invalid syntax (should be ignored)",
+			templates: []*chart.File{
+				{Name: "templates/invalid.yaml", Data: []byte(`{{ .Values.name }`)},
+				{Name: "templates/valid.yaml", Data: []byte(`data: "valid"`)},
+			},
+			expectedResult: false,
+		},
+		{
+			name: "Template with invalid syntax and another with lookup",
+			templates: []*chart.File{
+				{Name: "templates/invalid.yaml", Data: []byte(`{{ .Values.name }`)},
+				{Name: "templates/valid_with_lookup.yaml", Data: []byte(`{{ lookup "v1" "Service" "default" "kubernetes" }}`)},
+			},
+			expectedResult: true,
+		},
+		{
+			name: "Template with lookup as an argument",
+			templates: []*chart.File{
+				{Name: "templates/arg.yaml", Data: []byte(`{{- define "myTpl" }}{{ . }}{{ end -}}{{ template "myTpl" (lookup "v1" "Pod" "default" "my-pod") }}`)},
+			},
+			expectedResult: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			chart := &chart.Chart{
+				Metadata: &chart.Metadata{
+					Name:    "test-chart",
+					Version: "0.1.0",
+				},
+				Templates: tc.templates,
+			}
+
+			assert.Equal(tc.expectedResult, hasLookupFunction(chart))
+		})
+	}
+}