├── .dockerignore ├── .gitignore ├── .jshintrc ├── Dockerfile ├── config └── default.json ├── CHANGELOG.md ├── lib ├── generator.js ├── proxy.js └── marathon.js ├── package.json ├── LICENSE ├── templates └── haproxy.cfg ├── README.md └── index.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | config/* 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "bitwise": true, 5 | "camelcase": true, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "immed": true, 9 | "indent": 2, 10 | "latedef": true, 11 | "newcap": true, 12 | "noarg": true, 13 | "noempty": true, 14 | "quotmark": "single", 15 | "undef": true, 16 | "unused": true, 17 | "strict": true, 18 | "trailing": true, 19 | "maxlen": 80, 20 | "white": true 21 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM google/nodejs 2 | 3 | MAINTAINER Emilien Kenler 4 | 5 | RUN echo "deb http://cdn.debian.net/debian wheezy-backports main" > /etc/apt/sources.list.d/backports.list 6 | RUN apt-get update -y && apt-get install -y haproxy -t wheezy-backports 7 | 8 | ADD . /opt/frontrunner 9 | WORKDIR /opt/frontrunner 10 | RUN npm install --production 11 | 12 | ENV NODE_ENV production 13 | 14 | EXPOSE 80 15 | 16 | ENTRYPOINT ["node", "index.js"] 17 | 18 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "zookeeper": { 3 | "connectionString": "localhost:2181" 4 | }, 5 | "marathon": { 6 | "url": "http://localhost:8080", 7 | "retryDelay": 10000 8 | }, 9 | "proxy": { 10 | "haproxy": { 11 | "templatePath": "haproxy.cfg", 12 | "configFile": "/etc/haproxy/haproxy.cfg", 13 | "reloadCommand": "haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -sf $(cat /var/run/haproxy.pid)", 14 | "reloadDelay": 5000 15 | } 16 | }, 17 | "activeProxy": "haproxy" 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.2 4 | 5 | * Better error message if the request to the Marathon API fails. (#12) 6 | * Reload the config after a zookeeper reconnection. (#12) 7 | 8 | ## v0.1.1 9 | 10 | * Wait a specified amount of time between two reload. (#11) 11 | 12 | ## v0.1.0 13 | 14 | * Watch Marathon application nodes in ZooKeeper. 15 | * Generate a configuration file from a template via underscore. 16 | * Reload the proxy after generating the configuration file. 17 | * Use [node-config](https://github.com/lorenwest/node-config) to manage 18 | Frontrunner configuration. 19 | * Allow using YAML to write Frontrunner configuration file. 20 | 21 | -------------------------------------------------------------------------------- /lib/generator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | 6 | var _ = require('underscore'); 7 | 8 | function Generator(templateFile, targetFile) { 9 | templateFile = path.join(__dirname, 10 | '..', 11 | 'templates', 12 | templateFile); 13 | var templateContent = fs.readFileSync(templateFile, { 14 | encoding: 'utf8' 15 | }); 16 | 17 | this._template = _.template(templateContent); 18 | this._targetFile = targetFile; 19 | 20 | return this; 21 | } 22 | 23 | Generator.prototype.render = function (data, callback) { 24 | var output = this._template(data) 25 | .replace(/^\s*\n/gm, '\n'); 26 | fs.writeFile(this._targetFile, output, callback); 27 | }; 28 | 29 | module.exports = Generator; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontrunner", 3 | "version": "0.1.2", 4 | "description": "Watch the marathon application nodes in ZooKeeper to trigger HAProxy reconfiguration", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=0.10" 8 | }, 9 | "dependencies": { 10 | "node-zookeeper-client": "~0.2.1", 11 | "config": "^0.4.35", 12 | "request": "^2.34.0", 13 | "underscore": "^1.6.0" 14 | }, 15 | "optionalDependencies": { 16 | "js-yaml": "^3.0.2" 17 | }, 18 | "devDependencies": { 19 | "jshint": "*" 20 | }, 21 | "keywords": [ 22 | "component", 23 | "install" 24 | ], 25 | "scripts": { 26 | "start": "node index.js" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/Wizcorp/frontrunner.git" 31 | }, 32 | "author": "Emilien Kenler ", 33 | "license": "MIT" 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Wizcorp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/proxy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var exec = require('child_process').exec; 4 | 5 | var config = require('config'); 6 | var proxyConfig = config.proxy[config.activeProxy]; 7 | 8 | var lastReload = null; 9 | var reloadTimeout = null; 10 | 11 | function scheduledReload() { 12 | if (reloadTimeout) { 13 | clearTimeout(reloadTimeout); 14 | reloadTimeout = null; 15 | } 16 | module.exports.reload(function (err) { 17 | if (err) { 18 | console.error(err); 19 | } 20 | }); 21 | } 22 | 23 | 24 | module.exports.reload = function reload(cb) { 25 | 26 | var currentDate = Date.now(); 27 | 28 | // Something is already scheduled in the future 29 | if (reloadTimeout) { 30 | return cb(); 31 | } 32 | 33 | // Check if a reload has been done recently 34 | if (lastReload && proxyConfig.reloadDelay && 35 | (currentDate - lastReload) < proxyConfig.reloadDelay) { 36 | 37 | // Schedule the reload later 38 | reloadTimeout = setTimeout(scheduledReload, proxyConfig.reloadDelay); 39 | 40 | return cb(new Error('Proxy already reloaded in the last ' + 41 | proxyConfig.reloadDelay + 'ms. Schedule it later.')); 42 | } 43 | 44 | // Do the reload now 45 | exec(proxyConfig.reloadCommand, function (err) { 46 | lastReload = currentDate; 47 | return cb(err); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /lib/marathon.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var request = require('request'); 4 | 5 | function removeEmptyItemFilter(item) { 6 | return item.length > 0; 7 | } 8 | 9 | function tasksFromTextToObject(text) { 10 | return text 11 | .replace(/([ \t]+)/gm, ' ') // Remove duplicate spaces 12 | .split('\n') // Create an array with the tasks 13 | .filter(removeEmptyItemFilter) // Remove empty element 14 | .map(function (line) { // Each task must be an object 15 | var fields = line.split(' '); 16 | return { 17 | name: fields[0], // The first element is the name of the app 18 | port: parseInt(fields[1], 10), // The second element is the port to use 19 | endpoints: fields.slice(2) // The following elements are the endpoints 20 | .filter(removeEmptyItemFilter) 21 | }; 22 | }); 23 | } 24 | 25 | function Marathon(marathonUrl) { 26 | this._marathonUrl = marathonUrl; 27 | 28 | return this; 29 | } 30 | 31 | Marathon.prototype.getTasks = function (callback) { 32 | function onResponse(error, response, body) { 33 | if (error) { 34 | return callback(new Error('Unable to get the tasks using the Marathon ' + 35 | 'API. ' + error.toString())); 36 | } 37 | var tasks = tasksFromTextToObject(body); 38 | return callback(null, tasks); 39 | } 40 | 41 | var options = { 42 | url: this._marathonUrl + '/v2/tasks', 43 | headers: { 44 | 'Accept': 'text/plain' 45 | } 46 | }; 47 | request.get(options, onResponse); 48 | }; 49 | 50 | module.exports = Marathon; 51 | -------------------------------------------------------------------------------- /templates/haproxy.cfg: -------------------------------------------------------------------------------- 1 | global 2 | daemon 3 | log 127.0.0.1 local0 4 | log 127.0.0.1 local1 notice 5 | maxconn 4096 6 | 7 | defaults 8 | log global 9 | retries 3 10 | maxconn 2000 11 | contimeout 5000 12 | clitimeout 50000 13 | srvtimeout 50000 14 | 15 | listen stats 16 | bind 127.0.0.1:9090 17 | balance 18 | mode http 19 | stats enable 20 | stats auth admin:admin 21 | 22 | <% _.each(tasks, function (task) { %> 23 | <% if (task.port !== 80) { %> 24 | listen <%= task.name %>_<%= task.port %> 25 | bind 0.0.0.0:<%= task.port %> 26 | mode tcp 27 | option tcplog 28 | balance leastconn 29 | <% _.each(task.endpoints, function (endpoint, idx) { %> 30 | server <%= task.name %>-<%= idx %> <%= endpoint %> check 31 | <% }) %> 32 | <% } %> 33 | <% }) %> 34 | 35 | 36 | <% if (_.where(tasks, { port: 80 }).length > 0) { %> 37 | frontend http-in 38 | bind 0.0.0.0:80 39 | mode http 40 | option forwardfor except 127.0.0.0/8 41 | option http-server-close 42 | option httplog 43 | 44 | <% _.each(tasks, function (task) { %> 45 | <% if (task.port == 80) { %> 46 | acl host_<%= task.name %> hdr(host) -i <%= task.name %> 47 | use_backend <%= task.name %>_backend if host_<%= task.name %> 48 | <% } %> 49 | <% }) %> 50 | 51 | <% _.each(tasks, function (task) { %> 52 | <% if (task.port == 80) { %> 53 | backend <%= task.name %>_backend 54 | mode http 55 | option httplog 56 | option httpchk GET / 57 | balance leastconn 58 | 59 | <% _.each(task.endpoints, function (endpoint, idx) { %> 60 | server <%= task.name %>-<%= idx %> <%= endpoint %> check 61 | <% }) %> 62 | <% } %> 63 | <% }) %> 64 | <% } %> 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frontrunner 2 | 3 | Frontrunner is a daemon which triggers a reconfiguration of [HAProxy](http://haproxy.1wt.eu/), 4 | when an application is modified on [Marathon](https://github.com/mesosphere/marathon). 5 | 6 | It uses [node-zookeeper-client](https://github.com/alexguan/node-zookeeper-client) 7 | to connect to the [ZooKeeper](http://zookeeper.apache.org/) cluster 8 | used by [Marathon](https://github.com/mesosphere/marathon) and detect changes. 9 | 10 | ## Requirements 11 | 12 | * node.js 0.10+ 13 | 14 | ## Installation 15 | 16 | git clone https://github.com/Wizcorp/frontrunner.git 17 | 18 | To install dependencies, run the following command in the created directory: 19 | 20 | npm install --production 21 | 22 | If you don't plan to use yaml config files, you can use the following: 23 | 24 | npm install --production --no-optional 25 | 26 | ## Configuration 27 | 28 | Frontrunner use [node-config](https://github.com/lorenwest/node-config) to manage its configuration files. 29 | You can write your configuration files in JavaScript, JSON or YAML. 30 | 31 | A default [configuration file](config/default.json) is provided. 32 | It shouldn't be edited. 33 | 34 | It contains the following options: 35 | * `zookeeper.connectionString`: Comma separated host:port pairs, 36 | each represents a ZooKeeper server. 37 | * `marathon`: Marathon configuration. 38 | * `url`: Marathon API url used to get the tasks. 39 | * `retryDelay`: Delay between two attemps to get the tasks from the Marathon API, 40 | if the first request fails. 41 | * `activeProxy`: The name of the proxy to use. 42 | * `proxy`: Configuration objects for the supported proxies. 43 | Each object is as follows: 44 | * `templatePath`: Path to the proxy config generator. 45 | * `configFile`: Path to the proxy config file. 46 | * `reloadCommand`: Command to run to reload the proxy. 47 | * `reloadDelay`: The minimal time interval between two reload. 48 | 49 | To override some values, you have to create a new file with your environment name. 50 | 51 | # config/production.yml 52 | 53 | zookeeper: 54 | connectionString: "192.168.1.1:2181,192.168.1.2:2181,192.168.1.3:2181" 55 | marathon: 56 | url: "http://192.168.1.1:8080" 57 | proxy: 58 | haproxy: 59 | templatePath: "haproxy_custom.cfg" 60 | reloadCommand: "service haproxy reload" 61 | 62 | 63 | ## Usage 64 | 65 | To run Frontrunner in the production environment and use the config file, you've just created, run the following: 66 | 67 | NODE_ENV=production npm start 68 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var zookeeper = require('node-zookeeper-client'); 4 | 5 | var config = require('config'); 6 | var proxyConfig = config.proxy[config.activeProxy]; 7 | 8 | var Generator = require('./lib/generator'); 9 | var Marathon = require('./lib/marathon'); 10 | var proxy = require('./lib/proxy'); 11 | 12 | var generator = new Generator(proxyConfig.templatePath, proxyConfig.configFile); 13 | var marathon = new Marathon(config.marathon.url); 14 | 15 | var client = zookeeper.createClient(config.zookeeper.connectionString); 16 | var zkRootPath = '/marathon/state'; 17 | 18 | var watchers = {}; 19 | 20 | var reloadTimeout = null; 21 | 22 | function reloadConfig() { 23 | clearTimeout(reloadTimeout); 24 | reloadTimeout = null; 25 | 26 | marathon.getTasks(function (err, tasks) { 27 | if (err && !reloadTimeout) { 28 | reloadTimeout = setTimeout(reloadConfig, config.marathon.retryDelay); 29 | console.error(err); 30 | return; 31 | } 32 | generator.render({ tasks: tasks }, function (err) { 33 | if (err) { 34 | console.error(err); 35 | return; 36 | } 37 | proxy.reload(function (err) { 38 | if (err) { 39 | console.error(err); 40 | return; 41 | } 42 | }); 43 | }); 44 | }); 45 | } 46 | 47 | function watchData(child) { 48 | /* 49 | * Skip if the watcher already exists 50 | * to avoid calling more than once the callback for the same event. 51 | */ 52 | if (watchers[child]) { 53 | return; 54 | } 55 | 56 | var childPath = [zkRootPath, child].join('/'); 57 | client.getData(childPath, function (event) { 58 | console.log('[%s] Got watcher event: %s', childPath, event); 59 | 60 | // Reload proxy config 61 | reloadConfig(); 62 | 63 | // Delete the watcher, as the callback has been executed 64 | watchers[child] = null; 65 | 66 | // Create a new watcher if the node has not been deleted 67 | if (event && event.name !== 'NODE_DELETED') { 68 | watchData(child); 69 | } 70 | }, function (error) { 71 | if (error) { 72 | console.error( 73 | 'Failed to get datas of %s due to: %s.', 74 | childPath, 75 | error 76 | ); 77 | return; 78 | } 79 | 80 | // The watcher callback has been defined 81 | watchers[child] = true; 82 | }); 83 | } 84 | 85 | function listChildren() { 86 | client.getChildren( 87 | zkRootPath, 88 | function (event) { 89 | console.log('[%s] Got watcher event: %s', zkRootPath, event); 90 | reloadConfig(); 91 | listChildren(); 92 | }, 93 | function (error, children) { 94 | if (error) { 95 | console.error( 96 | 'Failed to list children of %s due to: %s.', 97 | zkRootPath, 98 | error 99 | ); 100 | return; 101 | } 102 | 103 | // List the running apps 104 | var apps = children 105 | .filter(function (child) { 106 | return child.indexOf('app') === 0; 107 | }) 108 | .map(function (child) { 109 | return child.substr(child.indexOf(':') + 1); 110 | }); 111 | 112 | // List the tasks for the running apps 113 | var tasks = children.filter(function (child) { 114 | if (child.indexOf('tasks') !== 0) { 115 | return false; 116 | } 117 | var appName = child.substr(child.indexOf(':') + 1); 118 | return (apps.indexOf(appName) !== -1); 119 | }); 120 | tasks.forEach(watchData); 121 | } 122 | ); 123 | } 124 | 125 | client.on('connected', function () { 126 | console.log('Connected to ZooKeeper.'); 127 | listChildren(); 128 | reloadConfig(); 129 | }); 130 | 131 | console.log('Trying to connect to ZooKeeper...'); 132 | client.connect(); 133 | --------------------------------------------------------------------------------