Repository: grafana/tanka
Pull request #766 (2022-09-27 22:14:27)

Use `zerolog` instead of `log`
Exposes a `--log-level` arg on every command
Replaces all `log` uses with either `fmt.Fprint(os.Stderr)` or zerolog calls

Benefits:
- We're currently mixing actual logs (that should have a level/are purely informational) and things that we want to print to stderr (possibly parsed by user). Using zerolog and fmt is more deliberate in meaning
- Logs can be disabled or turned on using the `--log-level` option
- We'll be able to add more verbose logs without potentially annoying people. An example is the "Loading environment" messages that the export command now prints. I shifted that to debug
- Will allow for trace level logs. Ex: https://github.com/grafana/tanka/issues/751, logs from the underlying `kubectl` exec or helm

github-actions[bot]: Benchstat (compared to main):

```
name                                 old time/op    new time/op    delta
pkg:github.com/grafana/tanka/pkg/jsonnet goos:linux goarch:amd64
GetSnippetHash-2                       19.4ms ± 2%    19.4ms ± 1%    ~     (p=0.631 n=10+10)
pkg:github.com/grafana/tanka/pkg/tanka goos:linux goarch:amd64
ExportEnvironmentsWithReplaceEnvs-2    7.09ms ± 1%    7.12ms ± 4%    ~     (p=0.661 n=9+10)

name                                 old alloc/op   new alloc/op   delta
pkg:github.com/grafana/tanka/pkg/jsonnet goos:linux goarch:amd64
GetSnippetHash-2                       3.85MB ± 0%    3.85MB ± 0%    ~     (p=0.631 n=10+10)
pkg:github.com/grafana/tanka/pkg/tanka goos:linux goarch:amd64
ExportEnvironmentsWithReplaceEnvs-2    2.04MB ± 0%    2.04MB ± 0%    ~     (p=0.075 n=10+10)

name                                 old allocs/op  new allocs/op  delta
pkg:github.com/grafana/tanka/pkg/jsonnet goos:linux goarch:amd64
GetSnippetHash-2                        32.3k ± 0%     32.3k ± 0%    ~     (p=0.838 n=10+10)
pkg:github.com/grafana/tanka/pkg/tanka goos:linux goarch:amd64
ExportEnvironmentsWithReplaceEnvs-2     23.5k ± 0%     23.5k ± 0%  -0.06%  (p=0.000 n=10+10)

```

Patch:
diff --git a/.drone/drone.jsonnet b/.drone/drone.jsonnet
index 92d647cf..e736a777 100644
--- a/.drone/drone.jsonnet
+++ b/.drone/drone.jsonnet
@@ -42,27 +42,29 @@ local pipeline(name) = {
   steps: [],
 };
 
-local docker(arch) = pipeline('docker-' + arch) {
-  platform: {
-    os: 'linux',
-    arch: arch,
-  },
-  steps: [
-    go('fetch-tags', ['git fetch origin --tags']),
-    make('static'),
-    {
-      name: 'container',
-      image: 'plugins/docker',
-      settings: {
-        repo: 'grafana/tanka',
-        auto_tag: true,
-        auto_tag_suffix: arch,
-        username: { from_secret: vault.dockerhub_username },
-        password: { from_secret: vault.dockerhub_password },
-      },
+local docker(arch, depends_on=[]) =
+  pipeline('docker-' + arch) {
+    platform: {
+      os: 'linux',
+      arch: arch,
     },
-  ],
-};
+    steps: [
+      go('fetch-tags', ['git fetch origin --tags']),
+      make('static'),
+      {
+        name: 'container',
+        image: 'plugins/docker',
+        settings: {
+          repo: 'grafana/tanka',
+          auto_tag: true,
+          auto_tag_suffix: arch,
+          username: { from_secret: vault.dockerhub_username },
+          password: { from_secret: vault.dockerhub_password },
+        },
+      },
+    ],
+    depends_on: depends_on,
+  };
 
 [
   pipeline('check') {
@@ -92,8 +94,32 @@ local docker(arch) = pipeline('docker-' + arch) {
     ],
   } + { depends_on: ['check'] } + constraints.tags,
 
-  docker('amd64') { depends_on: ['check'] } + constraints.tags + constraints.mainPush,
-  docker('arm64') { depends_on: ['check'] } + constraints.tags + constraints.mainPush,
+  docker('amd64', depends_on=['check']) + constraints.tags + constraints.mainPush,
+  docker('arm64', depends_on=['check']) + constraints.tags + constraints.mainPush,
+
+  pipeline('manifest-main') {
+    steps: [
+      go('fetch-tags', [
+        'git fetch origin --tags',
+        'echo "main-$(git describe --tags)" > .tags',
+      ]),
+      {
+        name: 'manifest',
+        image: 'plugins/manifest',
+        settings: {
+          ignore_missing: true,
+          spec: '.drone/docker-manifest.tmpl',
+          username: { from_secret: vault.dockerhub_username },
+          password: { from_secret: vault.dockerhub_password },
+        },
+      },
+    ],
+  } + {
+    depends_on: [
+      'docker-amd64',
+      'docker-arm64',
+    ],
+  } + constraints.mainPush,
 
   pipeline('manifest') {
     steps: [{
diff --git a/.drone/drone.yml b/.drone/drone.yml
index 5ac6f288..bb0b0e73 100644
--- a/.drone/drone.yml
+++ b/.drone/drone.yml
@@ -213,6 +213,36 @@ depends_on:
 - docker-amd64
 - docker-arm64
 kind: pipeline
+name: manifest-main
+steps:
+- commands:
+  - git fetch origin --tags
+  - echo "main-$(git describe --tags)" > .tags
+  image: golang:1.19
+  name: fetch-tags
+  volumes:
+  - name: gopath
+    path: /go
+- image: plugins/manifest
+  name: manifest
+  settings:
+    ignore_missing: true
+    password:
+      from_secret: dockerhub_password
+    spec: .drone/docker-manifest.tmpl
+    username:
+      from_secret: dockerhub_username
+trigger:
+  ref:
+  - refs/heads/main
+volumes:
+- name: gopath
+  temp: {}
+---
+depends_on:
+- docker-amd64
+- docker-arm64
+kind: pipeline
 name: manifest
 steps:
 - image: plugins/manifest
@@ -252,6 +282,6 @@ kind: secret
 name: dockerhub_password
 ---
 kind: signature
-hmac: 9d1c433944d46ded93d8637f570568e8a9e9887d4a4b3c790a25e9c83cd58921
+hmac: adbd6c28d92efbf2968766102c5abe0bbad5a4af1c76e84ada59b2c5cf9c958d
 
 ...
diff --git a/.github/workflows/benchstat-pr.yml b/.github/workflows/benchstat-pr.yml
deleted file mode 100644
index 08871dc7..00000000
--- a/.github/workflows/benchstat-pr.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: benchstat
-
-on: [pull_request]
-
-jobs:
-  benchstat:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Install Go
-        uses: actions/setup-go@v3
-        with:
-          go-version: '1.19'
-
-      # Generate benchmark report for main branch
-      - name: Checkout
-        uses: actions/checkout@v3
-        with:
-          ref: main
-      - name: Benchmark
-        run: go test ./... -count=10 -run="^$" -bench=. -benchmem | tee -a bench.txt
-      - name: Upload Benchmark
-        uses: actions/upload-artifact@v3
-        with:
-          name: bench-current
-          path: bench.txt
-
-      # Generate benchmark report for the PR
-      - name: Checkout
-        uses: actions/checkout@v3
-      - name: Benchmark
-        run: go test ./... -count=10 -run="^$" -bench=. -benchmem | tee -a bench.txt
-      - name: Upload Benchmark
-        uses: actions/upload-artifact@v3
-        with:
-          name: bench-incoming
-          path: bench.txt
-
-      # Compare the two reports
-      - name: Checkout
-        uses: actions/checkout@v3
-      - name: Install benchstat
-        run: go install golang.org/x/perf/cmd/benchstat@latest
-      - name: Download Incoming
-        uses: actions/download-artifact@v3
-        with:
-          name: bench-incoming
-          path: bench-incoming
-      - name: Download Current
-        uses: actions/download-artifact@v3
-        with:
-          name: bench-current
-          path: bench-current
-      - name: Benchstat Results
-        run: benchstat bench-current/bench.txt bench-incoming/bench.txt | tee -a benchstat.txt
-      - name: Upload benchstat results
-        uses: actions/upload-artifact@v3
-        with:
-          name: benchstat
-          path: benchstat.txt
-      - name: Read benchstat.txt
-        id: benchstat_content
-        uses: juliangruber/read-file-action@v1
-        with:
-          path: ./benchstat.txt
-      - name: Post PR Comment
-        uses: thollander/actions-comment-pull-request@v1
-        with:
-          message: |
-            Benchstat (compared to main):
-            
-            ```
-            ${{ steps.benchstat_content.outputs.content }}
-            ```
-          comment_includes: 'Benchstat (compared to main):'
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59c9ee34..a93b2464 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## 0.23.1 (2022-09-28)
+
+### Bug Fixes/Enhancements
+
+- **export**: Fix `getSnippetHash` not considering all files
+  **[#765](https://github.com/grafana/tanka/pull/765)**
+
 ## 0.23.0 (2022-09-26)
 
 ### Features
diff --git a/Makefile b/Makefile
index 0d3df8b0..09888112 100644
--- a/Makefile
+++ b/Makefile
@@ -12,7 +12,7 @@ lint: $(GOLINTER)
 	$(GOLINTER) run
 
 test:
-	go test ./...
+	go test ./... -bench=. -benchmem
 
 # Compilation
 dev:
diff --git a/cmd/tk/flags.go b/cmd/tk/flags.go
index fbf7e4fc..a9384a74 100644
--- a/cmd/tk/flags.go
+++ b/cmd/tk/flags.go
@@ -2,9 +2,9 @@ package main
 
 import (
 	"fmt"
-	"log"
 	"strings"
 
+	"github.com/rs/zerolog/log"
 	"github.com/spf13/pflag"
 	"k8s.io/apimachinery/pkg/labels"
 
@@ -31,7 +31,7 @@ func addApplyFlags(fs *pflag.FlagSet, opts *tanka.ApplyBaseOpts, autoApproveDepr
 	// Parse the auto-approve flag (choice), still supporting the deprecated dangerous-auto-approve flag (boolean)
 	fs.BoolVar(autoApproveDeprecated, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
 	if err := fs.MarkDeprecated("dangerous-auto-approve", "use --auto-approve instead"); err != nil {
-		log.Fatalf("failed to mark deprecated flag: %s", err)
+		log.Fatal().Msgf("failed to mark deprecated flag: %s", err)
 	}
 	fs.StringVar(autoApprove, "auto-approve", "", "skip interactive approval. Only for automation! Allowed values: 'always', 'never', 'if-no-changes'")
 }
@@ -43,7 +43,7 @@ func labelSelectorFlag(fs *pflag.FlagSet) func() labels.Selector {
 		if *labelSelector != "" {
 			selector, err := labels.Parse(*labelSelector)
 			if err != nil {
-				log.Fatalf("Could not parse selector (-l) %s", *labelSelector)
+				log.Fatal().Msgf("Could not parse selector (-l) %s", *labelSelector)
 			}
 			return selector
 		}
@@ -79,7 +79,7 @@ func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[stri
 			for _, s := range *code {
 				split := strings.SplitN(s, "=", 2)
 				if len(split) != 2 {
-					log.Fatalf(kind+"-code argument has wrong format: `%s`. Expected `key=<code>`", s)
+					log.Fatal().Msgf(kind+"-code argument has wrong format: `%s`. Expected `key=<code>`", s)
 				}
 				m[split[0]] = split[1]
 			}
@@ -87,7 +87,7 @@ func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[stri
 			for _, s := range *str {
 				split := strings.SplitN(s, "=", 2)
 				if len(split) != 2 {
-					log.Fatalf(kind+"-str argument has wrong format: `%s`. Expected `key=<value>`", s)
+					log.Fatal().Msgf(kind+"-str argument has wrong format: `%s`. Expected `key=<value>`", s)
 				}
 				m[split[0]] = fmt.Sprintf(`"%s"`, split[1])
 			}
diff --git a/cmd/tk/fmt.go b/cmd/tk/fmt.go
index 4b4ed926..dbfa7180 100644
--- a/cmd/tk/fmt.go
+++ b/cmd/tk/fmt.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"log"
 	"os"
 
 	"github.com/go-clix/cli"
@@ -58,7 +57,7 @@ func fmtCmd() *cli.Command {
 		case *stdout:
 			outFn = func(name, content string) error {
 				fmt.Printf("// %s\n%s", name, content)
-				log.Println() // some spacing
+				fmt.Fprintln(os.Stderr) // some spacing
 				return nil
 			}
 		}
@@ -75,20 +74,20 @@ func fmtCmd() *cli.Command {
 		}
 
 		if *verbose {
-			log.Println()
+			fmt.Fprintln(os.Stderr)
 		}
 
 		switch {
 		case *test && len(changed) > 0:
-			log.Println("The following files are not properly formatted:")
+			fmt.Fprintln(os.Stderr, "The following files are not properly formatted:")
 			for _, s := range changed {
-				log.Println(s)
+				fmt.Fprintln(os.Stderr, s)
 			}
 			os.Exit(ExitStatusDiff)
 		case len(changed) == 0:
-			log.Println("All discovered files are already formatted. No changes were made")
+			fmt.Fprintln(os.Stderr, "All discovered files are already formatted. No changes were made")
 		case len(changed) > 0:
-			log.Printf("Formatted %v files", len(changed))
+			fmt.Fprintf(os.Stderr, "Formatted %v files", len(changed))
 		}
 
 		return nil
diff --git a/cmd/tk/init.go b/cmd/tk/init.go
index 7f903061..32a5f3d9 100644
--- a/cmd/tk/init.go
+++ b/cmd/tk/init.go
@@ -3,7 +3,6 @@ package main
 import (
 	"errors"
 	"fmt"
-	"log"
 	"os"
 	"os/exec"
 	"strconv"
@@ -68,7 +67,7 @@ func initCmd() *cli.Command {
 		if doInstall {
 			if err := installK8sLib(version); err != nil {
 				// This is not fatal, as most of Tanka will work anyways
-				log.Println("Installing k.libsonnet:", err)
+				fmt.Println("Installing k.libsonnet:", err)
 				failed = true
 			}
 		}
@@ -79,7 +78,7 @@ func initCmd() *cli.Command {
 			fmt.Println("Directory structure set up! Remember to configure the API endpoint:\n`tk env set environments/default --server=https://127.0.0.1:6443`")
 		}
 		if failed {
-			log.Println("Errors occurred while initializing the project. Check the above logs for details.")
+			fmt.Println("Errors occurred while initializing the project. Check the above logs for details.")
 		}
 
 		return nil
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index af9280af..d88c01c8 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -1,11 +1,12 @@
 package main
 
 import (
-	"log"
+	"fmt"
 	"os"
+	"strings"
 
-	"github.com/fatih/color"
 	"github.com/go-clix/cli"
+	"github.com/rs/zerolog"
 	"golang.org/x/term"
 
 	"github.com/grafana/tanka/pkg/tanka"
@@ -14,16 +15,33 @@ import (
 var interactive = term.IsTerminal(int(os.Stdout.Fd()))
 
 func main() {
-	log.SetFlags(0)
-
 	rootCmd := &cli.Command{
 		Use:     "tk",
 		Short:   "tanka <3 jsonnet",
 		Version: tanka.CurrentVersion,
 	}
 
+	addCommandsWithLogLevel := func(cmds ...*cli.Command) {
+		for _, cmd := range cmds {
+			levels := []string{zerolog.Disabled.String(), zerolog.FatalLevel.String(), zerolog.ErrorLevel.String(), zerolog.WarnLevel.String(), zerolog.InfoLevel.String(), zerolog.DebugLevel.String(), zerolog.TraceLevel.String()}
+			cmd.Flags().String("log-level", zerolog.InfoLevel.String(), "possible values: "+strings.Join(levels, ", "))
+
+			cmdRun := cmd.Run
+			cmd.Run = func(cmd *cli.Command, args []string) error {
+				level, err := zerolog.ParseLevel(cmd.Flags().Lookup("log-level").Value.String())
+				if err != nil {
+					return err
+				}
+				zerolog.SetGlobalLevel(level)
+
+				return cmdRun(cmd, args)
+			}
+			rootCmd.AddCommand(cmd)
+		}
+	}
+
 	// workflow commands
-	rootCmd.AddCommand(
+	addCommandsWithLogLevel(
 		applyCmd(),
 		showCmd(),
 		diffCmd(),
@@ -31,14 +49,14 @@ func main() {
 		deleteCmd(),
 	)
 
-	rootCmd.AddCommand(
+	addCommandsWithLogLevel(
 		envCmd(),
 		statusCmd(),
 		exportCmd(),
 	)
 
 	// jsonnet commands
-	rootCmd.AddCommand(
+	addCommandsWithLogLevel(
 		fmtCmd(),
 		lintCmd(),
 		evalCmd(),
@@ -47,12 +65,12 @@ func main() {
 	)
 
 	// external commands prefixed with "tk-"
-	rootCmd.AddCommand(
+	addCommandsWithLogLevel(
 		prefixCommands("tk-")...,
 	)
 
 	// Run!
 	if err := rootCmd.Execute(); err != nil {
-		log.Fatalln(color.RedString("Error:"), err)
+		fmt.Fprintln(os.Stderr, "Error:", err)
 	}
 }
diff --git a/cmd/tk/tool.go b/cmd/tk/tool.go
index bf0fb93f..7e19bf0c 100644
--- a/cmd/tk/tool.go
+++ b/cmd/tk/tool.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
-	"log"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -53,10 +52,10 @@ func jpathCmd() *cli.Command {
 
 		if *debug {
 			// log to debug info to stderr
-			log.Println("main:", entrypoint)
-			log.Println("rootDir:", root)
-			log.Println("baseDir:", base)
-			log.Println("jpath:", jsonnetpath)
+			fmt.Fprintln(os.Stderr, "main:", entrypoint)
+			fmt.Fprintln(os.Stderr, "rootDir:", root)
+			fmt.Fprintln(os.Stderr, "baseDir:", base)
+			fmt.Fprintln(os.Stderr, "jpath:", jsonnetpath)
 		}
 
 		// print export JSONNET_PATH to stdout
diff --git a/cmd/tk/toolCharts.go b/cmd/tk/toolCharts.go
index 58ea7f84..ad8a4da8 100644
--- a/cmd/tk/toolCharts.go
+++ b/cmd/tk/toolCharts.go
@@ -2,7 +2,6 @@ package main
 
 import (
 	"fmt"
-	"log"
 	"os"
 	"path/filepath"
 
@@ -133,7 +132,7 @@ func chartsInitCmd() *cli.Command {
 			return err
 		}
 
-		log.Printf("Success! New Chartfile created at '%s'", path)
+		fmt.Fprintf(os.Stderr, "Success! New Chartfile created at '%s'", path)
 		return nil
 	}
 
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index abc7dcbe..cf3db63b 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -2,7 +2,6 @@ package main
 
 import (
 	"fmt"
-	"log"
 	"os"
 
 	"github.com/go-clix/cli"
@@ -203,7 +202,7 @@ func diffCmd() *cli.Command {
 		}
 
 		if changes == nil {
-			log.Println("No differences.")
+			fmt.Fprintln(os.Stderr, "No differences.")
 			os.Exit(ExitStatusClean)
 		}
 
diff --git a/go.mod b/go.mod
index 2f14343b..6bc69a48 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
 	github.com/karrick/godirwalk v1.16.1
 	github.com/pkg/errors v0.9.1
 	github.com/posener/complete v1.2.3
+	github.com/rs/zerolog v1.28.0
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/objx v0.3.0
 	github.com/stretchr/testify v1.7.1
diff --git a/go.sum b/go.sum
index 8253e9fb..79c33448 100644
--- a/go.sum
+++ b/go.sum
@@ -14,6 +14,7 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
 github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -46,6 +47,7 @@ github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL9
 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -144,6 +146,9 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
+github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index 0aef446c..c7d561d3 100644
--- a/pkg/helm/charts.go
+++ b/pkg/helm/charts.go
@@ -3,13 +3,13 @@ package helm
 import (
 	"errors"
 	"fmt"
-	"log"
 	"os"
 	"path/filepath"
 	"regexp"
 	"strings"
 
 	"github.com/Masterminds/semver"
+	"github.com/rs/zerolog/log"
 	"sigs.k8s.io/yaml"
 )
 
@@ -105,7 +105,7 @@ func (c Charts) Vendor(prune bool) error {
 	expectedDirs := make(map[string]bool)
 
 	repositoriesUpdated := false
-	log.Println("Vendoring...")
+	log.Info().Msg("Vendoring...")
 	for _, r := range c.Manifest.Requires {
 		chartSubDir := parseReqName(r.Chart)
 		if r.Directory != "" {
@@ -138,30 +138,30 @@ func (c Charts) Vendor(prune bool) error {
 			}
 
 			if chartYAML.Version.String() == r.Version.String() {
-				log.Printf(" %s exists", r)
+				log.Info().Msgf(" %s exists", r)
 				continue
 			} else {
-				log.Printf("Removing %s", r)
+				log.Info().Msgf("Removing %s", r)
 				if err := os.RemoveAll(chartPath); err != nil {
 					return err
 				}
 			}
 		} else if chartDirExists {
 			// If the chart dir exists but the manifest doesn't, we'll clear it out and re-download the chart
-			log.Printf("Removing %s", r)
+			log.Info().Msgf("Removing %s", r)
 			if err := os.RemoveAll(chartPath); err != nil {
 				return err
 			}
 		}
 
 		if !repositoriesUpdated {
-			log.Println("Syncing Repositories ...")
+			log.Info().Msg("Syncing Repositories ...")
 			if err := c.Helm.RepoUpdate(Opts{Repositories: c.Manifest.Repositories}); err != nil {
 				return err
 			}
 			repositoriesUpdated = true
 		}
-		log.Println("Pulling Charts ...")
+		log.Info().Msg("Pulling Charts ...")
 		if repoName := parseReqRepo(r.Chart); !c.Manifest.Repositories.HasName(repoName) {
 			return fmt.Errorf("repository %q not found for chart %q", repoName, r.Chart)
 		}
@@ -174,7 +174,7 @@ func (c Charts) Vendor(prune bool) error {
 			return err
 		}
 
-		log.Printf(" %s@%s downloaded", r.Chart, r.Version.String())
+		log.Info().Msgf(" %s@%s downloaded", r.Chart, r.Version.String())
 	}
 
 	if prune {
@@ -188,7 +188,7 @@ func (c Charts) Vendor(prune bool) error {
 				if i.IsDir() {
 					itemType = "directory"
 				}
-				log.Printf("Pruning %s: %s", itemType, i.Name())
+				log.Info().Msgf("Pruning %s: %s", itemType, i.Name())
 				if err := os.RemoveAll(filepath.Join(dir, i.Name())); err != nil {
 					return err
 				}
@@ -219,7 +219,7 @@ func (c *Charts) Add(reqs []string) error {
 		}
 
 		requirements = append(requirements, *r)
-		log.Println(" OK:", s)
+		log.Info().Msgf(" OK: %s", s)
 	}
 
 	if err := requirements.Validate(); err != nil {
@@ -258,7 +258,7 @@ func (c *Charts) AddRepos(repos ...Repo) error {
 
 		c.Manifest.Repositories = append(c.Manifest.Repositories, r)
 		added++
-		log.Println(" OK:", r.Name)
+		log.Info().Msgf(" OK: %s", r.Name)
 	}
 
 	// write out
diff --git a/pkg/jsonnet/imports.go b/pkg/jsonnet/imports.go
index 1c1a59a5..cbe2acb2 100644
--- a/pkg/jsonnet/imports.go
+++ b/pkg/jsonnet/imports.go
@@ -204,7 +204,7 @@ func findImportRecursiveRegexp(list map[string]bool, vm *jsonnet.VM, filename, c
 		}
 
 		if list[abs] {
-			return nil
+			continue
 		}
 		list[abs] = true
 
diff --git a/pkg/tanka/export.go b/pkg/tanka/export.go
index 99c350ea..3ec5fa73 100644
--- a/pkg/tanka/export.go
+++ b/pkg/tanka/export.go
@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"io"
 	"io/fs"
-	"log"
 	"os"
 	"path/filepath"
 	"strings"
@@ -14,6 +13,7 @@ import (
 
 	"github.com/Masterminds/sprig/v3"
 	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
 	"k8s.io/apimachinery/pkg/labels"
 
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
@@ -180,7 +180,7 @@ func deletePreviouslyExportedManifests(path string, envs []*v1alpha1.Environment
 	manifestFilePath := filepath.Join(path, manifestFile)
 	manifestContent, err := os.ReadFile(manifestFilePath)
 	if errors.Is(err, fs.ErrNotExist) {
-		log.Printf("Warning: No manifest file found at %s, skipping deletion of previously exported manifests\n", manifestFilePath)
+		log.Warn().Msgf("No manifest file found at %s, skipping deletion of previously exported manifests\n", manifestFilePath)
 		return nil
 	} else if err != nil {
 		return err
diff --git a/pkg/tanka/load.go b/pkg/tanka/load.go
index fa393b5a..31fd0a92 100644
--- a/pkg/tanka/load.go
+++ b/pkg/tanka/load.go
@@ -2,7 +2,6 @@ package tanka
 
 import (
 	"fmt"
-	"log"
 	"os"
 	"path/filepath"
 
@@ -13,6 +12,7 @@ import (
 	"github.com/grafana/tanka/pkg/spec"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
 )
 
 // environmentExtCode is the extCode ID `tk.env` uses underneath
@@ -44,7 +44,7 @@ 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)
+		log.Info().Msgf("Path %q does not exist, trying to use it as an environment name", path)
 		opts.Name = path
 		path = "."
 	} else if err != nil {
diff --git a/pkg/tanka/parallel.go b/pkg/tanka/parallel.go
index 5aaeb4c0..c07c4a29 100644
--- a/pkg/tanka/parallel.go
+++ b/pkg/tanka/parallel.go
@@ -2,7 +2,6 @@ package tanka
 
 import (
 	"fmt"
-	"log"
 	"path/filepath"
 	"time"
 
@@ -11,6 +10,7 @@ import (
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
 )
 
 const defaultParallelism = 8
@@ -89,7 +89,7 @@ type parallelOut struct {
 
 func parallelWorker(jobsCh <-chan parallelJob, outCh chan parallelOut) {
 	for job := range jobsCh {
-		log.Printf("Loading %s from %s", job.opts.Name, job.path)
+		log.Debug().Str("name", job.opts.Name).Str("path", job.path).Msg("Loading environment")
 		startTime := time.Now()
 
 		env, err := LoadEnvironment(job.path, job.opts)
@@ -98,6 +98,6 @@ func parallelWorker(jobsCh <-chan parallelJob, outCh chan parallelOut) {
 		}
 		outCh <- parallelOut{env: env, err: err}
 
-		log.Printf("Loaded %s from %s, time elapsed: %s", job.opts.Name, job.path, time.Since(startTime))
+		log.Debug().Str("name", job.opts.Name).Str("path", job.path).Dur("time", time.Since(startTime)).Msg("Finished loading environment")
 	}
 }
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
index 34358c70..5cf18f6a 100644
--- a/pkg/tanka/prune.go
+++ b/pkg/tanka/prune.go
@@ -2,7 +2,7 @@ package tanka
 
 import (
 	"fmt"
-	"log"
+	"os"
 
 	"github.com/fatih/color"
 
@@ -36,7 +36,7 @@ func Prune(baseDir string, opts PruneOpts) error {
 	}
 
 	if len(orphaned) == 0 {
-		log.Println("Nothing found to prune.")
+		fmt.Fprintln(os.Stderr, "Nothing found to prune.")
 		return nil
 	}
 
@@ -60,9 +60,9 @@ func Prune(baseDir string, opts PruneOpts) error {
 		warning := color.New(color.FgHiYellow, color.Bold).FprintfFunc()
 		warning(color.Error, "WARNING: This will delete following namespaces and all resources in them:\n")
 		for _, ns := range namespaces {
-			log.Printf(" - %s\n", ns)
+			fmt.Fprintf(os.Stderr, " - %s\n", ns)
 		}
-		log.Println("")
+		fmt.Fprintln(os.Stderr, "")
 	}
 
 	// prompt for confirm
diff --git a/pkg/tanka/static.go b/pkg/tanka/static.go
index 4da2e6dc..f076f943 100644
--- a/pkg/tanka/static.go
+++ b/pkg/tanka/static.go
@@ -2,10 +2,10 @@ package tanka
 
 import (
 	"encoding/json"
-	"log"
 
 	"github.com/grafana/tanka/pkg/spec"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/rs/zerolog/log"
 )
 
 // StaticLoader loads an environment from a static file called `spec.json`.
@@ -88,7 +88,7 @@ func parseStaticSpec(path string) (*v1alpha1.Environment, error) {
 		switch err.(type) {
 		// the config includes deprecated fields
 		case spec.ErrDeprecated:
-			log.Println(err)
+			log.Warn().Err(err).Msg("encountered deprecated fields in spec.json")
 		// spec.json missing. we can still work with the default value
 		case spec.ErrNoSpec:
 			return env, nil
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index 5bf089ec..145448ee 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -2,10 +2,10 @@ package tanka
 
 import (
 	"fmt"
-	"log"
 	"os"
 
 	"github.com/fatih/color"
+	"github.com/rs/zerolog/log"
 
 	"github.com/grafana/tanka/pkg/kubernetes"
 	"github.com/grafana/tanka/pkg/kubernetes/client"
@@ -103,7 +103,7 @@ func Apply(baseDir string, opts ApplyOpts) error {
 		switch {
 		case err != nil:
 			// This is not fatal, the diff is not strictly required
-			log.Println("Error diffing:", err)
+			log.Error().Err(err).Msg("error diffing")
 		case diff == nil:
 			noChanges = true
 			// If using KUBECTL_INTERACTIVE_DIFF, the stdout buffer is always empty


Test patch:
diff --git a/pkg/jsonnet/imports_test.go b/pkg/jsonnet/imports_test.go
index fe940c91..7c3b5e73 100644
--- a/pkg/jsonnet/imports_test.go
+++ b/pkg/jsonnet/imports_test.go
@@ -28,81 +28,114 @@ func TestTransitiveImports(t *testing.T) {
 	}, imports)
 }
 
-const testFile = `
-local localImport = <IMPORT>;
-local myFunc = function() <IMPORT>;
+func BenchmarkGetSnippetHash(b *testing.B) {
+	for _, tc := range []struct {
+		name           string
+		importFromMain bool
+		expectedHash   string
+	}{
+		{
+			name:           "all-imported-from-main",
+			importFromMain: true,
+			expectedHash:   "ktY8NYZOoPacsNYrH7-DslRgLG54EMRdk3MQSM3vcUg=",
+		},
+		{
+			name:           "deeply-nested",
+			importFromMain: false,
+			expectedHash:   "W1Q_uS6jTGcsd7nvJfc-i785sqjBmOzfOAzqzhXVc0A=",
+		},
+	} {
+		b.Run(tc.name, func(b *testing.B) {
+			// Create a very large and complex project
+			tempDir := b.TempDir()
+			generateTestProject(b, tempDir, 10000, tc.importFromMain)
+
+			// Create a VM. It's important to reuse the same VM
+			// While there is a caching mechanism that normally shouldn't be shared in a benchmark iteration,
+			// it's useful to evaluate its impact here, because the caching will also improve the evaluation performance afterwards.
+			vm := MakeVM(Opts{ImportPaths: []string{tempDir}})
+			content, err := os.ReadFile(filepath.Join(tempDir, "main.jsonnet"))
+			require.NoError(b, err)
+
+			// Run the benchmark
+			mainPath := filepath.Join(tempDir, "main.jsonnet")
+			c := string(content)
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				fileHashes = sync.Map{}
+				hash, err := getSnippetHash(vm, mainPath, c)
+				require.NoError(b, err)
+				require.Equal(b, tc.expectedHash, hash)
+			}
+		})
+	}
+}
 
-{
-	local this = self,
+func generateTestProject(t testing.TB, dir string, depth int, importAllFromMain bool) []string {
+	t.Helper()
 
-	attribute: {
-		name: 'test',
-		value: self.name,
-		otherValue: 'other ' + self.value,
-	},
-	nested: {
+	const testFile = `
+	local localImport = <IMPORT>;
+	local myFunc = function() <IMPORT>;
+	
+	{
+		local this = self,
+	
+		attribute: {
+			name: 'test',
+			value: self.name,
+			otherValue: 'other ' + self.value,
+		},
 		nested: {
 			nested: {
 				nested: {
-					nested1: {
-						nested: {
-							nested1: {
-								nested: {
-									attribute: <IMPORT>,
+					nested: {
+						nested1: {
+							nested: {
+								nested1: {
+									nested: {
+										attribute: <IMPORT>,
+									},
+								},
+								nested2: {
+									strValue: this.nested.nested.nested,
 								},
-							},
-							nested2: {
-								strValue: this.nested.nested.nested,
 							},
 						},
-					},
-					nested2: {
-						intValue: 1,
-						importValue: <IMPORT>,
+						nested2: {
+							intValue: 1,
+							importValue: <IMPORT>,
+						},
 					},
 				},
 			},
 		},
-	},
+	
+		other: myFunc(),
+		useLocal: localImport,
+	}`
 
-	other: myFunc(),
-	useLocal: localImport,
-}`
-
-func BenchmarkGetSnippetHash(b *testing.B) {
-	// Create a very large and complex project
-	tempDir := b.TempDir()
+	var allFiles []string
 
 	var mainContentSplit []string
 	for i := 0; i < 1000; i++ {
 		mainContentSplit = append(mainContentSplit, fmt.Sprintf("(import 'file%d.libsonnet')", i))
-	}
-	require.NoError(b, os.WriteFile(filepath.Join(tempDir, "main.jsonnet"), []byte(strings.Join(mainContentSplit, " + ")), 0644))
-	for i := 0; i < 1000; i++ {
+		filePath := filepath.Join(dir, fmt.Sprintf("file%d.libsonnet", i))
 		err := os.WriteFile(
-			filepath.Join(tempDir, fmt.Sprintf("file%d.libsonnet", i)),
+			filePath,
 			[]byte(strings.ReplaceAll(testFile, "<IMPORT>", fmt.Sprintf("import 'file%d.libsonnet'", i+1))),
 			0644,
 		)
-		require.NoError(b, err)
+		require.NoError(t, err)
+		allFiles = append(allFiles, filePath)
 	}
-	require.NoError(b, os.WriteFile(filepath.Join(tempDir, "file1000.libsonnet"), []byte(`"a string"`), 0644))
-
-	// Create a VM. It's important to reuse the same VM
-	// While there is a caching mechanism that normally shouldn't be shared in a benchmark iteration,
-	// it's useful to evaluate its impact here, because the caching will also improve the evaluation performance afterwards.
-	vm := MakeVM(Opts{ImportPaths: []string{tempDir}})
-	content, err := os.ReadFile(filepath.Join(tempDir, "main.jsonnet"))
-	require.NoError(b, err)
-
-	// Run the benchmark
-	mainPath := filepath.Join(tempDir, "main.jsonnet")
-	c := string(content)
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		fileHashes = sync.Map{}
-		hash, err := getSnippetHash(vm, mainPath, c)
-		require.NoError(b, err)
-		require.Equal(b, "XrkW8N2EvkFMvdIuHTsGsQespVUl9_xiFmM7v1mqX5s=", hash)
+	if !importAllFromMain {
+		mainContentSplit = append(mainContentSplit, "import 'file0.libsonnet'")
 	}
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "main.jsonnet"), []byte(strings.Join(mainContentSplit, " + ")), 0644))
+	allFiles = append(allFiles, filepath.Join(dir, "main.jsonnet"))
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "file1000.libsonnet"), []byte(`"a string"`), 0644))
+	allFiles = append(allFiles, filepath.Join(dir, "file1000.libsonnet"))
+
+	return allFiles
 }
diff --git a/pkg/tanka/export_test.go b/pkg/tanka/export_test.go
index 2c1acaaa..842e0b20 100644
--- a/pkg/tanka/export_test.go
+++ b/pkg/tanka/export_test.go
@@ -2,13 +2,12 @@ package tanka
 
 import (
 	"fmt"
-	"io"
-	"log"
 	"os"
 	"path/filepath"
 	"testing"
 
 	"github.com/grafana/tanka/pkg/jsonnet"
+	"github.com/rs/zerolog"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"k8s.io/apimachinery/pkg/labels"
@@ -125,7 +124,7 @@ func TestExportEnvironments(t *testing.T) {
 }
 
 func BenchmarkExportEnvironmentsWithReplaceEnvs(b *testing.B) {
-	log.SetOutput(io.Discard)
+	zerolog.SetGlobalLevel(zerolog.Disabled)
 	tempDir := b.TempDir()
 	require.NoError(b, os.Chdir("testdata"))
 	defer func() { require.NoError(b, os.Chdir("..")) }()