Skip to content

Commit

Permalink
feat(cli): bulk export (#450)
Browse files Browse the repository at this point in the history
Changes:
* moved generalized logic from `cmd/tk` to `pkg/tanka`
* add `ParseParallel` to evaluate envs in parallel
* `tk env list` uses `ParseParallel`
* `tk export --format` now takes `{{env.<...>}}` and fills it with the tanka.dev/Environment object
* `tk export` can export multiple environments and find them recursively with `--recursive`
* `tk export` creates a `manifest.json` file to map files back to an environment
* `tk export` can use `-l <labelFilters>`, similar to `tk env list` and `kubectl`

Examples:
```bash
# Format based on environment {{env.<...>}}
$ tk export exportDir environments/dev/ --format '{{env.metadata.labels.cluster}}/{{env.spec.namespace}}//{{.kind}}-{{.metadata.name}}'
# Export multiple environments
$ tk export exportDir environments/dev/ environments/qa/
# Recursive export
$ tk export exportDir environments/ --recursive
# Recursive export with labelSelector
$ tk export exportDir environments/ -r -l team=infra
```

Note: this changes `tk export`, `<exportDir>` now comes before the `<environment>`.
  • Loading branch information
Duologic authored Dec 31, 2020
1 parent aee9fd4 commit d5c878e
Show file tree
Hide file tree
Showing 14 changed files with 641 additions and 296 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
tk
/tk
/cmd/tk/tk
27 changes: 25 additions & 2 deletions cmd/tk/args.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
package main

import (
"os"
"path/filepath"

"github.com/go-clix/cli"
"github.com/posener/complete"

"github.com/grafana/tanka/pkg/tanka"
)

var workflowArgs = cli.Args{
Validator: cli.ValidateExact(1),
Predictor: cli.PredictFunc(func(args complete.Args) []string {
if dirs := findBaseDirs(); len(dirs) != 0 {
return dirs
pwd, err := os.Getwd()
if err != nil {
return nil
}

dirs, err := tanka.FindBaseDirs(pwd)
if err != nil {
return nil
}

var reldirs []string
for _, dir := range dirs {
reldir, err := filepath.Rel(pwd, dir)
if err == nil {
reldirs = append(reldirs, reldir)
}
}

if len(reldirs) != 0 {
return reldirs
}

return complete.PredictDirs("*").Predict(args)
Expand Down
86 changes: 57 additions & 29 deletions cmd/tk/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import (
"github.com/go-clix/cli"
"github.com/pkg/errors"
"github.com/posener/complete"
"github.com/spf13/pflag"
"k8s.io/apimachinery/pkg/labels"

"github.com/grafana/tanka/pkg/jsonnet/jpath"
"github.com/grafana/tanka/pkg/kubernetes/client"
"github.com/grafana/tanka/pkg/spec"
"github.com/grafana/tanka/pkg/spec/v1alpha1"
"github.com/grafana/tanka/pkg/tanka"
"github.com/grafana/tanka/pkg/term"
)

Expand All @@ -36,20 +36,39 @@ func envCmd() *cli.Command {
return cmd
}

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.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.")
}

var kubectlContexts = cli.PredictFunc(
func(complete.Args) []string {
c, _ := client.Contexts()
return c
},
)

func setupConfiguration(baseDir string) *v1alpha1.Environment {
_, baseDir, rootDir, err := jpath.Resolve(baseDir)
if err != nil {
log.Fatalln("Resolving jpath:", err)
}

// name of the environment: relative path from rootDir
name, _ := filepath.Rel(rootDir, baseDir)

config, err := spec.ParseDir(baseDir, name)
if err != nil {
switch err.(type) {
// the config includes deprecated fields
case spec.ErrDeprecated:
if verbose {
fmt.Print(err)
}
// some other error
default:
log.Fatalf("Reading spec.json: %s", err)
}
}

return config
}

func envSetCmd() *cli.Command {
cmd := &cli.Command{
Use: "set",
Expand Down Expand Up @@ -213,40 +232,49 @@ func envRemoveCmd() *cli.Command {
}

func envListCmd() *cli.Command {
args := workflowArgs
args.Validator = cli.ValidateFunc(func(args []string) error {
if len(args) > 1 {
return fmt.Errorf("expects at most 1 arg, received %v", len(args))
}
return nil
})

cmd := &cli.Command{
Use: "list",
Use: "list [<path>]",
Aliases: []string{"ls"},
Short: "list environments",
Args: cli.ArgsNone(),
Short: "list environments relative to current dir or <path>",
Args: args,
}

useJSON := cmd.Flags().Bool("json", false, "json output")
labelSelector := cmd.Flags().StringP("selector", "l", "", "Label selector. Uses the same syntax as kubectl does")
getLabelSelector := labelSelectorFlag(cmd.Flags())

useNames := cmd.Flags().Bool("names", false, "plain names output")

cmd.Run = func(cmd *cli.Command, args []string) error {
envs := []v1alpha1.Environment{}
dirs := findBaseDirs()
var selector labels.Selector
var dir string
var err error

if *labelSelector != "" {
selector, err = labels.Parse(*labelSelector)
if len(args) == 1 {
dir = args[0]
} else {
dir, err = os.Getwd()
if err != nil {
return err
return nil
}
}

for _, dir := range dirs {
env := setupConfiguration(dir)
if env == nil {
log.Printf("Could not setup configuration from %q", dir)
continue
}
if selector == nil || selector.Empty() || selector.Matches(env.Metadata) {
envs = append(envs, *env)
}
stat, err := os.Stat(dir)
if err != nil {
return err
}
if !stat.IsDir() {
return fmt.Errorf("Not a directory: %s", dir)
}

envs, err := tanka.FindEnvironments(dir, getLabelSelector())
if err != nil {
return err
}

if *useJSON {
Expand Down
153 changes: 52 additions & 101 deletions cmd/tk/export.go
Original file line number Diff line number Diff line change
@@ -1,137 +1,88 @@
package main

import (
"bytes"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"

"text/template"

"github.com/Masterminds/sprig/v3"
"github.com/go-clix/cli"
"github.com/pkg/errors"

"github.com/grafana/tanka/pkg/jsonnet/jpath"
"github.com/grafana/tanka/pkg/tanka"
)

// BelRune is a string of the Ascii character BEL which made computers ring in ancient times
// We use it as "magic" char for the subfolder creation as it is a non printable character and thereby will never be
// in a valid filepath by accident. Only when we include it.
const BelRune = string(rune(7))

func exportCmd() *cli.Command {
args := workflowArgs
args.Validator = cli.ValidateExact(2)
args.Validator = cli.ValidateFunc(func(args []string) error {
if len(args) < 2 {
return fmt.Errorf("expects at least 2 args, received %v", len(args))
}
return nil
})

cmd := &cli.Command{
Use: "export <environment> <outputDir>",
Short: "write each resources as a YAML file",
Use: "export <outputDir> <path> [<path>...]",
Short: "export environments found in path(s)",
Args: args,
}

format := cmd.Flags().String("format", "{{.apiVersion}}.{{.kind}}-{{.metadata.name}}", "https://tanka.dev/exporting#filenames")
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())
getLabelSelector := labelSelectorFlag(cmd.Flags())

cmd.Run = func(cmd *cli.Command, args []string) error {
// dir must be empty
to := args[1]
empty, err := dirEmpty(to)
if err != nil {
return fmt.Errorf("Checking target dir: %s", err)
}
if !empty && !*merge {
return fmt.Errorf("Output dir `%s` not empty. Pass --merge to ignore this", to)
}

// exit early if the template is bad
recursive := cmd.Flags().BoolP("recursive", "r", false, "Look recursively for Tanka environments")

// Replace all os.path separators in string with BelRune for creating subfolders
replacedFormat := strings.Replace(*format, string(os.PathSeparator), BelRune, -1)

tmpl, err := template.New("").
Funcs(sprig.TxtFuncMap()). // register Masterminds/sprig
Parse(replacedFormat) // parse template
if err != nil {
return fmt.Errorf("Parsing name format: %s", err)
}

// get the manifests
res, err := tanka.Show(args[0], tanka.Opts{
JsonnetOpts: getJsonnetOpts(),
Filters: stringsToRegexps(vars.targets),
})
if err != nil {
return err
cmd.Run = func(cmd *cli.Command, args []string) error {
opts := tanka.ExportEnvOpts{
Format: *format,
Extension: *extension,
Merge: *merge,
Targets: vars.targets,
ParseParallelOpts: tanka.ParseParallelOpts{
JsonnetOpts: getJsonnetOpts(),
Selector: getLabelSelector(),
},
}

// write each to a file
for _, m := range res {
buf := bytes.Buffer{}
if err := tmpl.Execute(&buf, m); err != nil {
log.Fatalln("executing name template:", err)
var paths []string
for _, path := range args[1:] {
if *recursive {
rootDir, err := jpath.FindRoot(path)
if err != nil {
return errors.Wrap(err, "resolving jpath")
}

// get absolute path to Environment
envs, err := tanka.FindEnvironments(path, opts.ParseParallelOpts.Selector)
if err != nil {
return err
}

for _, env := range envs {
paths = append(paths, filepath.Join(rootDir, env.Metadata.Namespace))
}
continue
}

// Replace all os.path separators in string in order to not accidentally create subfolders
name := strings.Replace(buf.String(), string(os.PathSeparator), "-", -1)
// Replace the BEL character inserted with a path separator again in order to create a subfolder
name = strings.Replace(name, BelRune, string(os.PathSeparator), -1)

// Create all subfolders in path
path := filepath.Join(to, name+"."+*extension)

// Abort if already exists
if exists, err := fileExists(path); err != nil {
jsonnetOpts := opts.ParseParallelOpts.JsonnetOpts
jsonnetOpts.EvalScript = tanka.EnvsOnlyEvalScript
_, _, err := tanka.ParseEnv(path, jsonnetOpts)
if err != nil {
return err
} else if exists {
return fmt.Errorf("File '%s' already exists. Aborting", path)
}

// Write file
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("creating filepath '%s': %s", filepath.Dir(path), err)
}
data := m.String()
if err := ioutil.WriteFile(path, []byte(data), 0644); err != nil {
return fmt.Errorf("writing manifest: %s", err)
}
paths = append(paths, path)
}

return nil
return tanka.ExportEnvironments(paths, args[0], &opts)
}
return cmd
}

func fileExists(name string) (bool, error) {
_, err := os.Stat(name)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}

func dirEmpty(dir string) (bool, error) {
f, err := os.Open(dir)
if os.IsNotExist(err) {
return true, os.MkdirAll(dir, os.ModePerm)
} else if err != nil {
return false, err
}
defer f.Close()

_, err = f.Readdirnames(1)
if err == io.EOF {
return true, nil
}
return false, err
}
Loading

0 comments on commit d5c878e

Please sign in to comment.