DevOps

Create a Slack Docker proxy in Go – Part 1

Last year I had the opportunity to work with lots of cool tools and technologies. A couple of those were go, slack and docker. We pretty much use slack for all our communication, and are slowly adding more and more integrations to slack to get information from various build and runtime tools. In our environment we’ve got a number of different docker environments, and currently either log into the host machine to check on the docker status, or use docker-ui (which we hacked to work with multiple docker environments). Even though docker-ui works great, and provides a great way to get an overview of what is happening within a specific docker daemon, it isn’t really lightweight and in some scenarios, requires a lot of clicks to get the information a developer is looking for.

An alternative would be for developers to just use the docker API, but that would involve opening a lot of firewall ports. So we started looking into using (or creating) a Slack integration which we could use to query our docker environments directly from slack. That way developers wouldn’t need to use a new tool, they already have slack open most of the time, and can quickly get the status of a specific docker container (and if time permits we’ll also add Jenkins integration for deployments to the various dev environments from slack).

If your team doesn’t use slack yet, you can start out for free, and use all the cool features of slack. I’ve worked in teams of 30+ people all using the same free slack version, so there is really no use not to use slack. So enough slack promotion, lets get started.

Sending commands from slack

Slack provides a number of different ways to integrate with external systems. In this scenario we’ll use a “Slash Command“. With slash commands we can just type in our command in Slack And slack will send a POST message with content-type application/x-www-form-urlencoded, to the configured end point which contains data like this:

token=gIkuvaNzQIHg97ATvDxqgjtO
team_id=T0001
team_domain=example
channel_id=C2147483705
channel_name=test
user_id=U2147483697
user_name=Steve
command=/weather
text=94070
response_url=https://hooks.slack.com/commands/1234/5678

Now we can either response directly, or send a response to the provided response_url. In this article we’ll just respond directly (which needs to be done within 3000 ms). So first off, lets configure slack to send a specific command:

docker-slack-1

In this case whenever we type /docker, the command will be sent to our configured endpoint. For instance if we type in “/docker CI ps”, we’ll get an overview of all the images running in the CI environment:

docker-slack-2

In the next couple of section we’ll show you how to implement such a proxy with go-lang.

Creating a go-lang proxy

All the code from here can be found on my github (https://github.com/josdirksen/slack-proxy) and if you just want to run it just follow these simple steps:

$ git clone https://github.com/josdirksen/slack-proxy
$ cd slack-proxy
$ export GOPATH=`pwd`
$ go get github.com/fsouza/go-dockerclient
$ go build src/github.com/
$ go build src/github.com/josdirksen/slackproxy/slack-proxy.go
# Make sure to edit the config.json to point to the correct docker location and keys/certificates
$ ./slack-proxy -config resources/config.json
{5cLHiZjpWaRDb0fP6ka02XCR {[{local tcp://192.168.99.100:2376 true /Users/jos/.docker/machine/machines/eris ca.pem cert.pem key.pem}]}}

Lets look at the code. The first file we’ll look at is the main slack-proxy.go file:

package main

import (
	"flag"
	"github.com/josdirksen/slackproxy/config"
	"github.com/josdirksen/slackproxy/handlers"
	"log"
	"net/http"
)

// setup the listener, with a config passed in.
func GetConfigListener(config *config.Configuration) func(http.ResponseWriter, *http.Request) {
	return func(w http.ResponseWriter, req *http.Request) {
		// get the contents from the request body, convert it to a command
		req.ParseForm()
		cmdToExecute := handlers.ParseInput(req.Form)

		// now check whether the token is valid
		if config.Token == cmdToExecute.Token {
			// execute the command
			handler := handlers.GetHandler("docker", config)
			handler.HandleCommand(cmdToExecute, w)
		} else {
			w.WriteHeader(406)
		}

	}
}

func StartListening(config *config.Configuration) {
	http.HandleFunc("/handleSlackCommand", GetConfigListener(config))
	err := http.ListenAndServe(":9000", nil)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

func main() {
	var configLocation = flag.String("config", "config.json", "specify the config file")
	flag.Parse()
	// first parse the config
	config.ParseConfig(*configLocation)
	// setup the handler that listens to 9000
	StartListening(config.GetConfig())
}

As you can see here, we don’t really do that much. The main thing we do is parse the configuration, and setup a HTTP listener on port 9000. So whenever we get a request on port 9000, the specified handler, returned by GetConfigListener, is called :

// setup the listener, with a config passed in.
func GetConfigListener(config *config.Configuration) func(http.ResponseWriter, *http.Request) {
	return func(w http.ResponseWriter, req *http.Request) {
		// get the contents from the request body, convert it to a command
		req.ParseForm()
		cmdToExecute := handlers.ParseInput(req.Form)

		// now check whether the token is valid
		if config.Token == cmdToExecute.Token {
			// execute the command
			handler := handlers.GetHandler("docker", config)
			handler.HandleCommand(cmdToExecute, w)
		} else {
			w.WriteHeader(406)
		}

	}
}

The handler itself also doesn’t really do that much. It just passes on the command that needs to be executed to the handler identified by “docker”. Note that at this step we only support “docker” commands. But we could easily use this setup to also handle other commands. More on that in a future article.

Handle commands

So lets look a bit closer at the handler.HandleCommand function (which you can find in the dockerCommandHandlers.go file:

package handlers

import (
	"io"
	"net/http"
	"fmt"
	"github.com/fsouza/go-dockerclient"
	"bytes"
	"time"
	"github.com/josdirksen/slackproxy/config"
	"log"
	"os"
)

type DockerHandler struct {
	config	*config.Configuration
}

func NewDockerHandler(configuration *config.Configuration) *DockerHandler {
	p := new(DockerHandler)
	p.config = configuration
	return p
}

func (dh DockerHandler) HandleCommand(cmdToExecute *Command, w http.ResponseWriter) {
	client := setupDockerClient(cmdToExecute.Environment)

	switch cmdToExecute.SlackCommand {
	case "ps" : handlePsCommand(client, w)
	case "imgs" : handleListImagesCommand(client, w)
	}
}

func handleListImagesCommand(client *docker.Client, w http.ResponseWriter) {
	images, _ := client.ListImages(docker.ListImagesOptions{All: false})
	for _, img := range images {
		fmt.Println("ID: ", img.ID)
		fmt.Println("RepoTags: ", img.RepoTags)
		fmt.Println("Created: ", img.Created)
		fmt.Println("Size: ", img.Size)
		fmt.Println("VirtualSize: ", img.VirtualSize)
		fmt.Println("ParentId: ", img.ParentID)
	}
}

func handlePsCommand(client *docker.Client, w http.ResponseWriter) {
	containers, _ := client.ListContainers(docker.ListContainersOptions{All: false})
	var buffer bytes.Buffer
	for _, container := range containers {
		buffer.WriteString(fmt.Sprintf("ID: %s\n", container.ID))
		buffer.WriteString(fmt.Sprintf("Command: %s\n", container.Command))
		buffer.WriteString(fmt.Sprintf("Created: %s\n", time.Unix(container.Created, 0)))
		buffer.WriteString(fmt.Sprintf("Image: %s\n", container.Image))
		buffer.WriteString(fmt.Sprintf("Status: %s\n", container.Status))
		buffer.WriteString(fmt.Sprintf("Names: %s\n", container.Names))
		if (len(container.Ports) > 0) {
			buffer.WriteString("Ports: \n")
			for _, port := range container.Ports {

				buffer.WriteString(fmt.Sprintf("\t type: %s IP: %s private: %d public: %d\n", port.Type, port.IP, port.PrivatePort, port.PublicPort))
			}
		}
		buffer.WriteString(fmt.Sprint("\n"))
	}

	io.WriteString(w, buffer.String())
}

func setupDockerClient(env string) *docker.Client {

	// first get the environment from the config
	cfg, err := config.GetDockerEnvironmentConfig(env)

	if (err != nil) {
		println(err)
		log.Fatal("Can't parse config, exiting")
		os.Exit(1)
	}

	if (cfg.Tls) {
		endpoint := cfg.Host
		path := cfg.Path
		ca := fmt.Sprintf("%s/%s", path, cfg.Ca)
		cert := fmt.Sprintf("%s/%s", path, cfg.Cert)
		key := fmt.Sprintf("%s/%s", path, cfg.Key)

		client,_ := docker.NewTLSClient(endpoint, cert, key, ca)
		return client
	} else {
		client,_ := docker.NewClient(cfg.Host)
		return client
	}

}

Lots of code, but the main thing to look at is the HandleCommand function. Here we just create a new docker client (see code above), and use a simple switch to determine which function to call.

func (dh DockerHandler) HandleCommand(cmdToExecute *Command, w http.ResponseWriter) {
	client := setupDockerClient(cmdToExecute.Environment)

	switch cmdToExecute.SlackCommand {
	case "ps" : handlePsCommand(client, w)
	case "imgs" : handleListImagesCommand(client, w)
	}
}

For instance if you look a bit closer to the handlePsCommand you can see that we just convert the response from the client.ListContainers function and write that to the http.ResponseWriter.

func handlePsCommand(client *docker.Client, w http.ResponseWriter) {
	containers, _ := client.ListContainers(docker.ListContainersOptions{All: false})
	var buffer bytes.Buffer
	for _, container := range containers {
		buffer.WriteString(fmt.Sprintf("ID: %s\n", container.ID))
		buffer.WriteString(fmt.Sprintf("Command: %s\n", container.Command))
		buffer.WriteString(fmt.Sprintf("Created: %s\n", time.Unix(container.Created, 0)))
		buffer.WriteString(fmt.Sprintf("Image: %s\n", container.Image))
		buffer.WriteString(fmt.Sprintf("Status: %s\n", container.Status))
		buffer.WriteString(fmt.Sprintf("Names: %s\n", container.Names))
		if (len(container.Ports) > 0) {
			buffer.WriteString("Ports: \n")
			for _, port := range container.Ports {

				buffer.WriteString(fmt.Sprintf("\t type: %s IP: %s private: %d public: %d\n", port.Type, port.IP, port.PrivatePort, port.PublicPort))
			}
		}
		buffer.WriteString(fmt.Sprint("\n"))
	}

	io.WriteString(w, buffer.String())
}

#Running and testing the code

Now we can build and run the code:

$ git clone https://github.com/josdirksen/slack-proxy
$ cd slack-proxy
$ export GOPATH=`pwd`
$ go get github.com/fsouza/go-dockerclient
$ go build src/github.com/
$ go build src/github.com/josdirksen/slackproxy/slack-proxy.go
# Make sure to edit the config.json to point to the correct docker location and keys/certificates
$ ./slack-proxy -config resources/config.json
{5cLHiZjpWaRDb0fP6ka02XCR {[{local tcp://192.168.99.100:2376 true /Users/jos/.docker/machine/machines/eris ca.pem cert.pem key.pem}]}}

And to test it we use curl to send a request. The request we test with looks like this:

$ curl 'http://localhost:9000/handleSlackCommand' -H 'Content-Type: application/x-www-form-urlencoded' --data 'token=5cLHiZjpWaRDb0fP6ka02XCR&team_id=T0001&team_domain=example&channel_id=C2147483705&channel_name=test&user_id=U2147483697&user_name=Steve,nd=/docker&text=local+ps'

Which returns information like this:

ID: 669265a1343601468e71fbe65dec0251595f6c043ceeb5748f6736b28fd5da39
Command: /bin/start -server -advertise 192.168.99.100 -bootstrap-expect 1
Created: 2016-01-06 08:05:53 +0100 CET
Image: docker-register.equeris.com/consul:eris-local
Status: Up 3 minutes
Names: [/consul]
Ports: 
         type: tcp IP: 192.168.99.100 private: 8400 public: 8400
         type: tcp IP: 192.168.99.100 private: 8302 public: 8302
         type: tcp IP: 192.168.99.100 private: 8301 public: 8301
         type: udp IP: 192.168.99.100 private: 53 public: 53
         type: tcp IP:  private: 53 public: 0
         type: tcp IP: 192.168.99.100 private: 8500 public: 8500
         type: udp IP: 192.168.99.100 private: 8301 public: 8301
         type: tcp IP: 192.168.99.100 private: 8300 public: 8300
         type: udp IP: 192.168.99.100 private: 8302 public: 8302
 
ID: 682f47f97fced8725d228a68a4bdf5486bf0d3e247f6864331e4f6b413b92f65
Command: /find-ip-add-to-consul.sh -Dcassandra.load_ring_state=false
Created: 2015-11-24 13:29:38 +0100 CET
Image: docker-register.equeris.com/cassandra:eris
Status: Up 3 minutes
Names: [/cassandra-eris]
Ports: 
         type: tcp IP: 192.168.99.100 private: 9160 public: 9160
         type: tcp IP: 192.168.99.100 private: 9042 public: 9042
         type: tcp IP:  private: 7199 public: 0
         type: tcp IP:  private: 7001 public: 0
         type: tcp IP: 192.168.99.100 private: 7000 public: 7000

That’s it for this article. Note that we’ve left a lot of things out, we only implemented the ps command, don’t do much validation or any error handling. In follow up articles we’ll look closer into adding more features to this proxy.

Reference: Create a Slack Docker proxy in Go – Part 1 from our JCG partner Jos Dirksen at the Smart Java blog.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button