Repository: GoogleContainerTools/skaffold
Pull request #8563 (2023-03-21 16:29:02)

feat: custom actions interfaces and actions runner implementation
Fixes: #8522

**Description**
This PR adds the set of interfaces and types used by the Custom Actions, and implements the ActionsRunner in charge of executing a custom action according to the configuration.


codecov[bot]: ## [Codecov](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) Report
> Merging [#8563](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) (3170f1a) into [main](https://codecov.io/gh/GoogleContainerTools/skaffold/commit/290280e8c6c19231e9ed5b5c027f2c01d22f75be?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) (290280e) will **decrease** coverage by `6.35%`.
> The diff coverage is `54.32%`.

```diff
@@            Coverage Diff             @@
##             main    #8563      +/-   ##
==========================================
- Coverage   70.48%   64.13%   -6.35%     
==========================================
  Files         515      608      +93     
  Lines       23150    30503    +7353     
==========================================
+ Hits        16317    19563    +3246     
- Misses       5776     9473    +3697     
- Partials     1057     1467     +410     
```


| [Impacted Files](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) | Coverage Δ | |
|---|---|---|
| [cmd/skaffold/app/cmd/completion.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvY29tcGxldGlvbi5nbw==) | `13.04% <0.00%> (-1.25%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/config/list.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvY29uZmlnL2xpc3QuZ28=) | `65.21% <ø> (ø)` | |
| [cmd/skaffold/app/cmd/config/set.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvY29uZmlnL3NldC5nbw==) | `88.72% <ø> (ø)` | |
| [cmd/skaffold/app/cmd/config/util.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvY29uZmlnL3V0aWwuZ28=) | `54.28% <ø> (ø)` | |
| [cmd/skaffold/app/cmd/credits.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvY3JlZGl0cy5nbw==) | `100.00% <ø> (ø)` | |
| [cmd/skaffold/app/cmd/credits/export.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvY3JlZGl0cy9leHBvcnQuZ28=) | `0.00% <0.00%> (ø)` | |
| [cmd/skaffold/app/cmd/deploy.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvZGVwbG95Lmdv) | `40.90% <0.00%> (-12.94%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/generate\_pipeline.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvZ2VuZXJhdGVfcGlwZWxpbmUuZ28=) | `60.00% <ø> (ø)` | |
| [cmd/skaffold/app/cmd/inspect\_modules.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvaW5zcGVjdF9tb2R1bGVzLmdv) | `65.00% <ø> (ø)` | |
| [cmd/skaffold/app/cmd/inspect\_profiles.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvaW5zcGVjdF9wcm9maWxlcy5nbw==) | `66.66% <ø> (ø)` | |
| ... and [38 more](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) | |

... and [387 files with indirect coverage changes](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/8563/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools)

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools)


Patch:
diff --git a/cmd/skaffold/app/cmd/inspect.go b/cmd/skaffold/app/cmd/inspect.go
index 8a6de4cf6..edfb1a6b3 100644
--- a/cmd/skaffold/app/cmd/inspect.go
+++ b/cmd/skaffold/app/cmd/inspect.go
@@ -43,7 +43,8 @@ func NewCmdInspect() *cobra.Command {
 		WithDescription("Helper commands for Cloud Code IDEs to interact with and modify skaffold configuration files.").
 		WithPersistentFlagAdder(cmdInspectFlags).
 		Hidden().
-		WithCommands(cmdModules(), cmdProfiles(), cmdBuildEnv(), cmdTests(), cmdNamespaces(), cmdJobManifestPaths())
+		WithCommands(cmdModules(), cmdProfiles(), cmdBuildEnv(), cmdTests(), cmdNamespaces(),
+			cmdJobManifestPaths(), cmdExecutionModes())
 }
 
 func cmdInspectFlags(f *pflag.FlagSet) {
diff --git a/cmd/skaffold/app/cmd/inspect_execution_modes.go b/cmd/skaffold/app/cmd/inspect_execution_modes.go
new file mode 100644
index 000000000..66caa15a0
--- /dev/null
+++ b/cmd/skaffold/app/cmd/inspect_execution_modes.go
@@ -0,0 +1,55 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+	"context"
+	"io"
+
+	"github.com/spf13/cobra"
+
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect"
+	executionModes "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect/executionModes"
+)
+
+func cmdExecutionModes() *cobra.Command {
+	return NewCmd("executionModes").
+		WithDescription("View skaffold executionMode information defined in the specified skaffold configuration").
+		WithCommands(cmdExecutionModesList())
+}
+
+func cmdExecutionModesList() *cobra.Command {
+	return NewCmd("list").
+		WithExample("Get list of executionModes", "inspect executionModes list --format json").
+		WithExample("Get list of executionModes targeting a specific configuration", "inspect executionModes list --profile local --format json").
+		WithDescription("Print the list of executionModes that would be run for a given configuration (default skaffold configuration, specific module, specific profile, etc).").
+		WithArgs(func(cmd *cobra.Command, args []string) error {
+			// skaffold inspect executionModes list take in an optional list of customActions as well
+			return nil
+		}, listExecutionModes)
+}
+
+func listExecutionModes(ctx context.Context, out io.Writer, args []string) error {
+	return executionModes.PrintExecutionModesList(ctx, out, inspect.Options{
+		Filename:          inspectFlags.filename,
+		RepoCacheDir:      inspectFlags.repoCacheDir,
+		OutFormat:         inspectFlags.outFormat,
+		Modules:           inspectFlags.modules,
+		Profiles:          inspectFlags.profiles,
+		PropagateProfiles: inspectFlags.propagateProfiles,
+	}, args)
+}
diff --git a/docs-v2/content/en/docs/renderers/_index.md b/docs-v2/content/en/docs/renderers/_index.md
index fc11b7a43..5cf62e611 100644
--- a/docs-v2/content/en/docs/renderers/_index.md
+++ b/docs-v2/content/en/docs/renderers/_index.md
@@ -7,7 +7,7 @@ aliases: [/docs/how-tos/renderers, /docs/pipeline-stages/renderers/]
 no_list: true
 ---
 
-When Skaffold renders your application to Kubernetes, it goes throught the following process:
+When Skaffold renders your application to Kubernetes, it goes through the following process:
 
 * the Skaffold renderer _renders_ the final Kubernetes manifests: Skaffold replaces untagged image names in the Kubernetes manifests with the final tagged image names.
 It also might go through the extra intermediate step of expanding templates (for helm) or calculating overlays (for kustomize).
@@ -28,4 +28,4 @@ a combination of multiple renderers in a single project.
 
 For a detailed discussion on Skaffold configuration, see
 [Skaffold Concepts]({{< relref "/docs/design/config.md" >}}) and
-[skaffold.yaml References]({{< relref "/docs/references/yaml" >}}).
\ No newline at end of file
+[skaffold.yaml References]({{< relref "/docs/references/yaml" >}}).
diff --git a/pkg/skaffold/actions/action.go b/pkg/skaffold/actions/action.go
new file mode 100644
index 000000000..99f6adc3d
--- /dev/null
+++ b/pkg/skaffold/actions/action.go
@@ -0,0 +1,64 @@
+/*
+Copyright 2023 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package actions
+
+import (
+	"context"
+	"io"
+	"time"
+)
+
+type Action struct {
+	name     string
+	timeout  time.Duration
+	tasks    []Task
+	execFunc ExecStrategy
+}
+
+func NewAction(name string, timeoutSeconds int, isFailFast bool, tasks []Task) *Action {
+	return &Action{
+		name:     name,
+		timeout:  time.Duration(timeoutSeconds * int(time.Second)),
+		execFunc: getExecFunc(isFailFast),
+		tasks:    tasks,
+	}
+}
+
+func (a Action) Name() string {
+	return a.name
+}
+
+func (a Action) Exec(ctx context.Context, out io.Writer) error {
+	ctxTimeout := ctx
+	cancel := func() {}
+
+	if a.timeout.Seconds() > 0 {
+		ctxTimeout, cancel = context.WithTimeout(ctx, a.timeout)
+	}
+
+	defer cancel()
+	return a.execFunc(ctxTimeout, out, a.tasks)
+}
+
+func (a Action) Cleanup(ctx context.Context, out io.Writer) error {
+	for _, t := range a.tasks {
+		if err := t.Cleanup(ctx, out); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/pkg/skaffold/actions/runner.go b/pkg/skaffold/actions/runner.go
new file mode 100644
index 000000000..9422ab284
--- /dev/null
+++ b/pkg/skaffold/actions/runner.go
@@ -0,0 +1,160 @@
+/*
+Copyright 2023 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package actions
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"math"
+
+	"github.com/fatih/semgroup"
+	"golang.org/x/sync/errgroup"
+
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/graph"
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/output/log"
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/runner"
+)
+
+var _ runner.ActionsRunner = Runner{}
+
+type Runner struct {
+	// Map to access the associated Exec environment of a given action.
+	execEnvByAction map[string]ExecEnv
+
+	// List with all the created exec environments; this helps to execute all the actions in the same order always.
+	orderedExecEnvs []ExecEnv
+
+	// Map to access the list of associated actions of a given Execution environment.
+	acsByExecEnv map[ExecEnv][]string
+}
+
+func NewRunner(execEnvByAction map[string]ExecEnv, orderedExecEnvs []ExecEnv, acsByExecEnv map[ExecEnv][]string) Runner {
+	return Runner{execEnvByAction, orderedExecEnvs, acsByExecEnv}
+}
+
+func (r Runner) ExecAll(ctx context.Context, out io.Writer, allbuilds []graph.Artifact) error {
+	acs, err := r.prepareAllActions(ctx, out, allbuilds)
+	if err != nil {
+		return err
+	}
+
+	defer r.cleanup(ctx, out, acs, r.orderedExecEnvs)
+	return execWithFailingSafe(ctx, out, acs)
+}
+
+func (r Runner) Exec(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, aName string) error {
+	execEnv, found := r.execEnvByAction[aName]
+	if !found {
+		return fmt.Errorf("custom action %v not found", aName)
+	}
+
+	acs, err := execEnv.PrepareActions(ctx, out, allbuilds, []string{aName})
+	if err != nil {
+		return err
+	}
+
+	// We expect only one action to be created.
+	if len(acs) != 1 {
+		return fmt.Errorf("failed to create %v action", aName)
+	}
+
+	a := acs[0]
+
+	defer r.cleanup(context.TODO(), out, []Task{a}, []ExecEnv{execEnv})
+	return a.Exec(ctx, out)
+}
+
+func (r Runner) prepareAllActions(ctx context.Context, out io.Writer, allbuilds []graph.Artifact) ([]Task, error) {
+	preparedAcs := []Task{}
+	for _, execEnv := range r.orderedExecEnvs {
+		acsNames := r.acsByExecEnv[execEnv]
+		acs, err := execEnv.PrepareActions(ctx, out, allbuilds, acsNames)
+		if err != nil {
+			return nil, err
+		}
+
+		for _, a := range acs {
+			preparedAcs = append(preparedAcs, a)
+		}
+	}
+
+	return preparedAcs, nil
+}
+
+func (r Runner) cleanup(ctx context.Context, out io.Writer, ts []Task, execEnvs []ExecEnv) {
+	log.Entry(ctx).Debugf("Starting execution cleanup")
+	for _, t := range ts {
+		if t == nil {
+			continue
+		}
+		if err := t.Cleanup(ctx, out); err != nil {
+			// TODO(renzor): known issue related with Docker client deleting containers + prune will cause some
+			// warnings here. We need to fix https://github.com/GoogleContainerTools/skaffold/issues/8605
+			log.Entry(ctx).Warnf("%v cleanup error:%v", t.Name(), err)
+		}
+	}
+	for _, execEnv := range execEnvs {
+		if execEnv == nil {
+			continue
+		}
+		if err := execEnv.Cleanup(ctx, out); err != nil {
+			log.Entry(ctx).Warnf("Execution environment cleanup error:%v", err)
+		}
+	}
+}
+
+func getExecFunc(isFailFast bool) ExecStrategy {
+	if isFailFast {
+		return execWithFailingFast
+	}
+	return execWithFailingSafe
+}
+
+func execWithFailingFast(ctx context.Context, out io.Writer, ts []Task) error {
+	g, gCtx := errgroup.WithContext(ctx)
+
+	for _, t := range ts {
+		t := t
+		g.Go(func() error {
+			return execAndLog(gCtx, out, t)
+		})
+	}
+
+	return g.Wait()
+}
+
+func execWithFailingSafe(ctx context.Context, out io.Writer, ts []Task) error {
+	const maxWorkers = math.MaxInt64
+	g := semgroup.NewGroup(context.Background(), maxWorkers)
+
+	for _, t := range ts {
+		t := t
+		g.Go(func() error {
+			return execAndLog(ctx, out, t)
+		})
+	}
+
+	return g.Wait()
+}
+
+func execAndLog(ctx context.Context, out io.Writer, t Task) error {
+	log.Entry(ctx).Debugf("Starting execution for %v", t.Name())
+	err := t.Exec(ctx, out)
+	log.Entry(ctx).Debugf("Execution finished for %v", t.Name())
+	return err
+}
diff --git a/pkg/skaffold/actions/types.go b/pkg/skaffold/actions/types.go
new file mode 100644
index 000000000..64886b4e4
--- /dev/null
+++ b/pkg/skaffold/actions/types.go
@@ -0,0 +1,49 @@
+/*
+Copyright 2023 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package actions
+
+import (
+	"context"
+	"io"
+
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/graph"
+)
+
+// ExecStrategy represents the functions to use to execute a list of tasks.
+type ExecStrategy func(ctx context.Context, out io.Writer, ts []Task) error
+
+// Task represents a single container from a custom action.
+type Task interface {
+	// Name is the unique name of the tasks across all other tasks and actions.
+	Name() string
+
+	// Exec triggers the execution of the task.
+	Exec(ctx context.Context, out io.Writer) error
+
+	// Cleanup frees the resources created by the task to execute itself.
+	Cleanup(ctx context.Context, out io.Writer) error
+}
+
+// ExecEnv represents every execution mode available for custom actions.
+type ExecEnv interface {
+	// PrepareActions creates the shared resources needed for the actions of an
+	// specific execution mode. It returns the actions of this execution mode.
+	PrepareActions(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error)
+
+	// Cleanup frees the shared resources created during PrepareActions.
+	Cleanup(ctx context.Context, out io.Writer) error
+}
diff --git a/pkg/skaffold/inspect/executionModes/list.go b/pkg/skaffold/inspect/executionModes/list.go
new file mode 100644
index 000000000..011129644
--- /dev/null
+++ b/pkg/skaffold/inspect/executionModes/list.go
@@ -0,0 +1,63 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package inspect
+
+import (
+	"context"
+	"io"
+
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/config"
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect"
+)
+
+type executionModeList struct {
+	VerifyExecutionModes        map[string]string `json:"verifyExecutionModes"`
+	CustomActionsExecutionModes map[string]string `json:"customActionsExecutionModes"`
+}
+
+func PrintExecutionModesList(ctx context.Context, out io.Writer, opts inspect.Options, customActions []string) error {
+	formatter := inspect.OutputFormatter(out, opts.OutFormat)
+	cfgs, err := inspect.GetConfigSet(ctx, config.SkaffoldOptions{
+		ConfigurationFile:   opts.Filename,
+		ConfigurationFilter: opts.Modules,
+		RepoCacheDir:        opts.RepoCacheDir,
+		Profiles:            opts.Profiles,
+		PropagateProfiles:   opts.PropagateProfiles,
+	})
+	if err != nil {
+		formatter.WriteErr(err)
+		return err
+	}
+
+	l := &executionModeList{
+		VerifyExecutionModes:        map[string]string{},
+		CustomActionsExecutionModes: map[string]string{},
+	}
+
+	for _, c := range cfgs {
+		for _, tc := range c.Verify {
+			if tc.ExecutionMode.KubernetesClusterExecutionMode != nil {
+				l.VerifyExecutionModes[tc.Name] = "kubernetesCluster"
+			} else {
+				l.VerifyExecutionModes[tc.Name] = "local"
+			}
+		}
+	}
+	// TODO(#8572) add similar logic for customAction schema fields when they are complete
+
+	return formatter.Write(l)
+}
diff --git a/pkg/skaffold/render/renderer/helm/helm.go b/pkg/skaffold/render/renderer/helm/helm.go
index f473dd29b..747b77de7 100644
--- a/pkg/skaffold/render/renderer/helm/helm.go
+++ b/pkg/skaffold/render/renderer/helm/helm.go
@@ -125,6 +125,11 @@ func (h Helm) generateHelmManifests(ctx context.Context, builds []graph.Artifact
 			return nil, helm.UserErr(fmt.Sprintf("cannot expand release name %q", release.Name), err)
 		}
 
+		release.ChartPath, err = sUtil.ExpandEnvTemplateOrFail(release.ChartPath, nil)
+		if err != nil {
+			return nil, helm.UserErr(fmt.Sprintf("cannot expand chart path %q", release.ChartPath), err)
+		}
+
 		args := []string{"template", releaseName, helm.ChartSource(release)}
 		args = append(args, postRendererArgs...)
 		if release.Packaged == nil && release.Version != "" {
diff --git a/pkg/skaffold/runner/actions_runner.go b/pkg/skaffold/runner/actions_runner.go
new file mode 100644
index 000000000..084b52cfe
--- /dev/null
+++ b/pkg/skaffold/runner/actions_runner.go
@@ -0,0 +1,30 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package runner
+
+import (
+	"context"
+	"io"
+
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/graph"
+)
+
+// ActionsRunner defines the API used to run custom actions.
+type ActionsRunner interface {
+	Exec(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, action string) error
+	ExecAll(ctx context.Context, out io.Writer, allbuilds []graph.Artifact) error
+}


Test patch:
diff --git a/pkg/skaffold/actions/runner_test.go b/pkg/skaffold/actions/runner_test.go
new file mode 100644
index 000000000..592b6e373
--- /dev/null
+++ b/pkg/skaffold/actions/runner_test.go
@@ -0,0 +1,369 @@
+/*
+Copyright 2023 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package actions
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"testing"
+	"time"
+
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/graph"
+	"github.com/GoogleContainerTools/skaffold/v2/testutil"
+)
+
+type mockTask struct {
+	ExecF    func(ctx context.Context, out io.Writer, m *mockTask) error
+	Finished bool
+}
+
+func (ma *mockTask) Name() string { return "" }
+
+func (ma *mockTask) Exec(ctx context.Context, out io.Writer) error {
+	if ma.ExecF != nil {
+		return ma.ExecF(ctx, out, ma)
+	}
+	ma.Finished = true
+	return nil
+}
+
+func (ma *mockTask) Cleanup(ctx context.Context, out io.Writer) error { return nil }
+
+type mockExecEnv struct {
+	Actions     []string
+	MockPrepAcs func(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error)
+	MockActions []Action
+}
+
+func (me mockExecEnv) PrepareActions(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error) {
+	if me.MockPrepAcs != nil {
+		return me.MockPrepAcs(ctx, out, allbuilds, acsNames)
+	}
+	return me.MockActions, nil
+}
+
+func (me mockExecEnv) Cleanup(ctx context.Context, out io.Writer) error {
+	return nil
+}
+
+func newRunner(mExecEnvs []mockExecEnv) Runner {
+	execEnvByAction := map[string]ExecEnv{}
+	acsByExecEnv := map[ExecEnv][]string{}
+	execEnvs := []ExecEnv{}
+
+	for _, execEnv := range mExecEnvs {
+		for _, a := range execEnv.Actions {
+			execEnvByAction[a] = &execEnv
+			acsByExecEnv[&execEnv] = append(acsByExecEnv[&execEnv], a)
+		}
+		execEnvs = append(execEnvs, execEnv)
+	}
+
+	return NewRunner(execEnvByAction, execEnvs, acsByExecEnv)
+}
+
+func TestActionsRunner_Exec(t *testing.T) {
+	tests := []struct {
+		description  string
+		actionToExec string
+		shouldErr    bool
+		err          string
+		execEnvs     []mockExecEnv
+	}{
+		{
+			description:  "action not found",
+			actionToExec: "action3",
+			shouldErr:    true,
+			err:          "custom action action3 not found",
+			execEnvs: []mockExecEnv{
+				{
+					Actions: []string{"action1", "action2"},
+				},
+			},
+		},
+		{
+			description:  "zero actions returned",
+			actionToExec: "action1",
+			shouldErr:    true,
+			err:          "failed to create action1 action",
+			execEnvs: []mockExecEnv{
+				{
+					Actions: []string{"action1", "action2"},
+					MockPrepAcs: func(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error) {
+						return []Action{}, nil
+					},
+				},
+			},
+		},
+		{
+			description:  "more than one action returned",
+			actionToExec: "action1",
+			shouldErr:    true,
+			err:          "failed to create action1 action",
+			execEnvs: []mockExecEnv{
+				{
+					Actions: []string{"action1", "action2"},
+					MockPrepAcs: func(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error) {
+						return []Action{{}, {}}, nil
+					},
+				},
+			},
+		},
+		{
+			description:  "forward exec error",
+			actionToExec: "action1",
+			shouldErr:    true,
+			err:          "error from action execution",
+			execEnvs: []mockExecEnv{
+				{
+					Actions: []string{"action1", "action2"},
+					MockPrepAcs: func(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error) {
+						return []Action{
+							{
+								execFunc: func(ctx context.Context, out io.Writer, ts []Task) error {
+									return fmt.Errorf("error from action execution")
+								},
+							},
+						}, nil
+					},
+				},
+			},
+		},
+		{
+			description:  "forward prepare actions error",
+			actionToExec: "action1",
+			shouldErr:    true,
+			err:          "error from prepare actions",
+			execEnvs: []mockExecEnv{
+				{
+					Actions: []string{"action1", "action2"},
+					MockPrepAcs: func(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error) {
+						return nil, fmt.Errorf("error from prepare actions")
+					},
+				},
+			},
+		},
+		{
+			description:  "exec successfully",
+			actionToExec: "action1",
+			execEnvs: []mockExecEnv{
+				{
+					Actions: []string{"action1", "action2"},
+					MockPrepAcs: func(ctx context.Context, out io.Writer, allbuilds []graph.Artifact, acsNames []string) ([]Action, error) {
+						return []Action{
+							{
+								execFunc: func(ctx context.Context, out io.Writer, ts []Task) error {
+									return nil
+								},
+							},
+						}, nil
+					},
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			runner := newRunner(test.execEnvs)
+			err := runner.Exec(context.TODO(), nil, nil, test.actionToExec)
+
+			if test.shouldErr {
+				t.CheckErrorContains(test.err, err)
+			} else {
+				t.CheckNoError(err)
+			}
+		})
+	}
+}
+
+func TestActionsRunner_ExecFailFast(t *testing.T) {
+	tests := []struct {
+		description  string
+		actionToExec string
+		shouldErr    bool
+		err          string
+		action       Action
+	}{
+		{
+			description:  "interrupt other tasks when one fails",
+			actionToExec: "action1",
+			shouldErr:    true,
+			err:          "error from mock task",
+			action: Action{
+				tasks: []Task{
+					&mockTask{
+						// This task will fail, interrupting the others.
+						ExecF: func(ctx context.Context, out io.Writer, m *mockTask) error {
+							m.Finished = false
+							return fmt.Errorf("error from mock task")
+						},
+					},
+					&mockTask{
+						ExecF: func(ctx context.Context, out io.Writer, m *mockTask) error {
+							m.Finished = false
+							var err error
+							select {
+							// This task will run for 10 secs, but should be interrupted
+							// in 2 seconds due to the other task failed, so this never finish.
+							case <-time.After(time.Second * 5):
+								m.Finished = true
+							case <-ctx.Done():
+								err = ctx.Err()
+							}
+							return err
+						},
+					},
+				},
+			},
+		},
+		{
+			description:  "interrupt all tasks when on action timeout",
+			actionToExec: "action1",
+			shouldErr:    true,
+			err:          "context deadline",
+			action: Action{
+				timeout: 1,
+				tasks: []Task{
+					&mockTask{
+						ExecF: func(ctx context.Context, out io.Writer, m *mockTask) error {
+							m.Finished = false
+							var err error
+							select {
+							case <-time.After(time.Second * 2):
+								m.Finished = true
+							case <-ctx.Done():
+								err = ctx.Err()
+							}
+							return err
+						},
+					},
+					&mockTask{
+						ExecF: func(ctx context.Context, out io.Writer, m *mockTask) error {
+							m.Finished = false
+							var err error
+							select {
+							case <-time.After(time.Second * 2):
+								m.Finished = true
+							case <-ctx.Done():
+								err = ctx.Err()
+							}
+							return err
+						},
+					},
+				},
+			},
+		},
+		{
+			description:  "execute all tasks till end",
+			actionToExec: "action1",
+			shouldErr:    false,
+			action: Action{
+				tasks: []Task{&mockTask{}, &mockTask{}},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			test.action.execFunc = execWithFailingFast
+			execEnvs := []mockExecEnv{
+				{
+					Actions:     []string{test.actionToExec},
+					MockActions: []Action{test.action},
+				},
+			}
+
+			runner := newRunner(execEnvs)
+			err := runner.Exec(context.TODO(), nil, nil, test.actionToExec)
+
+			if test.shouldErr {
+				t.CheckErrorContains(test.err, err)
+				for _, task := range test.action.tasks {
+					t.CheckFalse(task.(*mockTask).Finished)
+				}
+			} else {
+				for _, task := range test.action.tasks {
+					t.CheckTrue(task.(*mockTask).Finished)
+				}
+				t.CheckNoError(err)
+			}
+		})
+	}
+}
+
+func TestActionsRunner_ExecFailSafe(t *testing.T) {
+	tests := []struct {
+		description  string
+		actionToExec string
+		shouldErr    bool
+		err          string
+		action       Action
+	}{
+		{
+			description:  "run all tasks till end if one fails",
+			actionToExec: "action1",
+			shouldErr:    true,
+			err:          "error from mock task",
+			action: Action{
+				tasks: []Task{
+					&mockTask{},
+					&mockTask{
+						// This task will fail first.
+						ExecF: func(ctx context.Context, out io.Writer, m *mockTask) error {
+							m.Finished = true
+							return fmt.Errorf("error from mock task")
+						},
+					},
+				},
+			},
+		},
+		{
+			description:  "execute all tasks till end",
+			actionToExec: "action1",
+			shouldErr:    false,
+			action: Action{
+				tasks: []Task{&mockTask{}, &mockTask{}},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			test.action.execFunc = execWithFailingFast
+			execEnvs := []mockExecEnv{
+				{
+					Actions:     []string{test.actionToExec},
+					MockActions: []Action{test.action},
+				},
+			}
+
+			runner := newRunner(execEnvs)
+			err := runner.Exec(context.TODO(), nil, nil, test.actionToExec)
+
+			if test.shouldErr {
+				t.CheckErrorContains(test.err, err)
+			} else {
+				t.CheckNoError(err)
+			}
+			for _, task := range test.action.tasks {
+				t.CheckTrue(task.(*mockTask).Finished)
+			}
+		})
+	}
+}
diff --git a/pkg/skaffold/deploy/helm/helm_test.go b/pkg/skaffold/deploy/helm/helm_test.go
index efb7e31f8..702984d7c 100644
--- a/pkg/skaffold/deploy/helm/helm_test.go
+++ b/pkg/skaffold/deploy/helm/helm_test.go
@@ -89,6 +89,17 @@ var testDeployEnvTemplateNamespacedConfig = latest.LegacyHelmDeploy{
 	}},
 }
 
+var testDeployWithTemplatedChartPath = latest.LegacyHelmDeploy{
+	Releases: []latest.HelmRelease{{
+		Name:      "skaffold-helm",
+		ChartPath: "examples/{{.FOO}}",
+		Overrides: schemautil.HelmOverrides{Values: map[string]interface{}{"foo": "bar"}},
+		SetValues: map[string]string{
+			"some.key": "somevalue",
+		},
+	}},
+}
+
 var testDeployConfigRemoteRepo = latest.LegacyHelmDeploy{
 	Releases: []latest.HelmRelease{{
 		Name:      "skaffold-helm",
@@ -1212,6 +1223,15 @@ func TestHelmRender(t *testing.T) {
 			helm:   testDeployWithTemplatedName,
 			builds: testBuilds,
 		},
+		{
+			description: "render with templated chart path",
+			shouldErr:   false,
+			commands: testutil.
+				CmdRun("helm --kube-context kubecontext dep build examples/FOOBAR --kubeconfig kubeconfig").
+				AndRun("helm --kube-context kubecontext template skaffold-helm examples/FOOBAR --post-renderer SKAFFOLD-BINARY --set some.key=somevalue --kubeconfig kubeconfig"),
+			helm:   testDeployWithTemplatedChartPath,
+			builds: testBuilds,
+		},
 		{
 			description: "render with namespace",
 			shouldErr:   false,
diff --git a/pkg/skaffold/inspect/executionModes/list_test.go b/pkg/skaffold/inspect/executionModes/list_test.go
new file mode 100644
index 000000000..a0c924a81
--- /dev/null
+++ b/pkg/skaffold/inspect/executionModes/list_test.go
@@ -0,0 +1,146 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package inspect
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/config"
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect"
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/parser"
+	sErrors "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/schema/errors"
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/schema/latest"
+	"github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/util/stringslice"
+	"github.com/GoogleContainerTools/skaffold/v2/testutil"
+)
+
+func TestPrintExecutionModesList(t *testing.T) {
+	tests := []struct {
+		description   string
+		profiles      []string
+		module        []string
+		customActions []string
+		err           error
+		expected      string
+	}{
+		{
+			description: "print all executionModes where no executionMode is set in the verify config",
+			expected:    "{\"verifyExecutionModes\":{},\"customActionsExecutionModes\":{}}" + "\n",
+			module:      []string{"cfg-without-executionModes"},
+		},
+		{
+			description: "print all executionModes where one executionMode is set in the verify and customAction config via a profile but no customAction arg",
+			expected:    "{\"verifyExecutionModes\":{\"foo\":\"kubernetesCluster\"},\"customActionsExecutionModes\":{}}" + "\n",
+			profiles:    []string{"has-verify-executionMode"},
+			module:      []string{"cfg-without-executionModes"},
+		},
+		{
+			description: "print all executionModes where one executionMode is set in the verify config via a module",
+			expected:    "{\"verifyExecutionModes\":{\"bar\":\"kubernetesCluster\"},\"customActionsExecutionModes\":{}}" + "\n",
+			module:      []string{"cfg-with-executionModes"},
+		},
+		{
+			description: "actionable error",
+			err:         sErrors.MainConfigFileNotFoundErr("path/to/skaffold.yaml", fmt.Errorf("failed to read file : %q", "skaffold.yaml")),
+			expected:    `{"errorCode":"CONFIG_FILE_NOT_FOUND_ERR","errorMessage":"unable to find configuration file \"path/to/skaffold.yaml\": failed to read file : \"skaffold.yaml\". Check that the specified configuration file exists at \"path/to/skaffold.yaml\"."}` + "\n",
+		},
+		{
+			description: "generic error",
+			err:         errors.New("some error occurred"),
+			expected:    `{"errorCode":"INSPECT_UNKNOWN_ERR","errorMessage":"some error occurred"}` + "\n",
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			configSet := parser.SkaffoldConfigSet{
+				&parser.SkaffoldConfigEntry{SkaffoldConfig: &latest.SkaffoldConfig{
+					Metadata: latest.Metadata{Name: "cfg-without-executionModes"},
+					Pipeline: latest.Pipeline{},
+					Profiles: []latest.Profile{
+						{Name: "has-verify-executionMode",
+							Pipeline: latest.Pipeline{
+								Verify: []*latest.VerifyTestCase{
+									{
+										Name: "foo",
+										Container: latest.VerifyContainer{
+											Name:  "foo",
+											Image: "foo",
+										},
+										ExecutionMode: latest.VerifyExecutionModeConfig{
+											VerifyExecutionModeType: latest.VerifyExecutionModeType{
+												KubernetesClusterExecutionMode: &latest.KubernetesClusterVerifier{},
+											},
+										},
+									},
+								},
+							},
+						}},
+				}, SourceFile: "path/to/cfg-without-executionModes"},
+
+				&parser.SkaffoldConfigEntry{SkaffoldConfig: &latest.SkaffoldConfig{
+					Metadata: latest.Metadata{Name: "cfg-with-executionModes"},
+					Pipeline: latest.Pipeline{
+						Verify: []*latest.VerifyTestCase{
+							{
+								Name: "bar",
+								Container: latest.VerifyContainer{
+									Name:  "bar",
+									Image: "bar",
+								},
+								ExecutionMode: latest.VerifyExecutionModeConfig{
+									VerifyExecutionModeType: latest.VerifyExecutionModeType{
+										KubernetesClusterExecutionMode: &latest.KubernetesClusterVerifier{},
+									},
+								},
+							},
+						},
+					},
+				}, SourceFile: "path/to/cfg-with-default-namespace"},
+			}
+			t.Override(&inspect.GetConfigSet, func(_ context.Context, opts config.SkaffoldOptions) (parser.SkaffoldConfigSet, error) {
+				// mock profile activation
+				var set parser.SkaffoldConfigSet
+				for _, c := range configSet {
+					if len(opts.ConfigurationFilter) > 0 && !stringslice.Contains(opts.ConfigurationFilter, c.Metadata.Name) {
+						continue
+					}
+					for _, pName := range opts.Profiles {
+						for _, profile := range c.Profiles {
+							if profile.Name != pName {
+								continue
+							}
+							c.Verify = profile.Verify
+							c.CustomActions = profile.CustomActions
+						}
+					}
+					set = append(set, c)
+				}
+				return set, test.err
+			})
+			var buf bytes.Buffer
+			err := PrintExecutionModesList(context.Background(), &buf, inspect.Options{
+				OutFormat: "json", Modules: test.module, Profiles: test.profiles}, test.customActions)
+			t.CheckError(test.err != nil, err)
+			t.CheckDeepEqual(test.expected, buf.String())
+		})
+	}
+}