diff --git a/.gitignore b/.gitignore index 8ff34e8..9ccf548 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .DS_Store -etcdtool +.build bin pkg -.build *.rpm diff --git a/ebuild/dev-db/etcd-export/etcd-export-2.3.1.ebuild b/ebuild/dev-db/etcd-export/etcd-export-2.3.1.ebuild deleted file mode 100644 index fb59c2e..0000000 --- a/ebuild/dev-db/etcd-export/etcd-export-2.3.1.ebuild +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright 1999-2015 Gentoo Foundation -# Distributed under the terms of the GNU General Public License v2 -# $Id$ -# By Jean-Michel Smith, first created 9/21/15 - -EAPI=5 - -inherit user git-r3 - -DESCRIPTION="Expose hardware info using JSON/REST and provide a system HTML Front-End" -HOMEPAGE="https://github.com/mickep76/peekaboo.git" -SRC_URI="" - -LICENSE="Apache-2.0" -SLOT="0" -KEYWORDS="amd64" -IUSE="" - -DEPEND="dev-lang/go" - -EGIT_REPO_URI="https://github.com/mickep76/etcd-export.git" -EGIT_COMMIT="${PV}" - -GOPATH="${WORKDIR}/etcd-export-${PV}" - -src_compile() { - ebegin "Building etcd-export ${PV}" - export GOPATH - export PATH=${GOPATH}/bin:${PATH} - cd ${GOPATH} - ./build - cd - eend ${?} -} - -src_install() { - ebegin "installing etcd-export ${PV}" - dobin ${GOPATH}/bin/etcd-export - dobin ${GOPATH}/bin/etcd-import - dobin ${GOPATH}/bin/etcd-delete - dobin ${GOPATH}/bin/etcd-tree - dobin ${GOPATH}/bin/etcd-validate - dobin ${GOPATH}/bin/etcd-edit - eend ${?} -} diff --git a/ebuild/dev-db/etcdtool/etcdtool-2.6.ebuild b/ebuild/dev-db/etcdtool/etcdtool-2.6.ebuild new file mode 100644 index 0000000..97c64ac --- /dev/null +++ b/ebuild/dev-db/etcdtool/etcdtool-2.6.ebuild @@ -0,0 +1,40 @@ +# Copyright 1999-2015 Gentoo Foundation +# Distributed under the terms of the GNU General Public License v2 +# $Id$ +# By Jean-Michel Smith, first created 9/21/15 + +EAPI=5 + +inherit user git-r3 + +DESCRIPTION="Export/Import/Edit etcd directory as JSON/YAML/TOML and validate directory using JSON schema" +HOMEPAGE="https://github.com/mickep76/etcdtool.git" +SRC_URI="" + +LICENSE="Apache-2.0" +SLOT="0" +KEYWORDS="amd64" +IUSE="" + +DEPEND="dev-lang/go" + +EGIT_REPO_URI="https://github.com/mickep76/etcdtool.git" +EGIT_COMMIT="${PV}" + +GOPATH="${WORKDIR}/etcdtool-${PV}" + +src_compile() { + ebegin "Building etcdtool ${PV}" + export GOPATH + export PATH=${GOPATH}/bin:${PATH} + cd ${GOPATH} + ./build + cd + eend ${?} +} + +src_install() { + ebegin "installing etcdtool ${PV}" + dobin ${GOPATH}/bin/etcdtool + eend ${?} +} diff --git a/src/github.com/mickep76/etcdtool/command/connect.go b/src/github.com/mickep76/etcdtool/command/connect.go new file mode 100644 index 0000000..69aa028 --- /dev/null +++ b/src/github.com/mickep76/etcdtool/command/connect.go @@ -0,0 +1,63 @@ +package command + +import ( + "log" + "net/http" + "strings" + "time" + + "github.com/codegangsta/cli" + "github.com/coreos/etcd/client" + "github.com/coreos/etcd/pkg/transport" + "golang.org/x/net/context" +) + +func contextWithCommandTimeout(c *cli.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), c.GlobalDuration("command-timeout")) +} + +func newTransport(c *cli.Context) *http.Transport { + tls := transport.TLSInfo{ + CAFile: c.GlobalString("ca"), + CertFile: c.GlobalString("cert"), + KeyFile: c.GlobalString("key"), + } + + timeout := 30 * time.Second + tr, err := transport.NewTransport(tls, timeout) + if err != nil { + log.Fatal(err.Error()) + } + + return tr +} + +func newClient(c *cli.Context) client.Client { + cfg := client.Config{ + Transport: newTransport(c), + Endpoints: strings.Split(c.GlobalString("peers"), ","), + HeaderTimeoutPerRequest: c.GlobalDuration("timeout"), + } + + /* + uFlag := c.GlobalString("username") + if uFlag != "" { + username, password, err := getUsernamePasswordFromFlag(uFlag) + if err != nil { + return nil, err + } + cfg.Username = username + cfg.Password = password + } + */ + cl, err := client.New(cfg) + if err != nil { + log.Fatal(err.Error()) + } + + return cl +} + +func newKeyAPI(c *cli.Context) client.KeysAPI { + return client.NewKeysAPI(newClient(c)) +} diff --git a/src/github.com/mickep76/etcdtool/command/edit_command.go b/src/github.com/mickep76/etcdtool/command/edit_command.go new file mode 100644 index 0000000..e176d5d --- /dev/null +++ b/src/github.com/mickep76/etcdtool/command/edit_command.go @@ -0,0 +1,95 @@ +package command + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" + + "github.com/codegangsta/cli" + "github.com/coreos/etcd/client" + "github.com/mickep76/iodatafmt" +) + +// NewImportCommand sets data from input. +func NewEditCommand() cli.Command { + return cli.Command{ + Name: "edit", + Usage: "edit a directory", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "sort, s", Usage: "returns result in sorted order"}, + cli.BoolFlag{Name: "yes, y", Usage: "Answer yes to any questions"}, + cli.BoolFlag{Name: "replace, r", Usage: "Replace data"}, + cli.StringFlag{Name: "format, f", Value: "JSON", EnvVar: "ETCDTOOL_FORMAT", Usage: "Data serialization format YAML, TOML or JSON"}, + cli.StringFlag{Name: "editor, e", Value: "vim", Usage: "Editor", EnvVar: "EDITOR"}, + cli.StringFlag{Name: "tmp-file, t", Value: ".etcdtool.swp", Usage: "Temporary file"}, + }, + Action: func(c *cli.Context) { + editCommandFunc(c, newKeyAPI(c)) + }, + } +} + +func editFile(editor string, file string) error { + _, err := exec.LookPath(editor) + if err != nil { + log.Fatalf("Editor doesn't exist: %s", editor) + } + + cmd := exec.Command(editor, file) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + log.Fatal(err.Error()) + } + return nil +} + +// editCommandFunc edit data as either JSON, YAML or TOML. +func editCommandFunc(c *cli.Context, ki client.KeysAPI) { + var key string + if len(c.Args()) == 0 { + log.Fatal("You need to specify directory") + } else { + key = strings.TrimRight(c.Args()[0], "/") + "/" + } + + sort := c.Bool("sort") + + // Get data format. + f, err := iodatafmt.Format(c.String("format")) + if err != nil { + log.Fatal(err.Error()) + } + + // Export to file. + exportFunc(key, sort, c.String("tmp-file"), f, c, ki) + + // Get modified time stamp. + before, err := os.Stat(c.String("tmp-file")) + if err != nil { + log.Fatal(err.Error()) + } + + // Edit file. + editFile(c.String("editor"), c.String("tmp-file")) + + // Check modified time stamp. + after, err := os.Stat(c.String("tmp-file")) + if err != nil { + log.Fatal(err.Error()) + } + + // Import from file if it has changed. + if before.ModTime() != after.ModTime() { + importFunc(key, c.String("tmp-file"), f, c.Bool("replace"), c.Bool("yes"), c, ki) + } else { + fmt.Printf("File wasn't modified, skipping import\n") + } + + // Unlink file. + if err := os.Remove(c.String("tmp-file")); err != nil { + log.Fatal(err.Error()) + } +} diff --git a/src/github.com/mickep76/etcdtool/command/export_command.go b/src/github.com/mickep76/etcdtool/command/export_command.go new file mode 100644 index 0000000..f65edd9 --- /dev/null +++ b/src/github.com/mickep76/etcdtool/command/export_command.go @@ -0,0 +1,63 @@ +package command + +import ( + "log" + "strings" + + "github.com/codegangsta/cli" + "github.com/coreos/etcd/client" + "github.com/mickep76/etcdmap" + "github.com/mickep76/iodatafmt" +) + +// NewExportCommand returns data from export. +func NewExportCommand() cli.Command { + return cli.Command{ + Name: "export", + Usage: "export a directory", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "sort, s", Usage: "returns result in sorted order"}, + cli.StringFlag{Name: "format, f", EnvVar: "ETCDTOOL_FORMAT", Value: "JSON", Usage: "Data serialization format YAML, TOML or JSON"}, + cli.StringFlag{Name: "output, o", Value: "", Usage: "Output file"}, + }, + Action: func(c *cli.Context) { + exportCommandFunc(c, newKeyAPI(c)) + }, + } +} + +// exportCommandFunc exports data as either JSON, YAML or TOML. +func exportCommandFunc(c *cli.Context, ki client.KeysAPI) { + key := "/" + if len(c.Args()) != 0 { + key = strings.TrimRight(c.Args()[0], "/") + "/" + } + + sort := c.Bool("sort") + + // Get data format. + f, err := iodatafmt.Format(c.String("format")) + if err != nil { + log.Fatal(err.Error()) + } + + exportFunc(key, sort, c.String("output"), f, c, ki) +} + +// exportCommandFunc exports data as either JSON, YAML or TOML. +func exportFunc(key string, sort bool, file string, f iodatafmt.DataFmt, c *cli.Context, ki client.KeysAPI) { + ctx, cancel := contextWithCommandTimeout(c) + resp, err := ki.Get(ctx, key, &client.GetOptions{Sort: sort, Recursive: true}) + cancel() + if err != nil { + log.Fatal(err.Error()) + } + + // Export and write output. + m := etcdmap.Map(resp.Node) + if file != "" { + iodatafmt.Write(file, m, f) + } else { + iodatafmt.Print(m, f) + } +} diff --git a/src/github.com/mickep76/etcdtool/command/import_command.go b/src/github.com/mickep76/etcdtool/command/import_command.go new file mode 100644 index 0000000..d62895d --- /dev/null +++ b/src/github.com/mickep76/etcdtool/command/import_command.go @@ -0,0 +1,155 @@ +package command + +import ( + "bufio" + "fmt" + "log" + "os" + "reflect" + "strings" + + "github.com/codegangsta/cli" + "github.com/coreos/etcd/client" + "github.com/mickep76/etcdmap" + "github.com/mickep76/iodatafmt" + "golang.org/x/net/context" +) + +// NewImportCommand sets data from input. +func NewImportCommand() cli.Command { + return cli.Command{ + Name: "import", + Usage: "import a directory", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "yes, y", Usage: "Answer yes to any questions"}, + cli.BoolFlag{Name: "replace, r", Usage: "Replace data"}, + cli.StringFlag{Name: "format, f", Value: "JSON", EnvVar: "ETCDTOOL_FORMAT", Usage: "Data serialization format YAML, TOML or JSON"}, + }, + Action: func(c *cli.Context) { + importCommandFunc(c, newKeyAPI(c)) + }, + } +} + +func keyExists(key string, c *cli.Context, ki client.KeysAPI) (bool, error) { + ctx, cancel := contextWithCommandTimeout(c) + _, err := ki.Get(ctx, key, &client.GetOptions{}) + cancel() + if err != nil { + if cerr, ok := err.(client.Error); ok && cerr.Code == 100 { + return false, nil + } + return false, err + } + return true, nil +} + +func isDir(key string, c *cli.Context, ki client.KeysAPI) (bool, error) { + ctx, cancel := contextWithCommandTimeout(c) + resp, err := ki.Get(ctx, key, &client.GetOptions{}) + cancel() + if err != nil { + return false, err + } + if resp.Node.Dir { + return false, nil + } + return true, nil +} + +func askYesNo(msg string) bool { + stdin := bufio.NewReader(os.Stdin) + + for { + fmt.Printf("%s [yes/no]? ", msg) + inp, _, err := stdin.ReadLine() + if err != nil { + log.Fatal(err.Error()) + } + + switch strings.ToLower(string(inp)) { + case "yes": + return true + case "no": + return false + default: + fmt.Printf("Incorrect input: %s\n ", inp) + } + } +} + +// importCommandFunc imports data as either JSON, YAML or TOML. +func importCommandFunc(c *cli.Context, ki client.KeysAPI) { + if len(c.Args()) == 0 { + log.Fatal("You need to specify directory") + } + // Fix for root + key := strings.TrimRight(c.Args()[0], "/") //+ "/" + + if len(c.Args()) == 1 { + log.Fatal("You need to specify input file") + } + input := c.Args()[1] + + // Get data format. + f, err := iodatafmt.Format(c.String("format")) + if err != nil { + log.Fatal(err.Error()) + } + + importFunc(key, input, f, c.Bool("replace"), c.Bool("yes"), c, ki) +} + +func importFunc(key string, file string, f iodatafmt.DataFmt, replace bool, yes bool, c *cli.Context, ki client.KeysAPI) { + // Check if key exists and is a directory. + exists, err := keyExists(key, c, ki) + if err != nil { + log.Fatalf("Specified key doesn't exist: %s", key) + } + + if exists { + dir, err := isDir(key, c, ki) + if err != nil { + log.Fatal(err.Error()) + } + + if dir { + log.Fatalf("Specified key is not a directory: %s", key) + } + } + + // Load file. + m, err := iodatafmt.Load(file, f) + if err != nil { + log.Fatal(err.Error()) + } + + if exists { + if replace { + if !askYesNo(fmt.Sprintf("Do you want to overwrite data in directory: %s", key)) { + os.Exit(1) + } + + // Delete dir. + if _, err = ki.Delete(context.TODO(), key, &client.DeleteOptions{Recursive: true}); err != nil { + log.Fatal(err.Error()) + } + } else { + if !yes { + if !askYesNo(fmt.Sprintf("Do you want to overwrite data in directory: %s", key)) { + os.Exit(1) + } + } + } + } else { + // Create dir. + if _, err := ki.Set(context.TODO(), key, "", &client.SetOptions{Dir: true}); err != nil { + log.Fatal(err.Error()) + } + } + + // Import data. + if err = etcdmap.Create(ki, key, reflect.ValueOf(m)); err != nil { + log.Fatal(err.Error()) + } +} diff --git a/src/github.com/mickep76/etcdtool/command/tree_command.go b/src/github.com/mickep76/etcdtool/command/tree_command.go new file mode 100644 index 0000000..c503704 --- /dev/null +++ b/src/github.com/mickep76/etcdtool/command/tree_command.go @@ -0,0 +1,75 @@ +package command + +import ( + "fmt" + "log" + "strings" + + "github.com/codegangsta/cli" + "github.com/coreos/etcd/client" + "golang.org/x/net/context" +) + +func NewTreeCommand() cli.Command { + return cli.Command{ + Name: "tree", + Usage: "List directory as a tree", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "sort", Usage: "returns result in sorted order"}, + }, + Action: func(c *cli.Context) { + treeCommandFunc(c, newKeyAPI(c)) + }, + } +} + +var numDirs int +var numKeys int + +// treeCommandFunc executes the "tree" command. +func treeCommandFunc(c *cli.Context, ki client.KeysAPI) { + key := "/" + if len(c.Args()) != 0 { + key = c.Args()[0] + } + + sort := c.Bool("sort") + + resp, err := ki.Get(context.TODO(), key, &client.GetOptions{Sort: sort, Recursive: true}) + if err != nil { + log.Fatal(err.Error()) + } + + numDirs = 0 + numKeys = 0 + fmt.Println(strings.TrimRight(key, "/") + "/") + printTree(resp.Node, "") + fmt.Printf("\n%d directories, %d keys\n", numDirs, numKeys) +} + +// printTree writes a response out in a manner similar to the `tree` command in unix. +func printTree(root *client.Node, indent string) { + for i, n := range root.Nodes { + keys := strings.Split(n.Key, "/") + k := keys[len(keys)-1] + + if n.Dir { + if i == root.Nodes.Len()-1 { + fmt.Printf("%s└── %s/\n", indent, k) + printTree(n, indent+" ") + } else { + fmt.Printf("%s├── %s/\n", indent, k) + printTree(n, indent+"│ ") + } + numDirs++ + } else { + if i == root.Nodes.Len()-1 { + fmt.Printf("%s└── %s\n", indent, k) + } else { + fmt.Printf("%s├── %s\n", indent, k) + } + + numKeys++ + } + } +} diff --git a/src/github.com/mickep76/etcdtool/command/validate_command.go b/src/github.com/mickep76/etcdtool/command/validate_command.go new file mode 100644 index 0000000..3fe13c6 --- /dev/null +++ b/src/github.com/mickep76/etcdtool/command/validate_command.go @@ -0,0 +1,63 @@ +package command + +import ( + "fmt" + "log" + "strings" + + "github.com/codegangsta/cli" + "github.com/coreos/etcd/client" + "github.com/mickep76/etcdmap" + "github.com/xeipuuv/gojsonschema" +) + +// NewValidateCommand sets data from input. +func NewValidateCommand() cli.Command { + return cli.Command{ + Name: "validate", + Usage: "validate a directory", + Flags: []cli.Flag{}, + Action: func(c *cli.Context) { + validateCommandFunc(c, newKeyAPI(c)) + }, + } +} + +// validateCommandFunc validate data using JSON Schema. +func validateCommandFunc(c *cli.Context, ki client.KeysAPI) { + var key string + if len(c.Args()) == 0 { + log.Fatal("You need to specify directory") + } else { + key = strings.TrimRight(c.Args()[0], "/") + "/" + } + + if len(c.Args()) == 1 { + log.Fatal("You need to specify JSON schema URI") + } + schema := c.Args()[1] + + // Get directory. + ctx, cancel := contextWithCommandTimeout(c) + resp, err := ki.Get(ctx, key, &client.GetOptions{Recursive: true}) + cancel() + if err != nil { + log.Fatal(err.Error()) + } + m := etcdmap.Map(resp.Node) + + // Validate directory. + schemaLoader := gojsonschema.NewReferenceLoader(schema) + docLoader := gojsonschema.NewGoLoader(m) + result, err := gojsonschema.Validate(schemaLoader, docLoader) + if err != nil { + log.Fatal(err.Error()) + } + + // Print results. + if !result.Valid() { + for _, e := range result.Errors() { + fmt.Printf("%s: %s\n", strings.Replace(e.Context().String("/"), "(root)", key, 1), e.Description()) + } + } +} diff --git a/src/github.com/mickep76/etcdtool/etcdtool b/src/github.com/mickep76/etcdtool/etcdtool new file mode 120000 index 0000000..e44ac19 --- /dev/null +++ b/src/github.com/mickep76/etcdtool/etcdtool @@ -0,0 +1 @@ +../../../../bin/etcdtool \ No newline at end of file diff --git a/src/github.com/mickep76/etcdtool/main.go b/src/github.com/mickep76/etcdtool/main.go new file mode 100644 index 0000000..02d5fdd --- /dev/null +++ b/src/github.com/mickep76/etcdtool/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "time" + + "github.com/codegangsta/cli" + "github.com/mickep76/etcdtool/command" +) + +func main() { + app := cli.NewApp() + app.Name = "etcdtool" + app.Version = Version + app.Usage = "Command line tool for etcd to import, export, edit or validate data in either JSON, YAML or TOML format." + app.Flags = []cli.Flag{ + cli.StringFlag{Name: "peers, p", Value: "http://127.0.0.1:4001,http://127.0.0.1:2379", EnvVar: "ETCDTOOL_PEERS", Usage: "Comma-delimited list of hosts in the cluster"}, + cli.StringFlag{Name: "cert", Value: "", EnvVar: "ETCDTOOL_CERT", Usage: "Identify HTTPS client using this SSL certificate file"}, + cli.StringFlag{Name: "key", Value: "", EnvVar: "ETCDTOOL_KEY", Usage: "Identify HTTPS client using this SSL key file"}, + cli.StringFlag{Name: "ca", Value: "", EnvVar: "ETCDTOOL_CA", Usage: "Verify certificates of HTTPS-enabled servers using this CA bundle"}, + cli.StringFlag{Name: "user, u", Value: "", Usage: "User"}, + cli.DurationFlag{Name: "timeout, t", Value: time.Second, Usage: "Connection timeout"}, + cli.DurationFlag{Name: "command-timeout, T", Value: 5 * time.Second, Usage: "Command timeout"}, + } + app.Commands = []cli.Command{ + command.NewImportCommand(), + command.NewExportCommand(), + command.NewEditCommand(), + command.NewValidateCommand(), + command.NewTreeCommand(), + } + + app.Run(os.Args) +} diff --git a/src/github.com/mickep76/etcdtool/version.go b/src/github.com/mickep76/etcdtool/version.go new file mode 100644 index 0000000..871fcb4 --- /dev/null +++ b/src/github.com/mickep76/etcdtool/version.go @@ -0,0 +1,4 @@ +package main + +// Version +const Version = "2.6"