├── .gitignore ├── version.go ├── examples ├── host │ ├── schemas │ │ ├── root.json │ │ ├── all_hosts.json │ │ ├── all_interfaces.json │ │ ├── types.json │ │ ├── interface.json │ │ └── host.json │ ├── test1.example.com.json │ ├── test2.example.com.json │ ├── etcdtool.toml │ └── array.example.com.json └── ntp │ ├── ntp_template.json │ ├── routes.json │ ├── ntp-site1.json │ ├── ntp-site2.json │ ├── ntp_schema.json │ └── ntp_site_schema.json ├── command ├── log.go ├── print_config_command.go ├── connect.go ├── tree_command.go ├── ls_command.go ├── config.go ├── validate_command.go ├── edit_command.go ├── import_command.go └── export_command.go ├── etcdtool.spec ├── LICENSE ├── go.mod ├── ebuild └── dev-db │ └── etcdtool │ └── etcdtool-3.3.ebuild ├── Makefile ├── init-etcd.sh ├── go.sum ├── main.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /build 4 | /bin 5 | /pkg 6 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Version for app. 4 | const Version = "3.4" 5 | -------------------------------------------------------------------------------- /examples/host/schemas/root.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Root Schema", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "hosts": { 8 | "$ref": "all_hosts.json#" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/host/schemas/all_hosts.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Host Schema", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "patternProperties": { 7 | ".+": { 8 | "$ref": "host.json#" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/host/test1.example.com.json: -------------------------------------------------------------------------------- 1 | { 2 | "site": "emea-nl-1", 3 | "tenant": "ops", 4 | "interfaces": { 5 | "eth0": { 6 | "gw": "192.168.0.1", 7 | "hwaddr": "00:01:02:03:04:05", 8 | "ip": "192.168.0.2", 9 | "netmask": "255.255.255.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/host/test2.example.com.json: -------------------------------------------------------------------------------- 1 | { 2 | "site": "emea-nl-1", 3 | "tenant": "ops", 4 | "interfaces": { 5 | "eth0": { 6 | "gw": "1.192.168.0.1", 7 | "hwaddr": "00:01:02:03:04:05", 8 | "ip": "192.168.0.2", 9 | "netmask": "255.255.255.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/ntp/ntp_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | "ntp.example.com": { 4 | "options": "minpoll 3 maxpoll 7" 5 | } 6 | }, 7 | "driftfile": "/var/lib/ntp/drift", 8 | "enable_stats": "true", 9 | "statsdir": "/var/log/ntpstats", 10 | "statistics": "loopstats peerstats" 11 | } 12 | -------------------------------------------------------------------------------- /examples/host/schemas/all_interfaces.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "All Interfaces Schema", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "patternProperties": { 7 | ".+": { 8 | "$ref": "interface.json#" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/ntp/routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "ntp_site": { 3 | "regexp": "^/ntp$", 4 | "schema": "/schemas/ntp_site", 5 | "template": "/templates/ntp_site" 6 | }, 7 | "ntp": { 8 | "regexp": "^/ntp/[a-z0-9]+$", 9 | "schema": "/schemas/ntp", 10 | "template": "/templates/ntp" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/host/schemas/types.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Types Schema", 4 | "definitions": { 5 | "boolean": { 6 | "type": "string", 7 | "enum": [ 8 | "true", 9 | "false" 10 | ] 11 | }, 12 | "hwaddr": { 13 | "type": "string", 14 | "pattern": "^([0-9a-f]{2}:){5}[0-9a-f]{2}$" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /command/log.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | var debug bool 8 | 9 | func info(msg string) { 10 | if debug { 11 | log.Print(msg) 12 | } 13 | } 14 | 15 | func infof(fmt string, args ...interface{}) { 16 | if debug { 17 | log.Printf(fmt, args...) 18 | } 19 | } 20 | 21 | func fatal(msg string) { 22 | log.Fatal(msg) 23 | } 24 | 25 | func fatalf(fmt string, args ...interface{}) { 26 | log.Fatalf(fmt, args...) 27 | } 28 | -------------------------------------------------------------------------------- /examples/ntp/ntp-site1.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | "ntp1.site1.example.com": { 4 | "options": "minpoll 3 maxpoll 7" 5 | }, 6 | "ntp2.site1.example.com": { 7 | "options": "minpoll 3 maxpoll 7" 8 | }, 9 | "ntp3.site1.example.com": { 10 | "options": "minpoll 3 maxpoll 7" 11 | } 12 | }, 13 | "driftfile": "/var/lib/ntp/drift", 14 | "enable_stats": "true", 15 | "statsdir": "/var/log/ntpstats", 16 | "statistics": "loopstats peerstats" 17 | } 18 | -------------------------------------------------------------------------------- /examples/host/schemas/interface.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Interface Schema", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "gw": { 8 | "format": "ipv4" 9 | }, 10 | "hwaddr": { 11 | "$ref": "types.json#/definitions/hwaddr" 12 | }, 13 | "ip": { 14 | "format": "ipv4" 15 | }, 16 | "netmask": { 17 | "format": "ipv4" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/ntp/ntp-site2.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | "ntp1.site2.example.com": { 4 | "options": "minpoll 3 maxpoll 7" 5 | }, 6 | "ntp2.site2.example.com": { 7 | "options": "minpoll 3 maxpoll 7" 8 | }, 9 | ",ntp3.site2.example.com": { 10 | "options": "minpoll 3 maxpoll 7" 11 | } 12 | }, 13 | "driftfile": "/var/lib/ntp/drift", 14 | "enable_stats": "true", 15 | "statsdir": "/var/log/ntpstats/", 16 | "statistics": "loopstats peerstats" 17 | } 18 | -------------------------------------------------------------------------------- /examples/host/etcdtool.toml: -------------------------------------------------------------------------------- 1 | [[routes]] 2 | regexp = "^/$" 3 | schema = "file://schemas/root.json" 4 | 5 | [[routes]] 6 | regexp = "^/hosts$" 7 | schema = "file://schemas/all_hosts.json" 8 | 9 | [[routes]] 10 | regexp = "^/hosts/[a-z]([a-z0-9-]+\\.)+[a-z]+$" 11 | schema = "file://schemas/host.json" 12 | 13 | [[routes]] 14 | regexp = "^/hosts/[a-z]([a-z0-9-]+\\.)+[a-z]+/interfaces$" 15 | schema = "file://schemas/all_interfaces.json" 16 | 17 | [[routes]] 18 | regexp = "^/hosts/[a-z]([a-z0-9-]+\\.)+[a-z]+/interfaces/.+$" 19 | schema = "file://schemas/interface.json" 20 | -------------------------------------------------------------------------------- /etcdtool.spec: -------------------------------------------------------------------------------- 1 | %define name %NAME% 2 | %define version %VERSION% 3 | %define release %RELEASE% 4 | %define buildroot %{_topdir}/BUILDROOT 5 | %define sources %{_topdir}/SOURCES 6 | 7 | BuildRoot: %{buildroot} 8 | Source: %SOURCE% 9 | Summary: %{name} 10 | Name: %{name} 11 | Version: %{version} 12 | Release: %{release} 13 | License: Apache License, Version 2.0 14 | Group: System 15 | AutoReqProv: no 16 | 17 | %description 18 | %{name} 19 | 20 | %prep 21 | mkdir -p %{buildroot}/usr/bin 22 | cp %{sources}/%{name} %{buildroot}/usr/bin 23 | 24 | %files 25 | %defattr(-,root,root) 26 | /usr/bin/* 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | etcdtool 2 | 3 | Copyright (c), 2015 Michael Persson 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /examples/host/schemas/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Host Schema", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "required": [ 7 | "site" 8 | ], 9 | "properties": { 10 | "interfaces": { 11 | "type": "object", 12 | "patternProperties": { 13 | ".+": { 14 | "$ref": "interface.json#" 15 | } 16 | } 17 | }, 18 | "site": { 19 | "type": "string", 20 | "pattern": "^[a-z][a-z0-9-_]+$" 21 | }, 22 | "tenant": { 23 | "type": "string", 24 | "pattern": "^[a-z][a-z0-9-_]+$" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/host/array.example.com.json: -------------------------------------------------------------------------------- 1 | { 2 | "site": "emea-nl-1", 3 | "tenant": "ops", 4 | "interfaces": { 5 | "eth0": { 6 | "gw": "192.168.0.1", 7 | "hwaddr": "00:01:02:03:04:05", 8 | "ip": "192.168.0.2", 9 | "netmask": "255.255.255.0", 10 | "array_inside_map":[ 11 | { "key1": "value1"}, 12 | { "key2": "value2"}, 13 | { "key3": "value3"}, 14 | { "key": "another_value_fro_key","value":"true"}, 15 | { "key4": "True"} 16 | ] 17 | } 18 | }, 19 | "array_test":["first_elem","second_elem","third_elem"], 20 | "array_two_levels":[["first_elem","second_elem"],["third_elem"], 5, 5.0, true], 21 | "map_empty":{} 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mickep76/etcdtool 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.2.1-0.20160426202516-f0aeabca5a12 7 | github.com/bgentry/speakeasy v0.0.0-20150902231413-36e9cfdd6909 8 | github.com/codegangsta/cli v1.16.1-0.20160502160658-415b5e766a61 9 | github.com/coreos/etcd v2.3.1-0.20160502184358-a8139e2b0e23+incompatible 10 | github.com/mickep76/etcdmap v0.0.0-20160502152020-c53d6e11e292 11 | github.com/mickep76/iodatafmt v0.0.0-20181011194834-7c7e9a8f31cd 12 | github.com/ugorji/go v0.0.0-20160328060740-a396ed22fc04 13 | github.com/xeipuuv/gojsonpointer v0.0.0-20151027082146-e0fe6f683076 14 | github.com/xeipuuv/gojsonreference v0.0.0-20150808065054-e02fc20de94c 15 | github.com/xeipuuv/gojsonschema v0.0.0-20160430164825-d3178baac324 16 | golang.org/x/net v0.0.0-20160501043121-35ec611a141e 17 | gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129 18 | ) 19 | -------------------------------------------------------------------------------- /command/print_config_command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/codegangsta/cli" 5 | "github.com/mickep76/iodatafmt" 6 | ) 7 | 8 | // NewPrintConfigCommand print configuration. 9 | func NewPrintConfigCommand() cli.Command { 10 | return cli.Command{ 11 | Name: "print-config", 12 | Usage: "Print configuration", 13 | Flags: []cli.Flag{ 14 | cli.BoolFlag{Name: "sort", Usage: "returns result in sorted order"}, 15 | cli.StringFlag{Name: "format, f", EnvVar: "ETCDTOOL_FORMAT", Value: "JSON", Usage: "Data serialization format YAML, TOML or JSON"}, 16 | }, 17 | Action: func(c *cli.Context) error { 18 | printConfigCommandFunc(c) 19 | return nil 20 | }, 21 | } 22 | } 23 | 24 | func printConfigCommandFunc(c *cli.Context) { 25 | // Load configuration file. 26 | e := loadConfig(c) 27 | 28 | // Get data format. 29 | f, err := iodatafmt.Format(c.String("format")) 30 | if err != nil { 31 | fatal(err.Error()) 32 | } 33 | 34 | iodatafmt.Print(e, f) 35 | } 36 | -------------------------------------------------------------------------------- /ebuild/dev-db/etcdtool/etcdtool-3.3.ebuild: -------------------------------------------------------------------------------- 1 | # Copyright 1999-2015 Gentoo Foundation 2 | # Distributed under the terms of the GNU General Public License v2 3 | # $Id$ 4 | # By Jean-Michel Smith, first created 9/21/15 5 | 6 | EAPI=5 7 | 8 | inherit user git-r3 9 | 10 | DESCRIPTION="Export/Import/Edit etcd directory as JSON/YAML/TOML and validate directory using JSON schema" 11 | HOMEPAGE="https://github.com/mickep76/etcdtool.git" 12 | SRC_URI="" 13 | 14 | LICENSE="Apache-2.0" 15 | SLOT="0" 16 | KEYWORDS="amd64" 17 | IUSE="" 18 | 19 | DEPEND="dev-lang/go" 20 | 21 | EGIT_REPO_URI="https://github.com/mickep76/etcdtool.git" 22 | EGIT_COMMIT="${PV}" 23 | 24 | GOPATH="${WORKDIR}/etcdtool-${PV}" 25 | 26 | src_compile() { 27 | ebegin "Building etcdtool ${PV}" 28 | export GOPATH 29 | export PATH=${GOPATH}/bin:${PATH} 30 | cd ${GOPATH} 31 | ./build 32 | cd 33 | eend ${?} 34 | } 35 | 36 | src_install() { 37 | ebegin "installing etcdtool ${PV}" 38 | dobin ${GOPATH}/bin/etcdtool 39 | eend ${?} 40 | } 41 | -------------------------------------------------------------------------------- /examples/ntp/ntp_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "NTP configuration", 4 | "definitions": { 5 | "boolean": { 6 | "type": "string", 7 | "enum": [ "true", "false" ] 8 | }, 9 | "filepath": { 10 | "type": "string", 11 | "pattern": "^(.+)/([^/]+)$" 12 | } 13 | }, 14 | "type": "object", 15 | "required": [ "servers", "driftfile" ], 16 | "additionalProperties": false, 17 | "properties": { 18 | "servers": { 19 | "type": "object", 20 | "additionalProperties": false, 21 | "patternProperties": { 22 | "^[a-z0-9-.]+$": { 23 | "type": "object", 24 | "required": [ "options" ], 25 | "additionalProperties": false, 26 | "properties": { 27 | "options": { 28 | "type": "string" 29 | } 30 | } 31 | } 32 | } 33 | }, 34 | "driftfile": { 35 | "$ref": "#/definitions/filepath" 36 | }, 37 | "enable_stats": { 38 | "$ref": "#/definitions/boolean" 39 | }, 40 | "statsdir": { 41 | "$ref": "#/definitions/filepath" 42 | }, 43 | "statistics": { 44 | "type": "string" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/ntp/ntp_site_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "NTP configuration", 4 | "definitions": { 5 | "boolean": { 6 | "type": "string", 7 | "enum": [ "true", "false" ] 8 | }, 9 | "filepath": { 10 | "type": "string", 11 | "pattern": "^(.+)/([^/]+)$" 12 | } 13 | }, 14 | "type": "object", 15 | "additionalProperties": false, 16 | "patternProperties": { 17 | "^[a-z0-9]+$": { 18 | "type": "object", 19 | "required": [ "servers", "driftfile" ], 20 | "additionalProperties": false, 21 | "properties": { 22 | "servers": { 23 | "type": "object", 24 | "additionalProperties": false, 25 | "patternProperties": { 26 | "^[a-z0-9-.]+$": { 27 | "type": "object", 28 | "required": [ "options" ], 29 | "additionalProperties": false, 30 | "properties": { 31 | "options": { 32 | "type": "string" 33 | } 34 | } 35 | } 36 | } 37 | }, 38 | "driftfile": { 39 | "$ref": "#/definitions/filepath" 40 | }, 41 | "enable_stats": { 42 | "$ref": "#/definitions/boolean" 43 | }, 44 | "statsdir": { 45 | "$ref": "#/definitions/filepath" 46 | }, 47 | "statistics": { 48 | "type": "string" 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=etcdtool 2 | BUILDDIR=build 3 | SRCDIR=github.com/mickep76/$(NAME) 4 | VERSION:=$(shell git describe --abbrev=0 --tags) 5 | RELEASE:=$(shell date -u +%Y%m%d%H%M) 6 | ARCH:=$(shell uname -p) 7 | 8 | all: build 9 | 10 | clean: 11 | rm -rf ${BUILDDIR} release 12 | 13 | build build-binary: clean 14 | mkdir ${BUILDDIR} || true 15 | go build -o ${BUILDDIR}/${NAME} 16 | 17 | darwin: build 18 | mkdir release || true 19 | mv ${BUILDDIR}/${NAME} release/${NAME}-${VERSION}-${RELEASE}.darwin.x86_64 20 | 21 | rpm: 22 | docker pull mickep76/centos-golang:latest 23 | docker run --rm -it -v "$$PWD":/go/src/$(SRCDIR) -w /go/src/$(SRCDIR) mickep76/centos-golang:latest make build-rpm 24 | 25 | binary: 26 | docker pull mickep76/centos-golang:latest 27 | docker run --rm -it -v "$$PWD":/go/src/$(SRCDIR) -w /go/src/$(SRCDIR) mickep76/centos-golang:latest make build-binary 28 | mkdir release || true 29 | mv ${BUILDDIR}/${NAME} release/${NAME}-${VERSION}-${RELEASE}.linux.x86_64 30 | 31 | set-version: 32 | sed -i .tmp "s/const Version =.*/const Version = \"${VERSION}\"/" version.go 33 | rm -f version.go.tmp 34 | 35 | release: clean set-version darwin rpm binary 36 | 37 | build-rpm: build 38 | mkdir -p ${BUILDDIR}/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} 39 | cp ${BUILDDIR}/${NAME} ${BUILDDIR}/SOURCES 40 | sed -e "s/%NAME%/${NAME}/g" -e "s/%VERSION%/${VERSION}/g" -e "s/%RELEASE%/${RELEASE}/g" \ 41 | ${NAME}.spec >${BUILDDIR}/SPECS/${NAME}.spec 42 | rpmbuild -vv -bb --target="${ARCH}" --clean --define "_topdir $$(pwd)/${BUILDDIR}" ${BUILDDIR}/SPECS/${NAME}.spec 43 | mkdir release || true 44 | mv ${BUILDDIR}/RPMS/${ARCH}/*.rpm release 45 | -------------------------------------------------------------------------------- /command/connect.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/bgentry/speakeasy" 10 | "github.com/codegangsta/cli" 11 | "github.com/coreos/etcd/client" 12 | "github.com/coreos/etcd/pkg/transport" 13 | "golang.org/x/net/context" 14 | ) 15 | 16 | func contextWithCommandTimeout(c *cli.Context) (context.Context, context.CancelFunc) { 17 | return context.WithTimeout(context.Background(), c.GlobalDuration("command-timeout")) 18 | } 19 | 20 | func newTransport(e Etcdtool) *http.Transport { 21 | tls := transport.TLSInfo{ 22 | CAFile: e.CA, 23 | CertFile: e.Cert, 24 | KeyFile: e.Key, 25 | } 26 | 27 | timeout := 30 * time.Second 28 | tr, err := transport.NewTransport(tls, timeout) 29 | if err != nil { 30 | fatal(err.Error()) 31 | } 32 | 33 | return tr 34 | } 35 | 36 | func newClient(e Etcdtool) client.Client { 37 | cfg := client.Config{ 38 | Transport: newTransport(e), 39 | Endpoints: strings.Split(e.Peers, ","), 40 | HeaderTimeoutPerRequest: e.Timeout, 41 | } 42 | 43 | if e.User != "" { 44 | cfg.Username = e.User 45 | var err error 46 | if e.PasswordFilePath != "" { 47 | passBytes, err := ioutil.ReadFile(e.PasswordFilePath) 48 | if err != nil { 49 | fatal(err.Error()) 50 | } 51 | cfg.Password = strings.TrimRight(string(passBytes), "\n") 52 | } else { 53 | cfg.Password, err = speakeasy.Ask("Password: ") 54 | if err != nil { 55 | fatal(err.Error()) 56 | } 57 | } 58 | } 59 | 60 | cl, err := client.New(cfg) 61 | if err != nil { 62 | fatal(err.Error()) 63 | } 64 | 65 | return cl 66 | } 67 | 68 | func newKeyAPI(e Etcdtool) client.KeysAPI { 69 | return client.NewKeysAPI(newClient(e)) 70 | } 71 | -------------------------------------------------------------------------------- /init-etcd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | IMAGE='quay.io/coreos/etcd:latest' 4 | NAME='etcd' 5 | 6 | case $(uname -s) in 7 | 'Darwin') 8 | HOST_PORT=${DOCKER_HOST#tcp://} 9 | IP=${HOST_PORT%:[0-9]*} 10 | ;; 11 | 'Linux') 12 | IP=$(ifconfig eth0 | awk '/inet / {print $2}') 13 | ;; 14 | esac 15 | 16 | start() { 17 | docker run -d -v /usr/share/ca-certificates/:/etc/ssl/certs -p 4001:4001 -p 2380:2380 -p 2379:2379 \ 18 | --name ${NAME}0 quay.io/coreos/etcd:v2.0.8 \ 19 | -name ${NAME}0 \ 20 | -advertise-client-urls http://${IP}:2379,http://${IP}:4001 \ 21 | -listen-client-urls http://0.0.0.0:2379,http://0.0.0.0:4001 \ 22 | -initial-advertise-peer-urls http://${IP}:2380 \ 23 | -listen-peer-urls http://0.0.0.0:2380 \ 24 | -initial-cluster-token etcd-cluster-1 \ 25 | -initial-cluster etcd0=http://${IP}:2380 \ 26 | -initial-cluster-state new 27 | } 28 | 29 | stop() { 30 | docker stop ${NAME}0 31 | docker rm ${NAME}0 32 | } 33 | 34 | status() { 35 | echo NODE1: ${NAME}1 IP: ${IP} PORT: 4001 36 | } 37 | 38 | error() { 39 | echo "$1" >&2 40 | exit 1 41 | } 42 | 43 | which docker &>/dev/null || error "Docker binary is not in the \$PATH" 44 | docker ps &>/dev/null || error "Can't talk to the Docker daemon, either start the daemon or setup the required env. variables" 45 | 46 | CMD=$1 47 | case ${CMD} in 48 | 'start') 49 | start 50 | sleep 1 51 | status 52 | echo "# Run the following to export the environment" 53 | echo "# eval \"\$(./init-etcd.sh env)\"" 54 | ;; 55 | 'stop') 56 | stop 57 | ;; 58 | 'restart') 59 | stop 60 | start 61 | sleep 1 62 | status 63 | ;; 64 | 'status') 65 | status 66 | ;; 67 | 'env') 68 | echo "export ETCDCTL_PEERS=\"http://${IP}:4001\"" 69 | echo "export ETCDTOOL_PEERS=\"http://${IP}:4001\"" 70 | echo "# Run the following to export the environment" 71 | echo "# eval \"\$(./init-etcd.sh env)\"" 72 | 73 | ;; 74 | *) 75 | echo "Usage: $0 {start|stop|restart|status|env}" 76 | ;; 77 | esac 78 | -------------------------------------------------------------------------------- /command/tree_command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/codegangsta/cli" 8 | "github.com/coreos/etcd/client" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | // NewTreeCommand print directory as a tree. 13 | func NewTreeCommand() cli.Command { 14 | return cli.Command{ 15 | Name: "tree", 16 | Usage: "List directory as a tree", 17 | Flags: []cli.Flag{ 18 | cli.BoolFlag{Name: "sort", Usage: "returns result in sorted order"}, 19 | }, 20 | Action: func(c *cli.Context) error { 21 | treeCommandFunc(c) 22 | return nil 23 | }, 24 | } 25 | } 26 | 27 | var numDirs int 28 | var numKeys int 29 | 30 | // treeCommandFunc executes the "tree" command. 31 | func treeCommandFunc(c *cli.Context) { 32 | if len(c.Args()) == 0 { 33 | fatal("You need to specify directory") 34 | } 35 | dir := c.Args()[0] 36 | 37 | // Remove trailing slash. 38 | if dir != "/" { 39 | dir = strings.TrimRight(dir, "/") 40 | } 41 | infof("Using dir: %s", dir) 42 | 43 | // Load configuration file. 44 | e := loadConfig(c) 45 | 46 | // New dir API. 47 | ki := newKeyAPI(e) 48 | 49 | sort := c.Bool("sort") 50 | 51 | resp, err := ki.Get(context.TODO(), dir, &client.GetOptions{Sort: sort, Recursive: true}) 52 | if err != nil { 53 | fatal(err.Error()) 54 | } 55 | 56 | numDirs = 0 57 | numKeys = 0 58 | fmt.Println(strings.TrimRight(dir, "/") + "/") 59 | printTree(resp.Node, "") 60 | fmt.Printf("\n%d directories, %d dirs\n", numDirs, numKeys) 61 | } 62 | 63 | // printTree writes a response out in a manner similar to the `tree` command in unix. 64 | func printTree(root *client.Node, indent string) { 65 | for i, n := range root.Nodes { 66 | dirs := strings.Split(n.Key, "/") 67 | k := dirs[len(dirs)-1] 68 | 69 | if n.Dir { 70 | if i == root.Nodes.Len()-1 { 71 | fmt.Printf("%s└── %s/\n", indent, k) 72 | printTree(n, indent+" ") 73 | } else { 74 | fmt.Printf("%s├── %s/\n", indent, k) 75 | printTree(n, indent+"│ ") 76 | } 77 | numDirs++ 78 | } else { 79 | if i == root.Nodes.Len()-1 { 80 | fmt.Printf("%s└── %s\n", indent, k) 81 | } else { 82 | fmt.Printf("%s├── %s\n", indent, k) 83 | } 84 | 85 | numKeys++ 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /command/ls_command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/codegangsta/cli" 10 | "github.com/coreos/etcd/client" 11 | "github.com/mickep76/etcdmap" 12 | ) 13 | 14 | // NewLsCommand returns keys from directory 15 | func NewLsCommand() cli.Command { 16 | return cli.Command{ 17 | Name: "ls", 18 | Usage: "list a directory", 19 | Flags: []cli.Flag{ 20 | cli.StringFlag{Name: "output, o", Value: "", Usage: "Output file"}, 21 | }, 22 | Action: func(c *cli.Context) error { 23 | lsCommandFunc(c) 24 | return nil 25 | }, 26 | } 27 | } 28 | 29 | // lsCommandFunc does what `etcdctl ls -p` do 30 | func lsCommandFunc(c *cli.Context) { 31 | if len(c.Args()) == 0 { 32 | fatal("You need to specify directory") 33 | } 34 | dir := c.Args()[0] 35 | 36 | // Remove trailing slash. 37 | if dir != "/" { 38 | dir = strings.TrimRight(dir, "/") 39 | } 40 | infof("Using dir: %s", dir) 41 | 42 | // Load configuration file. 43 | e := loadConfig(c) 44 | 45 | // New dir API. 46 | ki := newKeyAPI(e) 47 | 48 | lsFunc(dir, c.String("output"), c, ki) 49 | } 50 | 51 | func lsFunc(dir string, file string, c *cli.Context, ki client.KeysAPI) { 52 | ctx, cancel := contextWithCommandTimeout(c) 53 | resp, err := ki.Get(ctx, dir, &client.GetOptions{Sort: true, Recursive: false}) 54 | cancel() 55 | if err != nil { 56 | fatal(err.Error()) 57 | } 58 | 59 | m := etcdmap.Map(resp.Node) 60 | 61 | keys := make([]string, 0, len(m)) 62 | for k := range m { 63 | isDir := false 64 | switch m[k].(type) { 65 | case map[string]interface {}: 66 | isDir = true 67 | default: 68 | // nothing to do 69 | } 70 | 71 | if isDir { 72 | keys = append(keys, k + "/") 73 | } else { 74 | keys = append(keys, k) 75 | } 76 | } 77 | sort.Strings(keys) 78 | 79 | var result strings.Builder 80 | for i, k := range(keys) { 81 | if i > 0 { 82 | result.WriteString("\n") 83 | } 84 | result.WriteString("/") 85 | result.WriteString(k) 86 | i++ 87 | } 88 | 89 | if file != "" { 90 | file, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE, 0755) 91 | if err != nil { 92 | fatal(err.Error()) 93 | } 94 | file.WriteString(result.String()) 95 | file.WriteString("\n") 96 | } else { 97 | fmt.Printf("%s\n", result.String()) 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.2.1-0.20160426202516-f0aeabca5a12 h1:8dFVzWNyce4k1Zmpd4a0HgqdYpd2o8jOqn2BRAnBHI0= 2 | github.com/BurntSushi/toml v0.2.1-0.20160426202516-f0aeabca5a12/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/bgentry/speakeasy v0.0.0-20150902231413-36e9cfdd6909 h1:mUVWHQ4tjVv86uJhxSbYqwdz4o+Imcl6HoZtoaqC3zM= 4 | github.com/bgentry/speakeasy v0.0.0-20150902231413-36e9cfdd6909/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 5 | github.com/codegangsta/cli v1.16.1-0.20160502160658-415b5e766a61 h1:mQ2qI5YtseZuGaPXKlqi0pnt9NSZX48XREJLW/otOiI= 6 | github.com/codegangsta/cli v1.16.1-0.20160502160658-415b5e766a61/go.mod h1:/qJNoX69yVSKu5o4jLyXAENLRyk1uhi7zkbQ3slBdOA= 7 | github.com/coreos/etcd v2.3.1-0.20160502184358-a8139e2b0e23+incompatible h1:Q6LwVGHdhaRvY7jCnFsnTFima51/PysLDuwnAHloUtE= 8 | github.com/coreos/etcd v2.3.1-0.20160502184358-a8139e2b0e23+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 9 | github.com/mickep76/etcdmap v0.0.0-20160502152020-c53d6e11e292 h1:cWsRqMY3/PlzNhtRdijo/j1phevl0h4uJGrFJNyccrQ= 10 | github.com/mickep76/etcdmap v0.0.0-20160502152020-c53d6e11e292/go.mod h1:dSTomsDkwYp/n/KnPHehjuyYTob82WWMo5ROgpdyaQk= 11 | github.com/mickep76/iodatafmt v0.0.0-20181011194834-7c7e9a8f31cd h1:LOd1oGUyKU+kdhKGoMZIS4ZeFmqRUFhJ6CpbRZhzB7Q= 12 | github.com/mickep76/iodatafmt v0.0.0-20181011194834-7c7e9a8f31cd/go.mod h1:4CLMizJparN2/tqNVx7xW0YJ0ze0+ArfSYv0mxMl3bY= 13 | github.com/ugorji/go v0.0.0-20160328060740-a396ed22fc04 h1:eE+Se4ffRJ16+igSBQWAYUXrIL9WBJ47bE/4uMxW9lA= 14 | github.com/ugorji/go v0.0.0-20160328060740-a396ed22fc04/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= 15 | github.com/xeipuuv/gojsonpointer v0.0.0-20151027082146-e0fe6f683076 h1:KM4T3G70MiR+JtqplcYkNVoNz7pDwYaBxWBXQK804So= 16 | github.com/xeipuuv/gojsonpointer v0.0.0-20151027082146-e0fe6f683076/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 17 | github.com/xeipuuv/gojsonreference v0.0.0-20150808065054-e02fc20de94c h1:XZWnr3bsDQWAZg4Ne+cPoXRPILrNlPNQfxBuwLl43is= 18 | github.com/xeipuuv/gojsonreference v0.0.0-20150808065054-e02fc20de94c/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 19 | github.com/xeipuuv/gojsonschema v0.0.0-20160430164825-d3178baac324 h1:9M5Fc21tMx/HEXfBg0ImElYv762R2F/UFWof+LA1UK4= 20 | github.com/xeipuuv/gojsonschema v0.0.0-20160430164825-d3178baac324/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= 21 | golang.org/x/net v0.0.0-20160501043121-35ec611a141e h1:w2KX2kEruytEo1pI7vQT5qf/tnfE+mbmLW9HQ0J/OYw= 22 | golang.org/x/net v0.0.0-20160501043121-35ec611a141e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 23 | gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129 h1:RBgb9aPUbZ9nu66ecQNIBNsA7j3mB5h8PNDIfhPjaJg= 24 | gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 25 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "log" 6 | "os" 7 | "path" 8 | "strings" 9 | "time" 10 | 11 | "github.com/codegangsta/cli" 12 | "github.com/mickep76/etcdtool/command" 13 | ) 14 | 15 | type Config struct { 16 | peers string 17 | } 18 | 19 | func readStandardConfig() Config { 20 | var ret Config 21 | dir, err := os.UserHomeDir() 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | cfg := path.Join(dir, ".etcdtool.conf") 26 | 27 | file, err := os.Open(cfg) 28 | if err != nil { 29 | return ret 30 | } 31 | defer file.Close() 32 | 33 | scanner := bufio.NewScanner(file) 34 | for scanner.Scan() { 35 | line := strings.TrimSpace(scanner.Text()) 36 | if strings.HasPrefix(line, "#") { 37 | continue 38 | } 39 | keyvals := strings.Split(line, " ") 40 | if len(keyvals) != 2 { 41 | log.Fatalf("Bad line in config file %s, line: %s", cfg, line) 42 | } 43 | if keyvals[0] == "peers" { 44 | ret.peers = keyvals[1] 45 | } 46 | } 47 | if err := scanner.Err(); err != nil { 48 | log.Fatal(err) 49 | } 50 | return ret 51 | } 52 | 53 | func main() { 54 | cfg := readStandardConfig() 55 | defaultPeers := "http://127.0.0.1:4001,http://127.0.0.1:2379" 56 | if len(cfg.peers) > 0 { 57 | defaultPeers = cfg.peers 58 | } 59 | 60 | app := cli.NewApp() 61 | app.Name = "etcdtool" 62 | app.Version = Version 63 | app.Usage = "Command line tool for etcd to import, export, edit or validate data in either JSON, YAML or TOML format." 64 | app.Flags = []cli.Flag{ 65 | cli.StringFlag{Name: "config, c", EnvVar: "ETCDTOOL_CONFIG", Usage: "Configuration file"}, 66 | cli.BoolFlag{Name: "debug, d", Usage: "Debug"}, 67 | cli.StringFlag{Name: "peers, p", Value: defaultPeers, EnvVar: "ETCDTOOL_PEERS", Usage: "Comma-delimited list of hosts in the cluster"}, 68 | cli.StringFlag{Name: "cert", Value: "", EnvVar: "ETCDTOOL_CERT", Usage: "Identify HTTPS client using this SSL certificate file"}, 69 | cli.StringFlag{Name: "key", Value: "", EnvVar: "ETCDTOOL_KEY", Usage: "Identify HTTPS client using this SSL key file"}, 70 | cli.StringFlag{Name: "ca", Value: "", EnvVar: "ETCDTOOL_CA", Usage: "Verify certificates of HTTPS-enabled servers using this CA bundle"}, 71 | cli.StringFlag{Name: "user, u", Value: "", Usage: "User"}, 72 | cli.StringFlag{Name: "password-file, F", Value: "", Usage: "File path to the user's password"}, 73 | cli.DurationFlag{Name: "timeout, t", Value: time.Second, Usage: "Connection timeout"}, 74 | cli.DurationFlag{Name: "command-timeout, T", Value: 5 * time.Second, Usage: "Command timeout"}, 75 | } 76 | app.Commands = []cli.Command{ 77 | command.NewImportCommand(), 78 | command.NewExportCommand(), 79 | command.NewLsCommand(), 80 | command.NewEditCommand(), 81 | command.NewValidateCommand(), 82 | command.NewTreeCommand(), 83 | command.NewPrintConfigCommand(), 84 | } 85 | 86 | app.Run(os.Args) 87 | } 88 | -------------------------------------------------------------------------------- /command/config.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os" 5 | "os/user" 6 | "time" 7 | 8 | "github.com/codegangsta/cli" 9 | "github.com/mickep76/iodatafmt" 10 | ) 11 | 12 | // Etcdtool configuration struct. 13 | type Etcdtool struct { 14 | Peers string `json:"peers,omitempty" yaml:"peers,omitempty" toml:"peers,omitempty"` 15 | Cert string `json:"cert,omitempty" yaml:"cert,omitempty" toml:"cert,omitempty"` 16 | Key string `json:"key,omitempty" yaml:"key,omitempty" toml:"key,omitempty"` 17 | CA string `json:"ca,omitempty" yaml:"ca,omitempty" toml:"peers,omitempty"` 18 | User string `json:"user,omitempty" yaml:"user,omitempty" toml:"user,omitempty"` 19 | Timeout time.Duration `json:"timeout,omitempty" yaml:"timeout,omitempty" toml:"timeout,omitempty"` 20 | CommandTimeout time.Duration `json:"commandTimeout,omitempty" yaml:"commandTimeout,omitempty" toml:"commandTimeout,omitempty"` 21 | Routes []Route `json:"routes" yaml:"routes" toml:"routes"` 22 | PasswordFilePath string 23 | } 24 | 25 | // Route configuration struct. 26 | type Route struct { 27 | Regexp string `json:"regexp" yaml:"regexp" toml:"regexp"` 28 | Schema string `json:"schema" yaml:"schema" toml:"schema"` 29 | } 30 | 31 | func loadConfig(c *cli.Context) Etcdtool { 32 | // Enable debug 33 | if c.GlobalBool("debug") { 34 | debug = true 35 | } 36 | 37 | // Default path for config file. 38 | u, _ := user.Current() 39 | cfgs := []string{ 40 | "/etcd/etcdtool.json", 41 | "/etcd/etcdtool.yaml", 42 | "/etcd/etcdtool.toml", 43 | u.HomeDir + "/.etcdtool.json", 44 | u.HomeDir + "/.etcdtool.yaml", 45 | u.HomeDir + "/.etcdtool.toml", 46 | } 47 | 48 | // Check if we have an arg. for config file and that it exist's. 49 | if c.GlobalString("config") != "" { 50 | if _, err := os.Stat(c.GlobalString("config")); os.IsNotExist(err) { 51 | fatalf("Config file doesn't exist: %s", c.GlobalString("config")) 52 | } 53 | cfgs = append([]string{c.GlobalString("config")}, cfgs...) 54 | } 55 | 56 | // Check if config file exists and load it. 57 | e := Etcdtool{} 58 | for _, fn := range cfgs { 59 | if _, err := os.Stat(fn); os.IsNotExist(err) { 60 | continue 61 | } 62 | infof("Using config file: %s", fn) 63 | f, err := iodatafmt.FileFormat(fn) 64 | if err != nil { 65 | fatal(err.Error()) 66 | } 67 | if err := iodatafmt.LoadPtr(&e, fn, f); err != nil { 68 | fatal(err.Error()) 69 | } 70 | } 71 | 72 | // Override with arguments or env. variables. 73 | if c.GlobalString("peers") != "" { 74 | e.Peers = c.GlobalString("peers") 75 | } 76 | 77 | if c.GlobalString("cert") != "" { 78 | e.Cert = c.GlobalString("cert") 79 | } 80 | 81 | if c.GlobalString("key") != "" { 82 | e.Key = c.GlobalString("key") 83 | } 84 | 85 | if c.GlobalString("ca") != "" { 86 | e.CA = c.GlobalString("ca") 87 | } 88 | 89 | if c.GlobalString("user") != "" { 90 | e.User = c.GlobalString("user") 91 | } 92 | 93 | if c.GlobalDuration("timeout") != 0 { 94 | e.Timeout = c.GlobalDuration("timeout") 95 | } 96 | 97 | if c.GlobalDuration("command-timeout") != 0 { 98 | e.CommandTimeout = c.GlobalDuration("command-timeout") 99 | } 100 | 101 | // Add password file path if set 102 | if c.GlobalString("password-file") != "" { 103 | e.PasswordFilePath = c.GlobalString("password-file") 104 | } 105 | 106 | return e 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # etcdtool 2 | 3 | Export/Import/Edit etcd directory as JSON/YAML/TOML and validate directory using JSON schema. 4 | 5 | # Use cases 6 | 7 | - Backup/Restore in a format which is not database or version specific. 8 | - Migration of data from production to testing. 9 | - Store configuration in Git and use import to populate etcd. 10 | - Validate directory entries using JSON schema. 11 | 12 | # Build 13 | 14 | ```bash 15 | git clone https://github.com/mickep76/etcdtool.git 16 | cd etcdtool 17 | make 18 | ``` 19 | 20 | # Build RPM 21 | 22 | Make sure you have Docker configured. 23 | 24 | ```bash 25 | git clone https://github.com/mickep76/etcdtool.git 26 | cd etcdtool 27 | make rpm 28 | ``` 29 | 30 | # Install using Homebrew on Mac OS X 31 | 32 | ```bash 33 | brew tap mickep76/funk-gnarge 34 | brew install etcdtool 35 | ``` 36 | 37 | **Update** 38 | 39 | ```bash 40 | brew update 41 | brew upgrade --all 42 | ``` 43 | 44 | # Example 45 | 46 | Make sure you have Docker configured. 47 | 48 | **Start etcd:** 49 | 50 | ``` 51 | ./init-etcd.sh start 52 | eval "$(./init-etcd.sh env)" 53 | ``` 54 | 55 | **Import some data:** 56 | 57 | ``` 58 | cd examples/host/ 59 | etcdtool import /hosts/test1.example.com test1.example.com.json 60 | etcdtool import /hosts/test2.example.com test2.example.com.json 61 | ``` 62 | 63 | **Inspect the content:** 64 | 65 | ``` 66 | etcdtool tree / 67 | etcdtool export / 68 | ``` 69 | 70 | **Export the content and infer numbers lists to keep original json:** 71 | 72 | ``` 73 | etcdtool export / --num-infer-list 74 | ``` 75 | 76 | **Validate data with different routes:** 77 | 78 | ``` 79 | etcdtool -d -c etcdtool.toml validate / 80 | etcdtool -d -c etcdtool.toml validate /hosts 81 | etcdtool -d -c etcdtool.toml validate /hosts/test2.example.com 82 | etcdtool -d -c etcdtool.toml validate /hosts/test2.example.com/interfaces 83 | etcdtool -d -c etcdtool.toml validate /hosts/test2.example.com/interfaces/eth0 84 | ``` 85 | 86 | **Import with validation**: 87 | 88 | ``` 89 | etcdtool -d -c etcdtool.toml import -v /hosts/test3.example.com test2.example.com.json 90 | ``` 91 | 92 | **Fix validation error:** 93 | 94 | ``` 95 | etcdtool -d -c etcdtool.toml edit -v -f toml /hosts/test2.example.com 96 | ``` 97 | 98 | ``` 99 | --- 100 | gw = "1.192.168.0.1" 101 | +++ 102 | gw = "192.168.0.1" 103 | ``` 104 | 105 | **Re-validate data:** 106 | 107 | ``` 108 | etcdtool -d -c etcdtool.toml validate /hosts 109 | ``` 110 | 111 | **Authentication** 112 | 113 | These commands will prompt you for the password for the user. Alternatively, you 114 | can pass the password in a file with `--password-file` or `-F`: 115 | 116 | ``` 117 | cat /path/to/passwordfile 118 | passwordstring 119 | 120 | etcdtool -password-file /path/to/passwordfile validate /hosts 121 | ``` 122 | 123 | # Caveats 124 | 125 | - etcd doesn't support list's, this is handled by using the index as the key: 126 | 127 | **JSON Input:** 128 | 129 | ```json 130 | { 131 | "users": [ 132 | { "username": "jblack", "first_name": "John", "last_name": "Blackbeard" }, 133 | { "username": "ltrier", "first_name": "Lars", "last_name": "Von Trier" } 134 | ] 135 | } 136 | ``` 137 | 138 | **Result in etcd:** 139 | 140 | ``` 141 | users/0/username: jblack 142 | users/0/first_name: John 143 | users/0/last_name: Blackbeard 144 | users/1/username: ltrier 145 | users/1/first_name: Ludwig 146 | users/1/last_name: Von Treimer 147 | ``` 148 | 149 | # TODO 150 | - Add detection of format for import based on file type 151 | -------------------------------------------------------------------------------- /command/validate_command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/codegangsta/cli" 9 | "github.com/coreos/etcd/client" 10 | "github.com/mickep76/etcdmap" 11 | "github.com/xeipuuv/gojsonschema" 12 | ) 13 | 14 | // NewValidateCommand sets data from input. 15 | func NewValidateCommand() cli.Command { 16 | return cli.Command{ 17 | Name: "validate", 18 | Usage: "validate a directory", 19 | Flags: []cli.Flag{}, 20 | Action: func(c *cli.Context) error { 21 | validateCommandFunc(c) 22 | return nil 23 | }, 24 | } 25 | } 26 | 27 | // validateCommandFunc validate data using JSON Schema. 28 | func validateCommandFunc(c *cli.Context) { 29 | if len(c.Args()) == 0 { 30 | fatal("You need to specify directory") 31 | } 32 | dir := c.Args()[0] 33 | 34 | // Remove trailing slash. 35 | if dir != "/" { 36 | dir = strings.TrimRight(dir, "/") 37 | } 38 | infof("Using dir: %s", dir) 39 | 40 | // Load configuration file. 41 | e := loadConfig(c) 42 | 43 | // New dir API. 44 | ki := newKeyAPI(e) 45 | 46 | // Map directory to routes. 47 | var schema string 48 | for _, r := range e.Routes { 49 | match, err := regexp.MatchString(r.Regexp, dir) 50 | if err != nil { 51 | fatal(err.Error()) 52 | } 53 | if match { 54 | schema = r.Schema 55 | } 56 | } 57 | 58 | if schema == "" && len(c.Args()) == 1 { 59 | fatal("You need to specify JSON schema URI") 60 | } 61 | 62 | if len(c.Args()) > 1 { 63 | schema = c.Args()[1] 64 | } 65 | 66 | // Get directory. 67 | ctx, cancel := contextWithCommandTimeout(c) 68 | resp, err := ki.Get(ctx, dir, &client.GetOptions{Recursive: true}) 69 | cancel() 70 | if err != nil { 71 | fatal(err.Error()) 72 | } 73 | m := etcdmap.Map(resp.Node) 74 | 75 | // Validate directory. 76 | infof("Using JSON schema: %s", schema) 77 | schemaLoader := gojsonschema.NewReferenceLoader(schema) 78 | docLoader := gojsonschema.NewGoLoader(m) 79 | fmt.Println("Validating...") 80 | result, err := gojsonschema.Validate(schemaLoader, docLoader) 81 | if err != nil { 82 | fatal(fmt.Sprintf("Error attempting to validate: %v", err.Error())) 83 | } 84 | 85 | // Print results. 86 | if !result.Valid() { 87 | for _, err := range result.Errors() { 88 | fmt.Printf("%s: %s [value: %s]\n", strings.Replace(err.Context().String("/"), "(root)", dir, 1), err.Description(), err.Value()) 89 | } 90 | } 91 | } 92 | 93 | func validateFunc(e Etcdtool, dir string, d interface{}) { 94 | // Map directory to routes. 95 | var schema string 96 | for _, r := range e.Routes { 97 | match, err := regexp.MatchString(r.Regexp, dir) 98 | if err != nil { 99 | fatal(err.Error()) 100 | } 101 | if match { 102 | schema = r.Schema 103 | } 104 | } 105 | 106 | if schema == "" { 107 | fatal("Couldn't determine which JSON schema to use for validation") 108 | } 109 | 110 | /* 111 | if schema == "" && len(c.Args()) == 1 { 112 | fatal("You need to specify JSON schema URI") 113 | } 114 | 115 | if len(c.Args()) > 1 { 116 | schema = c.Args()[1] 117 | } 118 | */ 119 | 120 | // Validate directory. 121 | infof("Using JSON schema: %s", schema) 122 | schemaLoader := gojsonschema.NewReferenceLoader(schema) 123 | docLoader := gojsonschema.NewGoLoader(d) 124 | result, err := gojsonschema.Validate(schemaLoader, docLoader) 125 | if err != nil { 126 | fatal(err.Error()) 127 | } 128 | 129 | // Print results. 130 | if !result.Valid() { 131 | for _, err := range result.Errors() { 132 | fmt.Printf("%s: %s [value: %s]\n", strings.Replace(err.Context().String("/"), "(root)", dir, 1), err.Description(), err.Value()) 133 | } 134 | fatal("Data validation failed aborting") 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /command/edit_command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/codegangsta/cli" 10 | "github.com/coreos/etcd/client" 11 | "github.com/mickep76/iodatafmt" 12 | "golang.org/x/net/context" 13 | ) 14 | 15 | // NewEditCommand sets data from input. 16 | func NewEditCommand() cli.Command { 17 | return cli.Command{ 18 | Name: "edit", 19 | Usage: "edit a directory", 20 | Flags: []cli.Flag{ 21 | cli.BoolFlag{Name: "sort, s", Usage: "returns result in sorted order"}, 22 | cli.BoolFlag{Name: "yes, y", Usage: "Answer yes to any questions"}, 23 | cli.BoolFlag{Name: "replace, r", Usage: "Replace data"}, 24 | cli.BoolFlag{Name: "validate, v", EnvVar: "ETCDTOOL_VALIDATE", Usage: "Validate data before import"}, 25 | cli.StringFlag{Name: "format, f", Value: "JSON", EnvVar: "ETCDTOOL_FORMAT", Usage: "Data serialization format YAML, TOML or JSON"}, 26 | cli.StringFlag{Name: "editor, e", Value: "vim", Usage: "Editor", EnvVar: "EDITOR"}, 27 | cli.StringFlag{Name: "tmp-file, t", Value: ".etcdtool", Usage: "Temporary file"}, 28 | }, 29 | Action: func(c *cli.Context) error { 30 | editCommandFunc(c) 31 | return nil 32 | }, 33 | } 34 | } 35 | 36 | func editFile(editor string, file string) error { 37 | _, err := exec.LookPath(editor) 38 | if err != nil { 39 | fatalf("Editor doesn't exist: %s", editor) 40 | } 41 | 42 | cmd := exec.Command(editor, file) 43 | cmd.Stdin = os.Stdin 44 | cmd.Stdout = os.Stdout 45 | if err := cmd.Run(); err != nil { 46 | fatal(err.Error()) 47 | } 48 | return nil 49 | } 50 | 51 | // editCommandFunc edit data as either JSON, YAML or TOML. 52 | func editCommandFunc(c *cli.Context) { 53 | if len(c.Args()) == 0 { 54 | fatal("You need to specify directory") 55 | } 56 | dir := c.Args()[0] 57 | 58 | // Remove trailing slash. 59 | if dir != "/" { 60 | dir = strings.TrimRight(dir, "/") 61 | } 62 | infof("Using dir: %s", dir) 63 | 64 | // Load configuration file. 65 | e := loadConfig(c) 66 | 67 | // New dir API. 68 | ki := newKeyAPI(e) 69 | 70 | sort := c.Bool("sort") 71 | 72 | // Get data format. 73 | f, err := iodatafmt.Format(c.String("format")) 74 | if err != nil { 75 | fatal(err.Error()) 76 | } 77 | 78 | // Temporary file append file type to support syntax highlighting 79 | tmpfile := c.String("tmp-file") + "." + strings.ToLower(c.String("format")) 80 | 81 | // Check if dir exists and is a directory. 82 | exists, err := dirExists(dir, c, ki) 83 | if err != nil { 84 | fatal(err.Error()) 85 | } 86 | 87 | if !exists { 88 | if askYesNo(fmt.Sprintf("Dir. doesn't exist: %s create it", dir)) { 89 | // Create dir. 90 | if _, err := ki.Set(context.TODO(), dir, "", &client.SetOptions{Dir: true}); err != nil { 91 | fatal(err.Error()) 92 | } 93 | exists = true 94 | } else { 95 | os.Exit(1) 96 | } 97 | } 98 | 99 | // If file exist's resume editing? 100 | if _, err := os.Stat(tmpfile); os.IsNotExist(err) { 101 | // Export to file. 102 | exportFunc(dir, sort, tmpfile, f, c, ki) 103 | } else { 104 | if !askYesNo(fmt.Sprintf("Temp. file already exist's resume editing")) { 105 | // Export to file. 106 | exportFunc(dir, sort, tmpfile, f, c, ki) 107 | } 108 | } 109 | 110 | // Get modified time stamp. 111 | before, err := os.Stat(tmpfile) 112 | if err != nil { 113 | fatal(err.Error()) 114 | } 115 | 116 | // Edit file. 117 | editFile(c.String("editor"), tmpfile) 118 | 119 | // Check modified time stamp. 120 | after, err := os.Stat(tmpfile) 121 | if err != nil { 122 | fatal(err.Error()) 123 | } 124 | 125 | // Import from file if it has changed. 126 | if before.ModTime() != after.ModTime() { 127 | importFunc(dir, tmpfile, f, c.Bool("replace"), c.Bool("yes"), e, c, ki) 128 | } else { 129 | fmt.Printf("File wasn't modified, skipping import\n") 130 | } 131 | 132 | // Unlink file. 133 | if err := os.Remove(tmpfile); err != nil { 134 | fatal(err.Error()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /command/import_command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/codegangsta/cli" 11 | "github.com/coreos/etcd/client" 12 | "github.com/mickep76/etcdmap" 13 | "github.com/mickep76/iodatafmt" 14 | "golang.org/x/net/context" 15 | ) 16 | 17 | // NewImportCommand sets data from input. 18 | func NewImportCommand() cli.Command { 19 | return cli.Command{ 20 | Name: "import", 21 | Usage: "import a directory", 22 | Flags: []cli.Flag{ 23 | cli.BoolFlag{Name: "yes, y", Usage: "Answer yes to any questions"}, 24 | cli.BoolFlag{Name: "replace, r", Usage: "Replace data"}, 25 | cli.BoolFlag{Name: "validate, v", EnvVar: "ETCDTOOL_VALIDATE", Usage: "Validate data before import"}, 26 | cli.StringFlag{Name: "format, f", Value: "JSON", EnvVar: "ETCDTOOL_FORMAT", Usage: "Data serialization format YAML, TOML or JSON"}, 27 | }, 28 | Action: func(c *cli.Context) error { 29 | importCommandFunc(c) 30 | return nil 31 | }, 32 | } 33 | } 34 | 35 | func dirExists(dir string, c *cli.Context, ki client.KeysAPI) (bool, error) { 36 | ctx, cancel := contextWithCommandTimeout(c) 37 | _, err := ki.Get(ctx, dir, &client.GetOptions{}) 38 | cancel() 39 | if err != nil { 40 | if cerr, ok := err.(client.Error); ok && cerr.Code == 100 { 41 | return false, nil 42 | } 43 | return false, err 44 | } 45 | return true, nil 46 | } 47 | 48 | func isDir(dir string, c *cli.Context, ki client.KeysAPI) (bool, error) { 49 | ctx, cancel := contextWithCommandTimeout(c) 50 | resp, err := ki.Get(ctx, dir, &client.GetOptions{}) 51 | cancel() 52 | if err != nil { 53 | return false, err 54 | } 55 | if resp.Node.Dir { 56 | return false, nil 57 | } 58 | return true, nil 59 | } 60 | 61 | func askYesNo(msg string) bool { 62 | stdin := bufio.NewReader(os.Stdin) 63 | 64 | for { 65 | fmt.Printf("%s [yes/no]? ", msg) 66 | inp, _, err := stdin.ReadLine() 67 | if err != nil { 68 | fatal(err.Error()) 69 | } 70 | 71 | switch strings.ToLower(string(inp)) { 72 | case "yes": 73 | return true 74 | case "no": 75 | return false 76 | default: 77 | fmt.Printf("Incorrect input: %s\n ", inp) 78 | } 79 | } 80 | } 81 | 82 | // importCommandFunc imports data as either JSON, YAML or TOML. 83 | func importCommandFunc(c *cli.Context) { 84 | if len(c.Args()) == 0 { 85 | fatal("You need to specify directory") 86 | } 87 | dir := c.Args()[0] 88 | 89 | // Remove trailing slash. 90 | if dir != "/" { 91 | dir = strings.TrimRight(dir, "/") 92 | } 93 | infof("Using dir: %s", dir) 94 | 95 | if len(c.Args()) == 1 { 96 | fatal("You need to specify input file") 97 | } 98 | input := c.Args()[1] 99 | 100 | // Get data format. 101 | f, err := iodatafmt.Format(c.String("format")) 102 | if err != nil { 103 | fatal(err.Error()) 104 | } 105 | 106 | // Load configuration file. 107 | e := loadConfig(c) 108 | 109 | // New dir API. 110 | ki := newKeyAPI(e) 111 | 112 | importFunc(dir, input, f, c.Bool("replace"), c.Bool("yes"), e, c, ki) 113 | } 114 | 115 | func importFunc(dir string, file string, f iodatafmt.DataFmt, replace bool, yes bool, e Etcdtool, c *cli.Context, ki client.KeysAPI) { 116 | // Check if dir exists and is a directory. 117 | exists, err := dirExists(dir, c, ki) 118 | if err != nil { 119 | fatalf("Specified dir doesn't exist: %s", dir) 120 | } 121 | 122 | if exists { 123 | exist, err := isDir(dir, c, ki) 124 | if err != nil { 125 | fatal(err.Error()) 126 | } 127 | 128 | if exist { 129 | fatalf("Specified dir is not a directory: %s", dir) 130 | } 131 | } 132 | 133 | // Load file. 134 | m, err := iodatafmt.Load(file, f) 135 | if err != nil { 136 | fatal(err.Error()) 137 | } 138 | 139 | // Validate data. 140 | if c.Bool("validate") { 141 | validateFunc(e, dir, m) 142 | } 143 | 144 | if exists { 145 | if replace { 146 | if !yes { 147 | if !askYesNo(fmt.Sprintf("Do you want to overwrite data in directory: %s", dir)) { 148 | os.Exit(1) 149 | } 150 | } 151 | 152 | // Delete dir. 153 | if _, err = ki.Delete(context.TODO(), dir, &client.DeleteOptions{Recursive: true}); err != nil { 154 | fatal(err.Error()) 155 | } 156 | } else { 157 | if !yes { 158 | if !askYesNo(fmt.Sprintf("Do you want to overwrite data in directory: %s", dir)) { 159 | os.Exit(1) 160 | } 161 | } 162 | } 163 | } else { 164 | // Create dir. 165 | if _, err := ki.Set(context.TODO(), dir, "", &client.SetOptions{Dir: true}); err != nil { 166 | fatal(err.Error()) 167 | } 168 | } 169 | 170 | // Import data. 171 | if err = etcdmap.Create(ki, dir, reflect.ValueOf(m)); err != nil { 172 | fatal(err.Error()) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /command/export_command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "strings" 5 | "github.com/codegangsta/cli" 6 | "github.com/coreos/etcd/client" 7 | "github.com/mickep76/etcdmap" 8 | "github.com/mickep76/iodatafmt" 9 | "strconv" 10 | "sort" 11 | "regexp" 12 | ) 13 | 14 | const flatten_fake_key = "flatten_fake_key" 15 | const num_infer_list_flag = "num-infer-list" 16 | const infer_types_flag = "infer-types" 17 | const keep_format_path_flag = "keep-format-path" 18 | 19 | // NewExportCommand returns data from export. 20 | func NewExportCommand() cli.Command { 21 | return cli.Command{ 22 | Name: "export", 23 | Usage: "export a directory", 24 | Flags: []cli.Flag{ 25 | cli.BoolFlag{Name: "sort, s", Usage: "returns result in sorted order"}, 26 | cli.StringFlag{Name: "format, f", EnvVar: "ETCDTOOL_FORMAT", Value: "JSON", Usage: "Data serialization format YAML, TOML or JSON"}, 27 | cli.StringFlag{Name: "output, o", Value: "", Usage: "Output file"}, 28 | cli.BoolFlag{Name: num_infer_list_flag, Usage: "returns result without extra levels of arrays"}, 29 | cli.BoolFlag{Name: infer_types_flag, Usage: "convert to original type if conversion is possible"}, 30 | cli.StringSliceFlag{ Name: keep_format_path_flag, Usage: "set one or more paths (allow regex) to keep the string format. Each field or level should be separated by '.'"}, 31 | }, 32 | Action: func(c *cli.Context) error { 33 | exportCommandFunc(c) 34 | return nil 35 | }, 36 | } 37 | } 38 | 39 | // exportCommandFunc exports data as either JSON, YAML or TOML. 40 | func exportCommandFunc(c *cli.Context) { 41 | if len(c.Args()) == 0 { 42 | fatal("You need to specify directory") 43 | } 44 | dir := c.Args()[0] 45 | 46 | // Remove trailing slash. 47 | if dir != "/" { 48 | dir = strings.TrimRight(dir, "/") 49 | } 50 | infof("Using dir: %s", dir) 51 | 52 | // Load configuration file. 53 | e := loadConfig(c) 54 | 55 | // New dir API. 56 | ki := newKeyAPI(e) 57 | 58 | sort := c.Bool("sort") 59 | 60 | // Get data format. 61 | f, err := iodatafmt.Format(c.String("format")) 62 | if err != nil { 63 | fatal(err.Error()) 64 | } 65 | 66 | exportFunc(dir, sort, c.String("output"), f, c, ki) 67 | } 68 | 69 | var keep_formatted_paths []*regexp.Regexp 70 | 71 | 72 | // exportCommandFunc exports data as either JSON, YAML or TOML. 73 | func exportFunc(dir string, sort bool, file string, f iodatafmt.DataFmt, c *cli.Context, ki client.KeysAPI) { 74 | ctx, cancel := contextWithCommandTimeout(c) 75 | resp, err := ki.Get(ctx, dir, &client.GetOptions{Sort: sort, Recursive: true}) 76 | cancel() 77 | if err != nil { 78 | fatal(err.Error()) 79 | } 80 | 81 | m := etcdmap.Map(resp.Node) 82 | if c.Bool(num_infer_list_flag) || c.Bool(infer_types_flag) { 83 | if c.StringSlice(keep_format_path_flag) != nil { 84 | for _,path := range(c.StringSlice(keep_format_path_flag)){ 85 | keep_formatted_paths =append(keep_formatted_paths,regexp.MustCompile(path)) 86 | } 87 | } 88 | 89 | m1 := removeExtraNumbersLevels(m, c.Bool(num_infer_list_flag), c.Bool(infer_types_flag), "") 90 | value, ok := m1.(map[string]interface{}) 91 | if ok { 92 | m = value 93 | } 94 | } 95 | 96 | // Export and write output. 97 | if file != "" { 98 | iodatafmt.Write(file, m, f) 99 | } else { 100 | iodatafmt.Print(m, f) 101 | } 102 | } 103 | 104 | // Remove extra levels of numbers created in etcd and infer numbers 105 | func removeExtraNumbersLevels(etcdmapObject interface{}, numInferList bool, inferTypes bool, path string) interface{} { 106 | 107 | var result map[string]interface{} = make(map[string]interface{}) 108 | // TRAVERSE MAP 109 | switch etcdmapObject.(type) { 110 | case map[string]interface{}: // map {string, K} case 111 | 112 | for k, v := range etcdmapObject.(map[string]interface{}) { 113 | var path_aux string = k 114 | if len(path) > 0 { 115 | if k == flatten_fake_key { 116 | path_aux = path 117 | }else { 118 | path_aux = path + "." + k 119 | } 120 | } 121 | 122 | // TRAVERSE VALUES TYPE 123 | switch v.(type) { 124 | case map[string]interface{}: 125 | if numInferList && checkAllKeysAreNumbers(v) { 126 | // traverse the values to create an array 127 | // and removeExtraNumbersLevels in the subsequent levels 128 | var results []interface{} 129 | 130 | value, ok := v.(map[string]interface{}) 131 | if ok { 132 | results = extractArrayFromFirstLevel(value,numInferList,numInferList, path_aux) 133 | } 134 | 135 | // set the processed subkeys to the result map 136 | if len(results) == 0 { 137 | c := []string{} 138 | result[k] = c 139 | } else { 140 | result[k] = results 141 | } 142 | } else { 143 | // set a normal key and removeExtraNumbersLevels in the subsequent levels 144 | result[k] = removeExtraNumbersLevels(v, numInferList, inferTypes, path_aux) 145 | } 146 | default: 147 | assignValue(result, k, v, inferTypes,path_aux) 148 | } 149 | } 150 | case string: 151 | // return a normal value 152 | return etcdmapObject 153 | } 154 | 155 | return result 156 | } 157 | 158 | func extractArrayFromFirstLevel(originalMap map[string]interface{}, numInferList bool, inferTypes bool, path string) []interface{} { 159 | // sort the keys to ensure the list will be in order 160 | keys := make([]int, 0) 161 | for k, _ := range originalMap { 162 | parsedInt , err := strconv.Atoi(k) 163 | if err != nil{ 164 | fatal(err.Error()) 165 | }else{ 166 | keys = append(keys, parsedInt) 167 | } 168 | } 169 | sort.Ints(keys) 170 | 171 | // process the map and extract the first level to build the array 172 | var results []interface{} 173 | for _, k := range keys { 174 | allKeyNumbersValue := originalMap[strconv.Itoa(k)] 175 | // create temporal map with a fake top level to be in accordance 176 | // with the logic of the function 177 | temporal_map := make(map[string]interface{}) 178 | temporal_map[flatten_fake_key] = allKeyNumbersValue 179 | // flat the temporal map 180 | flatten := removeExtraNumbersLevels(temporal_map, numInferList, inferTypes, path) 181 | // set the results depends on the type of the map returned 182 | value, ok := flatten.(map[string]interface{}) 183 | if ok { 184 | results = append(results, value[flatten_fake_key]) 185 | } else { 186 | results = append(results, flatten) 187 | } 188 | } 189 | return results 190 | } 191 | 192 | 193 | func assignValue(result map[string]interface{}, key string, value interface{}, inferTypes bool, path string) { 194 | isString, ok := value.(string) 195 | if ok && inferTypes { 196 | 197 | var keep_original_format bool=false 198 | for _,reg := range(keep_formatted_paths){ 199 | if reg.MatchString(path){ 200 | keep_original_format = true 201 | break 202 | } 203 | } 204 | 205 | if keep_original_format { 206 | result[key]=isString 207 | }else { 208 | 209 | // process a normal value 210 | val, err := strconv.Atoi(isString) 211 | if err == nil { 212 | result[key] = val 213 | } else { 214 | val, err := strconv.ParseFloat(isString, 64) 215 | if err == nil { 216 | result[key] = val 217 | } else { 218 | val, err := strconv.ParseBool(isString) 219 | if err == nil { 220 | result[key] = val 221 | } else { 222 | result[key] = isString 223 | 224 | } 225 | } 226 | } 227 | } 228 | } else { 229 | result[key] = value 230 | } 231 | 232 | } 233 | 234 | func checkAllKeysAreNumbers(numbersMap interface{}) bool { 235 | 236 | // zero length map shouldn't be regarded as an array 237 | if (len(numbersMap.(map[string]interface{})) == 0) { 238 | return false 239 | } 240 | 241 | allKeyNumbers := true 242 | for k, _ := range numbersMap.(map[string]interface{}) { 243 | _, err := strconv.Atoi(k) 244 | if err != nil { 245 | allKeyNumbers = false 246 | break 247 | } 248 | } 249 | return allKeyNumbers 250 | 251 | } 252 | 253 | --------------------------------------------------------------------------------