JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
basic features for updating/checking one instance
authorJason Woofenden <jason@jasonwoof.com>
Mon, 19 Nov 2012 16:53:56 +0000 (11:53 -0500)
committerJason Woofenden <jason@jasonwoof.com>
Tue, 22 Jan 2013 01:07:34 +0000 (20:07 -0500)
api.coffee [new file with mode: 0644]
client.coffee [new file with mode: 0644]

diff --git a/api.coffee b/api.coffee
new file mode 100644 (file)
index 0000000..cda3905
--- /dev/null
@@ -0,0 +1,226 @@
+# Copyright 2012 Jason Woofenden -- GPLv3
+
+fs = require 'fs'
+https = require 'https'
+async = require 'async'
+
+# SETTINGS
+HOST = 'api.appfog.com'
+PORT = 443
+
+# multipart/form-data isn't supposed to be chunk encoded, so we calculate the
+# length, and generate the textual parts as we go
+multipart_prepare = (boundary, data, callback) ->
+       arr = []
+       # async doesn't seem to work on dictionaries so convert to an array
+       for k, v of data
+               arr.push [k, v]
+       async.map arr,
+               ([k,v], callback) ->
+                       out = "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\""
+                       if v.file
+                               out += "; filename=\"#{v.file}\""
+                       out += "\r\n"
+                       if v instanceof Object
+                               if v['Content-Type']?
+                                       ct = v['Content-Type']
+                               else
+                                       if v.file?
+                                               ct = 'application/octet-stream'
+                                       else
+                                               ct = 'text/plain;charset=UTF-8'
+                               out += "Content-Type: #{ct}\r\n\r\n"
+                               if v.file?
+                                       fs.stat v.file, (err, stat) ->
+                                               return callback err if err?
+                                               callback null, text: out, file: v.file, file_length: stat.size
+                               else
+                                       callback null, text: out
+                       else
+                               #out += "Content-Type: text/plain;charset=UTF-8\r\n\r\n"
+                               #out += "Content-Type: text/plain\r\n\r\n"
+                               out += "\r\n"
+                               out += v.toString()
+                               callback null, text: out
+               (err, parts) ->
+                       return callback err if err
+                       parts.push text: "--#{boundary}--"
+                       size = 0
+                       for part in parts
+                               size += part.text.length
+                               if part.file_length?
+                                       size += part.file_length
+                               size += 2
+                       callback null, size, parts
+
+# encode and print data (as created by multipart_prepare()) to writable stream
+# req (and end() it)
+multipart_write = (req, parts, callback) ->
+       async.forEachSeries parts,
+               (part, callback) ->
+                       req.write part.text
+                       if part.file?
+                               done = false
+                               file = fs.createReadStream part.file
+                               file.on 'error', (err) ->
+                                       unless done
+                                               done = true
+                                               callback err
+                               file.on 'end', ->
+                                       unless done
+                                               done = true
+                                               req.write '\r\n'
+                                               callback()
+                               file.pipe req, end: false
+                               return
+                       else
+                               req.write '\r\n'
+                               callback()
+               (err) ->
+                       req.end()
+                       return callback err if err
+                       callback()
+
+new_multipart_boundary = ->
+       return "----bmawvch#{Math.floor(Math.random() * 1000000000)}m#{Math.floor(Math.random() * 1000000000)}9rch48dh"
+
+request = (method, path, content_type, data, token, callback) ->
+       opts =
+               host: HOST
+               port: PORT
+               path: path
+               method: method
+               headers: 'Content-Type': content_type
+       opts.headers['Authorization'] = token if token?
+       # no Accept header: -> unsupported media type
+       opts.headers['Accept'] = '*/*;q=0.8' # -> bad request (in json)
+       #opts.headers['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' # -> bad request (in json) or blank 404
+       #opts.headers['Accept'] = 'application/json' -> unsupported media type
+       async.waterfall [
+               (callback) ->
+                       if content_type is 'multipart/form-data'
+                               boundary = new_multipart_boundary()
+                               opts.headers['Content-Type'] = "#{content_type}; boundary=#{boundary}"
+                               multipart_prepare boundary, data, (err, size, parts) ->
+                                       return callback err if err
+                                       opts.headers['Content-Length'] = size
+                                       callback null, parts
+                       else
+                               opts.headers['Content-Length'] = data?.length ? 0
+                               callback null, data
+               (data, callback) ->
+                       called = false
+                       req = https.request opts, (res) ->
+                               res.setEncoding 'utf8'
+                               response_text = ''
+                               res.on 'data', (data) ->
+                                       response_text += data
+                               res.on 'end', ->
+                                       unless called
+                                               called = true
+                                               if res.statusCode isnt 200
+                                                       callback code: res.statusCode, path: path, response: response_text
+                                               else
+                                                       callback null, response_text
+                       req.on 'error', (err) ->
+                               unless called
+                                       called = true
+                                       callback err
+                       if content_type is 'multipart/form-data'
+                               multipart_write req, data, (err) ->
+                                       if err?
+                                               unless called
+                                                       called = true
+                                                       callback err
+                                               req.end()
+                       else
+                               req.write data if data?
+                               req.end()
+               ], callback
+
+json_request = (method, path, data, token, callback) ->
+       if data?
+               data = JSON.stringify data
+       request method, path, 'application/json', data, token, (err, response) ->
+               return callback err if err
+               return callback() if response is ' '
+               return callback() if response is ''
+               try
+                       callback null, JSON.parse(response)
+               catch error
+                       callback "Error: AF server returned invalid JSON for #{method} to #{path}: \"#{response}\""
+
+json_get = (path, token, callback) ->
+       json_request 'GET', path, null, token, callback
+
+json_post = (path, data, token, callback) ->
+       json_request 'POST', path, data, token, callback
+
+json_put = (path, data, token, callback) ->
+       json_request 'PUT', path, data, token, callback
+
+exports.login = login = (username, password, callback) ->
+       json_post "/users/#{username}/tokens", {password: password}, null, (err, response) ->
+               return callback err if err
+               if response.token?.length
+                       callback null, response.token
+               else
+                       callback "login: couldn't find the token in the server response: #{JSON.stringify response}"
+
+# this is the friendly one that updates the files then restarts the app
+exports.app_publish = (token, app_name, zip_file, callback) ->
+       exports.app_update_files token, app_name, zip_file, (err) ->
+               return callback err if err?
+               # FIXME add "restart" call that only calls app_info once
+               exports.app_stop token, app_name, (err) ->
+                       if err?
+                               return callback "Couldn't stop the app (to restart it). App is probably still running old version (though perhaps accessing new files.). Server responded: #{err}"
+                       exports.app_start token, app_name, (err) ->
+                               if err?
+                                       return callback "CRITICAL ERROR: Couldn't start the app back up! Server responded: #{err}"
+                               callback()
+
+exports.app_update_files = (token, app_name, zip_file, callback) ->
+       request(
+               'POST',
+               "/apps/#{app_name}/application",
+               'multipart/form-data',
+               {
+                       _method: 'put',
+                       resources: '[]',
+                       application:
+                               file: zip_file,
+                               "Content-Type": 'application/octet-stream'
+               },
+               token,
+               callback
+       )
+
+exports.app_set_info = (token, app_name, info, callback) ->
+       json_put "/apps/#{app_name}", info, token, callback
+
+app_set_state = (token, app_name, state, callback) ->
+       exports.app_info token, app_name, (err, info) ->
+               return callback err if err?
+               info.state = state
+               exports.app_set_info token, app_name, info, callback
+
+exports.app_start = (token, app_name, callback) ->
+       app_set_state token, app_name, 'STARTED', callback
+
+exports.app_stop = (token, app_name, callback) ->
+       app_set_state token, app_name, 'STOPPED', callback
+
+
+app_get = (path) ->
+       return (token, app_name, callback) ->
+               json_get "/apps/#{app_name}#{path}", token, callback
+
+exports.app_instances = app_get '/instances'
+exports.app_crashes   = app_get '/crashes'
+exports.app_info      = app_get ''
+exports.app_logs      = app_get '/instances/0/files/logs'
+exports.app_stdout    = app_get '/instances/0/files/logs/stdout.log'
+exports.app_stderr    = app_get '/instances/0/files/logs/stderr.log'
+exports.app_files     = app_get '/instances/0/files/app/app.js'
+exports.app_stats     = app_get '/stats'
diff --git a/client.coffee b/client.coffee
new file mode 100644 (file)
index 0000000..a378480
--- /dev/null
@@ -0,0 +1,76 @@
+fs = require 'fs'
+async = require 'async'
+af = require './api.coffee'
+
+token_file = "#{process.env.HOME}/.af-coffee-token"
+
+timeout = (ms, f) -> setTimeout f, ms
+
+# a session caches the api access token and prompts for the username and
+# password if it goes stale. It re-tries API calls that fail due to
+# invalid/expired token
+
+class Session
+       constructor: ->
+               @token = 0
+
+       api: (call, args..., callback) ->
+               async.waterfall [
+                       (callback) =>
+                               switch @token
+                                       when 0
+                                               get_token callback
+                                       when -1
+                                               login callback
+                                       else
+                                               callback null, @token
+                       (token, callback) =>
+                               @token = token
+                               af[call] @token, args..., callback
+               ], (err, result) =>
+                       # eg /app/xxx/stats sometimes returns 404 with wrong auth token
+                       if err?.code is 403 or err?.code is 404
+                               @token = -1
+                               @api(call, args..., callback)
+                       else
+                               callback err, result
+                               
+ask = (opts, callback) ->
+       process.stdout.write opts.prompt
+       process.stdin.setEncoding 'utf8'
+       process.stdin.resume()
+       process.stdin.once 'data', (line) ->
+               if opts.silent
+                       # send ^[[A^[[2K to move the cursor up one line, then clear that line
+                       process.stdout.write new Buffer [27, 91, 65, 27, 91, 50, 75]
+                       process.stdout.write opts.prompt + "***\n"
+               process.stdin.pause()
+               callback null, (line.substr 0, line.length - 1)
+
+get_token = (callback) ->
+       fs.readFile token_file, 'utf8', (err, token) ->
+               if err?
+                       login callback
+               else
+                       callback null, token
+
+login = (callback) ->
+       async.waterfall [
+               (callback) -> async.series [
+                       (callback) -> ask prompt: 'username: ', callback
+                       (callback) -> ask prompt: 'password: ', silent: true, callback
+               ], callback
+               ([username, password], callback) ->
+                       af.login username, password, callback
+               (token, callback) ->
+                       # wait for file write so there's no race condition if get_token gets called soon
+                       fs.writeFile token_file, token, (err) ->
+                               if err
+                                       console.log "Warning: couldn't cache auth token in #{token_file}: ", err
+                               # don't pass on error, it's ok if we can't cache it
+                               callback null, token
+       ], callback
+
+
+exports.new_session = ->
+       return new Session()