├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── VERSION ├── docker-spotter.service ├── release.sh ├── spotter.go ├── spotter_test.go └── test └── container.json /.gitignore: -------------------------------------------------------------------------------- 1 | spotter 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | MAINTAINER Johannes 'fish' Ziemke (@discordianfish) 3 | 4 | ENV GOPATH /go 5 | ENV APPPATH $GOPATH/src/github.com/docker-infra/docker-spotter 6 | COPY . $APPPATH 7 | RUN apk add --update -t build-deps go git libc-dev gcc libgcc \ 8 | && cd $APPPATH && go get -d && go build -o /bin/docker-spotter \ 9 | && mkdir /docker-spotter \ 10 | && ln -s /bin/docker-spotter /docker-spotter/docker-spotter \ 11 | && apk del --purge build-deps && rm -rf $GOPATH 12 | 13 | WORKDIR /docker-spotter 14 | ENTRYPOINT [ "/bin/docker-spotter" ] 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell cat VERSION) 2 | TAG := v$(VERSION) 3 | 4 | all: build 5 | 6 | docker-spotter: 7 | go build 8 | 9 | build: docker-spotter 10 | 11 | tag: 12 | git tag $(TAG) 13 | git push --tags 14 | 15 | release: build tag 16 | ./release.sh $(TAG) discordianfish/docker-spotter docker-spotter 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # docker-spotter 2 | 3 | docker-spotter connects to a docker daemon, receives events and 4 | executes commands provided on the command line. 5 | 6 | ## Usage 7 | 8 | -addr="/var/run/docker.sock": address to connect to 9 | -e=: Hook map with template text executed in docker event (see JSONMessage) context, 10 | format: container:event[,event]:command[:arg1:arg2...] 11 | -proto="unix": protocol to use 12 | -replay="": file to use to simulate/replay events from. Format = docker events 13 | -since="1": watch for events since given value in seconds since epoch 14 | -v=false: verbose logging 15 | 16 | The command and each parameter get parsed as 17 | [text/template](http://golang.org/pkg/text/template/) and will get 18 | rendered with {{.Name}} set to the containers name, {{.ID}} to it's ID 19 | and {{.Event}} to the [JSONMessage](http://godoc.org/github.com/dotcloud/docker/utils#JSONMessage) 20 | which triggered the event. 21 | 22 | Available events: 23 | 24 | - create 25 | - start 26 | - stop 27 | - destroy 28 | 29 | ## Example 30 | 31 | This example will run `pipework eth0 192.168.242.1/24` when a 32 | container named 'pxe-server' starts or restarts and `echo gone` when it stops. 33 | 34 | ./spotter \ 35 | -e 'pxe-server:start,restart:pipework:eth0:{{.ID}}:192.168.242.1/24' \ 36 | -e 'pxe-server:stop:echo:gone' 37 | 38 | ### tcp proto and wildcards for containers 39 | 40 | This example uses a tcp socket and specifies a wildcard to trigger the event on any container. 41 | The idea behind this is, to create a small program that assigns IP addresses depending on the container name. 42 | 43 | ./docker-spotter -addr=localhost:6000 -proto="tcp" 44 | -since=0 45 | -e '*:start:echo:{{.Name}}:up' 46 | 2014/07/01 10:30:36 = *:start:*:start:echo:{{.Name}}:up 47 | 2014/07/01 10:30:39 > /usr/bin/echo [ [/test up] ] 48 | 2014/07/01 10:30:39 - /test up 49 | 50 | ### the environment variable (ENV) key/value pair as the event trigger for container matching. 51 | 52 | This example will run `pipework eth0 192.168.242.1/24` when a 53 | container has the FOO=BAR environment variable set. 54 | 55 | ./docker-spotter \ 56 | -e 'FOO=BAR:start,restart:pipework:eth0:{{.ID}}:192.168.242.1/24' \ 57 | -e 'FOO=BAR:stop:echo:gone' 58 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.4 2 | -------------------------------------------------------------------------------- /docker-spotter.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Docker Spotter 3 | After=docker.socket 4 | Requires=docker.socket 5 | 6 | [Service] 7 | Type=simple 8 | EnvironmentFile=/etc/sysconfig/docker-spotter 9 | ExecStart=/usr/bin/docker-spotter $OPTIONS 10 | Restart=always 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | TAG=$1 5 | REPO=$2 6 | 7 | if [ -z "$REPO" ] 8 | then 9 | echo "$0 tag repo files..." 10 | exit 1 11 | fi 12 | shift 2 13 | FILES=$@ 14 | 15 | if [ ! -e "$HOME/.github-autorelease" ] 16 | then 17 | cat < "$TMP" 57 | github -H 'Content-type: application/gzip' "$UPLOAD_URL?name=$(basename $file).gz" --data-binary @$TMP | jq -r .state 58 | rm $TMP 59 | done 60 | 61 | -------------------------------------------------------------------------------- /spotter.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" 12 | "net/http" 13 | "net/http/httputil" 14 | "net/url" 15 | "os" 16 | "os/exec" 17 | "strings" 18 | "text/template" 19 | 20 | "github.com/docker/docker/pkg/jsonmessage" 21 | ) 22 | 23 | const APIVERSION = "1.23" 24 | 25 | var ( 26 | proto = flag.String("proto", "unix", "protocol to use") 27 | addr = flag.String("addr", "/var/run/docker.sock", "address to connect to") 28 | since = flag.String("since", "1", "watch for events since given value in seconds since epoch") 29 | replay = flag.String("replay", "", "file to use to simulate/replay events from. Format = docker events") 30 | debug = flag.Bool("v", false, "verbose logging") 31 | hm hookMap 32 | ) 33 | 34 | type ContainerConfig struct { 35 | Env []string 36 | } 37 | 38 | type Container struct { 39 | Name string 40 | ID string 41 | Config ContainerConfig 42 | Event jsonmessage.JSONMessage 43 | } 44 | 45 | // id, event, command 46 | type hookMap map[string]map[string][][]*template.Template 47 | 48 | func (hm hookMap) String() string { return "" } 49 | func (hm hookMap) Set(str string) error { 50 | parts := strings.Split(str, ":") 51 | if len(parts) < 3 { 52 | return fmt.Errorf("Couldn't parse %s", str) 53 | } 54 | id := parts[0] 55 | events := strings.Split(parts[1], ",") 56 | command, err := parseTemplates(parts[2:]) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if hm[id] == nil { 62 | hm[id] = make(map[string][][]*template.Template) 63 | } 64 | for _, event := range events { 65 | log.Printf("= %s:%s:%s", id, event, str) 66 | hm[id][event] = append(hm[id][event], command) 67 | } 68 | return nil 69 | } 70 | 71 | func parseTemplates(templates []string) ([]*template.Template, error) { 72 | tl := []*template.Template{} 73 | for i, t := range templates { 74 | tmpl, err := template.New(fmt.Sprintf("t-%d", i)).Parse(t) 75 | if err != nil { 76 | return nil, err 77 | } 78 | tl = append(tl, tmpl) 79 | } 80 | return tl, nil 81 | } 82 | 83 | func getContainer(event jsonmessage.JSONMessage) (*Container, error) { 84 | resp, err := request("/containers/" + event.ID + "/json") 85 | if err != nil { 86 | return nil, fmt.Errorf("Couldn't find container for event %#v: %s", event, err) 87 | } 88 | defer resp.Body.Close() 89 | container := &Container{} 90 | body, err := ioutil.ReadAll(resp.Body) 91 | if err != nil { 92 | return nil, err 93 | } 94 | container.Event = event 95 | container.ID = event.ID 96 | return container, json.Unmarshal(body, &container) 97 | } 98 | 99 | // leading slash is expected 100 | func request(path string) (*http.Response, error) { 101 | apiPath := fmt.Sprintf("/v%s%s", APIVERSION, path) 102 | if *debug { 103 | fmt.Printf("making API request: GET %s\n", apiPath) 104 | } 105 | 106 | req, err := http.NewRequest("GET", apiPath, nil) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | conn, err := net.Dial(*proto, *addr) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | clientconn := httputil.NewClientConn(conn, nil) 117 | resp, err := clientconn.Do(req) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | if resp.StatusCode < 200 || resp.StatusCode >= 400 { 123 | body, err := ioutil.ReadAll(resp.Body) 124 | if err != nil { 125 | return nil, err 126 | } 127 | if len(body) == 0 { 128 | return nil, fmt.Errorf("Error: %s", http.StatusText(resp.StatusCode)) 129 | } 130 | 131 | return nil, fmt.Errorf("HTTP %s: %s", http.StatusText(resp.StatusCode), body) 132 | } 133 | return resp, nil 134 | } 135 | 136 | func main() { 137 | hm = hookMap{} 138 | flag.Var(&hm, "e", "Hook map with template text executed in docker event (see JSONMessage) context, format: container:event[,event]:command[:arg1:arg2...]") 139 | flag.Parse() 140 | if len(hm) == 0 { 141 | fmt.Fprintf(os.Stderr, "Please set hooks via -e flag\n") 142 | flag.PrintDefaults() 143 | os.Exit(1) 144 | } 145 | 146 | v := url.Values{} 147 | v.Set("since", *since) 148 | 149 | resp, err := request("/events?" + v.Encode()) 150 | if err != nil { 151 | log.Fatal(err) 152 | } 153 | defer resp.Body.Close() 154 | if *replay != "" { 155 | file, err := os.Open(*replay) 156 | if err != nil { 157 | log.Fatalf("Couldn't replay from file %s: %s", *replay, err) 158 | } 159 | watch(file) 160 | } else { 161 | watch(resp.Body) 162 | } 163 | } 164 | 165 | func watch(r io.Reader) { 166 | dec := json.NewDecoder(r) 167 | for { 168 | event := jsonmessage.JSONMessage{} 169 | if err := dec.Decode(&event); err != nil { 170 | if err == io.EOF { 171 | break 172 | } 173 | log.Fatalf("Couldn't decode message: %s", err) 174 | } 175 | if event.ID == "" { 176 | if *debug { 177 | log.Printf("skipping non-container message") 178 | } 179 | 180 | continue 181 | } 182 | 183 | if *debug { 184 | log.Printf("< %s:%s", event.ID, event.Status) 185 | } 186 | if event.Status == "delete" || event.Status == "destroy" { 187 | // we can't get the container if it is destroyed. Trying to do so just 188 | // produces noise. 189 | if *debug { 190 | log.Printf("Skipping %s event for container %s", event.Status, event.ID) 191 | } 192 | continue 193 | } 194 | 195 | container, err := getContainer(event) 196 | if err != nil { 197 | log.Printf("Warning: Couldn't get container %s: %s", event.ID, err) 198 | continue 199 | } 200 | if *debug { 201 | log.Printf("Got container: %+v", container) 202 | } 203 | 204 | events := hm[event.ID] 205 | if events == nil { 206 | events = GetEvents(hm, container) 207 | if events == nil { 208 | events = hm["*"] 209 | } 210 | if events == nil { 211 | continue 212 | } 213 | } 214 | commands := events[event.Status] 215 | if len(commands) == 0 { 216 | continue 217 | } 218 | for _, command := range commands { 219 | if len(command) == 0 { 220 | continue 221 | } 222 | args := []string{} 223 | for _, template := range command { 224 | buf := bytes.NewBufferString("") 225 | if err := template.Execute(buf, container); err != nil { 226 | log.Fatalf("Couldn't render template: %s", err) 227 | } 228 | args = append(args, buf.String()) 229 | } 230 | 231 | command := exec.Command(args[0], args[1:]...) 232 | log.Printf("> %s [ %v ]", command.Path, command.Args[1:]) 233 | out, err := command.CombinedOutput() 234 | if err != nil { 235 | log.Printf("! ERROR %s: %s", err, out) 236 | continue 237 | } 238 | if out != nil { 239 | log.Printf("- %s", out) 240 | } 241 | } 242 | } 243 | } 244 | 245 | func GetEvents(hooks hookMap, container *Container) map[string][][]*template.Template { 246 | name := strings.TrimLeft(container.Name, "/") 247 | 248 | for key, value := range hooks { 249 | //if key is key/value pair search it in Env 250 | if strings.Contains(key, "=") && contains(container.Config.Env, key) { 251 | return value 252 | //looks like key is container's name 253 | } else if strings.HasPrefix(name, key) { 254 | return value 255 | } 256 | } 257 | 258 | return nil 259 | } 260 | 261 | //Check if source array contains target string 262 | func contains(source []string, target string) bool { 263 | for _, value := range source { 264 | if value == target { 265 | return true 266 | } 267 | } 268 | 269 | return false 270 | } 271 | -------------------------------------------------------------------------------- /spotter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "encoding/json" 6 | "os" 7 | ) 8 | 9 | func TestGetEvents(t *testing.T) { 10 | container := getContainerFromFile("test/container.json") 11 | hookSource := "test_123_hash_xyz:start,restart:pipework:eth0:{{.ID}}:192.168.242.1/24" 12 | hooks := getHooks(hookSource) 13 | events := GetEvents(hooks, &container) 14 | 15 | if (events == nil) { 16 | t.Error("Container not found by name", container.Name, "hooks: ", hookSource) 17 | } 18 | 19 | hookSource = "LIBVIRT_SERVICE_PORT=16509:start,restart:pipework:eth0:{{.ID}}:192.168.242.1/24" 20 | hooks = getHooks(hookSource) 21 | events = GetEvents(hooks, &container) 22 | 23 | if (events == nil) { 24 | t.Error("Container not found by env. Hooks: ", hookSource) 25 | } 26 | 27 | container = getContainerFromFile("test/container.json") 28 | hookSource = "teest_123_hash_xyz:start,restart:pipework:eth0:{{.ID}}:192.168.242.1/24" 29 | hooks = getHooks(hookSource) 30 | events = GetEvents(hooks, &container) 31 | 32 | if (events != nil) { 33 | t.Error("Container shouldn't be found by name", container.Name, "hooks: ", hookSource) 34 | } 35 | 36 | hookSource = "LIBVIRT_SERVICE_PORT=16599:start,restart:pipework:eth0:{{.ID}}:192.168.242.1/24" 37 | hooks = getHooks(hookSource) 38 | events = GetEvents(hooks, &container) 39 | 40 | if (events != nil) { 41 | t.Error("Container shouldn't be found by env. Hooks: ", hookSource) 42 | } 43 | } 44 | 45 | func getHooks(source string) hookMap { 46 | result := hookMap{} 47 | result.Set(source) 48 | return result 49 | } 50 | 51 | func getContainerFromFile(filename string) Container { 52 | 53 | result := Container{} 54 | reader, _ := os.Open(filename) 55 | decoder := json.NewDecoder(reader) 56 | decoder.Decode(&result) 57 | return result 58 | } 59 | -------------------------------------------------------------------------------- /test/container.json: -------------------------------------------------------------------------------- 1 | { 2 | "Args": [], 3 | "Config": { 4 | "AttachStderr": false, 5 | "AttachStdin": false, 6 | "AttachStdout": false, 7 | "Cmd": [ 8 | "/start.sh" 9 | ], 10 | "CpuShares": 0, 11 | "Cpuset": "", 12 | "Domainname": "", 13 | "Entrypoint": null, 14 | "Env": [ 15 | "DB_ROOT_PASSWORD=password", 16 | "MARIADB_SERVICE_HOST=10.10.10.5", 17 | "MARIADB_SERVICE_PORT=3306", 18 | "MARIADB_PORT=tcp://10.10.10.5:3306", 19 | "MARIADB_PORT_3306_TCP=tcp://10.10.10.5:3306", 20 | "MARIADB_PORT_3306_TCP_PROTO=tcp", 21 | "MARIADB_PORT_3306_TCP_PORT=3306", 22 | "MARIADB_PORT_3306_TCP_ADDR=10.10.10.5", 23 | "LIBVIRT_SERVICE_HOST=10.10.10.5", 24 | "LIBVIRT_SERVICE_PORT=16509", 25 | "LIBVIRT_PORT=tcp://10.10.10.5:16509", 26 | "LIBVIRT_PORT_16509_TCP=tcp://10.10.10.5:16509", 27 | "LIBVIRT_PORT_16509_TCP_PROTO=tcp", 28 | "LIBVIRT_PORT_16509_TCP_PORT=16509", 29 | "LIBVIRT_PORT_16509_TCP_ADDR=10.10.10.5", 30 | "RABBITMQ_SERVICE_HOST=10.10.10.5", 31 | "RABBITMQ_SERVICE_PORT=5672", 32 | "RABBITMQ_PORT=tcp://10.10.10.5:5672", 33 | "RABBITMQ_PORT_5672_TCP=tcp://10.10.10.5:5672", 34 | "RABBITMQ_PORT_5672_TCP_PROTO=tcp", 35 | "RABBITMQ_PORT_5672_TCP_PORT=5672", 36 | "RABBITMQ_PORT_5672_TCP_ADDR=10.10.10.5", 37 | "SERVICE_HOST=10.10.10.5", 38 | "HOME=/", 39 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 40 | ], 41 | "ExposedPorts": { 42 | "8770/tcp": {} 43 | }, 44 | "Hostname": "test", 45 | "Image": "test/test", 46 | "Memory": 0, 47 | "MemorySwap": 0, 48 | "NetworkDisabled": false, 49 | "OnBuild": null, 50 | "OpenStdin": false, 51 | "PortSpecs": null, 52 | "SecurityOpt": null, 53 | "StdinOnce": false, 54 | "Tty": false, 55 | "User": "", 56 | "Volumes": null, 57 | "WorkingDir": "" 58 | }, 59 | "Created": "2014-10-27T06:48:03.931128575Z", 60 | "Driver": "devicemapper", 61 | "ExecDriver": "native-0.2", 62 | "HostConfig": { 63 | "Binds": null, 64 | "CapAdd": null, 65 | "CapDrop": null, 66 | "ContainerIDFile": "", 67 | "Devices": null, 68 | "Dns": null, 69 | "DnsSearch": null, 70 | "ExtraHosts": null, 71 | "Links": null, 72 | "LxcConf": null, 73 | "NetworkMode": "container:16633da34f708af0f2993d31421ffec06bff09244c35bf6ed1c581df4c804529", 74 | "PortBindings": null, 75 | "Privileged": true, 76 | "PublishAllPorts": false, 77 | "RestartPolicy": { 78 | "MaximumRetryCount": 0, 79 | "Name": "" 80 | }, 81 | "VolumesFrom": null 82 | }, 83 | "HostnamePath": "", 84 | "HostsPath": "/var/lib/docker/containers/16633da34f708af0f2993d31421ffec06bff09244c35bf6ed1c581df4c804529/hosts", 85 | "Id": "a517c9f20ce77339755d5334783b4dc2cc7714fdb24364e96c919e4a8a0623ab", 86 | "Image": "2ce92540d5266feb8a6a1382d1b29514f9db0ea4baaf3cf96c0d046380077034", 87 | "MountLabel": "system_u:object_r:svirt_sandbox_file_t:s0:c226,c702", 88 | "Name": "/test_123_hash_xyz", 89 | "NetworkSettings": { 90 | "Bridge": "", 91 | "Gateway": "", 92 | "IPAddress": "", 93 | "IPPrefixLen": 0, 94 | "MacAddress": "", 95 | "PortMapping": null, 96 | "Ports": null 97 | }, 98 | "Path": "/start.sh", 99 | "ProcessLabel": "system_u:system_r:svirt_lxc_net_t:s0:c226,c702", 100 | "ResolvConfPath": "/var/lib/docker/containers/16633da34f708af0f2993d31421ffec06bff09244c35bf6ed1c581df4c804529/resolv.conf", 101 | "State": { 102 | "ExitCode": 0, 103 | "FinishedAt": "0001-01-01T00:00:00Z", 104 | "Paused": false, 105 | "Pid": 11348, 106 | "Restarting": false, 107 | "Running": true, 108 | "StartedAt": "2014-10-27T06:48:05.140680318Z" 109 | }, 110 | "Volumes": {}, 111 | "VolumesRW": {} 112 | } --------------------------------------------------------------------------------