Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions internal/helmdeployer/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
158 changes: 158 additions & 0 deletions internal/helmdeployer/lookup.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading