From d20d81a40aaf14801c8be6b94ff2e97dd54f15a1 Mon Sep 17 00:00:00 2001 From: Jason Woofenden Date: Mon, 19 Nov 2012 11:53:56 -0500 Subject: [PATCH] basic features for updating/checking one instance --- api.coffee | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ client.coffee | 76 +++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 api.coffee create mode 100644 client.coffee diff --git a/api.coffee b/api.coffee new file mode 100644 index 0000000..cda3905 --- /dev/null +++ b/api.coffee @@ -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 index 0000000..a378480 --- /dev/null +++ b/client.coffee @@ -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() -- 1.7.10.4