Repository: grafana/tanka
Pull request #704 (2022-05-17 12:56:15)

Adds support for contextNames in tk env subcommands
This compliments https://github.com/grafana/tanka/pull/674 and probably should have been in there to begin with. 

This PR adds support for `contextNames` using `tk env add` and `tk env set` commands. It leverages the `Connect` method to check validity of the resulting config.

Below are a few examples, including cases that would throw errors

```
(~/Code) Nash $ mkdir example-tk && cd example-tk
(~/Code/example-tk) Nash $ tk init
GET https://github.com/jsonnet-libs/k8s-libsonnet/archive/1942ec15993b5a273569bb9a59d522b01522d0db.tar.gz 200
GET https://github.com/grafana/jsonnet-libs/archive/73396f2821466dd8f91181e7ee4513e0a8ea45b3.tar.gz 200
Directory structure set up! Remember to configure the API endpoint:
`tk env set environments/default --server=https://127.0.0.1:6443`


(~/Code/example-tk) Nash $ tk env set environments/default --context-name "devwat-us-south.*" --server localhost --namespace default
updated spec.apiServer (`` -> `localhost`)
updated spec.contextNames (`[]` -> `[devwat-us-south.*]`)
Error: Your Environment's spec.json seems incomplete:
  * spec.apiServer|spec.contextNames: These fields are mutually exclusive, please only specify one.

Please see https://tanka.dev/config for reference

(~/Code/example-tk) Nash $ tk env set environments/default --context-name "devwat-us-south.*" --namespace default
updated spec.contextNames (`[]` -> `[devwat-us-south.*]`)
(~/Code/example-tk) Nash $ cat environments/default/spec.json
{
  "apiVersion": "tanka.dev/v1alpha1",
  "kind": "Environment",
  "metadata": {
    "name": "environments/default",
    "namespace": "environments/default/main.jsonnet"
  },
  "spec": {
    "apiServer": "",
    "contextNames": [
      "devwat-us-south.*"
    ],
    "namespace": "default",
    "resourceDefaults": {},
    "expectVersions": {}
  }
}

(~/Code/example-tk) Nash $ tk env set environments/default --server localhost --namespace default
updated spec.apiServer (`` -> `localhost`)
Error: Your Environment's spec.json seems incomplete:
  * spec.apiServer|spec.contextNames: These fields are mutually exclusive, please only specify one.

Please see https://tanka.dev/config for reference

(~/Code/example-tk) Nash $ tk env add example --context-name "devwat-us-south.*"
(~/Code/example-tk) Nash $ cat example/spec.json
{
  "apiVersion": "tanka.dev/v1alpha1",
  "kind": "Environment",
  "metadata": {
    "name": "example"
  },
  "spec": {
    "apiServer": "",
    "contextNames": [
      "devwat-us-south.*"
    ],
    "namespace": "default",
    "resourceDefaults": {},
    "expectVersions": {}
  }
}
(~/Code/example-tk) Nash $
```


Patch:
diff --git a/.drone/drone.jsonnet b/.drone/drone.jsonnet
index a7cc6954..a5ee149f 100644
--- a/.drone/drone.jsonnet
+++ b/.drone/drone.jsonnet
@@ -26,7 +26,16 @@ local go(name, commands) = {
   commands: commands,
 };
 
-local make(target) = go(target, ['make ' + target]);
+local make(target) = go(target, [
+  // Only download it once, then for every step, copy it to the right place.
+  'if [ ! -f linux-amd64/helm ]; then',
+  '  wget -q https://get.helm.sh/helm-v3.9.0-linux-amd64.tar.gz',
+  '  tar -zxvf helm-v3.9.0-linux-amd64.tar.gz',
+  '  rm -f helm-v3.9.0-linux-amd64.tar.gz',
+  'fi',
+  'cp linux-amd64/helm /usr/local/bin/helm',
+  'make ' + target,
+]);
 
 local pipeline(name) = {
   kind: 'pipeline',
@@ -61,9 +70,9 @@ local docker(arch) = pipeline('docker-' + arch) {
   pipeline('check') {
     steps: [
       go('download', ['go mod download']),
-      make('lint') { depends_on: ['download'] },
-      make('test') { depends_on: ['download'] },
-      make('cross') { name: 'build', depends_on: ['download'] },
+      make('lint'),
+      make('test'),
+      make('cross') { name: 'build' },
     ],
   } + constraints.always,
 
diff --git a/.drone/drone.yml b/.drone/drone.yml
index 2b60b66a..4c3af54e 100644
--- a/.drone/drone.yml
+++ b/.drone/drone.yml
@@ -10,27 +10,39 @@ steps:
   - name: gopath
     path: /go
 - commands:
+  - if [ ! -f linux-amd64/helm ]; then
+  - '  wget -q https://get.helm.sh/helm-v3.9.0-linux-amd64.tar.gz'
+  - '  tar -zxvf helm-v3.9.0-linux-amd64.tar.gz'
+  - '  rm -f helm-v3.9.0-linux-amd64.tar.gz'
+  - fi
+  - cp linux-amd64/helm /usr/local/bin/helm
   - make lint
-  depends_on:
-  - download
   image: golang:1.18
   name: lint
   volumes:
   - name: gopath
     path: /go
 - commands:
+  - if [ ! -f linux-amd64/helm ]; then
+  - '  wget -q https://get.helm.sh/helm-v3.9.0-linux-amd64.tar.gz'
+  - '  tar -zxvf helm-v3.9.0-linux-amd64.tar.gz'
+  - '  rm -f helm-v3.9.0-linux-amd64.tar.gz'
+  - fi
+  - cp linux-amd64/helm /usr/local/bin/helm
   - make test
-  depends_on:
-  - download
   image: golang:1.18
   name: test
   volumes:
   - name: gopath
     path: /go
 - commands:
+  - if [ ! -f linux-amd64/helm ]; then
+  - '  wget -q https://get.helm.sh/helm-v3.9.0-linux-amd64.tar.gz'
+  - '  tar -zxvf helm-v3.9.0-linux-amd64.tar.gz'
+  - '  rm -f helm-v3.9.0-linux-amd64.tar.gz'
+  - fi
+  - cp linux-amd64/helm /usr/local/bin/helm
   - make cross
-  depends_on:
-  - download
   image: golang:1.18
   name: build
   volumes:
@@ -53,6 +65,12 @@ steps:
   - name: gopath
     path: /go
 - commands:
+  - if [ ! -f linux-amd64/helm ]; then
+  - '  wget -q https://get.helm.sh/helm-v3.9.0-linux-amd64.tar.gz'
+  - '  tar -zxvf helm-v3.9.0-linux-amd64.tar.gz'
+  - '  rm -f helm-v3.9.0-linux-amd64.tar.gz'
+  - fi
+  - cp linux-amd64/helm /usr/local/bin/helm
   - make cross
   image: golang:1.18
   name: cross
@@ -111,6 +129,12 @@ steps:
   - name: gopath
     path: /go
 - commands:
+  - if [ ! -f linux-amd64/helm ]; then
+  - '  wget -q https://get.helm.sh/helm-v3.9.0-linux-amd64.tar.gz'
+  - '  tar -zxvf helm-v3.9.0-linux-amd64.tar.gz'
+  - '  rm -f helm-v3.9.0-linux-amd64.tar.gz'
+  - fi
+  - cp linux-amd64/helm /usr/local/bin/helm
   - make static
   image: golang:1.18
   name: static
@@ -152,6 +176,12 @@ steps:
   - name: gopath
     path: /go
 - commands:
+  - if [ ! -f linux-amd64/helm ]; then
+  - '  wget -q https://get.helm.sh/helm-v3.9.0-linux-amd64.tar.gz'
+  - '  tar -zxvf helm-v3.9.0-linux-amd64.tar.gz'
+  - '  rm -f helm-v3.9.0-linux-amd64.tar.gz'
+  - fi
+  - cp linux-amd64/helm /usr/local/bin/helm
   - make static
   image: golang:1.18
   name: static
@@ -221,6 +251,6 @@ kind: secret
 name: dockerhub_password
 ---
 kind: signature
-hmac: ff2f11c8d385d1b0f6a4f0d3d1a25193119d8ac8c950dc7492dfb1aed156aa45
+hmac: b8c8f657e5cc61d3b58dc956fc329f5af82e4b0b9157d8ea45d2e4e18a4463ab
 
 ...
diff --git a/.github/stale.yml b/.github/stale.yml
deleted file mode 100644
index bd0ff7f1..00000000
--- a/.github/stale.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-# Number of days of inactivity before an issue becomes stale
-daysUntilStale: 30
-# Number of days of inactivity before a stale issue is closed
-daysUntilClose: 7
-# Issues with these labels will never be considered stale
-exemptLabels:
-  - pinned
-  - security
-  - keepalive
-# Label to use when marking an issue as stale
-staleLabel: stale
-# Comment to post when marking an issue as stale. Set to `false` to disable
-markComment: >
-  This issue has been automatically marked as stale because it has not had
-  recent activity. It will be closed if no further activity occurs. Thank you
-  for your contributions.
-# Comment to post when closing a stale issue. Set to `false` to disable
-closeComment: false
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index 4b0d185d..300ac353 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -3,6 +3,7 @@ package main
 import (
 	"encoding/json"
 	"fmt"
+	"k8s.io/utils/strings/slices"
 	"os"
 	"path/filepath"
 	"sort"
@@ -87,6 +88,10 @@ func envSetCmd() *cli.Command {
 			fmt.Printf("updated spec.apiServer (`%s` -> `%s`)\n", cfg.Spec.APIServer, tmp.Spec.APIServer)
 			cfg.Spec.APIServer = tmp.Spec.APIServer
 		}
+		if tmp.Spec.ContextNames != nil && !slices.Equal(tmp.Spec.ContextNames, cfg.Spec.ContextNames) {
+			fmt.Printf("updated spec.contextNames (`%v` -> `%v`)\n", cfg.Spec.ContextNames, tmp.Spec.ContextNames)
+			cfg.Spec.ContextNames = tmp.Spec.ContextNames
+		}
 		if tmp.Spec.Namespace != "" && tmp.Spec.Namespace != cfg.Spec.Namespace {
 			fmt.Printf("updated spec.namespace (`%s` -> `%s`)\n", cfg.Spec.Namespace, tmp.Spec.Namespace)
 			cfg.Spec.Namespace = tmp.Spec.Namespace
@@ -100,6 +105,12 @@ func envSetCmd() *cli.Command {
 			cfg.Spec.InjectLabels = tmp.Spec.InjectLabels
 		}
 
+		// This ensures the environment is valid before setting it
+		l := tanka.LoadResult{Env: cfg}
+		if _, err := l.Connect(); err != nil {
+			return err
+		}
+
 		if err := writeJSON(cfg, filepath.Join(path, "spec.json")); err != nil {
 			return err
 		}
@@ -127,6 +138,13 @@ func envAddCmd() *cli.Command {
 			}
 			cfg.Spec.APIServer = server
 		}
+		// This ensures the environment is valid before adding it
+		if cmd.Flags().Changed("server-from-context") || cmd.Flags().Changed("context-name") {
+			l := tanka.LoadResult{Env: cfg}
+			if _, err := l.Connect(); err != nil {
+				return err
+			}
+		}
 
 		if err := addEnv(args[0], cfg, *inline); err != nil {
 			return err
diff --git a/cmd/tk/flags.go b/cmd/tk/flags.go
index 8b4e4b91..d9fa63ee 100644
--- a/cmd/tk/flags.go
+++ b/cmd/tk/flags.go
@@ -90,6 +90,7 @@ func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[stri
 func envSettingsFlags(env *v1alpha1.Environment, fs *pflag.FlagSet) {
 	fs.StringVar(&env.Spec.APIServer, "server", env.Spec.APIServer, "endpoint of the Kubernetes API")
 	fs.StringVar(&env.Spec.APIServer, "server-from-context", env.Spec.APIServer, "set the server to a known one from $KUBECONFIG")
+	fs.StringSliceVar(&env.Spec.ContextNames, "context-name", env.Spec.ContextNames, "valid context name for environment, can pass multiple, regex supported.")
 	fs.StringVar(&env.Spec.Namespace, "namespace", env.Spec.Namespace, "namespace to create objects in")
 	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
 	fs.BoolVar(&env.Spec.InjectLabels, "inject-labels", env.Spec.InjectLabels, "add tanka environment label to each created resource. Required for 'tk prune'.")
diff --git a/cmd/tk/toolCharts.go b/cmd/tk/toolCharts.go
index f8fc7d08..4b18892a 100644
--- a/cmd/tk/toolCharts.go
+++ b/cmd/tk/toolCharts.go
@@ -33,6 +33,7 @@ func chartsVendorCmd() *cli.Command {
 		Use:   "vendor",
 		Short: "Download Charts to a local folder",
 	}
+	prune := cmd.Flags().Bool("prune", false, "also remove non-vendored files from the destination directory")
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		c, err := loadChartfile()
@@ -40,7 +41,7 @@ func chartsVendorCmd() *cli.Command {
 			return err
 		}
 
-		return c.Vendor()
+		return c.Vendor(*prune)
 	}
 
 	return cmd
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 07216492..125ec02b 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -35,7 +35,7 @@ func applyCmd() *cli.Command {
 		Short: "apply the configuration to the cluster",
 		Args:  workflowArgs,
 		Predictors: complete.Flags{
-			"diff-strategy":  cli.PredictSet("native", "subset", "validate", "server"),
+			"diff-strategy":  cli.PredictSet("native", "subset", "validate", "server", "none"),
 			"apply-strategy": cli.PredictSet("client", "server"),
 		},
 	}
diff --git a/docs/docs/diff-strategy.md b/docs/docs/diff-strategy.md
index 2c07e7df..952afba0 100644
--- a/docs/docs/diff-strategy.md
+++ b/docs/docs/diff-strategy.md
@@ -75,3 +75,10 @@ runtime, which we cannot know of on the client side. To produce a somewhat
 usable output, we can effectively only compare what we already know about.
 
 If this is a problem for you, consider switching to [native](#native) mode.
+
+## External diff utilities
+
+You can use external diff utilities by setting the environment variable
+`KUBECTL_EXTERNAL_DIFF`. If you want to use a GUI or interactive diff utility
+you must also set `KUBECTL_INTERACTIVE_DIFF=1` to prevent Tanka from capturing
+stdout.
diff --git a/docs/docs/env-vars.md b/docs/docs/env-vars.md
index 27545ded..5a6f6828 100644
--- a/docs/docs/env-vars.md
+++ b/docs/docs/env-vars.md
@@ -30,3 +30,8 @@ menu: "References"
 
 **Description**: Path to the `kustomize` executable  
 **Default**: `$PATH/kustomize`
+
+### PAGER
+
+**Description**: Pager to use when displaying output. Set to an empty string to disable paging.
+**Default**: `less --RAW-CONTROL-CHARS --quit-if-one-screen --no-init`
diff --git a/docs/docs/tutorial/k-lib.mdx b/docs/docs/tutorial/k-lib.mdx
index 2f65e01b..5b1372bb 100644
--- a/docs/docs/tutorial/k-lib.mdx
+++ b/docs/docs/tutorial/k-lib.mdx
@@ -129,7 +129,7 @@ local k = import "github.com/grafana/jsonnet-libs/ksonnet-util/kausal.libsonnet"
 
     // instead of using a service constructor, our wrapper provides
     // a handy helper to automatically generate a service for a Deployment
-    service: $.util.serviceFor(self.deployment)
+    service: k.util.serv.util.serviceFor(self.deployment)
              + service.mixin.spec.withType("NodePort"),
   }
 }
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index 5a007532..12ba0724 100644
--- a/pkg/helm/charts.go
+++ b/pkg/helm/charts.go
@@ -69,7 +69,8 @@ type Charts struct {
 
 // chartManifest represents a Helm chart's Chart.yaml
 type chartManifest struct {
-	Version string `yaml:"version"`
+	Name    string         `yaml:"name"`
+	Version semver.Version `yaml:"version"`
 }
 
 // ChartDir returns the directory pulled charts are saved in
@@ -84,17 +85,29 @@ func (c Charts) ManifestFile() string {
 
 // Vendor pulls all Charts specified in the manifest into the local charts
 // directory. It fetches the repository index before doing so.
-func (c Charts) Vendor() error {
+func (c Charts) Vendor(prune bool) error {
 	dir := c.ChartDir()
 	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
 		return err
 	}
 
+	// Check that there are no output conflicts before vendoring
+	if err := c.Manifest.Requires.CheckForOutputConflicts(); err != nil {
+		return err
+	}
+
+	expectedDirs := make(map[string]bool)
+
 	repositoriesUpdated := false
 	log.Println("Pulling Charts ...")
 	for _, r := range c.Manifest.Requires {
 		chartName := parseReqName(r.Chart)
 		chartPath := filepath.Join(dir, chartName)
+		expectedDirs[chartName] = true
+
+		if r.Directory != "" {
+			chartPath = filepath.Join(dir, r.Directory)
+		}
 
 		_, err := os.Stat(chartPath)
 		if err == nil {
@@ -108,11 +121,11 @@ func (c Charts) Vendor() error {
 				return fmt.Errorf("unmarshalling chart manifest: %w", err)
 			}
 
-			if chartYAML.Version == r.Version.String() {
-				log.Printf(" %s@%s exists", r.Chart, r.Version.String())
+			if chartYAML.Version.String() == r.Version.String() {
+				log.Printf(" %s exists", r)
 				continue
 			} else {
-				log.Printf("Removing %s@%s", r.Chart, r.Version.String())
+				log.Printf("Removing %s", r)
 				if err := os.RemoveAll(chartPath); err != nil {
 					return err
 				}
@@ -129,8 +142,9 @@ func (c Charts) Vendor() error {
 			repositoriesUpdated = true
 		}
 		err = c.Helm.Pull(r.Chart, r.Version.String(), PullOpts{
-			Destination: dir,
-			Opts:        Opts{Repositories: c.Manifest.Repositories},
+			Destination:      dir,
+			ExtractDirectory: r.Directory,
+			Opts:             Opts{Repositories: c.Manifest.Repositories},
 		})
 		if err != nil {
 			return err
@@ -139,6 +153,25 @@ func (c Charts) Vendor() error {
 		log.Printf(" %s@%s downloaded", r.Chart, r.Version.String())
 	}
 
+	if prune {
+		items, err := os.ReadDir(dir)
+		if err != nil {
+			return fmt.Errorf("error listing the content of the charts dir: %w", err)
+		}
+		for _, i := range items {
+			if !expectedDirs[i.Name()] {
+				itemType := "file"
+				if i.IsDir() {
+					itemType = "directory"
+				}
+				log.Printf("Pruning %s: %s", itemType, i.Name())
+				if err := os.RemoveAll(filepath.Join(dir, i.Name())); err != nil {
+					return err
+				}
+			}
+		}
+	}
+
 	return nil
 }
 
@@ -148,7 +181,7 @@ func (c *Charts) Add(reqs []string) error {
 	log.Printf("Adding %v Charts ...", len(reqs))
 
 	// parse new charts, append in memory
-	added := 0
+	requirements := c.Manifest.Requires
 	for _, s := range reqs {
 		r, err := parseReq(s)
 		if err != nil {
@@ -156,17 +189,22 @@ func (c *Charts) Add(reqs []string) error {
 			continue
 		}
 
-		if c.Manifest.Requires.Has(*r) {
+		if requirements.Has(*r) {
 			skip(s, fmt.Errorf("already exists"))
 			continue
 		}
 
-		c.Manifest.Requires = append(c.Manifest.Requires, *r)
-		added++
+		requirements = append(requirements, *r)
 		log.Println(" OK:", s)
 	}
 
+	if err := requirements.CheckForOutputConflicts(); err != nil {
+		return err
+	}
+
 	// write out
+	added := len(requirements) - len(c.Manifest.Requires)
+	c.Manifest.Requires = requirements
 	if err := write(c.Manifest, c.ManifestFile()); err != nil {
 		return err
 	}
@@ -178,7 +216,7 @@ func (c *Charts) Add(reqs []string) error {
 
 	// worked fine? vendor it
 	log.Printf("Added %v Charts to helmfile.yaml. Vendoring ...", added)
-	return c.Vendor()
+	return c.Vendor(false)
 }
 
 func (c *Charts) AddRepos(repos ...Repo) error {
@@ -233,15 +271,23 @@ func write(c Chartfile, dest string) error {
 	return os.WriteFile(dest, data, 0644)
 }
 
-var chartExp = regexp.MustCompile(`\w+\/.+@.+`)
+// https://regex101.com/r/VAklNg/1
+var chartExp = regexp.MustCompile(`\w+\/.+@.+(\:.+)?`)
 
 // parseReq parses a requirement from a string of the format `repo/name@version`
 func parseReq(s string) (*Requirement, error) {
 	if !chartExp.MatchString(s) {
-		return nil, fmt.Errorf("not of form 'repo/chart@version'")
+		return nil, fmt.Errorf("not of form 'repo/chart@version(:path)'")
+	}
+
+	elems := strings.Split(s, ":")
+	directory := ""
+	if len(elems) > 1 {
+		s = elems[0]
+		directory = elems[1]
 	}
 
-	elems := strings.Split(s, "@")
+	elems = strings.Split(s, "@")
 	chart := elems[0]
 	ver, err := semver.NewVersion(elems[1])
 	if errors.Is(err, semver.ErrInvalidSemVer) {
@@ -251,8 +297,9 @@ func parseReq(s string) (*Requirement, error) {
 	}
 
 	return &Requirement{
-		Chart:   chart,
-		Version: *ver,
+		Chart:     chart,
+		Version:   *ver,
+		Directory: directory,
 	}, nil
 }
 
diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go
index db5b54a7..719b9332 100644
--- a/pkg/helm/helm.go
+++ b/pkg/helm/helm.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"path/filepath"
 
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
@@ -28,6 +29,9 @@ type PullOpts struct {
 
 	// Directory to put the resulting .tgz into
 	Destination string
+
+	// Where to extract the chart to, defaults to the name of the chart
+	ExtractDirectory string
 }
 
 // Opts are additional, non-required options that all Helm operations accept
@@ -46,14 +50,35 @@ func (e ExecHelm) Pull(chart, version string, opts PullOpts) error {
 	}
 	defer os.Remove(repoFile)
 
+	tempDir, err := os.MkdirTemp("", "charts-pull")
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(tempDir)
+
 	cmd := e.cmd("pull", chart,
 		"--version", version,
-		"--destination", opts.Destination,
 		"--repository-config", repoFile,
+		"--destination", tempDir,
 		"--untar",
 	)
 
-	return cmd.Run()
+	if err = cmd.Run(); err != nil {
+		return err
+	}
+
+	chartName := parseReqName(chart)
+
+	if opts.ExtractDirectory == "" {
+		opts.ExtractDirectory = chartName
+	}
+
+	// It is not possible to tell `helm pull` to extract to a specific directory
+	// so we extract to a temp dir and then move the files to the destination
+	return os.Rename(
+		filepath.Join(tempDir, chartName),
+		filepath.Join(opts.Destination, opts.ExtractDirectory),
+	)
 }
 
 // RepoUpdate implements Helm.RepoUpdate
diff --git a/pkg/helm/spec.go b/pkg/helm/spec.go
index ab1feabc..563b9680 100644
--- a/pkg/helm/spec.go
+++ b/pkg/helm/spec.go
@@ -1,6 +1,9 @@
 package helm
 
 import (
+	"fmt"
+	"strings"
+
 	"github.com/Masterminds/semver"
 )
 
@@ -59,8 +62,17 @@ func (r Repos) Has(repo Repo) bool {
 // Requirement describes a single required Helm Chart.
 // Both, Chart and Version are required
 type Requirement struct {
-	Chart   string         `json:"chart"`
-	Version semver.Version `json:"version"`
+	Chart     string         `json:"chart"`
+	Version   semver.Version `json:"version"`
+	Directory string         `json:"directory,omitempty"`
+}
+
+func (r Requirement) String() string {
+	dir := r.Directory
+	if dir == "" {
+		dir = parseReqName(r.Chart)
+	}
+	return fmt.Sprintf("%s@%s (dir: %s)", r.Chart, r.Version.String(), dir)
 }
 
 // Requirements is an aggregate of all required Charts
@@ -76,3 +88,25 @@ func (r Requirements) Has(req Requirement) bool {
 
 	return false
 }
+
+func (r Requirements) CheckForOutputConflicts() error {
+	outputDirs := make(map[string]Requirement)
+	errs := make([]string, 0)
+
+	for _, req := range r {
+		dir := req.Directory
+		if dir == "" {
+			dir = parseReqName(req.Chart)
+		}
+		if previous, ok := outputDirs[dir]; ok {
+			errs = append(errs, fmt.Sprintf(`output directory %q is used twice, by charts "%s@%s" and "%s@%s"`, dir, previous.Chart, previous.Version.String(), req.Chart, req.Version.String()))
+		}
+		outputDirs[dir] = req
+	}
+
+	if len(errs) > 0 {
+		return fmt.Errorf("Output directory conflicts found:\n - " + strings.Join(errs, "\n - "))
+	}
+
+	return nil
+}
diff --git a/pkg/kubernetes/client/diff.go b/pkg/kubernetes/client/diff.go
index edfb3d78..9b6e23e7 100644
--- a/pkg/kubernetes/client/diff.go
+++ b/pkg/kubernetes/client/diff.go
@@ -2,6 +2,7 @@ package client
 
 import (
 	"bytes"
+	"os"
 	"os/exec"
 	"regexp"
 	"strings"
@@ -43,7 +44,12 @@ func (k Kubectl) diff(data manifest.List, serverSide bool) (*string, error) {
 	cmd := k.ctl("diff", args...)
 
 	raw := bytes.Buffer{}
-	cmd.Stdout = &raw
+	// If using an external diff tool, let it keep the parent's stdout
+	if os.Getenv("KUBECTL_INTERACTIVE_DIFF") != "" {
+		cmd.Stdout = os.Stdout
+	} else {
+		cmd.Stdout = &raw
+	}
 	cmd.Stderr = &fw
 	cmd.Stdin = strings.NewReader(data.String())
 	err := cmd.Run()
diff --git a/pkg/spec/v1alpha1/environment.go b/pkg/spec/v1alpha1/environment.go
index 55f0713e..a477e974 100644
--- a/pkg/spec/v1alpha1/environment.go
+++ b/pkg/spec/v1alpha1/environment.go
@@ -57,8 +57,8 @@ func (m Metadata) NameLabel() string {
 
 // Spec defines Kubernetes properties
 type Spec struct {
-	APIServer        string           `json:"apiServer"`
-	ContextNames     []string         `json:"contextNames"`
+	APIServer        string           `json:"apiServer,omitempty"`
+	ContextNames     []string         `json:"contextNames,omitempty"`
 	Namespace        string           `json:"namespace"`
 	DiffStrategy     string           `json:"diffStrategy,omitempty"`
 	ApplyStrategy    string           `json:"applyStrategy,omitempty"`
diff --git a/pkg/tanka/errors.go b/pkg/tanka/errors.go
index 74443832..6cc1c92b 100644
--- a/pkg/tanka/errors.go
+++ b/pkg/tanka/errors.go
@@ -17,12 +17,17 @@ func (e ErrNoEnv) Error() string {
 
 // ErrMultipleEnvs means that the given jsonnet has multiple Environment objects
 type ErrMultipleEnvs struct {
-	path  string
-	names []string
+	path      string
+	givenName string
+	foundEnvs []string
 }
 
 func (e ErrMultipleEnvs) Error() string {
-	return fmt.Sprintf("found multiple Environments in '%s'. Use `--name` to select a single one: \n - %s", e.path, strings.Join(e.names, "\n - "))
+	if e.givenName != "" {
+		return fmt.Sprintf("found multiple Environments in %q matching %q. Provide a more specific name that matches a single one: \n - %s", e.path, e.givenName, strings.Join(e.foundEnvs, "\n - "))
+	}
+
+	return fmt.Sprintf("found multiple Environments in %q. Use `--name` to select a single one: \n - %s", e.path, strings.Join(e.foundEnvs, "\n - "))
 }
 
 // ErrParallel is an array of errors collected while processing in parallel
diff --git a/pkg/tanka/inline.go b/pkg/tanka/inline.go
index 3e11feb4..e7f6a97b 100644
--- a/pkg/tanka/inline.go
+++ b/pkg/tanka/inline.go
@@ -46,7 +46,7 @@ func (i *InlineLoader) Load(path string, opts LoaderOpts) (*v1alpha1.Environment
 		}
 		if len(envs) > 1 {
 			sort.Strings(names)
-			return nil, ErrMultipleEnvs{path, names}
+			return nil, ErrMultipleEnvs{path, opts.Name, names}
 		}
 	}
 
diff --git a/pkg/tanka/load.go b/pkg/tanka/load.go
index b254a21e..ec033414 100644
--- a/pkg/tanka/load.go
+++ b/pkg/tanka/load.go
@@ -2,6 +2,7 @@ package tanka
 
 import (
 	"fmt"
+	"log"
 	"os"
 	"path/filepath"
 
@@ -41,6 +42,15 @@ func Load(path string, opts Opts) (*LoadResult, error) {
 }
 
 func LoadEnvironment(path string, opts Opts) (*v1alpha1.Environment, error) {
+	_, err := os.Stat(path)
+	if os.IsNotExist(err) {
+		log.Printf("Path %q does not exist, trying to use it as an environment name", path)
+		opts.Name = path
+		path = "."
+	} else if err != nil {
+		return nil, err
+	}
+
 	loader, err := DetectLoader(path)
 	if err != nil {
 		return nil, err
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index a31c0040..38cb2b79 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -3,6 +3,7 @@ package tanka
 import (
 	"fmt"
 	"log"
+	"os"
 
 	"github.com/fatih/color"
 
@@ -75,21 +76,26 @@ func Apply(baseDir string, opts ApplyOpts) error {
 	}
 	defer kube.Close()
 
-	// show diff
-	diff, err := kube.Diff(l.Resources, kubernetes.DiffOpts{Strategy: opts.DiffStrategy})
-	switch {
-	case err != nil:
-		// This is not fatal, the diff is not strictly required
-		log.Println("Error diffing:", err)
-	case diff == nil:
-		tmp := "Warning: There are no differences. Your apply may not do anything at all."
-		diff = &tmp
-	}
+	if opts.DiffStrategy != "none" {
+		// show diff
+		diff, err := kube.Diff(l.Resources, kubernetes.DiffOpts{Strategy: opts.DiffStrategy})
+		switch {
+		case err != nil:
+			// This is not fatal, the diff is not strictly required
+			log.Println("Error diffing:", err)
+		case diff == nil:
+			// If using KUBECTL_INTERACTIVE_DIFF, the stdout buffer is always empty
+			if os.Getenv("KUBECTL_INTERACTIVE_DIFF") == "" {
+				tmp := "Warning: There are no differences. Your apply may not do anything at all."
+				diff = &tmp
+			}
+		}
 
-	// in case of non-fatal error diff may be nil
-	if diff != nil {
-		b := term.Colordiff(*diff)
-		fmt.Print(b.String())
+		// in case of non-fatal error diff may be nil
+		if diff != nil {
+			b := term.Colordiff(*diff)
+			fmt.Print(b.String())
+		}
 	}
 
 	// prompt for confirmation


Test patch:
diff --git a/pkg/helm/charts_test.go b/pkg/helm/charts_test.go
index c2c43a0c..c8b1d655 100644
--- a/pkg/helm/charts_test.go
+++ b/pkg/helm/charts_test.go
@@ -1,28 +1,15 @@
 package helm
 
 import (
+	"fmt"
+	"os"
 	"path/filepath"
 	"testing"
 
-	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-type fakeHelm struct{}
-
-func (f fakeHelm) Pull(chart, version string, opts PullOpts) error {
-	return nil
-}
-
-func (f fakeHelm) RepoUpdate(opts Opts) error {
-	return nil
-}
-
-func (f fakeHelm) Template(name, chart string, opts TemplateOpts) (manifest.List, error) {
-	return nil, nil
-}
-
 func TestAddRepos(t *testing.T) {
 	c, err := InitChartfile(filepath.Join(t.TempDir(), Filename))
 	require.NoError(t, err)
@@ -40,18 +27,74 @@ func TestAddRepos(t *testing.T) {
 }
 
 func TestAdd(t *testing.T) {
-	c, err := InitChartfile(filepath.Join(t.TempDir(), Filename))
+	tempDir := t.TempDir()
+	c, err := InitChartfile(filepath.Join(tempDir, Filename))
 	require.NoError(t, err)
-	c.Helm = &fakeHelm{}
 
-	err = c.Add([]string{"stable/package@1.0.0"})
+	err = c.Add([]string{"stable/prometheus@11.12.1"})
 	assert.NoError(t, err)
 
 	// Adding again the same chart
-	err = c.Add([]string{"stable/package@1.0.0"})
+	err = c.Add([]string{"stable/prometheus@11.12.1"})
 	assert.EqualError(t, err, "1 Chart(s) were skipped. Please check above logs for details")
 
-	// Update a version
-	err = c.Add([]string{"stable/package@1.1.0"})
+	// Adding a chart with a different version to the same path, causes a conflict
+	err = c.Add([]string{"stable/prometheus@11.12.0"})
+	assert.EqualError(t, err, `Output directory conflicts found:
+ - output directory "prometheus" is used twice, by charts "stable/prometheus@11.12.1" and "stable/prometheus@11.12.0"`)
+
+	// Add a chart with a specific extract directory
+	err = c.Add([]string{"stable/prometheus@11.12.0:prometheus-11.12.0"})
+	assert.NoError(t, err)
+
+	// Check file contents
+	listResult, err := os.ReadDir(filepath.Join(tempDir, "charts"))
 	assert.NoError(t, err)
+	assert.Equal(t, 2, len(listResult))
+	assert.Equal(t, "prometheus", listResult[0].Name())
+	assert.Equal(t, "prometheus-11.12.0", listResult[1].Name())
+
+	chartContent, err := os.ReadFile(filepath.Join(tempDir, "charts", "prometheus", "Chart.yaml"))
+	assert.NoError(t, err)
+	assert.Contains(t, string(chartContent), `version: 11.12.1`)
+
+	chartContent, err = os.ReadFile(filepath.Join(tempDir, "charts", "prometheus-11.12.0", "Chart.yaml"))
+	assert.NoError(t, err)
+	assert.Contains(t, string(chartContent), `version: 11.12.0`)
+}
+
+func TestPrune(t *testing.T) {
+	for _, prune := range []bool{false, true} {
+		t.Run(fmt.Sprintf("%t", prune), func(t *testing.T) {
+			tempDir := t.TempDir()
+			c, err := InitChartfile(filepath.Join(tempDir, Filename))
+			require.NoError(t, err)
+
+			// Add a chart
+			err = c.Add([]string{"stable/prometheus@11.12.1"})
+			require.NoError(t, err)
+
+			// Add unrelated files and folders
+			err = os.WriteFile(filepath.Join(tempDir, "charts", "foo.txt"), []byte("foo"), 0644)
+			err = os.Mkdir(filepath.Join(tempDir, "charts", "foo"), 0755)
+			require.NoError(t, err)
+			err = os.WriteFile(filepath.Join(tempDir, "charts", "foo", "Chart.yaml"), []byte("foo"), 0644)
+			require.NoError(t, err)
+
+			err = c.Vendor(prune)
+			require.NoError(t, err)
+
+			// Check if files are pruned
+			listResult, err := os.ReadDir(filepath.Join(tempDir, "charts"))
+			assert.NoError(t, err)
+			if prune {
+				assert.Equal(t, 1, len(listResult))
+			} else {
+				assert.Equal(t, 3, len(listResult))
+				chartContent, err := os.ReadFile(filepath.Join(tempDir, "charts", "foo", "Chart.yaml"))
+				assert.NoError(t, err)
+				assert.Contains(t, string(chartContent), `foo`)
+			}
+		})
+	}
 }
diff --git a/pkg/tanka/load_test.go b/pkg/tanka/load_test.go
index dc2b59ab..21aec591 100644
--- a/pkg/tanka/load_test.go
+++ b/pkg/tanka/load_test.go
@@ -1,6 +1,7 @@
 package tanka
 
 import (
+	"os"
 	"testing"
 
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
@@ -157,11 +158,11 @@ func TestLoadSelectEnvironment(t *testing.T) {
 
 	// Empty options, match all environments
 	_, err = Load("./testdata/cases/multiple-inline-envs", Opts{})
-	assert.EqualError(t, err, "found multiple Environments in './testdata/cases/multiple-inline-envs'. Use `--name` to select a single one: \n - project1-env1\n - project1-env2\n - project2-env1")
+	assert.EqualError(t, err, "found multiple Environments in \"./testdata/cases/multiple-inline-envs\". Use `--name` to select a single one: \n - project1-env1\n - project1-env2\n - project2-env1")
 
 	// Partial match two environments
 	_, err = Load("./testdata/cases/multiple-inline-envs", Opts{Name: "env1"})
-	assert.EqualError(t, err, "found multiple Environments in './testdata/cases/multiple-inline-envs'. Use `--name` to select a single one: \n - project1-env1\n - project2-env1")
+	assert.EqualError(t, err, "found multiple Environments in \"./testdata/cases/multiple-inline-envs\" matching \"env1\". Provide a more specific name that matches a single one: \n - project1-env1\n - project2-env1")
 
 	// Partial match
 	result, err := Load("./testdata/cases/multiple-inline-envs", Opts{Name: "project2"})
@@ -174,6 +175,30 @@ func TestLoadSelectEnvironment(t *testing.T) {
 	assert.Equal(t, "project1-env1", result.Env.Metadata.Name)
 }
 
+// Tests that the load function will consider the path to be an environment name if it is not found
+func TestLoadEnvironmentFallbackToName(t *testing.T) {
+	// Temporarily change the working directory to the testdata directory
+	cwd, err := os.Getwd()
+	require.NoError(t, err)
+	err = os.Chdir("./testdata/cases/multiple-inline-envs")
+	require.NoError(t, err)
+	defer os.Chdir(cwd)
+
+	// Partial match two environments
+	_, err = Load("env1", Opts{})
+	assert.EqualError(t, err, "found multiple Environments in \".\" matching \"env1\". Provide a more specific name that matches a single one: \n - project1-env1\n - project2-env1")
+
+	// Partial match
+	result, err := Load("project2", Opts{})
+	require.NoError(t, err)
+	assert.Equal(t, "project2-env1", result.Env.Metadata.Name)
+
+	// Full match
+	result, err = Load("project1-env1", Opts{})
+	require.NoError(t, err)
+	assert.Equal(t, "project1-env1", result.Env.Metadata.Name)
+}
+
 func TestLoadSelectEnvironmentFullMatchHasPriority(t *testing.T) {
 	// `base` matches both `base` and `base-and-more`
 	// However, the full match should win