Skip to content

Commit 32a24f0

Browse files
committed
Adds support for Helm lookup (#4302)
* Adds support for Helm lookup Adds support for using templates with the _lookup_ command from _Helm_ as it was previously not working because of the first run that we do for the _install_ command we do it in _dryRun_ mode to check for errors. In that case the Helm command internally was not doing the call to the cluster and the _lookup_ calls where resolving to `nil`. This PR adds a new function that checks if a given _Chart_ has templates with the _lookup_ command and, if so, it enables the `DryRunOption="server"` and also sets the option `ClientOnly` to _false_ so Helm connects the cluster and the _lookup_ does not return `nil` Refers to: #1851 --------- Signed-off-by: Xavi Garcia <[email protected]>
1 parent 5be8a70 commit 32a24f0

File tree

3 files changed

+322
-12
lines changed

3 files changed

+322
-12
lines changed

internal/helmdeployer/install.go

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import (
2222
"sigs.k8s.io/controller-runtime/pkg/log"
2323
)
2424

25+
type dryRunConfig struct {
26+
DryRun bool
27+
DryRunOption string
28+
}
29+
2530
// Deploy deploys an unpacked content resource with helm. bundleID is the name of the bundledeployment.
2631
func (h *Helm) Deploy(ctx context.Context, bundleID string, manifest *manifest.Manifest, options fleet.BundleDeploymentOptions) (*release.Release, error) {
2732
if options.Helm == nil {
@@ -53,18 +58,18 @@ func (h *Helm) Deploy(ctx context.Context, bundleID string, manifest *manifest.M
5358
chart.Metadata.Annotations[CommitAnnotation] = manifest.Commit
5459
}
5560

56-
if release, err := h.install(ctx, bundleID, manifest, chart, options, true); err != nil {
61+
if release, err := h.install(ctx, bundleID, manifest, chart, options, getDryRunConfig(chart, true)); err != nil {
5762
return nil, err
5863
} else if h.template {
5964
return release, nil
6065
}
6166

62-
return h.install(ctx, bundleID, manifest, chart, options, false)
67+
return h.install(ctx, bundleID, manifest, chart, options, getDryRunConfig(chart, false))
6368
}
6469

6570
// install runs helm install or upgrade and supports dry running the action. Will run helm rollback in case of a failed upgrade.
66-
func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.Manifest, chart *chart.Chart, options fleet.BundleDeploymentOptions, dryRun bool) (*release.Release, error) {
67-
logger := log.FromContext(ctx).WithName("helm-deployer").WithName("install").WithValues("commit", manifest.Commit, "dryRun", dryRun)
71+
func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.Manifest, chart *chart.Chart, options fleet.BundleDeploymentOptions, dryRunCfg dryRunConfig) (*release.Release, error) {
72+
logger := log.FromContext(ctx).WithName("helm-deployer").WithName("install").WithValues("commit", manifest.Commit, "dryRun", dryRunCfg.DryRun)
6873
timeout, defaultNamespace, releaseName := h.getOpts(bundleID, options)
6974

7075
values, err := h.getValues(ctx, options, defaultNamespace)
@@ -84,10 +89,10 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
8489

8590
if uninstall {
8691
logger.Info("Uninstalling helm release first")
87-
if err := h.delete(ctx, bundleID, options, dryRun); err != nil {
92+
if err := h.delete(ctx, bundleID, options, dryRunCfg.DryRun); err != nil {
8893
return nil, err
8994
}
90-
if dryRun {
95+
if dryRunCfg.DryRun {
9196
return nil, nil
9297
}
9398
}
@@ -116,7 +121,7 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
116121

117122
if install {
118123
u := action.NewInstall(&cfg)
119-
u.ClientOnly = h.template || dryRun
124+
u.ClientOnly = h.template || (dryRunCfg.DryRun && dryRunCfg.DryRunOption == "")
120125
if cfg.Capabilities != nil {
121126
if cfg.Capabilities.KubeVersion.Version != "" {
122127
u.KubeVersion = &cfg.Capabilities.KubeVersion
@@ -133,14 +138,15 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
133138
u.CreateNamespace = true
134139
u.Namespace = defaultNamespace
135140
u.Timeout = timeout
136-
u.DryRun = dryRun
141+
u.DryRun = dryRunCfg.DryRun
142+
u.DryRunOption = dryRunCfg.DryRunOption
137143
u.SkipSchemaValidation = options.Helm.SkipSchemaValidation
138144
u.PostRenderer = pr
139145
u.WaitForJobs = options.Helm.WaitForJobs
140146
if u.Timeout > 0 {
141147
u.Wait = true
142148
}
143-
if !dryRun {
149+
if !dryRunCfg.DryRun {
144150
logger.Info("Installing helm release")
145151
}
146152
return u.Run(chart, values)
@@ -160,15 +166,16 @@ func (h *Helm) install(ctx context.Context, bundleID string, manifest *manifest.
160166
}
161167
u.Namespace = defaultNamespace
162168
u.Timeout = timeout
163-
u.DryRun = dryRun
169+
u.DryRun = dryRunCfg.DryRun
170+
u.DryRunOption = dryRunCfg.DryRunOption
164171
u.SkipSchemaValidation = options.Helm.SkipSchemaValidation
165-
u.DisableOpenAPIValidation = h.template || dryRun
172+
u.DisableOpenAPIValidation = h.template || dryRunCfg.DryRun
166173
u.PostRenderer = pr
167174
u.WaitForJobs = options.Helm.WaitForJobs
168175
if u.Timeout > 0 {
169176
u.Wait = true
170177
}
171-
if !dryRun {
178+
if !dryRunCfg.DryRun {
172179
logger.Info("Upgrading helm release")
173180
}
174181
rel, err := u.Run(releaseName, chart, values)
@@ -349,3 +356,17 @@ func mergeValues(dest, src map[string]interface{}) map[string]interface{} {
349356
}
350357
return dest
351358
}
359+
360+
// getDryRunConfig determines the dry-run configuration based on whether the chart
361+
// uses the Helm "lookup" function.
362+
// If the chart contains the "lookup" function, DryRunOption is set to "server"
363+
// to allow the lookup function to interact with the Kubernetes API during a dry-run.
364+
// Otherwise, DryRunOption remains empty, implying a client-side dry-run.
365+
func getDryRunConfig(chart *chart.Chart, dryRun bool) dryRunConfig {
366+
cfg := dryRunConfig{DryRun: dryRun}
367+
if dryRun && hasLookupFunction(chart) {
368+
cfg.DryRunOption = "server"
369+
}
370+
371+
return cfg
372+
}

internal/helmdeployer/lookup.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package helmdeployer
2+
3+
import (
4+
"reflect"
5+
"strings"
6+
"text/template"
7+
"text/template/parse"
8+
9+
"helm.sh/helm/v3/pkg/chart"
10+
)
11+
12+
// hasLookupFunction checks if any template in the given Helm chart
13+
// calls the "lookup" function. It parses the templates to ensure it's a function
14+
// call and not just the word "lookup" in text or comments.
15+
func hasLookupFunction(ch *chart.Chart) bool {
16+
for _, tpl := range ch.Templates {
17+
// Parse the template into an AST.
18+
t, err := template.New(
19+
tpl.Name,
20+
).Option(
21+
"missingkey=zero",
22+
).Funcs(
23+
map[string]interface{}{"lookup": func() error { return nil }},
24+
).Parse(string(tpl.Data))
25+
if err != nil {
26+
// Some templates might not parse correctly if they depend on values
27+
// that aren't available. We can safely ignore these errors and continue,
28+
// as a parse error means we couldn't definitively find a valid 'lookup' call.
29+
continue
30+
}
31+
32+
// Walk all parse trees in this template and look for lookup invocations.
33+
if t.Tree != nil && t.Root != nil {
34+
if containsLookup(t.Root) {
35+
return true
36+
}
37+
}
38+
}
39+
40+
return false
41+
}
42+
43+
// containsLookup recursively checks whether a parse.Node (and its children)
44+
// contains a call to the "lookup" function.
45+
func containsLookup(node parse.Node) bool { // nolint: gocyclo // recursive logic
46+
if nodeIsNil(node) {
47+
return false
48+
}
49+
50+
// Quick textual pre-check. If the node's string representation does not
51+
// contain the word "lookup", there's no need to traverse it deeply.
52+
// This avoids unnecessary recursion for nodes that clearly don't reference
53+
// the lookup function. If the textual representation contains
54+
// "lookup", fall through to the detailed inspection below.
55+
if !nodeExprContainsLookup(node) {
56+
return false
57+
}
58+
59+
switch node.Type() {
60+
case parse.NodeAction:
61+
if n, ok := node.(*parse.ActionNode); ok && n != nil {
62+
return containsLookup(n.Pipe)
63+
}
64+
return false
65+
case parse.NodeIf:
66+
if n, ok := node.(*parse.IfNode); ok && n != nil {
67+
// check if any of the sub-nodes contain lookup
68+
return containsLookup(n.ElseList) || containsLookup(n.Pipe) || containsLookup(n.List)
69+
}
70+
return false
71+
case parse.NodeList:
72+
if n, ok := node.(*parse.ListNode); ok && n != nil {
73+
for _, subNode := range n.Nodes {
74+
if containsLookup(subNode) {
75+
return true
76+
}
77+
}
78+
}
79+
return false
80+
case parse.NodeRange:
81+
if n, ok := node.(*parse.RangeNode); ok && n != nil {
82+
// check if any of the sub-nodes contain lookup
83+
return containsLookup(n.ElseList) || containsLookup(n.Pipe) || containsLookup(n.List)
84+
}
85+
return false
86+
case parse.NodeTemplate:
87+
if n, ok := node.(*parse.TemplateNode); ok && n != nil {
88+
return containsLookup(n.Pipe)
89+
}
90+
return false
91+
case parse.NodeWith:
92+
if n, ok := node.(*parse.WithNode); ok && n != nil {
93+
// check if any of the sub-nodes contain lookup
94+
return containsLookup(n.Pipe) || containsLookup(n.List) || containsLookup(n.ElseList)
95+
}
96+
return false
97+
case parse.NodePipe:
98+
if n, ok := node.(*parse.PipeNode); ok && n != nil {
99+
for _, cmd := range n.Cmds {
100+
if containsLookup(cmd) {
101+
return true
102+
}
103+
}
104+
}
105+
return false
106+
case parse.NodeCommand:
107+
if n, ok := node.(*parse.CommandNode); ok && n != nil {
108+
for i, arg := range n.Args {
109+
// The first argument of a command node is usually the function name.
110+
if i == 0 {
111+
ident, ok := arg.(*parse.IdentifierNode)
112+
if ok && ident != nil && ident.Ident == "lookup" {
113+
return true
114+
}
115+
}
116+
// Recurse into arguments to find nested lookups, e.g., {{ template "foo" (lookup ...) }}
117+
if containsLookup(arg) {
118+
return true
119+
}
120+
}
121+
}
122+
return false
123+
case parse.NodeChain:
124+
if n, ok := node.(*parse.ChainNode); ok && n != nil {
125+
// Covers cases like (lookup ...).items where the lookup is part of a chained expression.
126+
if n.Node != nil {
127+
return containsLookup(n.Node)
128+
}
129+
}
130+
return false
131+
default:
132+
return false
133+
}
134+
}
135+
136+
// nodeExprContainsLookup returns true when the node has a textual
137+
// expression (via String()) and that textual representation contains the
138+
// substring "lookup". This is a cheap pre-check to avoid deep traversal for
139+
// nodes that don't reference the lookup function at all.
140+
func nodeExprContainsLookup(node parse.Node) bool {
141+
if nodeIsNil(node) {
142+
return false
143+
}
144+
s := node.String()
145+
146+
return strings.Contains(s, "lookup")
147+
}
148+
149+
func nodeIsNil(node parse.Node) bool {
150+
if node == nil {
151+
return true
152+
}
153+
rv := reflect.ValueOf(node)
154+
if rv.Kind() == reflect.Ptr && rv.IsNil() {
155+
return true
156+
}
157+
return false
158+
}

0 commit comments

Comments
 (0)