Repository: grafana/tanka
Pull request #376 (2020-09-10 09:22:26)

refactor(api): use structs
Refactors our API away from the Modifier pattern to plain old struct arguments.

- This requires less boilerplate. No longer a Setter must be created for each possible field
- Logical separation: Using struct embedding, fields can be properly grouped and scoped for their respective action, etc. This also requires less copying around in memory
- Stricter checking: Every action got its own composed struct type, so only those fields can be set that actually make sense.

Not quite sure who to assign for review on this. This is intended as a refactor in the strict sense, being a no-op


Patch:
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index d9324d83..ba6d3ff4 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -32,12 +32,13 @@ func exportCmd() *cli.Command {
 		Args:  args,
 	}
 
-	vars := workflowFlags(cmd.Flags())
-	getExtCode, getTLACode := cliCodeParser(cmd.Flags())
 	format := cmd.Flags().String("format", "{{.apiVersion}}.{{.kind}}-{{.metadata.name}}", "https://tanka.dev/exporting#filenames")
 	extension := cmd.Flags().String("extension", "yaml", "File extension")
 	merge := cmd.Flags().Bool("merge", false, "Allow merging with existing directory")
 
+	vars := workflowFlags(cmd.Flags())
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
+
 	templateFuncMap := template.FuncMap{
 		"lower": func(s string) string {
 			return strings.ToLower(s)
@@ -66,11 +67,10 @@ func exportCmd() *cli.Command {
 		}
 
 		// get the manifests
-		res, err := tanka.Show(args[0],
-			tanka.WithExtCode(getExtCode()),
-			tanka.WithTLACode(getTLACode()),
-			tanka.WithTargets(stringsToRegexps(vars.targets)),
-		)
+		res, err := tanka.Show(args[0], tanka.Opts{
+			JsonnetOpts: getJsonnetOpts(),
+			Filters:     stringsToRegexps(vars.targets),
+		})
 		if err != nil {
 			return err
 		}
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index e42a0a25..f77267fb 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -19,13 +19,12 @@ func evalCmd() *cli.Command {
 		Args:  workflowArgs,
 	}
 
-	getExtCode, getTLACode := cliCodeParser(cmd.Flags())
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		raw, err := tanka.Eval(args[0],
-			tanka.WithExtCode(getExtCode()),
-			tanka.WithTLACode(getTLACode()),
-		)
+		raw, err := tanka.Eval(args[0], tanka.Opts{
+			JsonnetOpts: getJsonnetOpts(),
+		})
 
 		if err != nil {
 			return err
@@ -36,13 +35,27 @@ func evalCmd() *cli.Command {
 			return err
 		}
 
-		pageln(string(out))
+		if err := pageln(string(out)); err != nil {
+			return err
+		}
+
 		return nil
 	}
 
 	return cmd
 }
 
+func jsonnetFlags(fs *pflag.FlagSet) func() tanka.JsonnetOpts {
+	getExtCode, getTLACode := cliCodeParser(fs)
+
+	return func() tanka.JsonnetOpts {
+		return tanka.JsonnetOpts{
+			ExtCode: getExtCode(),
+			TLACode: getTLACode(),
+		}
+	}
+}
+
 func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[string]string) {
 	// need to use StringArray instead of StringSlice, because pflag attempts to
 	// parse StringSlice using the csv parser, which breaks when passing objects
diff --git a/cmd/tk/status.go b/cmd/tk/status.go
index 3d956b6f..d18530d5 100644
--- a/cmd/tk/status.go
+++ b/cmd/tk/status.go
@@ -18,8 +18,13 @@ func statusCmd() *cli.Command {
 		Short: "display an overview of the environment, including contents and metadata.",
 		Args:  workflowArgs,
 	}
+
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
+
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		status, err := tanka.Status(args[0])
+		status, err := tanka.Status(args[0], tanka.Opts{
+			JsonnetOpts: getJsonnetOpts(),
+		})
 		if err != nil {
 			return err
 		}
diff --git a/cmd/tk/util.go b/cmd/tk/util.go
index 65ae3946..3c9b4b44 100644
--- a/cmd/tk/util.go
+++ b/cmd/tk/util.go
@@ -5,42 +5,42 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
-	"log"
 	"os"
 	"os/exec"
 	"strings"
 )
 
-func pageln(i ...interface{}) {
-	fPageln(strings.NewReader(fmt.Sprint(i...)))
+func pageln(i ...interface{}) error {
+	return fPageln(strings.NewReader(fmt.Sprint(i...)))
 }
 
-// fPageln invokes the systems pager with the supplied data
-// falls back to fmt.Println() when paging fails or non-interactive
-func fPageln(r io.Reader) {
-	// get system pager, fallback to `less`
-	pager := os.Getenv("PAGER")
-	var args []string
-	if pager == "" || pager == "less" {
+// fPageln invokes the systems pager with the supplied data.
+// If the PAGER environment variable is empty, no pager is used.
+// If the PAGER environment variable is unset, use GNU less with convenience flags.
+func fPageln(r io.Reader) error {
+	pager, ok := os.LookupEnv("PAGER")
+	if !ok {
 		// --RAW-CONTROL-CHARS  Honors colors from diff. Must be in all caps, otherwise display issues occur.
 		// --quit-if-one-screen Closer to the git experience.
 		// --no-init            Don't clear the screen when exiting.
-		pager = "less"
-		args = []string{"--RAW-CONTROL-CHARS", "--quit-if-one-screen", "--no-init"}
+		pager = "less --RAW-CONTROL-CHARS --quit-if-one-screen --no-init"
 	}
 
-	// invoke pager
-	cmd := exec.Command(pager, args...)
-	cmd.Stdin = r
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
+	if interactive && pager != "" {
+		cmd := exec.Command("sh", "-c", pager)
+		cmd.Stdin = r
+		cmd.Stdout = os.Stdout
+		cmd.Stderr = os.Stderr
 
-	// if this fails, just print it
-	if err := cmd.Run(); err != nil {
-		if _, err = io.Copy(os.Stdout, r); err != nil {
-			log.Fatalln("Writing to Stdout:", err)
+		if err := cmd.Run(); err != nil {
+			// Fallthrough on failure so that the contents of the reader are copied to stdout.
+		} else {
+			return nil
 		}
 	}
+
+	_, err := io.Copy(os.Stdout, r)
+	return err
 }
 
 // writeJSON writes the given object to the path as a JSON file
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 352a1b03..4f1d46a6 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -40,26 +40,19 @@ func applyCmd() *cli.Command {
 		Args:  workflowArgs,
 	}
 
-	vars := workflowFlags(cmd.Flags())
-	force := cmd.Flags().Bool("force", false, "force applying (kubectl apply --force)")
-	validate := cmd.Flags().Bool("validate", true, "validation of resources (kubectl --validate=false)")
-	autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
+	var opts tanka.ApplyOpts
+	cmd.Flags().BoolVar(&opts.Force, "force", false, "force applying (kubectl apply --force)")
+	cmd.Flags().BoolVar(&opts.Validate, "validate", true, "validation of resources (kubectl --validate=false)")
+	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
 
-	getExtCode, getTLACode := cliCodeParser(cmd.Flags())
+	vars := workflowFlags(cmd.Flags())
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		err := tanka.Apply(args[0],
-			tanka.WithTargets(stringsToRegexps(vars.targets)),
-			tanka.WithExtCode(getExtCode()),
-			tanka.WithTLACode(getTLACode()),
-			tanka.WithApplyForce(*force),
-			tanka.WithApplyValidate(*validate),
-			tanka.WithApplyAutoApprove(*autoApprove),
-		)
-		if err != nil {
-			return err
-		}
-		return nil
+		opts.Filters = stringsToRegexps(vars.targets)
+		opts.JsonnetOpts = getJsonnetOpts()
+
+		return tanka.Apply(args[0], opts)
 	}
 	return cmd
 }
@@ -71,17 +64,15 @@ func pruneCmd() *cli.Command {
 		Args:  workflowArgs,
 	}
 
-	getExtCode, getTLACode := cliCodeParser(cmd.Flags())
-	autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
-	force := cmd.Flags().Bool("force", false, "force deleting (kubectl delete --force)")
+	var opts tanka.PruneOpts
+	cmd.Flags().BoolVar(&opts.Force, "force", false, "force deleting (kubectl delete --force)")
+	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		return tanka.Prune(args[0],
-			tanka.WithExtCode(getExtCode()),
-			tanka.WithTLACode(getTLACode()),
-			tanka.WithApplyAutoApprove(*autoApprove),
-			tanka.WithApplyForce(*force),
-		)
+		opts.JsonnetOpts = getJsonnetOpts()
+
+		return tanka.Prune(args[0], opts)
 	}
 
 	return cmd
@@ -94,25 +85,19 @@ func deleteCmd() *cli.Command {
 		Args:  workflowArgs,
 	}
 
+	var opts tanka.DeleteOpts
+	cmd.Flags().BoolVar(&opts.Force, "force", false, "force deleting (kubectl delete --force)")
+	cmd.Flags().BoolVar(&opts.Validate, "validate", true, "validation of resources (kubectl --validate=false)")
+	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
+
 	vars := workflowFlags(cmd.Flags())
-	force := cmd.Flags().Bool("force", false, "force deleting (kubectl delete --force)")
-	validate := cmd.Flags().Bool("validate", true, "validation of resources (kubectl --validate=false)")
-	autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
-	getExtCode, getTLACode := cliCodeParser(cmd.Flags())
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		err := tanka.Delete(args[0],
-			tanka.WithTargets(stringsToRegexps(vars.targets)),
-			tanka.WithExtCode(getExtCode()),
-			tanka.WithTLACode(getTLACode()),
-			tanka.WithApplyForce(*force),
-			tanka.WithApplyValidate(*validate),
-			tanka.WithApplyAutoApprove(*autoApprove),
-		)
-		if err != nil {
-			return err
-		}
-		return nil
+		opts.Filters = stringsToRegexps(vars.targets)
+		opts.JsonnetOpts = getJsonnetOpts()
+
+		return tanka.Delete(args[0], opts)
 	}
 	return cmd
 }
@@ -127,23 +112,18 @@ func diffCmd() *cli.Command {
 		},
 	}
 
-	// flags
-	var (
-		vars         = workflowFlags(cmd.Flags())
-		diffStrategy = cmd.Flags().String("diff-strategy", "", "force the diff-strategy to use. Automatically chosen if not set.")
-		summarize    = cmd.Flags().BoolP("summarize", "s", false, "quick summary of the differences, hides file contents")
-	)
+	var opts tanka.DiffOpts
+	cmd.Flags().StringVar(&opts.Strategy, "diff-strategy", "", "force the diff-strategy to use. Automatically chosen if not set.")
+	cmd.Flags().BoolVarP(&opts.Summarize, "summarize", "s", false, "print summary of the differences, not the actual contents")
 
-	getExtCode, getTLACode := cliCodeParser(cmd.Flags())
+	vars := workflowFlags(cmd.Flags())
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		changes, err := tanka.Diff(args[0],
-			tanka.WithTargets(stringsToRegexps(vars.targets)),
-			tanka.WithExtCode(getExtCode()),
-			tanka.WithTLACode(getTLACode()),
-			tanka.WithDiffStrategy(*diffStrategy),
-			tanka.WithDiffSummarize(*summarize),
-		)
+		opts.Filters = stringsToRegexps(vars.targets)
+		opts.JsonnetOpts = getJsonnetOpts()
+
+		changes, err := tanka.Diff(args[0], opts)
 		if err != nil {
 			return err
 		}
@@ -153,11 +133,9 @@ func diffCmd() *cli.Command {
 			os.Exit(ExitStatusClean)
 		}
 
-		if interactive {
-			r := term.Colordiff(*changes)
-			fPageln(r)
-		} else {
-			fmt.Println(*changes)
+		r := term.Colordiff(*changes)
+		if err := fPageln(r); err != nil {
+			return err
 		}
 
 		os.Exit(ExitStatusDiff)
@@ -173,9 +151,12 @@ func showCmd() *cli.Command {
 		Short: "jsonnet as yaml",
 		Args:  workflowArgs,
 	}
-	vars := workflowFlags(cmd.Flags())
+
 	allowRedirect := cmd.Flags().Bool("dangerous-allow-redirect", false, "allow redirecting output to a file or a pipe.")
-	getExtCode, getTLACode := cliCodeParser(cmd.Flags())
+
+	vars := workflowFlags(cmd.Flags())
+	getJsonnetOpts := jsonnetFlags(cmd.Flags())
+
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		if !interactive && !*allowRedirect {
 			fmt.Fprintln(os.Stderr, `Redirection of the output of tk show is discouraged and disabled by default.
@@ -184,17 +165,16 @@ Otherwise run tk show --dangerous-allow-redirect to bypass this check.`)
 			return nil
 		}
 
-		pretty, err := tanka.Show(args[0],
-			tanka.WithExtCode(getExtCode()),
-			tanka.WithTLACode(getTLACode()),
-			tanka.WithTargets(stringsToRegexps(vars.targets)),
-		)
+		pretty, err := tanka.Show(args[0], tanka.Opts{
+			JsonnetOpts: getJsonnetOpts(),
+			Filters:     stringsToRegexps(vars.targets),
+		})
+
 		if err != nil {
 			return err
 		}
 
-		pageln(pretty.String())
-		return nil
+		return pageln(pretty.String())
 	}
 	return cmd
 }
diff --git a/go.sum b/go.sum
index 44580aba..17b5936c 100644
--- a/go.sum
+++ b/go.sum
@@ -35,6 +35,7 @@ github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2 h1:CZtx9gNen+kr3PuC/JQff3n1pJbgpy7Wr3hzjnupqdw=
 github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-jsonnet v0.15.1-0.20200331184325-4f4aa80dd785 h1:+dlQ7fPoeAqO0U9V+94golo/rW1/V2Pn+v8aPp3ljRM=
 github.com/google/go-jsonnet v0.15.1-0.20200331184325-4f4aa80dd785/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
 github.com/google/go-jsonnet v0.16.1-0.20200908152747-b70cbd441a39 h1:noLRnY1ESguFGDPxXvIcESe2rG63f+ZSbSGYfVa6iHo=
diff --git a/pkg/kubernetes/delete.go b/pkg/kubernetes/delete.go
index e0a234fe..cfdb3249 100644
--- a/pkg/kubernetes/delete.go
+++ b/pkg/kubernetes/delete.go
@@ -8,7 +8,7 @@ import (
 
 type DeleteOpts client.DeleteOpts
 
-func (k *Kubernetes) Delete(state manifest.List, opts ApplyOpts) error {
+func (k *Kubernetes) Delete(state manifest.List, opts DeleteOpts) error {
 	// Sort and reverse the manifests to avoid cascading deletions
 	process.Sort(state)
 	for i := 0; i < len(state)/2; i++ {
diff --git a/pkg/process/process.go b/pkg/process/process.go
index 3af81ac5..c14ae8b1 100644
--- a/pkg/process/process.go
+++ b/pkg/process/process.go
@@ -41,6 +41,9 @@ func Process(raw interface{}, cfg v1alpha1.Config, exprs Matchers) (manifest.Lis
 	// tanka.dev/** labels
 	out = Label(out, cfg)
 
+	// arbitrary labels and annotations from spec
+	out = ResourceDefaults(out, cfg)
+
 	// Perhaps filter for kind/name expressions
 	if len(exprs) > 0 {
 		out = Filter(out, exprs)
@@ -65,6 +68,27 @@ func Label(list manifest.List, cfg v1alpha1.Config) manifest.List {
 	return list
 }
 
+func ResourceDefaults(list manifest.List, cfg v1alpha1.Config) manifest.List {
+	for i, m := range list {
+		for k, v := range cfg.Spec.ResourceDefaults.Annotations {
+			annotations := m.Metadata().Annotations()
+			if _, ok := annotations[k]; !ok {
+				annotations[k] = v
+			}
+		}
+
+		for k, v := range cfg.Spec.ResourceDefaults.Labels {
+			labels := m.Metadata().Labels()
+			if _, ok := labels[k]; !ok {
+				labels[k] = v
+			}
+		}
+
+		list[i] = m
+	}
+	return list
+}
+
 // Unwrap returns all Kubernetes objects in the manifest. If m is not a List
 // type, a one item List is returned
 func Unwrap(manifests map[string]manifest.Manifest) error {
diff --git a/pkg/spec/v1alpha1/config.go b/pkg/spec/v1alpha1/config.go
index 8a623d63..97f9e1ff 100644
--- a/pkg/spec/v1alpha1/config.go
+++ b/pkg/spec/v1alpha1/config.go
@@ -50,8 +50,15 @@ func (m Metadata) NameLabel() string {
 
 // Spec defines Kubernetes properties
 type Spec struct {
-	APIServer    string `json:"apiServer"`
-	Namespace    string `json:"namespace"`
-	DiffStrategy string `json:"diffStrategy,omitempty"`
-	InjectLabels bool   `json:"injectLabels,omitempty"`
+	APIServer        string           `json:"apiServer"`
+	Namespace        string           `json:"namespace"`
+	DiffStrategy     string           `json:"diffStrategy,omitempty"`
+	InjectLabels     bool             `json:"injectLabels,omitempty"`
+	ResourceDefaults ResourceDefaults `json:"resourceDefaults,omitempty"`
+}
+
+// ResourceDefaults will be inserted in any manifests that tanka processes.
+type ResourceDefaults struct {
+	Annotations map[string]string `json:"annotations,omitempty"`
+	Labels      map[string]string `json:"labels,omitempty"`
 }
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
index cb6b4915..420d8390 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -55,13 +55,13 @@ func (p *loaded) connect() (*kubernetes.Kubernetes, error) {
 }
 
 // load runs all processing stages described at the Processed type
-func load(dir string, opts *options) (*loaded, error) {
-	raw, env, err := eval(dir, opts.jsonnet)
+func load(dir string, opts Opts) (*loaded, error) {
+	raw, env, err := eval(dir, opts.JsonnetOpts)
 	if err != nil {
 		return nil, err
 	}
 
-	rec, err := process.Process(raw, *env, opts.targets)
+	rec, err := process.Process(raw, *env, opts.Filters)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
index 92a68ceb..c6178e7b 100644
--- a/pkg/tanka/prune.go
+++ b/pkg/tanka/prune.go
@@ -7,13 +7,21 @@ import (
 	"github.com/grafana/tanka/pkg/term"
 )
 
+// PruneOpts specify additional properties for the Prune action
+type PruneOpts struct {
+	Opts
+
+	// AutoApprove skips the interactive approval
+	AutoApprove bool
+	// Force ignores any warnings kubectl might have
+	Force bool
+}
+
 // Prune deletes all resources from the cluster, that are no longer present in
 // Jsonnet. It uses the `tanka.dev/environment` label to identify those.
-func Prune(baseDir string, mods ...Modifier) error {
-	opts := parseModifiers(mods)
-
+func Prune(baseDir string, opts PruneOpts) error {
 	// parse jsonnet, init k8s client
-	p, err := load(baseDir, opts)
+	p, err := load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
@@ -44,11 +52,13 @@ func Prune(baseDir string, mods ...Modifier) error {
 	fmt.Print(term.Colordiff(*diff).String())
 
 	// prompt for confirm
-	if opts.apply.AutoApprove {
+	if opts.AutoApprove {
 	} else if err := confirmPrompt("Pruning from", p.Env.Spec.Namespace, kube.Info()); err != nil {
 		return err
 	}
 
 	// delete resources
-	return kube.Delete(orphaned, opts.apply)
+	return kube.Delete(orphaned, kubernetes.DeleteOpts{
+		Force: opts.Force,
+	})
 }
diff --git a/pkg/tanka/status.go b/pkg/tanka/status.go
index 68cbe057..c9e22d89 100644
--- a/pkg/tanka/status.go
+++ b/pkg/tanka/status.go
@@ -16,9 +16,7 @@ type Info struct {
 }
 
 // Status returns information about the particular environment
-func Status(baseDir string, mods ...Modifier) (*Info, error) {
-	opts := parseModifiers(mods)
-
+func Status(baseDir string, opts Opts) (*Info, error) {
 	r, err := load(baseDir, opts)
 	if err != nil {
 		return nil, err
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index 16dd87b3..a5bd6cd3 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -6,94 +6,15 @@ package tanka
 
 import (
 	"github.com/grafana/tanka/pkg/jsonnet"
-	"github.com/grafana/tanka/pkg/kubernetes"
 	"github.com/grafana/tanka/pkg/process"
 )
 
-// parseModifiers parses all modifiers into an options struct
-func parseModifiers(mods []Modifier) *options {
-	o := &options{}
-	for _, mod := range mods {
-		mod(o)
-	}
-	return o
-}
-
-type options struct {
-	// `std.extVar`
-	jsonnet jsonnet.Opts
-
-	// target regular expressions to limit the working set
-	targets process.Matchers
-
-	// additional options for diff
-	diff kubernetes.DiffOpts
-
-	// additional options for apply
-	apply kubernetes.ApplyOpts
-}
-
-// Modifier allow to influence the behavior of certain `tanka.*` actions. They
-// are roughly equivalent to flags on the command line. See the `tanka.With*`
-// functions for available options.
-type Modifier func(*options)
-
-// WithExtCode allows to pass external variables (jsonnet code) to the VM
-func WithExtCode(code map[string]string) Modifier {
-	return func(opts *options) {
-		opts.jsonnet.ExtCode = code
-	}
-}
-
-func WithTLACode(code map[string]string) Modifier {
-	return func(opts *options) {
-		opts.jsonnet.TLACode = code
-	}
-}
-
-// WithTargets allows to submit regular expressions to limit the working set of
-// objects (https://tanka.dev/output-filtering/).
-func WithTargets(t process.Matchers) Modifier {
-	return func(opts *options) {
-		opts.targets = t
-	}
-}
-
-// WithDiffStrategy allows to set the used diff strategy.
-// An empty string is ignored.
-func WithDiffStrategy(ds string) Modifier {
-	return func(opts *options) {
-		if ds != "" {
-			opts.diff.Strategy = ds
-		}
-	}
-}
+// Opts specify general, optional properties that apply to all actions
+type Opts struct {
+	JsonnetOpts
 
-// WithDiffSummarize enables summary mode, which invokes `diffstat(1)` on the
-// set of changes to create an overview
-func WithDiffSummarize(b bool) Modifier {
-	return func(opts *options) {
-		opts.diff.Summarize = b
-	}
+	// Filters are used to optionally select a subset of the resources
+	Filters process.Matchers
 }
 
-// WithApplyForce allows to invoke `kubectl apply` with the `--force` flag
-func WithApplyForce(b bool) Modifier {
-	return func(opts *options) {
-		opts.apply.Force = b
-	}
-}
-
-// WithApplyValidate allows to invoke `kubectl apply` with the `--validate=false` flag
-func WithApplyValidate(b bool) Modifier {
-	return func(opts *options) {
-		opts.apply.Validate = b
-	}
-}
-
-// WithApplyAutoApprove allows to skip the interactive approval
-func WithApplyAutoApprove(b bool) Modifier {
-	return func(opts *options) {
-		opts.apply.AutoApprove = b
-	}
-}
+type JsonnetOpts = jsonnet.Opts
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index 61cd6897..bbe8f51e 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -11,13 +11,26 @@ import (
 	"github.com/grafana/tanka/pkg/term"
 )
 
+// ApplyOpts specify additional properties for the Apply action
+type ApplyOpts struct {
+	Opts
+
+	// AutoApprove skips the interactive approval
+	AutoApprove bool
+	// DiffStrategy to use for printing the diff before approval
+	DiffStrategy string
+
+	// Force ignores any warnings kubectl might have
+	Force bool
+	// Validate set to false ignores invalid Kubernetes schemas
+	Validate bool
+}
+
 // Apply parses the environment at the given directory (a `baseDir`) and applies
 // the evaluated jsonnet to the Kubernetes cluster defined in the environments
 // `spec.json`.
-func Apply(baseDir string, mods ...Modifier) error {
-	opts := parseModifiers(mods)
-
-	l, err := load(baseDir, opts)
+func Apply(baseDir string, opts ApplyOpts) error {
+	l, err := load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
@@ -28,7 +41,7 @@ func Apply(baseDir string, mods ...Modifier) error {
 	defer kube.Close()
 
 	// show diff
-	diff, err := kube.Diff(l.Resources, kubernetes.DiffOpts{Strategy: opts.diff.Strategy})
+	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
@@ -45,12 +58,15 @@ func Apply(baseDir string, mods ...Modifier) error {
 	}
 
 	// prompt for confirmation
-	if opts.apply.AutoApprove {
+	if opts.AutoApprove {
 	} else if err := confirmPrompt("Applying to", l.Env.Spec.Namespace, kube.Info()); err != nil {
 		return err
 	}
 
-	return kube.Apply(l.Resources, opts.apply)
+	return kube.Apply(l.Resources, kubernetes.ApplyOpts{
+		Force:    opts.Force,
+		Validate: opts.Validate,
+	})
 }
 
 // confirmPrompt asks the user for confirmation before apply
@@ -68,16 +84,24 @@ func confirmPrompt(action, namespace string, info client.Info) error {
 	)
 }
 
+// DiffOpts specify additional properties for the Diff action
+type DiffOpts struct {
+	Opts
+
+	// Strategy must be one of "native" or "subset"
+	Strategy string
+	// Summarize prints a summary, instead of the actual diff
+	Summarize bool
+}
+
 // Diff parses the environment at the given directory (a `baseDir`) and returns
 // the differences from the live cluster state in `diff(1)` format. If the
 // `WithDiffSummarize` modifier is used, a histogram created using `diffstat(1)`
 // is returned instead.
 // The cluster information is retrieved from the environments `spec.json`.
 // NOTE: This function requires on `diff(1)`, `kubectl(1)` and perhaps `diffstat(1)`
-func Diff(baseDir string, mods ...Modifier) (*string, error) {
-	opts := parseModifiers(mods)
-
-	l, err := load(baseDir, opts)
+func Diff(baseDir string, opts DiffOpts) (*string, error) {
+	l, err := load(baseDir, opts.Opts)
 	if err != nil {
 		return nil, err
 	}
@@ -87,16 +111,30 @@ func Diff(baseDir string, mods ...Modifier) (*string, error) {
 	}
 	defer kube.Close()
 
-	return kube.Diff(l.Resources, opts.diff)
+	return kube.Diff(l.Resources, kubernetes.DiffOpts{
+		Summarize: opts.Summarize,
+		Strategy:  opts.Strategy,
+	})
+}
+
+// DeleteOpts specify additional properties for the Delete operation
+type DeleteOpts struct {
+	Opts
+
+	// AutoApprove skips the interactive approval
+	AutoApprove bool
+
+	// Force ignores any warnings kubectl might have
+	Force bool
+	// Validate set to false ignores invalid Kubernetes schemas
+	Validate bool
 }
 
 // Delete parses the environment at the given directory (a `baseDir`) and deletes
 // the generated objects from the Kubernetes cluster defined in the environment's
 // `spec.json`.
-func Delete(baseDir string, mods ...Modifier) error {
-	opts := parseModifiers(mods)
-
-	l, err := load(baseDir, opts)
+func Delete(baseDir string, opts DeleteOpts) error {
+	l, err := load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
@@ -121,20 +159,21 @@ func Delete(baseDir string, mods ...Modifier) error {
 	}
 
 	// prompt for confirmation
-	if opts.apply.AutoApprove {
+	if opts.AutoApprove {
 	} else if err := confirmPrompt("Deleting from", l.Env.Spec.Namespace, kube.Info()); err != nil {
 		return err
 	}
 
-	return kube.Delete(l.Resources, opts.apply)
+	return kube.Delete(l.Resources, kubernetes.DeleteOpts{
+		Force:    opts.Force,
+		Validate: opts.Validate,
+	})
 }
 
 // Show parses the environment at the given directory (a `baseDir`) and returns
 // the list of Kubernetes objects.
 // Tip: use the `String()` function on the returned list to get the familiar yaml stream
-func Show(baseDir string, mods ...Modifier) (manifest.List, error) {
-	opts := parseModifiers(mods)
-
+func Show(baseDir string, opts Opts) (manifest.List, error) {
 	l, err := load(baseDir, opts)
 	if err != nil {
 		return nil, err
@@ -144,10 +183,8 @@ func Show(baseDir string, mods ...Modifier) (manifest.List, error) {
 }
 
 // Eval returns the raw evaluated Jsonnet output (without any transformations)
-func Eval(dir string, mods ...Modifier) (raw interface{}, err error) {
-	opts := parseModifiers(mods)
-
-	r, _, err := eval(dir, opts.jsonnet)
+func Eval(dir string, opts Opts) (raw interface{}, err error) {
+	r, _, err := eval(dir, opts.JsonnetOpts)
 	if err != nil {
 		return nil, err
 	}


Test patch:
diff --git a/pkg/process/resourceDefaults_test.go b/pkg/process/resourceDefaults_test.go
new file mode 100644
index 00000000..bed46b58
--- /dev/null
+++ b/pkg/process/resourceDefaults_test.go
@@ -0,0 +1,92 @@
+package process
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+func TestResourceDefaults(t *testing.T) {
+	cases := []struct {
+		name                string
+		beforeAnnotations   map[string]string
+		beforeLabels        map[string]string
+		specAnnotations     map[string]string
+		specLabels          map[string]string
+		expectedAnnotations map[string]string
+		expectedLabels      map[string]string
+	}{
+		// resource without a namespace: set it
+		{
+			name: "No change",
+		},
+		{
+			name:                "Add annotation",
+			specAnnotations:     map[string]string{"a": "b"},
+			expectedAnnotations: map[string]string{"a": "b"},
+		},
+		{
+			name:           "Add Label",
+			specLabels:     map[string]string{"a": "b"},
+			expectedLabels: map[string]string{"a": "b"},
+		},
+		{
+			name:                "Add leaves existing",
+			beforeAnnotations:   map[string]string{"1": "2"},
+			beforeLabels:        map[string]string{"1": "2"},
+			specAnnotations:     map[string]string{"a": "b"},
+			specLabels:          map[string]string{"a": "b"},
+			expectedAnnotations: map[string]string{"a": "b", "1": "2"},
+			expectedLabels:      map[string]string{"a": "b", "1": "2"},
+		},
+		{
+			name:                "Existing overrides spec",
+			beforeAnnotations:   map[string]string{"a": "c", "1": "2"},
+			beforeLabels:        map[string]string{"a": "c", "1": "2"},
+			specAnnotations:     map[string]string{"a": "b"},
+			specLabels:          map[string]string{"a": "b"},
+			expectedAnnotations: map[string]string{"a": "c", "1": "2"},
+			expectedLabels:      map[string]string{"a": "c", "1": "2"},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			cfg := v1alpha1.Config{
+				Spec: v1alpha1.Spec{
+					ResourceDefaults: v1alpha1.ResourceDefaults{
+						Annotations: c.specAnnotations,
+						Labels:      c.specLabels,
+					},
+				},
+			}
+
+			before := manifest.Manifest{
+				"kind": "Deployment",
+			}
+			for k, v := range c.beforeAnnotations {
+				before.Metadata().Annotations()[k] = v
+			}
+			for k, v := range c.beforeLabels {
+				before.Metadata().Labels()[k] = v
+			}
+
+			expected := manifest.Metadata{}
+			for k, v := range c.expectedAnnotations {
+				expected.Annotations()[k] = v
+			}
+			for k, v := range c.expectedLabels {
+				expected.Labels()[k] = v
+			}
+
+			result := ResourceDefaults(manifest.List{before}, cfg)
+			actual := result[0]
+			if diff := cmp.Diff(expected, actual.Metadata()); diff != "" {
+				t.Error(diff)
+			}
+		})
+	}
+}