├── TODO.md ├── imgs ├── GetARedisHomePage.gif ├── SystemArchitecture.png └── SystemArchitecture.xml ├── cmd ├── getaredis-server │ ├── public │ │ ├── imgs │ │ │ └── GetARedisHomePage.png │ │ ├── font-awesome │ │ │ ├── fonts │ │ │ │ ├── FontAwesome.otf │ │ │ │ ├── fontawesome-webfont.eot │ │ │ │ ├── fontawesome-webfont.ttf │ │ │ │ └── fontawesome-webfont.woff │ │ │ └── css │ │ │ │ └── font-awesome.min.css │ │ ├── js │ │ │ └── main.js │ │ └── css │ │ │ ├── line-scale.min.css │ │ │ └── main.css │ ├── server.go │ └── templates │ │ └── index.tmpl └── getaredis-jobs │ └── run-jobs.go ├── utils_test.go ├── .gitignore ├── utils.go ├── scheduler.go ├── config.yml ├── config-files ├── redis-mysql-node.sh ├── server-job-node.cnf └── stack.cnf ├── LICENSE ├── instances_test.go ├── jobs.go ├── context.go ├── instances.go ├── README.md └── hosts.go /TODO.md: -------------------------------------------------------------------------------- 1 | #TODO 2 | - ~~Open Docker port on hosts for the master.~~ 3 | - ~~Pull redis image on new hosts.~~ 4 | -------------------------------------------------------------------------------- /imgs/GetARedisHomePage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohamedBassem/getaredis/HEAD/imgs/GetARedisHomePage.gif -------------------------------------------------------------------------------- /imgs/SystemArchitecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohamedBassem/getaredis/HEAD/imgs/SystemArchitecture.png -------------------------------------------------------------------------------- /cmd/getaredis-server/public/imgs/GetARedisHomePage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohamedBassem/getaredis/HEAD/cmd/getaredis-server/public/imgs/GetARedisHomePage.png -------------------------------------------------------------------------------- /cmd/getaredis-server/public/font-awesome/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohamedBassem/getaredis/HEAD/cmd/getaredis-server/public/font-awesome/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /cmd/getaredis-server/public/font-awesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohamedBassem/getaredis/HEAD/cmd/getaredis-server/public/font-awesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /cmd/getaredis-server/public/font-awesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohamedBassem/getaredis/HEAD/cmd/getaredis-server/public/font-awesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /cmd/getaredis-server/public/font-awesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohamedBassem/getaredis/HEAD/cmd/getaredis-server/public/font-awesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGenerateRandomString(t *testing.T) { 10 | tests := [...]int{0, 10, 20, 30} 11 | 12 | for test := range tests { 13 | str := generateRandomString(test) 14 | assert.Equal(t, test, len(str), "String should be equal the specified length") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ########## 2 | # Go # 3 | ########## 4 | 5 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 6 | *.o 7 | *.a 8 | *.so 9 | 10 | # Folders 11 | _obj 12 | _test 13 | 14 | # Architecture specific extensions/prefixes 15 | *.[568vq] 16 | [568vq].out 17 | 18 | *.cgo1.go 19 | *.cgo2.c 20 | _cgo_defun.c 21 | _cgo_gotypes.go 22 | _cgo_export.* 23 | 24 | _testmain.go 25 | 26 | *.exe 27 | *.test 28 | *.prof 29 | config_production.yml 30 | config_test.yml 31 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | ) 7 | 8 | func generateDockerAddress(ip, username, password string) string { 9 | return fmt.Sprintf("tcp://%v:%v@%v:2377", username, password, ip) 10 | } 11 | 12 | func generateRandomString(length int) string { 13 | var ret string 14 | runes := []rune("abcdefghijklmnopqrstuvwxyz0123456789") 15 | for i := 0; i < length; i++ { 16 | ret += string(runes[rand.Intn(len(runes))]) 17 | } 18 | return ret 19 | } 20 | -------------------------------------------------------------------------------- /scheduler.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import "errors" 4 | 5 | // Returns the ip for the host which can hold this container 6 | func (ctx *context) scheduleNewContainer() (publicIP, privateIP string, err error) { 7 | hosts := ctx.ListHosts() 8 | maximumNumberOfContainers := ctx.config.MaxContainersPerHost 9 | maximumNumber := -1 10 | chosenHost := -1 11 | for i, host := range hosts { 12 | if host.NumberOfContainers < maximumNumberOfContainers && maximumNumber < host.NumberOfContainers { 13 | maximumNumber = host.NumberOfContainers 14 | chosenHost = i 15 | } 16 | } 17 | 18 | if chosenHost == -1 { 19 | return "", "", errors.New("Cannot schedule container.") 20 | } 21 | return hosts[chosenHost].PublicIP, hosts[chosenHost].PrivateIP, nil 22 | } 23 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # The MySQL Database for instances info 2 | database: 3 | user: root 4 | password: toor 5 | host: localhost:3306 6 | dbname: getaredis 7 | 8 | # The Redis Server for service discovery 9 | redisAddress: "localhost:6379" 10 | redisPassword: "" 11 | 12 | # Docker Daemon Authentication 13 | docker: 14 | user: docker 15 | password: "" 16 | 17 | # Digitalocean API token for autoscaling 18 | digitalOceanToken: "" 19 | 20 | # An SSH key for newly created droplets 21 | dropletSSHKeyID: XXXX 22 | 23 | # The redis instances maximum memory in MB 24 | maxInstanceSize: 5 25 | 26 | # Maximum number of simultinous connections to a redis instance 27 | maxRedisConnections: 5 28 | 29 | # The number of hours to keep an instance alive 30 | maxInstanceTime: 12 31 | 32 | # Maximum number of instances per IP 33 | maxInstancesPerIP: 6 34 | 35 | # Maximum number of containers per Host 36 | maxContainersPerHost: 40 37 | -------------------------------------------------------------------------------- /config-files/redis-mysql-node.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MYSQL_PASSWORD= 4 | REDIS_PASSWORD= 5 | DATABASE_NAME=getaredis 6 | 7 | #Installing MySQL 8 | apt-get update 9 | echo mysql-server mysql-server/root_password password $MYSQL_PASSWORD | sudo debconf-set-selections 10 | echo mysql-server mysql-server/root_password_again password $MYSQL_PASSWORD | sudo debconf-set-selections 11 | apt-get install -y mysql-server mysql-client 12 | 13 | #Allowing MySQL remote access 14 | sed -i 's/\(bind-address.*=.*\)127.0.0.1/\1 0.0.0.0/g' /etc/mysql/my.cnf 15 | echo "CREATE DATABASE $DATABASE_NAME; GRANT ALL ON $DATABASE_NAME.* TO root@'%' IDENTIFIED BY '$MYSQL_PASSWORD';" > /tmp/mysqltmp 16 | mysql -p$MYSQL_PASSWORD < /tmp/mysqltmp 17 | rm /tmp/mysqltmp 18 | service mysql restart 19 | 20 | #Installing Redis 21 | add-apt-repository -y ppa:rwky/redis 22 | apt-get update 23 | apt-get install -y redis-server 24 | echo "requirepass $REDIS_PASSWORD" >> /etc/redis/redis.conf 25 | service redis-server restart 26 | 27 | #Installing htop 28 | apt-get install -y htop 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Mohamed Bassem 2 | https://github.com/MohamedBassem/getaredis 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /imgs/SystemArchitecture.xml: -------------------------------------------------------------------------------- 1 | 7Zrfk5owEMf/Gh/vBoh63uP9qjedXqetD22fOhRykCkSG+Kp/etvgY0Eg1elFHQGH5QsYbP5fJOwyTggd/P1VLiL8In7NBo4lr8ekPuB40zsEXynhk1uGBIrNwSC+bnJLgwz9oeiUVVbMp8mpYqS80iyRdno8TimnizZnnlUbmLhBsp9YZh5bmRavzJfhtgHZ1zYHykLQtWMPb7O7yRyo3z49NldRvIiM8G99PbcVb6yXpEHACY4Bzfp1Xx9R6MUmgKSd/3dnrvbIAWNMZC3H8AgXtxoiTHec+8XFT8eeSLRm9aBVcgknS1cLy2vQNQBuQ3lPIKSDZfojApJUeOKgDITRjOlfE6l2EAV9cAYxcUxoYqrgrmNfbJCjbeq56L0wdZz0Xu4QADVMIgBY+CMI2jgNlm4cYnE+Pcyleg2YjG9UHHcQBV7eEmu08/YnoyIRRbrojJcBemvhnjbAsSWN5JX6YA8UdOqA/LD9smD2KdCfjjqjjwuwRr5L9RnSQcURtdlCrai0gYGXMU1DE+b2ecPJ4DBUS/FNjBcGRimHMrv+c8uRgSZlFEQ1e02UEyqUcyogD51AMOxOoSBqYwG42PAYlhgO88PbIXlbxiU7V8w2BjuCXDYfVu3y8FMG7visPvubJdDVd6yg4DG/o0QfAUlL3KThHllBnTN5De4ti5HWPqelrZ0qG/sS3bYQHN8KTLExbolXRFQrJXNXpOgRkhB1AEpm6CRK9lLOYgqatjCJ84gvGLCVuc2ykEeOj6jb1R23BjTvuwm767hJhNw2+XDNDUzouM1jaE5TdS0mKrakt75MtWZ4Fc7Cczw5CU3s7+zkzyLuJ/jBwpu5rkNrttvDYV6gmO4/ZpeV28zmT83vTte089NcHPDcnaC9yv6EYKrbL6ZV7guqnUJZ2T6aLjSh0N9yXGE6pJnmWev+IGKm3vxZhWvJ6s6Iuh1raurebbwv2ZyMxNZHYb0itdVvIlTlD2K7xe8/var4mWdbSB7vQ/Uu9Htdid69/P7GL0b3W2X0q9yNm4OhcbOV3rBjxG8ie32QRPcGAyNCd6v6PsFh2LxD6W8evH/LvLwCg== -------------------------------------------------------------------------------- /instances_test.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/fsouza/go-dockerclient" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func getDockerClient() (*docker.Client, error) { 12 | return docker.NewClient("unix:///var/run/docker.sock") 13 | } 14 | 15 | func forceRemoveContainer(ctx *context, id string) { 16 | dockerClient, _ := getDockerClient() 17 | dockerClient.RemoveContainer(docker.RemoveContainerOptions{ 18 | ID: id, 19 | Force: true, 20 | }) 21 | } 22 | 23 | // TODO Add redis authentication check 24 | func TestStartRedisInstance(t *testing.T) { 25 | ctx, _ := Init("config.yml") 26 | containerName := generateRandomString(20) 27 | password := generateRandomString(20) 28 | container, err := startRedisInstance(ctx, "unix:///var/run/docker.sock", containerName, password) 29 | if !assert.NoError(t, err, "Starting docker container should not return an Error.") { 30 | return 31 | } 32 | time.Sleep(time.Second) 33 | dockerClient, _ := getDockerClient() 34 | container, err = dockerClient.InspectContainer(containerName) 35 | if !assert.True(t, container.State.Running, "Container Failed to start.") { 36 | return 37 | } 38 | assert.NotEmpty(t, container.NetworkSettings.Ports["6379/tcp"], "Should have a port mapping for redis port") 39 | forceRemoveContainer(ctx, container.ID) 40 | } 41 | -------------------------------------------------------------------------------- /jobs.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import "time" 4 | 5 | func CleanRedisInstances(ctx *context) (containerNames []string) { 6 | var instances = make([]Instance, 0) 7 | containerNames = make([]string, 0) 8 | var maxTimeStamp = time.Now().Add(-1 * time.Second * 60 * 60 * time.Duration(ctx.config.MaxInstanceTime)) 9 | ctx.db.Model(&Instance{}).Where("running = 1 AND created_at < ?", maxTimeStamp).Find(&instances) 10 | 11 | for _, instance := range instances { 12 | ctx.RemoveContainer(instance.HostedAtIP, instance.ContainerID) 13 | containerNames = append(containerNames, instance.Name) 14 | } 15 | ctx.db.Model(&Instance{}).Where("running = 1 AND created_at < ?", maxTimeStamp).UpdateColumn("running", false) 16 | 17 | return 18 | } 19 | 20 | func MonitorHosts(ctx *context) (startedHosts bool, deletedHosts []string, err error) { 21 | hosts := ctx.ListHosts() 22 | deletedHosts = make([]string, 0) 23 | zeros := 0 24 | mini := 1000000 25 | for _, host := range hosts { 26 | if host.NumberOfContainers == 0 { 27 | zeros++ 28 | } else if host.NumberOfContainers < mini { 29 | mini = host.NumberOfContainers 30 | } 31 | } 32 | if zeros == 0 && mini > ctx.config.MaxContainersPerHost/2 { 33 | err = ctx.NewHostFromImage() 34 | if err != nil { 35 | return 36 | } 37 | startedHosts = true 38 | return 39 | } else if zeros > 0 { 40 | for _, host := range hosts { 41 | if mini > ctx.config.MaxContainersPerHost/2 && zeros == 1 { 42 | break 43 | } 44 | if host.NumberOfContainers == 0 { 45 | err = ctx.DeleteHost(host.PublicIP) 46 | zeros-- 47 | deletedHosts = append(deletedHosts, host.PublicIP) 48 | } 49 | } 50 | } 51 | return 52 | } 53 | -------------------------------------------------------------------------------- /cmd/getaredis-server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/MohamedBassem/getaredis" 12 | "github.com/codegangsta/martini-contrib/render" 13 | "github.com/go-martini/martini" 14 | ) 15 | 16 | func getIP(r *http.Request) string { 17 | if ipProxy := r.Header.Get("X-FORWARDED-FOR"); len(ipProxy) > 0 { 18 | return ipProxy 19 | } 20 | ip, _, _ := net.SplitHostPort(r.RemoteAddr) 21 | return ip 22 | } 23 | 24 | func main() { 25 | configFileName := flag.String("config", "", "Configuration file path") 26 | port := flag.Int("port", 8080, "Server listening port") 27 | flag.Parse() 28 | 29 | if *configFileName == "" { 30 | log.Fatal("A configuration file must be provided.") 31 | } 32 | 33 | ctx, err := getaredis.Init(*configFileName) 34 | if err != nil { 35 | fmt.Println(err) 36 | return 37 | } 38 | 39 | m := martini.Classic() 40 | m.Use(render.Renderer()) 41 | 42 | m.Get("/", func(r render.Render) { 43 | count, err := ctx.CountContainers(true) 44 | if err != nil { 45 | count = -1 46 | } 47 | r.HTML(200, "index", count) 48 | }) 49 | 50 | m.Post("/instance", func(res http.ResponseWriter, req *http.Request) (int, string) { 51 | requesterIP := getIP(req) 52 | err := getaredis.CheckInstanceLimit(ctx, requesterIP) 53 | if err != nil { 54 | return 403, err.Error() 55 | } 56 | instance, err := ctx.NewInstance(requesterIP) 57 | if err != nil { 58 | return 500, err.Error() 59 | } 60 | return 200, fmt.Sprintf("{\"IP\": \"%v\", \"port\": \"%v\", \"password\": \"%v\"}", instance.HostedAtIP, instance.HostedAtPort, instance.Password) 61 | }) 62 | m.RunOnAddr("127.0.0.1:" + strconv.Itoa(*port)) 63 | } 64 | -------------------------------------------------------------------------------- /config-files/server-job-node.cnf: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | runcmd: 3 | - apt-get install -y git wget 4 | - wget https://storage.googleapis.com/golang/go1.4.2.linux-amd64.tar.gz 5 | - tar -C /usr/local -xzf go1.4.2.linux-amd64.tar.gz 6 | - echo 'export PATH=$PATH:/usr/local/go/bin' >> /root/.bashrc 7 | - mkdir /root/go 8 | - export HOME=/root 9 | - echo 'export GOROOT=/usr/local/go' >> /root/.bashrc 10 | - echo 'export GOPATH=$HOME/go' >> /root/.bashrc 11 | - echo 'export PATH=$PATH:$GOPATH/bin' >> /root/.bashrc 12 | - export GOPATH=/root/go 13 | - export GOROOT=/usr/local/go 14 | - /usr/local/go/bin/go get github.com/MohamedBassem/getaredis/... 15 | - apt-get install -y supervisor nginx 16 | - rm -r /etc/nginx/sites-enabled /etc/nginx/sites-available 17 | - service nginx restart 18 | # You will need to upload the config file manually 19 | write_files: 20 | - path: /etc/supervisor/conf.d/go_jobs.conf 21 | content: | 22 | [program:go_jobs] 23 | command=/root/go/bin/getaredis-jobs --config /root/config.yml 24 | autostart=true 25 | autorestart=true 26 | stderr_logfile=/var/log/go_jobs.err.log 27 | stdout_logfile=/var/log/go_jobs.out.log 28 | 29 | [program:go_server] 30 | directory:/root/go/src/github.com/MohamedBassem/getaredis/cmd/getaredis-server 31 | command=/root/go/bin/getaredis-server --config /root/config.yml 32 | autostart=true 33 | autorestart=true 34 | stderr_logfile=/var/log/getaredis-server.err.log 35 | stdout_logfile=/var/log/getaredis-server.out.log 36 | - path: /etc/nginx/conf.d/server.conf 37 | content: | 38 | server { 39 | listen 80; 40 | location / { 41 | proxy_set_header X-Real-IP $remote_addr; 42 | proxy_set_header X-Forwarded-For $remote_addr; 43 | proxy_set_header Host $host; 44 | proxy_pass http://127.0.0.1:8080; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cmd/getaredis-jobs/run-jobs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | 8 | "github.com/MohamedBassem/getaredis" 9 | "github.com/robfig/cron" 10 | ) 11 | 12 | var configFileName *string 13 | 14 | func monitorHosts() { 15 | ctx, err := getaredis.Init(*configFileName) 16 | if ctx != nil { 17 | defer ctx.Close() 18 | } 19 | errLogger := log.New(os.Stderr, "MonitorHosts ", log.LstdFlags) 20 | outLogger := log.New(os.Stdout, "MonitorHosts ", log.LstdFlags) 21 | outLogger.Println("Started") 22 | defer outLogger.Println("Finished") 23 | if err != nil { 24 | errLogger.Println("Error :" + err.Error()) 25 | return 26 | } 27 | 28 | started, deleted, err := getaredis.MonitorHosts(ctx) 29 | if err != nil { 30 | errLogger.Println("Error :" + err.Error()) 31 | return 32 | } 33 | 34 | if started { 35 | outLogger.Println("A new host has started!") 36 | } 37 | 38 | if deleted != nil && len(deleted) > 0 { 39 | outLogger.Printf("Hosts %v have been removed.", deleted) 40 | } 41 | } 42 | 43 | func cleanRedisInstances() { 44 | ctx, err := getaredis.Init(*configFileName) 45 | if ctx != nil { 46 | defer ctx.Close() 47 | } 48 | errLogger := log.New(os.Stderr, "CleanRedisInstances ", log.LstdFlags) 49 | outLogger := log.New(os.Stdout, "CleanRedisInstances ", log.LstdFlags) 50 | outLogger.Println("Started") 51 | defer outLogger.Println("Finished") 52 | if err != nil { 53 | errLogger.Println("Error :" + err.Error()) 54 | return 55 | } 56 | 57 | cleanedInstances := getaredis.CleanRedisInstances(ctx) 58 | if cleanedInstances != nil && len(cleanedInstances) > 0 { 59 | outLogger.Printf("Containers %v have been removed.", cleanedInstances) 60 | } 61 | } 62 | 63 | func main() { 64 | configFileName = flag.String("config", "", "Configuration file path") 65 | flag.Parse() 66 | 67 | if *configFileName == "" { 68 | log.Fatal("A configuration file must be provided.") 69 | } 70 | c := cron.New() 71 | c.AddFunc("@every 20m", cleanRedisInstances) 72 | c.AddFunc("@every 10m", monitorHosts) 73 | c.Start() 74 | monitorHosts() 75 | <-make(chan struct{}) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/getaredis-server/public/js/main.js: -------------------------------------------------------------------------------- 1 | function fixLoaderPosition(){ 2 | var loader = document.getElementById("loader"); 3 | var loaderContainerWidth = loader.clientWidth; 4 | var loaderWidth = parseInt(loader.children[0].clientWidth)*10; 5 | loader.children[0].style.marginLeft = (loaderContainerWidth/2 - loaderWidth/2 - 10) + "px"; 6 | } 7 | 8 | function fixFooterPosition(){ 9 | var height = window.innerHeight; 10 | var container = document.getElementById("container").parentNode; 11 | var footerHeight = document.getElementById("footer").clientHeight; 12 | if( container.clientHeight + footerHeight > height ){ 13 | container.style.marginBottom = "0px"; 14 | }else{ 15 | container.style.marginBottom = (height-container.clientHeight-footerHeight) + "px"; 16 | } 17 | } 18 | 19 | (function() { 20 | fixLoaderPosition(); 21 | fixFooterPosition(); 22 | window.addEventListener("resize", fixLoaderPosition); 23 | window.addEventListener("resize", fixFooterPosition); 24 | 25 | var button = document.getElementById("getaredis"); 26 | var errorMessage = document.getElementById("error"); 27 | var message = document.getElementById("message"); 28 | button.addEventListener("click", function() { 29 | button.style.display = "none"; 30 | errorMessage.style.display = "none"; 31 | message.style.display = "none"; 32 | loader.style.visibility = "visible"; 33 | fixFooterPosition(); 34 | var http = new XMLHttpRequest(); 35 | var url = "/instance"; 36 | http.open("POST", url, true); 37 | 38 | http.onreadystatechange = function() { //Call a function when the state changes. 39 | if(http.readyState == 4) { 40 | loader.style.visibility = "hidden"; 41 | if(http.status == 200){ 42 | data = JSON.parse(http.responseText); 43 | message.innerHTML = '# Your Instance is Ready!\n' 44 | message.innerHTML+= "$ telnet " + data["IP"] + " " + data["port"] + "\n"; 45 | message.innerHTML+= "AUTH " + data["password"] +"\n"; 46 | message.innerHTML+= '+OK\n'; 47 | message.style.display = ""; 48 | }else{ 49 | errorMessage.innerHTML = http.responseText; 50 | errorMessage.style.display = ""; 51 | button.style.display = ""; 52 | } 53 | fixFooterPosition(); 54 | } 55 | } 56 | http.send(); 57 | }) 58 | 59 | })(); 60 | -------------------------------------------------------------------------------- /cmd/getaredis-server/public/css/line-scale.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Load Awesome v1.0.1 (http://github.danielcardoso.net/load-awesome/) 3 | * Copyright 2015 Daniel Cardoso <@DanielCardoso> 4 | * Licensed under MIT 5 | */ 6 | .la-line-scale{position:relative}.la-line-scale>div{position:relative;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;color:#fff;background:#fff;border-color:#fff;border-style:solid;border-width:0}.la-line-scale:after{display:table;line-height:0;clear:both;content:""}.la-line-scale.la-dark>div{color:#444;background:#444;border-color:#444}.la-line-scale{height:32px}.la-line-scale>div{display:inline-block;width:4px;height:32px;margin:2px;margin-top:0;margin-bottom:0;float:left;border-radius:0;-webkit-animation:line-scale 1.2s infinite ease;-moz-animation:line-scale 1.2s infinite ease;-o-animation:line-scale 1.2s infinite ease;animation:line-scale 1.2s infinite ease}.la-line-scale>div:nth-child(1){-webkit-animation-delay:-1.2s;-moz-animation-delay:-1.2s;-o-animation-delay:-1.2s;animation-delay:-1.2s}.la-line-scale>div:nth-child(2){-webkit-animation-delay:-1.1s;-moz-animation-delay:-1.1s;-o-animation-delay:-1.1s;animation-delay:-1.1s}.la-line-scale>div:nth-child(3){-webkit-animation-delay:-1s;-moz-animation-delay:-1s;-o-animation-delay:-1s;animation-delay:-1s}.la-line-scale>div:nth-child(4){-webkit-animation-delay:-.9s;-moz-animation-delay:-.9s;-o-animation-delay:-.9s;animation-delay:-.9s}.la-line-scale>div:nth-child(5){-webkit-animation-delay:-.8s;-moz-animation-delay:-.8s;-o-animation-delay:-.8s;animation-delay:-.8s}.la-line-scale.la-sm{height:16px}.la-line-scale.la-sm>div{width:2px;height:16px;margin:1px}.la-line-scale.la-lg{height:48px}.la-line-scale.la-lg>div{width:6px;height:48px;margin:3px}.la-line-scale.la-2x{height:64px}.la-line-scale.la-2x>div{width:8px;height:64px;margin:4px}@-webkit-keyframes line-scale{0%, 7 | 40%, 8 | 100%{-webkit-transform:scaleY(0.4);transform:scaleY(0.4)}20%{-webkit-transform:scaleY(1);transform:scaleY(1)}}@-moz-keyframes line-scale{0%, 9 | 40%, 10 | 100%{-webkit-transform:scaleY(0.4);-moz-transform:scaleY(0.4);transform:scaleY(0.4)}20%{-webkit-transform:scaleY(1);-moz-transform:scaleY(1);transform:scaleY(1)}}@-o-keyframes line-scale{0%, 11 | 40%, 12 | 100%{-webkit-transform:scaleY(0.4);-o-transform:scaleY(0.4);transform:scaleY(0.4)}20%{-webkit-transform:scaleY(1);-o-transform:scaleY(1);transform:scaleY(1)}}@keyframes line-scale{0%, 13 | 40%, 14 | 100%{-webkit-transform:scaleY(0.4);-moz-transform:scaleY(0.4);-o-transform:scaleY(0.4);transform:scaleY(0.4)}20%{-webkit-transform:scaleY(1);-moz-transform:scaleY(1);-o-transform:scaleY(1);transform:scaleY(1)}} 15 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "math/rand" 7 | "time" 8 | 9 | "golang.org/x/oauth2" 10 | 11 | "gopkg.in/yaml.v2" 12 | 13 | "github.com/digitalocean/godo" 14 | "github.com/garyburd/redigo/redis" 15 | _ "github.com/go-sql-driver/mysql" 16 | "github.com/jinzhu/gorm" 17 | ) 18 | 19 | type configuration struct { 20 | Database map[string]string `yaml:"database"` 21 | Docker map[string]string `yaml:"docker"` 22 | RedisAddress string `yaml:"redisAddress"` 23 | RedisPassword string `yaml:"redisPassword"` 24 | DigitalOceanToken string `yaml:"digitalOceanToken"` 25 | DropletSSHKeyID int `yaml:"dropletSSHKeyID"` 26 | MaxInstanceSize int `yaml:"maxInstanceSize"` 27 | MaxInstanceTime int `yaml:"maxInstanceTime"` 28 | MaxInstancesPerIP int `yaml:"maxInstancesPerIP"` 29 | MaxRedisConnections int `yaml:"maxRedisConnections"` 30 | MaxContainersPerHost int `yaml:"maxContainersPerHost"` 31 | } 32 | 33 | type context struct { 34 | db gorm.DB 35 | redis redis.Conn 36 | digitalocean godo.Client 37 | config configuration 38 | } 39 | 40 | func Init(configPath string) (*context, error) { 41 | rand.Seed(time.Now().UnixNano()) 42 | data, err := ioutil.ReadFile(configPath) 43 | if err != nil { 44 | return nil, err 45 | } 46 | config := new(configuration) 47 | config.DropletSSHKeyID = -1 // Default Value 48 | err = yaml.Unmarshal(data, config) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | // Starting mysql connection 54 | databaseHost := fmt.Sprintf("%v:%v@tcp(%v)/%v?charset=utf8&parseTime=True&loc=Local", config.Database["user"], config.Database["password"], config.Database["host"], config.Database["dbname"]) 55 | tmp2, err := gorm.Open("mysql", databaseHost) 56 | if err != nil { 57 | return nil, err 58 | } 59 | tmp2.AutoMigrate(&Instance{}) 60 | 61 | // Starting redis connection 62 | tmp3, err := redis.Dial("tcp", config.RedisAddress) 63 | if err != nil { 64 | return nil, err 65 | } 66 | tmp3.Do("AUTH", config.RedisPassword) 67 | 68 | // Starting digital ocean client 69 | oauthClient := oauth2.NewClient(oauth2.NoContext, &TokenSource{AccessToken: config.DigitalOceanToken}) 70 | tmp4 := godo.NewClient(oauthClient) 71 | 72 | ctx := context{ 73 | config: *config, 74 | db: tmp2, 75 | redis: tmp3, 76 | digitalocean: *tmp4, 77 | } 78 | 79 | return &ctx, nil 80 | } 81 | 82 | func (ctx *context) Close() { 83 | ctx.db.Close() 84 | ctx.redis.Close() 85 | } 86 | -------------------------------------------------------------------------------- /config-files/stack.cnf: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | runcmd: 3 | #Installing MySQL 4 | - export MYSQL_PASSWORD= 5 | - export REDIS_PASSWORD= 6 | - export DATABASE_NAME=getaredis 7 | #Installing MySQL 8 | - apt-get update 9 | - echo mysql-server mysql-server/root_password password $MYSQL_PASSWORD | sudo debconf-set-selections 10 | - echo mysql-server mysql-server/root_password_again password $MYSQL_PASSWORD | sudo debconf-set-selections 11 | - apt-get install -y mysql-server mysql-client 12 | #Allowing MySQL remote access 13 | - sed -i 's/\(bind-address.*=.*\)127.0.0.1/\1 0.0.0.0/g' /etc/mysql/my.cnf 14 | - echo "CREATE DATABASE $DATABASE_NAME; GRANT ALL ON $DATABASE_NAME.* TO root@'%' IDENTIFIED BY '$MYSQL_PASSWORD';" > /tmp/mysqltmp 15 | - mysql -p$MYSQL_PASSWORD < /tmp/mysqltmp 16 | - rm /tmp/mysqltmp 17 | - service mysql restart 18 | #Installing Redis 19 | - add-apt-repository -y ppa:rwky/redis 20 | - apt-get update 21 | - apt-get install -y redis-server 22 | - echo "requirepass $REDIS_PASSWORD" >> /etc/redis/redis.conf 23 | - service redis-server restart 24 | #Installing htop 25 | - apt-get install -y htop 26 | #Installing Go 27 | - apt-get update 28 | - apt-get install -y git wget 29 | - wget https://storage.googleapis.com/golang/go1.4.2.linux-amd64.tar.gz 30 | - tar -C /usr/local -xzf go1.4.2.linux-amd64.tar.gz 31 | - echo 'export PATH=$PATH:/usr/local/go/bin' >> /root/.bashrc 32 | - mkdir /root/go 33 | - export HOME=/root 34 | - echo 'export GOROOT=/usr/local/go' >> /root/.bashrc 35 | - echo 'export GOPATH=$HOME/go' >> /root/.bashrc 36 | - echo 'export PATH=$PATH:$GOPATH/bin' >> /root/.bashrc 37 | - export GOPATH=/root/go 38 | - export GOROOT=/usr/local/go 39 | #Pulling the server repo 40 | - /usr/local/go/bin/go get github.com/MohamedBassem/getaredis/... 41 | #Installing supervisor and nginx 42 | - apt-get install -y supervisor nginx 43 | - rm -r /etc/nginx/sites-enabled /etc/nginx/sites-available 44 | - service nginx restart 45 | # You will need to upload the config file manually 46 | write_files: 47 | - path: /etc/supervisor/conf.d/go_jobs.conf 48 | content: | 49 | [program:go_jobs] 50 | command=/root/go/bin/getaredis-jobs --config /root/config.yml 51 | autostart=true 52 | autorestart=true 53 | stderr_logfile=/var/log/go_jobs.err.log 54 | stdout_logfile=/var/log/go_jobs.out.log 55 | 56 | [program:go_server] 57 | directory:/root/go/src/github.com/MohamedBassem/getaredis/cmd/getaredis-server 58 | command=/root/go/bin/getaredis-server --config /root/config.yml 59 | autostart=true 60 | autorestart=true 61 | stderr_logfile=/var/log/getaredis-server.err.log 62 | stdout_logfile=/var/log/getaredis-server.out.log 63 | - path: /etc/nginx/conf.d/server.conf 64 | content: | 65 | server { 66 | listen 80; 67 | location / { 68 | proxy_set_header X-Real-IP $remote_addr; 69 | proxy_set_header X-Forwarded-For $remote_addr; 70 | proxy_set_header Host $host; 71 | proxy_pass http://127.0.0.1:8080; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /cmd/getaredis-server/public/css/main.css: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Start Bootstrap - Freelancer Bootstrap Theme (http://startbootstrap.com) 4 | * Code licensed under the Apache License v2.0. 5 | * For details, see http://www.apache.org/licenses/LICENSE-2.0. 6 | */ 7 | 8 | body { 9 | overflow-x: hidden; 10 | background-color: #18bc9c 11 | } 12 | 13 | a, 14 | a:hover, 15 | a:focus, 16 | a:active, 17 | a.active { 18 | outline: 0; 19 | color: #18bc9c; 20 | } 21 | 22 | hr.star-light, 23 | hr.star-primary { 24 | margin: 25px auto 30px; 25 | padding: 0; 26 | max-width: 250px; 27 | border: 0; 28 | border-top: solid 5px; 29 | text-align: center; 30 | } 31 | 32 | hr.star-light:after, 33 | hr.star-primary:after { 34 | content: "\f005"; 35 | display: inline-block; 36 | position: relative; 37 | top: -.8em; 38 | padding: 0 .25em; 39 | font-family: FontAwesome; 40 | font-size: 2em; 41 | } 42 | 43 | hr.star-light { 44 | border-color: #fff; 45 | } 46 | 47 | hr.star-light:after { 48 | color: #fff; 49 | background-color: #18bc9c; 50 | } 51 | 52 | hr.star-primary { 53 | border-color: #2c3e50; 54 | } 55 | 56 | hr.star-primary:after { 57 | color: #2c3e50; 58 | background-color: #fff; 59 | } 60 | 61 | .img-centered { 62 | margin: 0 auto; 63 | } 64 | 65 | header { 66 | text-align: center; 67 | color: #fff; 68 | background: #18bc9c; 69 | } 70 | 71 | header .container { 72 | padding-top: 100px; 73 | padding-bottom: 50px; 74 | } 75 | 76 | header img { 77 | display: block; 78 | margin: 0 auto 20px; 79 | } 80 | 81 | header .intro-text .name { 82 | display: block; 83 | text-transform: uppercase; 84 | font-family: Montserrat,"Helvetica Neue",Helvetica,Arial,sans-serif; 85 | font-size: 2em; 86 | font-weight: 700; 87 | } 88 | 89 | header .intro-text .skills { 90 | font-size: 1.25em; 91 | font-weight: 300; 92 | } 93 | 94 | 95 | @media(min-width:768px) { 96 | header .container { 97 | padding-top: 120px; 98 | padding-bottom: 80px; 99 | } 100 | 101 | header .intro-text .name { 102 | font-size: 4.75em; 103 | } 104 | 105 | header .intro-text .skills { 106 | font-size: 1.75em; 107 | } 108 | } 109 | 110 | .btn-outline { 111 | margin-top: 15px; 112 | border: solid 2px #fff; 113 | font-size: 20px; 114 | color: #fff; 115 | background: 0 0; 116 | transition: all .1s ease-in-out; 117 | } 118 | 119 | .btn-outline:hover, 120 | .btn-outline:focus, 121 | .btn-outline:active, 122 | .btn-outline.active { 123 | border: solid 2px #fff; 124 | color: #18bc9c; 125 | background: #fff; 126 | } 127 | 128 | #footer{ 129 | color: #fff; 130 | } 131 | 132 | #footer a,a:hover,a:active,a:focus { 133 | color: #fff; 134 | text-decoration: none; 135 | font-family: Montserrat,"Helvetica Neue",Helvetica,Arial,sans-serif; 136 | } 137 | 138 | #error{ 139 | margin-top: 10px; 140 | color: #fff; 141 | } 142 | 143 | #message { 144 | background-color: #11846d; 145 | border-radius: 3px; 146 | border: 2px solid #052821; 147 | padding-top: 10px; 148 | padding-bottom: 10px; 149 | margin-top: 20px; 150 | text-align: left; 151 | background-color: black; 152 | color: white; 153 | font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace; 154 | /*font-weight: bold;*/ 155 | } 156 | -------------------------------------------------------------------------------- /instances.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | 7 | "github.com/fsouza/go-dockerclient" 8 | ) 9 | 10 | import "time" 11 | 12 | type Instance struct { 13 | ID int `sql:"AUTO_INCREMENT"` 14 | Name string 15 | CreatorIP string 16 | CreatedAt time.Time 17 | HostedAtIP string 18 | HostedAtPort string 19 | Password string `sql:"-"` // Don't Store passwords in the database 20 | Running bool 21 | ContainerID string 22 | } 23 | 24 | func generateRedisConfig(ctx *context, name, password string) docker.CreateContainerOptions { 25 | return docker.CreateContainerOptions{ 26 | Name: name, 27 | Config: &docker.Config{ 28 | Image: "redis", 29 | Memory: int64(ctx.config.MaxInstanceSize) * 1024 * 1024, 30 | MemorySwap: -1, 31 | Cmd: []string{"redis-server", "--requirepass", password, "--maxclients", strconv.Itoa(ctx.config.MaxRedisConnections)}, 32 | }, 33 | } 34 | } 35 | 36 | func startRedisInstance(ctx *context, dockerAdderss, name, password string) (*docker.Container, error) { 37 | dockerClient, err := docker.NewClient(dockerAdderss) 38 | if err != nil { 39 | return nil, err 40 | } 41 | container, err := dockerClient.CreateContainer(generateRedisConfig(ctx, name, password)) 42 | if err != nil { 43 | return nil, err 44 | } 45 | err = dockerClient.StartContainer(container.ID, &docker.HostConfig{PublishAllPorts: true}) 46 | if err != nil { 47 | return nil, err 48 | } 49 | time.Sleep(time.Second) 50 | container, err = dockerClient.InspectContainer(container.ID) 51 | if err != nil || !container.State.Running { 52 | if err != nil { 53 | return nil, err 54 | } 55 | return nil, errors.New("Container Failed to start") 56 | } 57 | return container, nil 58 | } 59 | 60 | func CheckInstanceLimit(ctx *context, ip string) error { 61 | var count int 62 | ctx.db.Model(&Instance{}).Where("running = 1 AND creator_ip = ?", ip).Count(&count) 63 | if count >= ctx.config.MaxInstancesPerIP { 64 | return errors.New("Instances limit per IP reached") 65 | } 66 | return nil 67 | } 68 | 69 | // Creates a new docker instance with a random name, and returns the instance details back 70 | func (ctx *context) NewInstance(creatorIP string) (*Instance, error) { 71 | dockerHostPublicIP, dockerHostPrivateIP, err := ctx.scheduleNewContainer() 72 | if err != nil { 73 | return nil, err 74 | } 75 | dockerAddress := generateDockerAddress(dockerHostPrivateIP, ctx.config.Docker["user"], ctx.config.Docker["password"]) 76 | name := generateRandomString(20) 77 | password := generateRandomString(20) 78 | var count int 79 | for ctx.db.Model(&Instance{}).Where(&Instance{Name: name}).Count(&count); count != 0; name = generateRandomString(20) { 80 | // Keep Trying! 81 | } 82 | 83 | container, err := startRedisInstance(ctx, dockerAddress, name, password) 84 | if err != nil { 85 | return nil, err 86 | } 87 | instance := &Instance{ 88 | Name: name, 89 | CreatorIP: creatorIP, 90 | CreatedAt: time.Now(), 91 | HostedAtIP: dockerHostPublicIP, 92 | HostedAtPort: container.NetworkSettings.Ports["6379/tcp"][0].HostPort, 93 | Password: password, 94 | Running: true, 95 | ContainerID: container.ID, 96 | } 97 | ctx.db.Create(instance) 98 | if ctx.db.NewRecord(instance) { 99 | return nil, errors.New("Failed to write to the database") 100 | } 101 | return instance, nil 102 | } 103 | 104 | func (ctx *context) RemoveContainer(hostIP, id string) error { 105 | dockerClient, err := docker.NewClient(generateDockerAddress(hostIP, ctx.config.Docker["user"], ctx.config.Docker["password"])) 106 | if err != nil { 107 | return err 108 | } 109 | err = dockerClient.RemoveContainer(docker.RemoveContainerOptions{ 110 | ID: id, 111 | Force: true, 112 | }) 113 | return err 114 | } 115 | 116 | func (ctx *context) CountContainers(includeNotRunning bool) (int, error) { 117 | var count = -1 118 | var where string 119 | if !includeNotRunning { 120 | where = "running = 1" 121 | } 122 | err := ctx.db.Model(&Instance{}).Where(where).Count(&count) 123 | return count, err.Error 124 | } 125 | -------------------------------------------------------------------------------- /cmd/getaredis-server/templates/index.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Get A Redis 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | Fork me on GitHub 45 | 46 |
47 |
48 |
49 |
50 | 51 |
52 | Get A Redis 53 |
54 | Get a 5MB redis instance for 12 hours with one click* 55 |
56 | 57 | Get A Redis 58 | 59 |
60 | 61 | 62 | 69 |
70 |
71 |
72 |
73 |
74 | 75 | 80 | 81 | 82 | 83 | 84 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Get A Redis 2 | 3 | A one click, docker based, auto scaling, Redis host implemented in Go and hosted on Digitalocean. 4 | 5 | ![Homepage](https://raw.githubusercontent.com/MohamedBassem/getaredis/master/imgs/GetARedisHomePage.gif) 6 | 7 | ##Tags 8 | 9 | - Docker 10 | - Redis 11 | - Digitalocean 12 | - Service Discovery 13 | - Auto Scalability 14 | 15 | ##Why? 16 | 17 | I started the project to enhance my Go skills which I started to learn few weeks ago. Then I found that the idea may be useful for hackathons and proof of concept projects. 18 | 19 | ##Technical Details 20 | 21 | ###System Components 22 | 23 | ####Go Server 24 | 25 | Running [martini](https://github.com/go-martini/martini) to serve the webpage and accepts starting new instance requests. Nginx is installed on this machine to act as a reverse proxy for martini. 26 | 27 | ####Go Jobs 28 | 29 | Currently two jobs are scheduled to run every certain amount of time. The first one is a job to kill the containers that has been running for a preconfigured amount of time. The second one is a job to spin up and tear down digitalocean droplets based on the current load of the other servers. 30 | 31 | ####Docker Hosts 32 | 33 | Digitalocean droplets that are used to host redis containers. Nginx is installed on those machines to act as a reverse proxy to the running docker daemon with HTTP authentication. 34 | 35 | ####Redis 36 | 37 | Redis is used only for service discovery. Service discovery will be explained in a later section. 38 | 39 | ####MySQL Database 40 | 41 | To store the details of the running containers, such as the container host, port, id, state and the creator IP. All of the containers' information could be collected from running docker hosts but the database is mainly used for throttling the number of containers per IP. 42 | 43 | ###System Architecture 44 | 45 | ![System Architecture](https://raw.githubusercontent.com/MohamedBassem/getaredis/master/imgs/SystemArchitecture.png) 46 | 47 | The server, running nginx, listens for requests on port 80 and forwards those requests to Go running [Martini](https://github.com/go-martini/martini) as a web framework. Go queries redis, which will be explained later, for active docker hosts. Go then tries to schedule the new container on one of the hosts based on a certain criteria. The current criteria is to try to schedule as much containers as possible on the host to reduce the running costs, since it's currently a free service :grimacing:. The maximum number of containers per host is configurable. The server then schedules the container on the chosen host and insert these data into the database. The details of the scheduled container (host, port, redis password) are returned back to the user. The user can then connect to the redis container directly. A background job runs every 20 minutes to kill containers that have passed their maximum allowed number of hours, currently 12 hours. 48 | 49 | ####Auto Scaling 50 | 51 | Another job runs in the background every 10 minutes to check the load of the docker hosts. Since the containers have a maximum memory of 5MB, the load is estimated by the number of containers running on each host. As we mentioned before scheduling tries to add more containers to the busiest hosts as long as they can hold more. Another host is waiting in a standby state. Whenever the standby host starts getting some containers, the job will start another container. Whenever the job detects that there are more that one host containing no containers, it kills them until one is left. 52 | 53 | ####Service Discovery 54 | 55 | Service discovery is needed because we have automatically scaling hosts that the scheduler needs to detect. The are many tools that can be used for service discovery, such as Apache Zookeeper, etcd and Consul. I needed a very simple discovery service so I decided to implement my own. 56 | 57 | Redis has a command to expire some key after a certain amount of time. Whenever this command is called it resets the timeout. Using this idea, docker hosts can add a key for themselves in redis and constantly refreshes the timeout. If the key times out, this means that the host didn't send a heartbeat which means that it got disconnected. 58 | 59 | The code is as simple as this: 60 | ```bash 61 | #!/bin/bash 62 | ( 63 | PRIVATE_IP=$(curl http://169.254.169.254/metadata/v1/interfaces/private/0/ipv4/address) 64 | echo "AUTH "; 65 | while true; do 66 | NUMBER_OF_CONTAINERS=$(($(docker ps | wc -l) - 1)) 67 | echo "SET server:$NODE_NAME '{\"PrivateIP\":\"$PRIVATE_IP\",\"NumberOfContainers\":$NUMBER_OF_CONTAINERS}'"; 68 | echo "EXPIRE server:$NODE_NAME 10"; 69 | sleep 4; 70 | done 71 | ) | telnet REDIS_IP REDIS_PORT 72 | ``` 73 | 74 | ##TODO 75 | - ~~Open Docker port on hosts for the master.~~ 76 | - ~~Pull redis image on new hosts.~~ 77 | - ~~Authenticate redis master when connecting to docker hosts.~~ 78 | - Supporting more containers other than redis. 79 | - Monitoring console. 80 | - Better deployment method. 81 | - Better documentation. 82 | 83 | ##Note 84 | The project is still in beta and not 100% stable. 85 | 86 | ##Contribution 87 | Your contributions and ideas are welcomed through issues and pull requests. 88 | 89 | ##License 90 | Copyright (c) 2015, Mohamed Bassem. (MIT License) 91 | 92 | See LICENSE for more info. 93 | 94 | -------------------------------------------------------------------------------- /hosts.go: -------------------------------------------------------------------------------- 1 | package getaredis 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "golang.org/x/oauth2" 10 | 11 | "github.com/digitalocean/godo" 12 | "github.com/garyburd/redigo/redis" 13 | ) 14 | 15 | type TokenSource struct { 16 | AccessToken string 17 | } 18 | 19 | func (t *TokenSource) Token() (*oauth2.Token, error) { 20 | token := &oauth2.Token{ 21 | AccessToken: t.AccessToken, 22 | } 23 | return token, nil 24 | } 25 | 26 | type Host struct { 27 | PublicIP string 28 | PrivateIP string 29 | Name string 30 | NumberOfContainers int 31 | MemoryFree float32 32 | } 33 | 34 | func (ctx *context) ListHosts() []Host { 35 | redisServerKeys, _ := redis.Strings(ctx.redis.Do("KEYS", "server:*")) 36 | servers := make([]interface{}, len(redisServerKeys)) 37 | for i, t := range redisServerKeys { 38 | servers[i] = t 39 | } 40 | serverConfigs, _ := redis.Strings(ctx.redis.Do("MGET", servers...)) 41 | 42 | hosts := make([]Host, len(serverConfigs)) 43 | for i, val := range serverConfigs { 44 | newHost := new(Host) 45 | err := json.Unmarshal([]byte(val), newHost) 46 | if err != nil { 47 | continue 48 | } 49 | hosts[i] = *newHost 50 | } 51 | return hosts 52 | } 53 | 54 | func (ctx *context) NewHostFromConfig() error { 55 | redisIP := strings.Split(ctx.config.RedisAddress, ":")[0] 56 | redisPort := strings.Split(ctx.config.RedisAddress, ":")[1] 57 | dropletName := "getaredis-server-" + generateRandomString(10) 58 | userData := `#cloud-config 59 | runcmd: 60 | - docker pull redis 61 | - apt-get update 62 | - apt-get install -y supervisor nginx apache2-utils 63 | - echo 'DOCKER_OPTS=$DOCKER_OPTS" -H unix:///var/run/docker.sock -H tcp://127.0.0.1:2375"' >> /etc/default/docker 64 | - service docker restart 65 | - mkdir -p /etc/nginx/docker_auth 66 | - htpasswd -b -c /etc/nginx/docker_auth/.htpasswd %v %v 67 | - service nginx reload 68 | write_files: 69 | - path: /etc/supervisor/conf.d/go_jobs.conf 70 | content: | 71 | [program:service_discovery] 72 | command=/usr/local/bin/service_discovery 73 | autostart=true 74 | autorestart=true 75 | stderr_logfile=/var/log/service_discovery.err.log 76 | stdout_logfile=/var/log/service_discovery.out.log 77 | - path: /usr/local/bin/service_discovery 78 | permissions: '0755' 79 | content: | 80 | #!/bin/bash 81 | ( 82 | PUBLIC_IP=$(curl http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address) 83 | PRIVATE_IP=$(curl http://169.254.169.254/metadata/v1/interfaces/private/0/ipv4/address) 84 | NODE_NAME=%v 85 | echo "AUTH %v"; 86 | while true; do 87 | NUMBER_OF_CONTAINERS=$(($(docker ps | wc -l) - 1)) 88 | echo "SET server:$NODE_NAME '{\"PublicIP\":\"$PUBLIC_IP\",\"PrivateIP\":\"$PRIVATE_IP\",\"Name\":\"$NODE_NAME\",\"NumberOfContainers\":$NUMBER_OF_CONTAINERS}'"; 89 | echo "EXPIRE server:$NODE_NAME 10"; 90 | sleep 4; 91 | done 92 | ) | telnet %v %v 93 | - path : /etc/nginx/conf.d/docker.conf 94 | content: | 95 | server { 96 | listen 2377; 97 | location / { 98 | auth_basic "Restricted"; 99 | auth_basic_user_file /etc/nginx/docker_auth/.htpasswd; 100 | proxy_buffering off; 101 | proxy_pass http://localhost:2375; 102 | } 103 | } 104 | ` 105 | 106 | userData = fmt.Sprintf(userData, ctx.config.Docker["user"], ctx.config.Docker["password"], dropletName, ctx.config.RedisPassword, redisIP, redisPort) 107 | 108 | var sshKey *godo.DropletCreateSSHKey 109 | if ctx.config.DropletSSHKeyID != -1 { 110 | sshKey = &godo.DropletCreateSSHKey{ID: ctx.config.DropletSSHKeyID} 111 | } 112 | 113 | createRequest := &godo.DropletCreateRequest{ 114 | Name: dropletName, 115 | Region: "nyc3", 116 | Size: "512mb", 117 | Image: godo.DropletCreateImage{ 118 | Slug: "docker", 119 | }, 120 | UserData: userData, 121 | PrivateNetworking: true, 122 | SSHKeys: []godo.DropletCreateSSHKey{*sshKey}, 123 | } 124 | 125 | _, _, err := ctx.digitalocean.Droplets.Create(createRequest) 126 | return err 127 | } 128 | 129 | func (ctx *context) NewHostFromImage() error { 130 | redisIP := strings.Split(ctx.config.RedisAddress, ":")[0] 131 | redisPort := strings.Split(ctx.config.RedisAddress, ":")[1] 132 | dropletName := "getaredis-server-" + generateRandomString(10) 133 | userData := `#cloud-config 134 | runcmd: 135 | - service docker restart 136 | - htpasswd -b -c /etc/nginx/docker_auth/.htpasswd %v %v 137 | - service nginx reload 138 | write_files: 139 | - path: /usr/local/bin/service_discovery 140 | permissions: '0755' 141 | content: | 142 | #!/bin/bash 143 | ( 144 | PUBLIC_IP=$(curl http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address) 145 | PRIVATE_IP=$(curl http://169.254.169.254/metadata/v1/interfaces/private/0/ipv4/address) 146 | NODE_NAME=%v 147 | echo "AUTH %v"; 148 | while true; do 149 | NUMBER_OF_CONTAINERS=$(($(docker ps | wc -l) - 1)) 150 | echo "SET server:$NODE_NAME '{\"PublicIP\":\"$PUBLIC_IP\",\"PrivateIP\":\"$PRIVATE_IP\",\"Name\":\"$NODE_NAME\",\"NumberOfContainers\":$NUMBER_OF_CONTAINERS}'"; 151 | echo "EXPIRE server:$NODE_NAME 10"; 152 | sleep 4; 153 | done 154 | ) | telnet %v %v 155 | ` 156 | userData = fmt.Sprintf(userData, ctx.config.Docker["user"], ctx.config.Docker["password"], dropletName, ctx.config.RedisPassword, redisIP, redisPort) 157 | 158 | var sshKey *godo.DropletCreateSSHKey 159 | if ctx.config.DropletSSHKeyID != -1 { 160 | sshKey = &godo.DropletCreateSSHKey{ID: ctx.config.DropletSSHKeyID} 161 | } 162 | 163 | createRequest := &godo.DropletCreateRequest{ 164 | Name: dropletName, 165 | Region: "nyc3", 166 | Size: "512mb", 167 | Image: godo.DropletCreateImage{ 168 | ID: 12949456, 169 | }, 170 | PrivateNetworking: true, 171 | UserData: userData, 172 | SSHKeys: []godo.DropletCreateSSHKey{*sshKey}, 173 | } 174 | 175 | _, _, err := ctx.digitalocean.Droplets.Create(createRequest) 176 | return err 177 | } 178 | 179 | func (ctx *context) DeleteHost(ip string) error { 180 | droplets, _, err := ctx.digitalocean.Droplets.List(nil) 181 | if err != nil { 182 | return err 183 | } 184 | deleted := false 185 | for _, d := range droplets { 186 | for _, intr := range d.Networks.V4 { 187 | if intr.IPAddress == ip { 188 | _, err := ctx.digitalocean.Droplets.Delete(d.ID) 189 | if err != nil { 190 | return err 191 | } 192 | deleted = true 193 | break 194 | } 195 | } 196 | } 197 | if !deleted { 198 | return errors.New("Couldn't find droplet with this IP") 199 | } 200 | return nil 201 | } 202 | -------------------------------------------------------------------------------- /cmd/getaredis-server/public/font-awesome/css/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.2.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.2.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.2.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff?v=4.2.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.2.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.2.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"} --------------------------------------------------------------------------------