UNPKG

5.42 kBtext/coffeescriptView Raw
1#
2# Diversion - a proxy that chooses backends based on an X-Version header
3#
4# Copyright (C) 2011 Bet Smart Media Inc. (http://www.betsmartmedia.com)
5#
6
7{request} = require 'http'
8fs = require 'fs'
9bouncy = require 'bouncy'
10semver = require 'semver'
11dateFormat = require 'dateformat'
12
13unless (configFile = process.argv[2])
14 console.error "usage: #{process.argv[1]} <config_file>"
15 process.exit 1
16
17# Parse config and state files
18config = JSON.parse fs.readFileSync configFile
19try
20 fs.statSync config.stateFile
21catch e
22 fs.writeFileSync config.stateFile, JSON.stringify backends: {}, null, 2
23state = JSON.parse fs.readFileSync config.stateFile
24
25# Simple wrapper that reverses the arguments to setInterval
26doEvery = (delay, cb) -> setInterval cb, delay
27
28# Basic logging with date/time stamp
29log = (args...) ->
30 now = dateFormat new Date, "yyyy-mm-dd HH:MM:ss"
31 console.log "[" + now + "]", args...
32
33# Register a new backend with the proxy.
34# This *will* block while it saves the state file.
35registerBackend = (version, location, cfg) ->
36 state.backends[version] ?= {}
37 state.backends[version][location] = cfg
38 fs.writeFileSync config.stateFile, JSON.stringify state, null, 2
39 {status: 'ok', location: location, version: version}
40
41# Un-register a backend
42unregisterBackend = (version, location) ->
43 backends = state.backends[version]
44 delete backends[location] if backends?
45
46# Update the status (alive/dead) of an existing backend.
47# This *will* block while it saves the state file.
48updateBackend = (version, location, alive) ->
49 state.backends[version][location].alive = alive
50 fs.writeFileSync config.stateFile, JSON.stringify state, null, 2
51
52# Pick the maximum known version that satisfies a given range
53pickVersion = (range) ->
54 semver.maxSatisfying Object.keys(state.backends), range
55
56# List all known backends for a specific version
57listBackends = (version) ->
58 return [null, []] unless version
59 [version, state.backends[version] or {}]
60
61# Pick one backend from a list, does a simple round-robin for now
62pickBackend = (backends) ->
63 # TODO: make this round-robin
64 for loc, stat of backends
65 return loc if stat.alive
66 null
67
68# The actual proxy server
69bouncy((req, bounce) ->
70 reqVer = req.headers['x-version'] ? config.defaultVersion
71 unless semver.validRange(reqVer) and (version = pickVersion reqVer)
72 res = bounce.respond()
73 res.statusCode = 400
74 return res.end JSON.stringify error: "Bad version: #{reqVer}"
75 unavailable = ->
76 res = bounce.respond()
77 res.statusCode = 404
78 res.end JSON.stringify error: "Version unavailable: #{version}"
79 forward = ->
80 backends = state.backends[version]
81 if backends? and Object.keys(backends).length
82 loc = pickBackend backends
83 return unavailable() unless loc?
84 [host, port] = loc.split ':'
85 bounce(host, port).on 'error', (exc) ->
86 updateBackend version, loc, false
87 if config.retry then forward() else unavailable()
88 else
89 unavailable()
90 forward()
91).listen config.ports.proxy
92
93# Poll backends to see who's dead and who's alive
94if config.pollFrequency
95 doEvery config.pollFrequency, ->
96 for ver, backends of state.backends
97 for loc, stat of backends
98 do (ver, loc, stat) ->
99 [host, port] = loc.split ':'
100 path = stat.healthCheckPath or '/'
101 method = 'GET'
102 req = request {host:host, port:port, path:path, method:method}, (res) ->
103 if res.statusCode == 200 and not stat.alive
104 log "Backend #{ver} is ALIVE: #{host}:#{port}" unless stat.alive
105 updateBackend ver, loc, true
106 else if res.statusCode != 200 and stat.alive
107 log "Backend #{ver} is DEAD: #{host}:#{port}" if stat.alive
108 updateBackend ver, loc, false
109 req.on 'error', ->
110 log "Backend #{ver} is DEAD: #{host}:#{port}" if stat.alive
111 updateBackend ver, loc, false
112 req.end()
113
114if config.ports.management
115 require('lazorse') ->
116 @port = config.ports.management
117 @route '/defaultVersion':
118 shortName: 'defaultVersion'
119 GET: -> @ok config.defaultVersion
120
121 @route '/versions':
122 shortName: "versions"
123 GET: -> @ok Object.keys state.backends
124
125 @route '/version/{range}':
126 shortName: "versionForRange"
127 GET: ->
128 ver = pickVersion @range
129 return @ok "none" unless ver?
130 @ok ver
131
132 @coerce range: (r, next) ->
133 if (valid = semver.validRange r) then return next null, valid
134 @error 'InvalidParameter', 'range', r
135
136 @helper getHostAndPort: (withPort) ->
137 if (port = @req.body.port)
138 host = @req.body.host ? @req.connection.remoteAddress
139 withPort.call @, host, port
140 else
141 @res.statusCode = 422
142 @res.end '"port" is required'
143
144 @route '/backends/{version}':
145 shortName: "backends"
146 GET: -> @ok listBackends @version
147 POST: ->
148 @getHostAndPort (host, port) ->
149 cfg = alive: true
150 if @req.body.healthCheckPath
151 cfg.healthCheckPath = @req.body.healthCheckPath
152 @ok registerBackend @version, "#{host}:#{port}", cfg
153 DELETE: ->
154 @getHostAndPort (host, port) ->
155 @ok unregisterBackend @version, "#{host}:#{port}"
156
157 @coerce 'version': (v, next) =>
158 if (valid = semver.valid v) then return next null, valid
159 next new @errors.InvalidParameter 'version', v