├── docs
└── consul-kv-dashboard.png
├── assets
├── css
│ └── style.css
├── index.html
└── scripts
│ └── dashboard.js
├── status_string.go
├── .gitignore
├── Makefile
├── http.go
├── assets.go
├── LICENSE
├── README.md
└── dashboard.go
/docs/consul-kv-dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fujiwara/consul-kv-dashboard/HEAD/docs/consul-kv-dashboard.png
--------------------------------------------------------------------------------
/assets/css/style.css:
--------------------------------------------------------------------------------
1 | pre {
2 | white-space: pre-wrap;
3 | word-wrap: break-word;
4 | overflow: auto;
5 | }
6 | .item_body {
7 | max-height: 6em;
8 | font-size: 0.8em;
9 | cursor: pointer;
10 | }
11 | .item_body_expanded {
12 | font-size: 0.8em;
13 | cursor: pointer;
14 | }
15 | .item_data_col {
16 | width: 50%;
17 | }
18 | .item_timestamp_col {
19 | width: 15em;
20 | }
21 |
--------------------------------------------------------------------------------
/status_string.go:
--------------------------------------------------------------------------------
1 | // Code generated by "stringer -type=Status"; DO NOT EDIT
2 |
3 | package main
4 |
5 | import "fmt"
6 |
7 | const _Status_name = "SuccessWarningDangerInfo"
8 |
9 | var _Status_index = [...]uint8{0, 7, 14, 20, 24}
10 |
11 | func (i Status) String() string {
12 | if i < 0 || i >= Status(len(_Status_index)-1) {
13 | return fmt.Sprintf("Status(%d)", i)
14 | }
15 | return _Status_name[_Status_index[i]:_Status_index[i+1]]
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | consul-kv-dashboard
2 | pkg/
3 |
4 | # auto generated by go-bindata
5 | bindata.go
6 |
7 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
8 | *.o
9 | *.a
10 | *.so
11 |
12 | # Folders
13 | _obj
14 | _test
15 |
16 | # Architecture specific extensions/prefixes
17 | *.[568vq]
18 | [568vq].out
19 |
20 | *.cgo1.go
21 | *.cgo2.c
22 | _cgo_defun.c
23 | _cgo_gotypes.go
24 | _cgo_export.*
25 |
26 | _testmain.go
27 |
28 | *.exe
29 | *.test
30 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | GIT_VER := $(shell git describe --tags)
2 |
3 | .PHONY: packages clean
4 |
5 | consul-kv-dashboard: dashboard.go bindata.go
6 | stringer -type=Status
7 | go build
8 |
9 | bindata.go: assets/index.html assets/scripts/dashboard.js assets/css/style.css
10 | go-bindata -prefix=assets assets/...
11 |
12 | packages: bindata.go dashboard.go
13 | gox -os="linux darwin windows" -arch="amd64 386" -output "pkg/{{.Dir}}-${GIT_VER}-{{.OS}}-{{.Arch}}" -ldflags "-w -s -X main.Version=${GIT_VER}"
14 | cd pkg && find . -name "*${GIT_VER}*" -type f -exec zip {}.zip {} \;
15 |
16 | clean:
17 | rm -fr pkg/* consul-kv-dashboard
18 |
--------------------------------------------------------------------------------
/assets/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Consul KV Dashboard
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/http.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "compress/gzip"
5 | "io"
6 | "net/http"
7 | "strings"
8 | )
9 |
10 | type gzipResponseWriter struct {
11 | io.Writer
12 | http.ResponseWriter
13 | }
14 |
15 | func (w gzipResponseWriter) Write(b []byte) (int, error) {
16 | return w.Writer.Write(b)
17 | }
18 |
19 | func (w gzipResponseWriter) Header() http.Header {
20 | return w.ResponseWriter.Header()
21 | }
22 |
23 | func (w gzipResponseWriter) WriteHeader(code int) {
24 | w.ResponseWriter.WriteHeader(code)
25 | }
26 |
27 | func makeGzipHandler(fn http.HandlerFunc) http.HandlerFunc {
28 | return func(w http.ResponseWriter, r *http.Request) {
29 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
30 | fn(w, r)
31 | return
32 | }
33 | w.Header().Set("Content-Encoding", "gzip")
34 | gz := gzip.NewWriter(w)
35 | defer gz.Close()
36 | gzr := gzipResponseWriter{Writer: gz, ResponseWriter: w}
37 | fn(gzr, r)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/assets.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "log"
6 | "net/http"
7 | "os"
8 | "strings"
9 | )
10 |
11 | type AssetFileSystem struct {
12 | Prefix string
13 | }
14 |
15 | func NewAssetFileSystem(prefix string) AssetFileSystem {
16 | if prefix == "" {
17 | prefix = "/"
18 | }
19 | return AssetFileSystem{Prefix: prefix}
20 | }
21 |
22 | type AssetFile struct {
23 | *bytes.Reader
24 | os.FileInfo
25 | }
26 |
27 | func (fs AssetFileSystem) Open(name string) (http.File, error) {
28 | path := name
29 | path = strings.TrimPrefix(path, fs.Prefix)
30 | data, err := Asset(path)
31 | if err != nil {
32 | log.Println(err)
33 | return nil, err
34 | }
35 | info, _ := AssetInfo(path)
36 | file := &AssetFile{
37 | bytes.NewReader(data),
38 | info,
39 | }
40 | return file, nil
41 | }
42 |
43 | func (f *AssetFile) Close() error {
44 | return nil
45 | }
46 |
47 | func (f *AssetFile) Readdir(count int) ([]os.FileInfo, error) {
48 | return []os.FileInfo{}, nil
49 | }
50 |
51 | func (f *AssetFile) Stat() (os.FileInfo, error) {
52 | return f.FileInfo, nil
53 | }
54 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 FUJIWARA Shunichiro
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # consul-kv-dashboard
2 |
3 | A dashboard web console based on Consul's key value store.
4 |
5 | 
6 |
7 | ## Build
8 |
9 | $ make
10 |
11 | ## Usage
12 |
13 | ```
14 | Usase of ./consul-kv-dashboard:
15 | -asset="": Serve files located in /assets from local directory. If not specified, use built-in asset.
16 | -namespace="dashboard": Consul kv top level key name. (/v1/kv/{namespace}/...)
17 | -port=3000: http listen port
18 | -trigger="": trigger command
19 | -v=false: show vesion
20 | -version=false: show vesion
21 | ```
22 |
23 | ## Quick start
24 |
25 | 1. Run consul cluster.
26 | 1. Run consul-kv-dashboard.
27 | 2. Access to `http://myhost.example.com:3000/`
28 | 3. Put a dashboard event to Consul KV.
29 | ```
30 | $ curl -X PUT -d "content" localhost:8500/v1/kv/dashboard/example/myhost?flags=1422597159000
31 | ```
32 |
33 | ### Consul KV's key name specification
34 |
35 | ```
36 | /v1/kv/{namespace}/{category}/{node}(/{key})?flags={flags}
37 | ```
38 |
39 | * {namespace} : namespace. (default: `dashboard`)
40 | * {category} : dashboard category (e.g. `chef`, `serverspec`, `deploy`...)
41 | * {node} : consul node name. How to get a self node name using consul API, `curl -s localhost:8500/v1/agent/self | jq -r .Member.Name`
42 | * {key} : sub event name's key. (optional)
43 | * {flags} : timestamp(unix\_time) * 1000 + {status}
44 | * {status}
45 | * 0 : Success
46 | * 1 : Warning
47 | * 2 : Danger
48 | * 3 : Info
49 |
50 | ## Trigger
51 |
52 | ```
53 | $ consul-kv-dashboard -trigger /path/to/command
54 | ```
55 |
56 | Invoke trigger command when dashboard item's status was changed.
57 |
58 | Pass a changed item (encoded as json) to command's stdin.
59 |
60 | ```json
61 | {"category":"testing","node":"web01","address":"192.168.1.10","timestamp":"2015-01-21 11:22:33 +0900","status":"danger","key":"","data":"failure!!"}
62 | ```
63 |
64 | ## LICENSE
65 |
66 | MIT
67 |
--------------------------------------------------------------------------------
/assets/scripts/dashboard.js:
--------------------------------------------------------------------------------
1 | var StatusSelector = React.createClass({
2 | handleChange: function(event) {
3 | this.props.updateStatusFilter(event.target.value)
4 | },
5 | handleNodeGrep: function(event) {
6 | this.props.updateNodeFilter(event.target.value)
7 | },
8 | handleKeyGrep: function(event) {
9 | this.props.updateKeyFilter(event.target.value)
10 | },
11 | render: function() {
12 | return (
13 |
14 |
27 |
28 | );
29 | }
30 | });
31 |
32 | var Title = React.createClass({
33 | render: function() {
34 | return (
35 | : {this.props.category}
36 | );
37 | }
38 | });
39 |
40 | var Category = React.createClass({
41 | render: function() {
42 | var active = this.props.currentCategory == this.props.name ? "active" : "";
43 | var href = "/" + this.props.name;
44 | return (
45 | {this.props.name}
46 | );
47 | }
48 | });
49 |
50 | var Categories = React.createClass({
51 | render: function() {
52 | var currentCategory = this.props.currentCategory
53 | var handleChange = this.handleChange
54 | var cats = this.props.data.map(function(cat, index) {
55 | return (
56 |
57 | );
58 | });
59 | return (
60 |
63 | );
64 | }
65 | });
66 |
67 | var Item = React.createClass({
68 | render: function() {
69 | var item = this.props.item;
70 | var icon = "glyphicon";
71 | var status = item.status;
72 | if (item.status != "success" && item.status != "info") {
73 | icon += " glyphicon-alert";
74 | status += " alert-" + item.status;
75 | }
76 | return (
77 |
78 |
79 | | {item.node} |
80 | {item.address} |
81 | {item.key} |
82 | {item.timestamp} |
83 |
84 |
85 | | {item.data} |
86 |
87 |
88 | );
89 | }
90 | });
91 |
92 | var ItemBody = React.createClass({
93 | handleClick: function() {
94 | if ( this.state.expanded && window.getSelection().toString() != "" ) {
95 | return;
96 | }
97 | this.setState({ expanded: !this.state.expanded })
98 | },
99 | getInitialState: function() {
100 | return { expanded: false };
101 | },
102 | render: function() {
103 | var classString = "item_body"
104 | if (this.state.expanded) {
105 | classString = "item_body_expanded"
106 | }
107 | return (
108 | {this.props.children}
109 | );
110 | }
111 | });
112 |
113 | var Dashboard = React.createClass({
114 | loadCategoriesFromServer: function() {
115 | $.ajax({
116 | url: "/api/?keys",
117 | dataType: 'json',
118 | success: function(data, textStatus, request) {
119 | if (!this.state.currentCategory) {
120 | location.pathname = "/" + data[0]
121 | } else {
122 | this.setState({categories: data})
123 | }
124 | }.bind(this),
125 | error: function(xhr, status, err) {
126 | console.error("/api/?keys", status, err.toString());
127 | }.bind(this)
128 | });
129 | },
130 | loadDashboardFromServer: function() {
131 | if (!this.state.currentCategory) {
132 | setTimeout(this.loadDashboardFromServer, this.props.pollWait / 5);
133 | return;
134 | }
135 | var statusFilter = this.state.statusFilter;
136 | var ajax = $.ajax({
137 | url: "/api/" + this.state.currentCategory + "?recurse&wait=55s&index=" + this.state.index || 0,
138 | dataType: 'json',
139 | success: function(data, textStatus, request) {
140 | var timer = setTimeout(this.loadDashboardFromServer, this.props.pollWait);
141 | var index = request.getResponseHeader('X-Consul-Index')
142 | this.setState({
143 | items: data,
144 | index: index,
145 | timer: timer,
146 | });
147 | }.bind(this),
148 | error: function(xhr, status, err) {
149 | console.log("ajax error:" + err)
150 | var wait = this.props.pollWait * 5
151 | if (err == "abort") {
152 | wait = 0
153 | }
154 | var timer = setTimeout(this.loadDashboardFromServer, wait);
155 | this.setState({ timer: timer })
156 | }.bind(this)
157 | });
158 | this.setState({ajax: ajax})
159 | },
160 | getInitialState: function() {
161 | var cat = location.pathname.replace(/^\//,"")
162 | if (cat == "") {
163 | cat = undefined
164 | }
165 | return {
166 | items: [],
167 | categories: [],
168 | index: 0,
169 | ajax: undefined,
170 | timer: undefined,
171 | statusFilter: "",
172 | nodeFilter: "",
173 | keyFilter: "",
174 | currentCategory: cat
175 | };
176 | },
177 | componentDidMount: function() {
178 | this.loadCategoriesFromServer();
179 | this.loadDashboardFromServer();
180 | },
181 | updateStatusFilter: function(filter) {
182 | this.setState({ statusFilter: filter });
183 | },
184 | updateNodeFilter: function(filter) {
185 | this.setState({ nodeFilter: filter });
186 | },
187 | updateKeyFilter: function(filter) {
188 | this.setState({ keyFilter: filter });
189 | },
190 | render: function() {
191 | var statusFilter = this.state.statusFilter;
192 | var nodeFilter = this.state.nodeFilter;
193 | var keyFilter = this.state.keyFilter;
194 | var items = this.state.items.map(function(item, index) {
195 | if ((statusFilter == "" || item.status == statusFilter) && (nodeFilter == "" || item.node.indexOf(nodeFilter) != -1 ) && (keyFilter == "" || item.key.indexOf(keyFilter) != -1 )) {
196 | return (
197 |
198 | );
199 | } else {
200 | return;
201 | }
202 | });
203 | return (
204 |
205 |
Dashboard
206 |
207 |
208 |
209 |
210 |
211 | | node | service |
212 | address |
213 | key |
214 | timestamp |
215 |
216 |
217 | {items}
218 |
219 |
220 | );
221 | }
222 | });
223 |
224 | React.render(
225 | ,
226 | document.getElementById('content')
227 | );
228 |
--------------------------------------------------------------------------------
/dashboard.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "flag"
7 | "fmt"
8 | "io"
9 | "io/ioutil"
10 | "log"
11 | "net/http"
12 | "os"
13 | "os/exec"
14 | "sort"
15 | "strconv"
16 | "strings"
17 | "sync"
18 | "time"
19 | )
20 |
21 | var (
22 | Namespace = "dashboard"
23 | ConsulAddr = "127.0.0.1:8500"
24 | Version string
25 | ExtAssetDir string
26 | Nodes []Node
27 | Services map[string][]string
28 | mutex sync.RWMutex
29 | )
30 |
31 | type KVPair struct {
32 | Key string
33 | CreateIndex int64
34 | ModifyIndex int64
35 | LockIndex int64
36 | Flags int64
37 | Value []byte
38 | }
39 |
40 | type Status int64
41 |
42 | const (
43 | Success Status = iota
44 | Warning
45 | Danger
46 | Info
47 | )
48 |
49 | func (s Status) MarshalText() ([]byte, error) {
50 | if s <= Info {
51 | return []byte(strings.ToLower(s.String())), nil
52 | } else {
53 | return []byte(strconv.FormatInt(int64(s), 10)), nil
54 | }
55 | }
56 |
57 | type Item struct {
58 | Category string `json:"category"`
59 | Node string `json:"node"`
60 | Address string `json:"address"`
61 | Timestamp string `json:"timestamp"`
62 | Status Status `json:"status"`
63 | Key string `json:"key"`
64 | Data string `json:"data"`
65 | }
66 |
67 | func (kv *KVPair) NewItem() Item {
68 | item := Item{
69 | Data: string(kv.Value),
70 | Timestamp: time.Unix(kv.Flags/1000, 0).Format("2006-01-02 15:04:05 -0700"),
71 | }
72 | item.Status = Status(kv.Flags % 1000)
73 |
74 | // kv.Key : {namespace}/{category}/{node}/{key}
75 | path := strings.Split(kv.Key, "/")
76 | item.Category = path[1]
77 | if len(path) >= 3 {
78 | item.Node = path[2]
79 | }
80 | if len(path) >= 4 {
81 | item.Key = path[3]
82 | }
83 | return item
84 | }
85 |
86 | type Node struct {
87 | Node string
88 | Address string
89 | }
90 |
91 | func main() {
92 | var (
93 | port int
94 | showVersion bool
95 | trigger string
96 | )
97 | flag.StringVar(&Namespace, "namespace", Namespace, "Consul kv top level key name. (/v1/kv/{namespace}/...)")
98 | flag.IntVar(&port, "port", 3000, "http listen port")
99 | flag.StringVar(&ExtAssetDir, "asset", "", "Serve files located in /assets from local directory. If not specified, use built-in asset.")
100 | flag.BoolVar(&showVersion, "v", false, "show vesion")
101 | flag.BoolVar(&showVersion, "version", false, "show vesion")
102 | flag.StringVar(&trigger, "trigger", "", "trigger command")
103 | flag.Parse()
104 |
105 | if showVersion {
106 | fmt.Println("consul-kv-dashboard: version:", Version)
107 | return
108 | }
109 |
110 | mux := http.NewServeMux()
111 | mux.HandleFunc("/", makeGzipHandler(indexPage))
112 | mux.HandleFunc("/api/", makeGzipHandler(kvApiProxy))
113 |
114 | if ExtAssetDir != "" {
115 | mux.Handle("/assets/",
116 | http.StripPrefix("/assets/", http.FileServer(http.Dir(ExtAssetDir))))
117 | } else {
118 | mux.Handle("/assets/",
119 | http.FileServer(NewAssetFileSystem("/assets/")))
120 | }
121 | http.Handle("/", mux)
122 |
123 | log.Println("listen port:", port)
124 | log.Println("asset directory:", ExtAssetDir)
125 | log.Println("namespace:", Namespace)
126 | if trigger != "" {
127 | log.Println("trigger:", trigger)
128 | go watchForTrigger(trigger)
129 | }
130 | go updateNodes()
131 | go updateServices()
132 |
133 | log.Fatal(http.ListenAndServe(":"+strconv.Itoa(port), nil))
134 | }
135 |
136 | func indexPage(w http.ResponseWriter, r *http.Request) {
137 | var (
138 | data []byte
139 | err error
140 | )
141 | if ExtAssetDir == "" {
142 | data, err = Asset("index.html")
143 | } else {
144 | var f *os.File
145 | f, err = os.Open(ExtAssetDir + "/index.html")
146 | data, err = ioutil.ReadAll(f)
147 | }
148 | if err != nil {
149 | log.Println(err)
150 | }
151 | w.Header().Set("Content-Type", "text/html; charset=utf-8")
152 | fmt.Fprint(w, string(data))
153 | }
154 |
155 | func kvApiProxy(w http.ResponseWriter, r *http.Request) {
156 | r.ParseForm()
157 | path := strings.TrimPrefix(r.URL.Path, "/api/")
158 | resp, _, err := callConsulAPI(
159 | "/v1/kv/" + Namespace + "/" + path + "?" + r.URL.RawQuery,
160 | )
161 | if err != nil {
162 | http.Error(w, fmt.Sprintf("%s", err), http.StatusInternalServerError)
163 | return
164 | }
165 | defer resp.Body.Close()
166 | if resp.StatusCode == http.StatusNotFound {
167 | w.Header().Set("Content-Type", "application/json")
168 | http.Error(w, "[]", resp.StatusCode)
169 | return
170 | }
171 | if resp.StatusCode != http.StatusOK {
172 | http.Error(w, "", resp.StatusCode)
173 | io.Copy(w, resp.Body)
174 | return
175 | }
176 | // copy response header to client
177 | for name, value := range resp.Header {
178 | if strings.HasPrefix(name, "X-") || name == "Content-Type" {
179 | for _, v := range value {
180 | w.Header().Set(name, v)
181 | }
182 | }
183 | }
184 |
185 | // keys or values
186 | dec := json.NewDecoder(resp.Body)
187 | enc := json.NewEncoder(w)
188 | if _, t := r.Form["keys"]; t {
189 | var keys []string
190 | uniqKeyMap := make(map[string]bool)
191 | dec.Decode(&keys)
192 | for _, key := range keys {
193 | path := strings.Split(key, "/")
194 | if len(path) >= 2 {
195 | uniqKeyMap[path[1]] = true
196 | }
197 | }
198 | uniqKeys := make([]string, 0, len(uniqKeyMap))
199 | for key, _ := range uniqKeyMap {
200 | uniqKeys = append(uniqKeys, key)
201 | }
202 | sort.Strings(uniqKeys)
203 | enc.Encode(uniqKeys)
204 | } else {
205 | var kvps []*KVPair
206 | dec.Decode(&kvps)
207 | items := make([]Item, 0, len(kvps))
208 | for _, kv := range kvps {
209 | item := kv.NewItem()
210 | if itemInCatalog(&item) {
211 | items = append(items, item)
212 | }
213 | }
214 | enc.Encode(items)
215 | }
216 | }
217 |
218 | func watchForTrigger(command string) {
219 | var index int64
220 | lastStatus := make(map[string]Status)
221 | prevItem := make(map[Item]Status)
222 | for {
223 | resp, newIndex, err := callConsulAPI(
224 | "/v1/kv/" + Namespace + "/?recurse&wait=55s&index=" + strconv.FormatInt(index, 10),
225 | )
226 | if err != nil {
227 | log.Println("[error]", err)
228 | time.Sleep(10 * time.Second)
229 | continue
230 | }
231 | index = newIndex
232 | var kvps []*KVPair
233 | dec := json.NewDecoder(resp.Body)
234 | dec.Decode(&kvps)
235 | resp.Body.Close()
236 |
237 | // find each current item of category
238 | currentItem := make(map[string]Item)
239 | for _, kv := range kvps {
240 | item := kv.NewItem()
241 | if !itemInCatalog(&item) {
242 | continue
243 | }
244 |
245 | current := compactItem(item)
246 | _, exist := prevItem[current]
247 | if exist && prevItem[current] != item.Status {
248 | currentItem[item.Category] = item
249 | }
250 | }
251 | for _, kv := range kvps {
252 | item := kv.NewItem()
253 | if !itemInCatalog(&item) {
254 | continue
255 | }
256 | if _, exist := currentItem[item.Category]; !exist {
257 | currentItem[item.Category] = item
258 | } else if currentItem[item.Category].Status < item.Status {
259 | currentItem[item.Category] = item
260 | }
261 | }
262 |
263 | // invoke trigger when a category status was changed
264 | for category, item := range currentItem {
265 | if _, exist := lastStatus[category]; !exist {
266 | // at first initialize
267 | lastStatus[category] = item.Status
268 | log.Printf("[info] %s: status %s", category, item.Status)
269 | } else if lastStatus[category] != item.Status {
270 | // status changed. invoking trigger.
271 | log.Printf("[info] %s: status %s -> %s", category, lastStatus[category], item.Status)
272 | lastStatus[category] = item.Status
273 | b, _ := json.Marshal(item)
274 | err := invokePipe(command, bytes.NewReader(b))
275 | if err != nil {
276 | log.Println("[error]", err)
277 | }
278 | }
279 | }
280 |
281 | // update previous item status
282 | for _, kv := range kvps {
283 | item := kv.NewItem()
284 | prev := compactItem(item)
285 | prevItem[prev] = item.Status
286 | }
287 |
288 | time.Sleep(1 * time.Second)
289 | }
290 | }
291 |
292 | // compactItem builds `Item` struct that has only `Category`, `Key`, and `Node` fields.
293 | func compactItem(item Item) Item {
294 | return Item{
295 | Key: item.Key,
296 | Category: item.Category,
297 | Node: item.Node,
298 | }
299 | }
300 |
301 | func invokePipe(command string, src io.Reader) error {
302 | log.Println("[info] Invoking command:", command)
303 | cmd := exec.Command("sh", "-c", command)
304 | stdin, err := cmd.StdinPipe()
305 | if err != nil {
306 | return err
307 | }
308 | stdout, err := cmd.StdoutPipe()
309 | if err != nil {
310 | return err
311 | }
312 | stderr, err := cmd.StderrPipe()
313 | if err != nil {
314 | return err
315 | }
316 |
317 | err = cmd.Start()
318 | if err != nil {
319 | return err
320 | }
321 | cmdCh := make(chan error)
322 | // src => stdin
323 | go func() {
324 | _, err := io.Copy(stdin, src)
325 | if err != nil {
326 | cmdCh <- err
327 | }
328 | stdin.Close()
329 | }()
330 | // wait for command exit
331 | go func() {
332 | cmdCh <- cmd.Wait()
333 | }()
334 | go io.Copy(os.Stdout, stdout)
335 | go io.Copy(os.Stderr, stderr)
336 |
337 | cmdErr := <-cmdCh
338 | return cmdErr
339 | }
340 |
341 | func updateNodes() {
342 | var index int64
343 | for {
344 | resp, newIndex, err := callConsulAPI(
345 | "/v1/catalog/nodes?index=" + strconv.FormatInt(index, 10) + "&wait=55s",
346 | )
347 | if err != nil {
348 | log.Println("[error]", err)
349 | time.Sleep(10 * time.Second)
350 | continue
351 | }
352 | index = newIndex
353 | dec := json.NewDecoder(resp.Body)
354 | mutex.Lock()
355 | dec.Decode(&Nodes)
356 | log.Println("[info]", Nodes)
357 | mutex.Unlock()
358 | time.Sleep(1 * time.Second)
359 | resp.Body.Close()
360 | }
361 | }
362 |
363 | func updateServices() {
364 | var index int64
365 | for {
366 | resp, newIndex, err := callConsulAPI(
367 | "/v1/catalog/services?index=" + strconv.FormatInt(index, 10) + "&wait=55s",
368 | )
369 | if err != nil {
370 | log.Println("[error]", err)
371 | time.Sleep(10 * time.Second)
372 | continue
373 | }
374 | index = newIndex
375 | dec := json.NewDecoder(resp.Body)
376 | mutex.Lock()
377 | dec.Decode(&Services)
378 | mutex.Unlock()
379 | time.Sleep(1 * time.Second)
380 | resp.Body.Close()
381 | }
382 | }
383 |
384 | func itemInCatalog(item *Item) bool {
385 | mutex.RLock()
386 | defer mutex.RUnlock()
387 | for _, node := range Nodes {
388 | if item.Node == node.Node {
389 | item.Address = node.Address
390 | return true
391 | }
392 | }
393 | for name, tags := range Services {
394 | if item.Node == name {
395 | item.Address = "service"
396 | return true
397 | }
398 | for _, tag := range tags {
399 | if item.Node == fmt.Sprintf("%s.%s", tag, name) {
400 | item.Address = "service"
401 | return true
402 | }
403 | }
404 | }
405 | return false
406 | }
407 |
408 | func callConsulAPI(path string) (*http.Response, int64, error) {
409 | var index int64
410 | _url := "http://" + ConsulAddr + path
411 | log.Println("[info] get", _url)
412 | resp, err := http.Get(_url)
413 | if err != nil {
414 | log.Println("[error]", err)
415 | return nil, index, err
416 | }
417 | _indexes := resp.Header["X-Consul-Index"]
418 | if len(_indexes) > 0 {
419 | index, _ = strconv.ParseInt(_indexes[0], 10, 64)
420 | }
421 | return resp, index, nil
422 | }
423 |
--------------------------------------------------------------------------------