azuki 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +71 -0
  3. data/bin/azuki +17 -0
  4. data/data/cacert.pem +3988 -0
  5. data/lib/azuki.rb +17 -0
  6. data/lib/azuki/auth.rb +339 -0
  7. data/lib/azuki/cli.rb +38 -0
  8. data/lib/azuki/client.rb +764 -0
  9. data/lib/azuki/client/azuki_postgresql.rb +141 -0
  10. data/lib/azuki/client/cisaurus.rb +26 -0
  11. data/lib/azuki/client/pgbackups.rb +113 -0
  12. data/lib/azuki/client/rendezvous.rb +108 -0
  13. data/lib/azuki/client/ssl_endpoint.rb +25 -0
  14. data/lib/azuki/command.rb +294 -0
  15. data/lib/azuki/command/account.rb +23 -0
  16. data/lib/azuki/command/accounts.rb +34 -0
  17. data/lib/azuki/command/addons.rb +305 -0
  18. data/lib/azuki/command/apps.rb +393 -0
  19. data/lib/azuki/command/auth.rb +86 -0
  20. data/lib/azuki/command/base.rb +230 -0
  21. data/lib/azuki/command/certs.rb +209 -0
  22. data/lib/azuki/command/config.rb +137 -0
  23. data/lib/azuki/command/db.rb +218 -0
  24. data/lib/azuki/command/domains.rb +85 -0
  25. data/lib/azuki/command/drains.rb +46 -0
  26. data/lib/azuki/command/fork.rb +164 -0
  27. data/lib/azuki/command/git.rb +64 -0
  28. data/lib/azuki/command/help.rb +179 -0
  29. data/lib/azuki/command/keys.rb +115 -0
  30. data/lib/azuki/command/labs.rb +147 -0
  31. data/lib/azuki/command/logs.rb +45 -0
  32. data/lib/azuki/command/maintenance.rb +61 -0
  33. data/lib/azuki/command/pg.rb +269 -0
  34. data/lib/azuki/command/pgbackups.rb +329 -0
  35. data/lib/azuki/command/plugins.rb +110 -0
  36. data/lib/azuki/command/ps.rb +232 -0
  37. data/lib/azuki/command/regions.rb +22 -0
  38. data/lib/azuki/command/releases.rb +124 -0
  39. data/lib/azuki/command/run.rb +180 -0
  40. data/lib/azuki/command/sharing.rb +89 -0
  41. data/lib/azuki/command/ssl.rb +43 -0
  42. data/lib/azuki/command/stack.rb +62 -0
  43. data/lib/azuki/command/status.rb +51 -0
  44. data/lib/azuki/command/update.rb +47 -0
  45. data/lib/azuki/command/version.rb +23 -0
  46. data/lib/azuki/deprecated.rb +5 -0
  47. data/lib/azuki/deprecated/help.rb +38 -0
  48. data/lib/azuki/distribution.rb +9 -0
  49. data/lib/azuki/excon.rb +9 -0
  50. data/lib/azuki/helpers.rb +517 -0
  51. data/lib/azuki/helpers/azuki_postgresql.rb +165 -0
  52. data/lib/azuki/helpers/log_displayer.rb +70 -0
  53. data/lib/azuki/plugin.rb +163 -0
  54. data/lib/azuki/updater.rb +171 -0
  55. data/lib/azuki/version.rb +3 -0
  56. data/lib/vendor/azuki/okjson.rb +598 -0
  57. data/spec/azuki/auth_spec.rb +256 -0
  58. data/spec/azuki/client/azuki_postgresql_spec.rb +71 -0
  59. data/spec/azuki/client/pgbackups_spec.rb +43 -0
  60. data/spec/azuki/client/rendezvous_spec.rb +62 -0
  61. data/spec/azuki/client/ssl_endpoint_spec.rb +48 -0
  62. data/spec/azuki/client_spec.rb +564 -0
  63. data/spec/azuki/command/addons_spec.rb +601 -0
  64. data/spec/azuki/command/apps_spec.rb +351 -0
  65. data/spec/azuki/command/auth_spec.rb +38 -0
  66. data/spec/azuki/command/base_spec.rb +109 -0
  67. data/spec/azuki/command/certs_spec.rb +178 -0
  68. data/spec/azuki/command/config_spec.rb +144 -0
  69. data/spec/azuki/command/db_spec.rb +110 -0
  70. data/spec/azuki/command/domains_spec.rb +87 -0
  71. data/spec/azuki/command/drains_spec.rb +34 -0
  72. data/spec/azuki/command/fork_spec.rb +56 -0
  73. data/spec/azuki/command/git_spec.rb +144 -0
  74. data/spec/azuki/command/help_spec.rb +93 -0
  75. data/spec/azuki/command/keys_spec.rb +120 -0
  76. data/spec/azuki/command/labs_spec.rb +100 -0
  77. data/spec/azuki/command/logs_spec.rb +60 -0
  78. data/spec/azuki/command/maintenance_spec.rb +51 -0
  79. data/spec/azuki/command/pg_spec.rb +236 -0
  80. data/spec/azuki/command/pgbackups_spec.rb +307 -0
  81. data/spec/azuki/command/plugins_spec.rb +104 -0
  82. data/spec/azuki/command/ps_spec.rb +195 -0
  83. data/spec/azuki/command/releases_spec.rb +130 -0
  84. data/spec/azuki/command/run_spec.rb +83 -0
  85. data/spec/azuki/command/sharing_spec.rb +59 -0
  86. data/spec/azuki/command/stack_spec.rb +46 -0
  87. data/spec/azuki/command/status_spec.rb +48 -0
  88. data/spec/azuki/command/version_spec.rb +16 -0
  89. data/spec/azuki/command_spec.rb +211 -0
  90. data/spec/azuki/helpers/azuki_postgresql_spec.rb +155 -0
  91. data/spec/azuki/helpers_spec.rb +48 -0
  92. data/spec/azuki/plugin_spec.rb +172 -0
  93. data/spec/azuki/updater_spec.rb +44 -0
  94. data/spec/helper/legacy_help.rb +16 -0
  95. data/spec/spec.opts +1 -0
  96. data/spec/spec_helper.rb +224 -0
  97. data/spec/support/display_message_matcher.rb +49 -0
  98. data/spec/support/openssl_mock_helper.rb +8 -0
  99. metadata +211 -0
@@ -0,0 +1,17 @@
1
+ require "azuki/client"
2
+ require "azuki/updater"
3
+ require "azuki/version"
4
+
5
+ module Azuki
6
+
7
+ USER_AGENT = "azuki-gem/#{Azuki::VERSION} (#{RUBY_PLATFORM}) ruby/#{RUBY_VERSION}"
8
+
9
+ def self.user_agent
10
+ @@user_agent ||= USER_AGENT
11
+ end
12
+
13
+ def self.user_agent=(agent)
14
+ @@user_agent = agent
15
+ end
16
+
17
+ end
@@ -0,0 +1,339 @@
1
+ require "cgi"
2
+ require "azuki"
3
+ require "azuki/client"
4
+ require "azuki/helpers"
5
+
6
+ require "netrc"
7
+
8
+ class Azuki::Auth
9
+ class << self
10
+ include Azuki::Helpers
11
+
12
+ attr_accessor :credentials
13
+
14
+ def api
15
+ @api ||= begin
16
+ require("azuki-api")
17
+ api = Azuki::API.new(default_params.merge(:api_key => password))
18
+
19
+ def api.request(params, &block)
20
+ response = super
21
+ if response.headers.has_key?('X-Azuki-Warning')
22
+ Azuki::Command.warnings.concat(response.headers['X-Azuki-Warning'].split("\n"))
23
+ end
24
+ response
25
+ end
26
+
27
+ api
28
+ end
29
+ end
30
+
31
+ def client
32
+ @client ||= begin
33
+ client = Azuki::Client.new(user, password, host)
34
+ client.on_warning { |msg| self.display("\n#{msg}\n\n") }
35
+ client
36
+ end
37
+ end
38
+
39
+ def login
40
+ delete_credentials
41
+ get_credentials
42
+ end
43
+
44
+ def logout
45
+ delete_credentials
46
+ end
47
+
48
+ # just a stub; will raise if not authenticated
49
+ def check
50
+ api.get_user
51
+ end
52
+
53
+ def default_host
54
+ "azukiapp.com"
55
+ end
56
+
57
+ def git_host
58
+ ENV['AZUKI_GIT_HOST'] || host
59
+ end
60
+
61
+ def host
62
+ ENV['AZUKI_HOST'] || default_host
63
+ end
64
+
65
+ def reauthorize
66
+ @credentials = ask_for_and_save_credentials
67
+ end
68
+
69
+ def user # :nodoc:
70
+ get_credentials[0]
71
+ end
72
+
73
+ def password # :nodoc:
74
+ get_credentials[1]
75
+ end
76
+
77
+ def api_key(user = get_credentials[0], password = get_credentials[1])
78
+ require("azuki-api")
79
+ api = Azuki::API.new(default_params)
80
+ api.post_login(user, password).body["api_key"]
81
+ end
82
+
83
+ def get_credentials # :nodoc:
84
+ @credentials ||= (read_credentials || ask_for_and_save_credentials)
85
+ end
86
+
87
+ def delete_credentials
88
+ if File.exists?(legacy_credentials_path)
89
+ FileUtils.rm_f(legacy_credentials_path)
90
+ end
91
+ if netrc
92
+ netrc.delete("api.#{host}")
93
+ netrc.delete("code.#{host}")
94
+ netrc.save
95
+ end
96
+ @api, @client, @credentials = nil, nil
97
+ end
98
+
99
+ def legacy_credentials_path
100
+ if host == default_host
101
+ "#{home_directory}/.azuki/credentials"
102
+ else
103
+ "#{home_directory}/.azuki/credentials.#{CGI.escape(host)}"
104
+ end
105
+ end
106
+
107
+ def netrc_path
108
+ default = Netrc.default_path
109
+ encrypted = default + ".gpg"
110
+ if File.exists?(encrypted)
111
+ encrypted
112
+ else
113
+ default
114
+ end
115
+ end
116
+
117
+ def netrc # :nodoc:
118
+ @netrc ||= begin
119
+ File.exists?(netrc_path) && Netrc.read(netrc_path)
120
+ rescue => error
121
+ if error.message =~ /^Permission bits for/
122
+ perm = File.stat(netrc_path).mode & 0777
123
+ abort("Permissions #{perm} for '#{netrc_path}' are too open. You should run `chmod 0600 #{netrc_path}` so that your credentials are NOT accessible by others.")
124
+ else
125
+ raise error
126
+ end
127
+ end
128
+ end
129
+
130
+ def read_credentials
131
+ if ENV['AZUKI_API_KEY']
132
+ ['', ENV['AZUKI_API_KEY']]
133
+ else
134
+ # convert legacy credentials to netrc
135
+ if File.exists?(legacy_credentials_path)
136
+ @api, @client = nil
137
+ @credentials = File.read(legacy_credentials_path).split("\n")
138
+ write_credentials
139
+ FileUtils.rm_f(legacy_credentials_path)
140
+ end
141
+
142
+ # read netrc credentials if they exist
143
+ if netrc
144
+ # force migration of long api tokens (80 chars) to short ones (40)
145
+ # #write_credentials rewrites both api.* and code.*
146
+ credentials = netrc["api.#{host}"]
147
+ if credentials && credentials[1].length > 40
148
+ @credentials = [ credentials[0], credentials[1][0,40] ]
149
+ write_credentials
150
+ end
151
+
152
+ netrc["api.#{host}"]
153
+ end
154
+ end
155
+ end
156
+
157
+ def write_credentials
158
+ FileUtils.mkdir_p(File.dirname(netrc_path))
159
+ FileUtils.touch(netrc_path)
160
+ unless running_on_windows?
161
+ FileUtils.chmod(0600, netrc_path)
162
+ end
163
+ netrc["api.#{host}"] = self.credentials
164
+ netrc["code.#{host}"] = self.credentials
165
+ netrc.save
166
+ end
167
+
168
+ def echo_off
169
+ with_tty do
170
+ system "stty -echo"
171
+ end
172
+ end
173
+
174
+ def echo_on
175
+ with_tty do
176
+ system "stty echo"
177
+ end
178
+ end
179
+
180
+ def ask_for_credentials
181
+ puts "Enter your Azuki credentials."
182
+
183
+ print "Email: "
184
+ user = ask
185
+
186
+ print "Password (typing will be hidden): "
187
+ password = running_on_windows? ? ask_for_password_on_windows : ask_for_password
188
+
189
+ [user, api_key(user, password)]
190
+ end
191
+
192
+ def ask_for_password_on_windows
193
+ require "Win32API"
194
+ char = nil
195
+ password = ''
196
+
197
+ while char = Win32API.new("crtdll", "_getch", [ ], "L").Call do
198
+ break if char == 10 || char == 13 # received carriage return or newline
199
+ if char == 127 || char == 8 # backspace and delete
200
+ password.slice!(-1, 1)
201
+ else
202
+ # windows might throw a -1 at us so make sure to handle RangeError
203
+ (password << char.chr) rescue RangeError
204
+ end
205
+ end
206
+ puts
207
+ return password
208
+ end
209
+
210
+ def ask_for_password
211
+ echo_off
212
+ password = ask
213
+ puts
214
+ echo_on
215
+ return password
216
+ end
217
+
218
+ def ask_for_and_save_credentials
219
+ require("azuki-api") # for the errors
220
+ begin
221
+ @credentials = ask_for_credentials
222
+ write_credentials
223
+ check
224
+ rescue Azuki::API::Errors::NotFound, Azuki::API::Errors::Unauthorized => e
225
+ delete_credentials
226
+ display "Authentication failed."
227
+ retry if retry_login?
228
+ exit 1
229
+ rescue Exception => e
230
+ delete_credentials
231
+ raise e
232
+ end
233
+ check_for_associated_ssh_key unless Azuki::Command.current_command == "keys:add"
234
+ @credentials
235
+ end
236
+
237
+ def check_for_associated_ssh_key
238
+ if api.get_keys.body.empty?
239
+ associate_or_generate_ssh_key
240
+ end
241
+ end
242
+
243
+ def associate_or_generate_ssh_key
244
+ public_keys = Dir.glob("#{home_directory}/.ssh/*.pub").sort
245
+
246
+ case public_keys.length
247
+ when 0 then
248
+ display "Could not find an existing public key."
249
+ display "Would you like to generate one? [Yn] ", false
250
+ unless ask.strip.downcase == "n"
251
+ display "Generating new SSH public key."
252
+ generate_ssh_key("id_rsa")
253
+ associate_key("#{home_directory}/.ssh/id_rsa.pub")
254
+ end
255
+ when 1 then
256
+ display "Found existing public key: #{public_keys.first}"
257
+ associate_key(public_keys.first)
258
+ else
259
+ display "Found the following SSH public keys:"
260
+ public_keys.each_with_index do |key, index|
261
+ display "#{index+1}) #{File.basename(key)}"
262
+ end
263
+ display "Which would you like to use with your Azuki account? ", false
264
+ choice = ask.to_i - 1
265
+ chosen = public_keys[choice]
266
+ if choice == -1 || chosen.nil?
267
+ error("Invalid choice")
268
+ end
269
+ associate_key(chosen)
270
+ end
271
+ end
272
+
273
+ def generate_ssh_key(keyfile)
274
+ ssh_dir = File.join(home_directory, ".ssh")
275
+ unless File.exists?(ssh_dir)
276
+ FileUtils.mkdir_p ssh_dir
277
+ unless running_on_windows?
278
+ File.chmod(0700, ssh_dir)
279
+ end
280
+ end
281
+ output = `ssh-keygen -t rsa -N "" -f \"#{home_directory}/.ssh/#{keyfile}\" 2>&1`
282
+ if ! $?.success?
283
+ error("Could not generate key: #{output}")
284
+ end
285
+ end
286
+
287
+ def associate_key(key)
288
+ action("Uploading SSH public key #{key}") do
289
+ if File.exists?(key)
290
+ api.post_key(File.read(key))
291
+ else
292
+ error("Could not upload SSH public key: key file '" + key + "' does not exist")
293
+ end
294
+ end
295
+ end
296
+
297
+ def retry_login?
298
+ @login_attempts ||= 0
299
+ @login_attempts += 1
300
+ @login_attempts < 3
301
+ end
302
+
303
+ def verified_hosts
304
+ %w( azukiapp.com azuki-shadow.com )
305
+ end
306
+
307
+ def base_host(host)
308
+ parts = URI.parse(full_host(host)).host.split(".")
309
+ return parts.first if parts.size == 1
310
+ parts[-2..-1].join(".")
311
+ end
312
+
313
+ def full_host(host)
314
+ (host =~ /^http/) ? host : "https://api.#{host}"
315
+ end
316
+
317
+ def verify_host?(host)
318
+ hostname = base_host(host)
319
+ verified = verified_hosts.include?(hostname)
320
+ verified = false if ENV["AZUKI_SSL_VERIFY"] == "disable"
321
+ verified
322
+ end
323
+
324
+ protected
325
+
326
+ def default_params
327
+ uri = URI.parse(full_host(host))
328
+ {
329
+ :headers => {
330
+ 'User-Agent' => Azuki.user_agent
331
+ },
332
+ :host => uri.host,
333
+ :port => uri.port.to_s,
334
+ :scheme => uri.scheme,
335
+ :ssl_verify_peer => verify_host?(host)
336
+ }
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,38 @@
1
+ load('azuki/helpers.rb') # reload helpers after possible inject_loadpath
2
+ load('azuki/updater.rb') # reload updater after possible inject_loadpath
3
+
4
+ require "azuki"
5
+ require "azuki/command"
6
+ require "azuki/helpers"
7
+
8
+ # workaround for rescue/reraise to define errors in command.rb failing in 1.8.6
9
+ if RUBY_VERSION =~ /^1.8.6/
10
+ require('azuki-api')
11
+ require('rest_client')
12
+ end
13
+
14
+ class Azuki::CLI
15
+
16
+ extend Azuki::Helpers
17
+
18
+ def self.start(*args)
19
+ begin
20
+ if $stdin.isatty
21
+ $stdin.sync = true
22
+ end
23
+ if $stdout.isatty
24
+ $stdout.sync = true
25
+ end
26
+ command = args.shift.strip rescue "help"
27
+ Azuki::Command.load
28
+ Azuki::Command.run(command, args)
29
+ rescue Interrupt
30
+ `stty icanon echo`
31
+ error("Command cancelled.")
32
+ rescue => error
33
+ styled_error(error)
34
+ exit(1)
35
+ end
36
+ end
37
+
38
+ end
@@ -0,0 +1,764 @@
1
+ require 'rexml/document'
2
+ require 'uri'
3
+ require 'time'
4
+ require 'azuki/auth'
5
+ require 'azuki/command'
6
+ require 'azuki/helpers'
7
+ require 'azuki/version'
8
+ require 'azuki/client/ssl_endpoint'
9
+
10
+ # A Ruby class to call the Azuki REST API. You might use this if you want to
11
+ # manage your Azuki apps from within a Ruby program, such as Capistrano.
12
+ #
13
+ # Example:
14
+ #
15
+ # require 'azuki'
16
+ # azuki = Azuki::Client.new('me@example.com', 'mypass')
17
+ # azuki.create()
18
+ #
19
+ class Azuki::Client
20
+
21
+ include Azuki::Helpers
22
+ extend Azuki::Helpers
23
+
24
+ def self.version
25
+ Azuki::VERSION
26
+ end
27
+
28
+ def self.gem_version_string
29
+ "azuki-gem/#{version}"
30
+ end
31
+
32
+ attr_accessor :host, :user, :password
33
+
34
+ def initialize(user, password, host=Azuki::Auth.host)
35
+ require 'rest_client'
36
+ @user = user
37
+ @password = password
38
+ @host = host
39
+ end
40
+
41
+ def self.deprecate
42
+ method = caller.first.split('`').last[0...-1]
43
+ source = caller[1].split(' ').first[0...-3]
44
+ $stderr.puts(" ! DEPRECATED: Azuki::Client##{method} is deprecated, please use the azuki-api gem.")
45
+ $stderr.puts(" ! DEPRECATED: More information available at https://github.com/azuki/azuki.rb")
46
+ $stderr.puts(" ! DEPRECATED: Deprecated method called from #{source}.")
47
+ end
48
+
49
+ def deprecate
50
+ self.class.deprecate
51
+ end
52
+
53
+ def self.auth(user, password, host=Azuki::Auth.host)
54
+ deprecate # 08/01/2012
55
+ client = new(user, password, host)
56
+ json_decode client.post('/login', { :username => user, :password => password }, :accept => 'json').to_s
57
+ end
58
+
59
+ # Show a list of apps which you are a collaborator on.
60
+ def list
61
+ deprecate # 07/26/2012
62
+ doc = xml(get('/apps').to_s)
63
+ doc.elements.to_a("//apps/app").map do |a|
64
+ name = a.elements.to_a("name").first
65
+ owner = a.elements.to_a("owner").first
66
+ [name.text, owner.text]
67
+ end
68
+ end
69
+
70
+ # Show info such as mode, custom domain, and collaborators on an app.
71
+ def info(name_or_domain)
72
+ deprecate # 07/26/2012
73
+ raise ArgumentError.new("name_or_domain is required for info") unless name_or_domain
74
+ name_or_domain = name_or_domain.gsub(/^(http:\/\/)?(www\.)?/, '')
75
+ doc = xml(get("/apps/#{name_or_domain}").to_s)
76
+ attrs = hash_from_xml_doc(doc)[:app]
77
+ attrs.merge!(:collaborators => list_collaborators(attrs[:name]))
78
+ attrs.merge!(:addons => installed_addons(attrs[:name]))
79
+ end
80
+
81
+ # Create a new app, with an optional name.
82
+ def create(name=nil, options={})
83
+ deprecate # 07/26/2012
84
+ name = create_request(name, options)
85
+ loop do
86
+ break if create_complete?(name)
87
+ sleep 1
88
+ end
89
+ name
90
+ end
91
+
92
+ def create_app(name=nil, options={})
93
+ deprecate # 07/26/2012
94
+ options[:name] = name if name
95
+ json_decode(post("/apps", { :app => options }, :accept => "application/json").to_s)
96
+ end
97
+
98
+ def create_request(name=nil, options={})
99
+ deprecate # 07/26/2012
100
+ options[:name] = name if name
101
+ xml(post('/apps', :app => options).to_s).elements["//app/name"].text
102
+ end
103
+
104
+ def create_complete?(name)
105
+ deprecate # 07/26/2012
106
+ put("/apps/#{name}/status", {}).code == 201
107
+ end
108
+
109
+ # Update an app. Available attributes:
110
+ # :name => rename the app (changes http and git urls)
111
+ def update(name, attributes)
112
+ deprecate # 07/26/2012
113
+ put("/apps/#{name}", :app => attributes).to_s
114
+ end
115
+
116
+ # Destroy the app permanently.
117
+ def destroy(name)
118
+ deprecate # 07/26/2012
119
+ delete("/apps/#{name}").to_s
120
+ end
121
+
122
+ def maintenance(app_name, mode)
123
+ deprecate # 07/31/2012
124
+ mode = mode == :on ? '1' : '0'
125
+ post("/apps/#{app_name}/server/maintenance", :maintenance_mode => mode).to_s
126
+ end
127
+
128
+ def config_vars(app_name)
129
+ deprecate # 07/27/2012
130
+ json_decode get("/apps/#{app_name}/config_vars", :accept => :json).to_s
131
+ end
132
+
133
+ def add_config_vars(app_name, new_vars)
134
+ deprecate # 07/27/2012
135
+ put("/apps/#{app_name}/config_vars", json_encode(new_vars), :accept => :json).to_s
136
+ end
137
+
138
+ def remove_config_var(app_name, key)
139
+ deprecate # 07/27/2012
140
+ delete("/apps/#{app_name}/config_vars/#{escape(key)}", :accept => :json).to_s
141
+ end
142
+
143
+ def clear_config_vars(app_name)
144
+ deprecate # 07/27/2012
145
+ delete("/apps/#{app_name}/config_vars").to_s
146
+ end
147
+
148
+ # Get a list of collaborators on the app, returns an array of hashes each with :email
149
+ def list_collaborators(app_name)
150
+ deprecate # 07/31/2012
151
+ doc = xml(get("/apps/#{app_name}/collaborators").to_s)
152
+ doc.elements.to_a("//collaborators/collaborator").map do |a|
153
+ { :email => a.elements['email'].text }
154
+ end
155
+ end
156
+
157
+ # Invite a person by email address to collaborate on the app.
158
+ def add_collaborator(app_name, email)
159
+ deprecate # 07/31/2012
160
+ xml(post("/apps/#{app_name}/collaborators", { 'collaborator[email]' => email }).to_s)
161
+ end
162
+
163
+ # Remove a collaborator.
164
+ def remove_collaborator(app_name, email)
165
+ deprecate # 07/31/2012
166
+ delete("/apps/#{app_name}/collaborators/#{escape(email)}").to_s
167
+ end
168
+
169
+ def list_domains(app_name)
170
+ deprecate # 08/02/2012
171
+ doc = xml(get("/apps/#{app_name}/domains").to_s)
172
+ doc.elements.to_a("//domain-names/*").map do |d|
173
+ attrs = { :domain => d.elements['domain'].text }
174
+ if cert = d.elements['cert']
175
+ attrs[:cert] = {
176
+ :expires_at => Time.parse(cert.elements['expires-at'].text),
177
+ :subject => cert.elements['subject'].text,
178
+ :issuer => cert.elements['issuer'].text,
179
+ }
180
+ end
181
+ attrs
182
+ end
183
+ end
184
+
185
+ def add_domain(app_name, domain)
186
+ deprecate # 07/31/2012
187
+ post("/apps/#{app_name}/domains", domain).to_s
188
+ end
189
+
190
+ def remove_domain(app_name, domain)
191
+ deprecate # 07/31/2012
192
+ raise ArgumentError.new("invalid domain: #{domain.inspect}") if domain.to_s.strip == ""
193
+ delete("/apps/#{app_name}/domains/#{domain}").to_s
194
+ end
195
+
196
+ def remove_domains(app_name)
197
+ deprecate # 07/31/2012
198
+ delete("/apps/#{app_name}/domains").to_s
199
+ end
200
+
201
+ # Get the list of ssh public keys for the current user.
202
+ def keys
203
+ deprecate # 07/31/2012
204
+ doc = xml get('/user/keys').to_s
205
+ doc.elements.to_a('//keys/key').map do |key|
206
+ key.elements['contents'].text
207
+ end
208
+ end
209
+
210
+ # Add an ssh public key to the current user.
211
+ def add_key(key)
212
+ deprecate # 07/31/2012
213
+ post("/user/keys", key, { 'Content-Type' => 'text/ssh-authkey' }).to_s
214
+ end
215
+
216
+ # Remove an existing ssh public key from the current user.
217
+ def remove_key(key)
218
+ deprecate # 07/31/2012
219
+ delete("/user/keys/#{escape(key)}").to_s
220
+ end
221
+
222
+ # Clear all keys on the current user.
223
+ def remove_all_keys
224
+ deprecate # 07/31/2012
225
+ delete("/user/keys").to_s
226
+ end
227
+
228
+ # Retreive ps list for the given app name.
229
+ def ps(app_name)
230
+ deprecate # 07/31/2012
231
+ json_decode get("/apps/#{app_name}/ps", :accept => 'application/json').to_s
232
+ end
233
+
234
+ # Restart the app servers.
235
+ def restart(app_name)
236
+ deprecate # 07/31/2012
237
+ delete("/apps/#{app_name}/server").to_s
238
+ end
239
+
240
+ def dynos(app_name)
241
+ deprecate # 07/31/2012
242
+ doc = xml(get("/apps/#{app_name}").to_s)
243
+ doc.elements["//app/dynos"].text.to_i
244
+ end
245
+
246
+ def workers(app_name)
247
+ deprecate # 07/31/2012
248
+ doc = xml(get("/apps/#{app_name}").to_s)
249
+ doc.elements["//app/workers"].text.to_i
250
+ end
251
+
252
+ # Scales the web processes.
253
+ def set_dynos(app_name, qty)
254
+ deprecate # 07/31/2012
255
+ put("/apps/#{app_name}/dynos", :dynos => qty).to_s
256
+ end
257
+
258
+ # Scales the background processes.
259
+ def set_workers(app_name, qty)
260
+ deprecate # 07/31/2012
261
+ put("/apps/#{app_name}/workers", :workers => qty).to_s
262
+ end
263
+
264
+ def ps_run(app, opts={})
265
+ deprecate # 07/31/2012
266
+ json_decode post("/apps/#{app}/ps", opts, :accept => :json).to_s
267
+ end
268
+
269
+ def ps_scale(app, opts={})
270
+ deprecate # 07/31/2012
271
+ Integer(post("/apps/#{app}/ps/scale", opts).to_s)
272
+ end
273
+
274
+ def ps_restart(app, opts={})
275
+ deprecate # 07/31/2012
276
+ post("/apps/#{app}/ps/restart", opts)
277
+ end
278
+
279
+ def ps_stop(app, opts={})
280
+ deprecate # 07/31/2012
281
+ post("/apps/#{app}/ps/stop", opts)
282
+ end
283
+
284
+ def releases(app)
285
+ deprecate # 07/31/2012
286
+ json_decode get("/apps/#{app}/releases", :accept => :json).to_s
287
+ end
288
+
289
+ def release(app, release)
290
+ deprecate # 07/31/2012
291
+ json_decode get("/apps/#{app}/releases/#{release}", :accept => :json).to_s
292
+ end
293
+
294
+ def rollback(app, release=nil)
295
+ deprecate # 07/31/2012
296
+ post("/apps/#{app}/releases", :rollback => release)
297
+ end
298
+
299
+ # Fetch recent logs from the app server.
300
+ def logs(app_name)
301
+ deprecate # 07/31/2012
302
+ get("/apps/#{app_name}/logs").to_s
303
+ end
304
+
305
+ def list_features(app)
306
+ deprecate # 07/31/2012
307
+ json_decode(get("features?app=#{app}", :accept => :json).to_s)
308
+ end
309
+
310
+ def get_feature(app, name)
311
+ deprecate # 07/31/2012
312
+ json_decode get("features/#{name}?app=#{app}", :accept => :json).to_s
313
+ end
314
+
315
+ def enable_feature(app, name)
316
+ deprecate # 07/31/2012
317
+ json_decode post("/features/#{name}?app=#{app}", :accept => :json).to_s
318
+ end
319
+
320
+ def disable_feature(app, name)
321
+ deprecate # 07/31/2012
322
+ json_decode delete("/features/#{name}?app=#{app}", :accept => :json).to_s
323
+ end
324
+
325
+ # Get a list of stacks available to the app, with the current one marked.
326
+ def list_stacks(app_name, options={})
327
+ deprecate # 07/31/2012
328
+ include_deprecated = options.delete(:include_deprecated) || false
329
+
330
+ json_decode get("/apps/#{app_name}/stack",
331
+ :params => { :include_deprecated => include_deprecated },
332
+ :accept => 'application/json'
333
+ ).to_s
334
+ end
335
+
336
+ # Request a stack migration.
337
+ def migrate_to_stack(app_name, stack)
338
+ deprecate # 07/31/2012
339
+ put("/apps/#{app_name}/stack", stack, :accept => 'text/plain').to_s
340
+ end
341
+
342
+ # Run a rake command on the Azuki app and return output as a string
343
+ def rake(app_name, cmd)
344
+ # deprecated by virtue of start deprecation 08/02/2012
345
+ start(app_name, "rake #{cmd}", :attached).to_s
346
+ end
347
+
348
+ class Service
349
+ attr_accessor :attached
350
+
351
+ def initialize(client, app)
352
+ require 'rest_client'
353
+ @client = client
354
+ @app = app
355
+ end
356
+
357
+ # start the service
358
+ def start(command, attached=false)
359
+ @attached = attached
360
+ @response = @client.post(
361
+ "/apps/#{@app}/services",
362
+ command,
363
+ :content_type => 'text/plain'
364
+ )
365
+ @next_chunk = @response.to_s
366
+ @interval = 0
367
+ self
368
+ rescue RestClient::RequestFailed => e
369
+ raise AppCrashed, e.http_body if e.http_code == 502
370
+ raise
371
+ end
372
+
373
+ # Does the service have any remaining output?
374
+ def end_of_stream?
375
+ @next_chunk.nil?
376
+ end
377
+
378
+ # Read the next chunk of output.
379
+ def read
380
+ chunk = @client.get(@next_chunk)
381
+ if chunk.headers[:location].nil? && chunk.code != 204
382
+ # no more chunks
383
+ @next_chunk = nil
384
+ chunk.to_s
385
+ elsif chunk.to_s == ''
386
+ # assume no content and back off
387
+ @interval = 2
388
+ ''
389
+ elsif location = chunk.headers[:location]
390
+ # some data read and next chunk available
391
+ @next_chunk = location
392
+ @interval = 0
393
+ chunk.to_s
394
+ end
395
+ end
396
+
397
+ # Iterate over all output chunks until EOF is reached.
398
+ def each
399
+ until end_of_stream?
400
+ sleep(@interval)
401
+ output = read
402
+ yield output unless output.empty?
403
+ end
404
+ end
405
+
406
+ # All output as a string
407
+ def to_s
408
+ buf = []
409
+ each { |part| buf << part }
410
+ buf.join
411
+ end
412
+ end
413
+
414
+ # Run a service. If Responds to #each and yields output as it's received.
415
+ def start(app_name, command, attached=false)
416
+ deprecate # 08/02/2012
417
+ service = Service.new(self, app_name)
418
+ service.start(command, attached)
419
+ end
420
+
421
+ def add_ssl(app_name, pem, key)
422
+ json_decode(post("/apps/#{app_name}/ssl", :pem => pem, :key => key).to_s)
423
+ end
424
+
425
+ def remove_ssl(app_name, domain)
426
+ delete("/apps/#{app_name}/domains/#{domain}/ssl").to_s
427
+ end
428
+
429
+ def clear_ssl(app_name)
430
+ delete("/apps/#{app_name}/ssl")
431
+ end
432
+
433
+ class AppCrashed < RuntimeError; end
434
+
435
+ # support for console sessions
436
+ class ConsoleSession
437
+ def initialize(id, app, client)
438
+ require 'rest_client'
439
+ @id = id; @app = app; @client = client
440
+ end
441
+ def run(cmd)
442
+ @client.run_console_command("/apps/#{@app}/consoles/#{@id}/command", cmd, "=> ")
443
+ end
444
+ end
445
+
446
+ # Execute a one-off console command, or start a new console tty session if
447
+ # cmd is nil.
448
+ def console(app_name, cmd=nil)
449
+ if block_given?
450
+ id = post("/apps/#{app_name}/consoles").to_s
451
+ yield ConsoleSession.new(id, app_name, self)
452
+ delete("/apps/#{app_name}/consoles/#{id}").to_s
453
+ else
454
+ run_console_command("/apps/#{app_name}/console", cmd)
455
+ end
456
+ rescue RestClient::BadGateway => e
457
+ raise(AppCrashed, <<-ERROR)
458
+ Unable to attach to a dyno to open a console session.
459
+ Your application may have crashed.
460
+ Check the output of "azuki ps" and "azuki logs" for more information.
461
+ ERROR
462
+ end
463
+
464
+ # internal method to run console commands formatting the output
465
+ def run_console_command(url, command, prefix=nil)
466
+ output = post(url, { :command => command }, :accept => "text/plain").to_s
467
+ return output unless prefix
468
+ if output.include?("\n")
469
+ lines = output.split("\n")
470
+ (lines[0..-2] << "#{prefix}#{lines.last}").join("\n")
471
+ else
472
+ prefix + output
473
+ end
474
+ rescue RestClient::RequestFailed => e
475
+ if e.http_code == 422
476
+ Azuki::Command.extract_error(e.http_body, :raw => true)
477
+ else
478
+ raise e
479
+ end
480
+ end
481
+
482
+ def read_logs(app_name, options=[])
483
+ query = "&" + options.join("&") unless options.empty?
484
+ url = get("/apps/#{app_name}/logs?logplex=true#{query}").to_s
485
+ if url == 'Use old logs'
486
+ puts get("/apps/#{app_name}/logs").to_s
487
+ else
488
+ uri = URI.parse(url);
489
+
490
+ if uri.scheme == 'https'
491
+ proxy = https_proxy
492
+ else
493
+ proxy = http_proxy
494
+ end
495
+
496
+ if proxy
497
+ proxy_uri = URI.parse(proxy)
498
+ http = Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
499
+ else
500
+ http = Net::HTTP.new(uri.host, uri.port)
501
+ end
502
+
503
+ if uri.scheme == 'https'
504
+ http.use_ssl = true
505
+ if ENV["AZUKI_SSL_VERIFY"] == "disable"
506
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
507
+ else
508
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
509
+ http.ca_file = local_ca_file
510
+ http.verify_callback = lambda do |preverify_ok, ssl_context|
511
+ if (!preverify_ok) || ssl_context.error != 0
512
+ error "WARNING: Unable to verify SSL certificate for #{host}\nTo disable SSL verification, run with AZUKI_SSL_VERIFY=disable"
513
+ end
514
+ true
515
+ end
516
+ end
517
+ end
518
+
519
+ http.read_timeout = 60 * 60 * 24
520
+
521
+ begin
522
+ http.start do
523
+ http.request_get(uri.path + (uri.query ? "?" + uri.query : "")) do |request|
524
+ request.read_body do |chunk|
525
+ yield chunk
526
+ end
527
+ end
528
+ end
529
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError
530
+ error("Could not connect to logging service")
531
+ rescue Timeout::Error, EOFError
532
+ error("\nRequest timed out")
533
+ end
534
+ end
535
+ end
536
+
537
+ def list_drains(app_name)
538
+ get("/apps/#{app_name}/logs/drains").to_s
539
+ end
540
+
541
+ def add_drain(app_name, url)
542
+ post("/apps/#{app_name}/logs/drains", "url=#{url}").to_s
543
+ end
544
+
545
+ def remove_drain(app_name, url)
546
+ delete("/apps/#{app_name}/logs/drains?url=#{URI.escape(url)}").to_s
547
+ end
548
+
549
+ def addons
550
+ json_decode get("/addons", :accept => 'application/json').to_s
551
+ end
552
+
553
+ def installed_addons(app_name)
554
+ json_decode get("/apps/#{app_name}/addons", :accept => 'application/json').to_s
555
+ end
556
+
557
+ def install_addon(app_name, addon, config={})
558
+ configure_addon :install, app_name, addon, config
559
+ end
560
+
561
+ def upgrade_addon(app_name, addon, config={})
562
+ configure_addon :upgrade, app_name, addon, config
563
+ end
564
+ alias_method :downgrade_addon, :upgrade_addon
565
+
566
+ def uninstall_addon(app_name, addon, options={})
567
+ configure_addon :uninstall, app_name, addon, options
568
+ end
569
+
570
+ def database_session(app_name)
571
+ json_decode(post("/apps/#{app_name}/database/session2", '', :x_taps_version => ::Taps.version).to_s)
572
+ end
573
+
574
+ def database_reset(app_name)
575
+ post("/apps/#{app_name}/database/reset", '').to_s
576
+ end
577
+
578
+ def httpcache_purge(app_name)
579
+ delete("/apps/#{app_name}/httpcache").to_s
580
+ end
581
+
582
+ def confirm_billing
583
+ post("/user/#{escape(@user)}/confirm_billing").to_s
584
+ end
585
+
586
+ def on_warning(&blk)
587
+ @warning_callback = blk
588
+ end
589
+
590
+ ##################
591
+
592
+ def resource(uri, options={})
593
+ RestClient.proxy = case URI.parse(realize_full_uri(uri)).scheme
594
+ when "http"
595
+ http_proxy
596
+ when "https"
597
+ https_proxy
598
+ end
599
+ RestClient::Resource.new(realize_full_uri(uri), options.merge(:user => user, :password => password))
600
+ end
601
+
602
+ def get(uri, extra_headers={}) # :nodoc:
603
+ process(:get, uri, extra_headers)
604
+ end
605
+
606
+ def post(uri, payload="", extra_headers={}) # :nodoc:
607
+ process(:post, uri, extra_headers, payload)
608
+ end
609
+
610
+ def put(uri, payload, extra_headers={}) # :nodoc:
611
+ process(:put, uri, extra_headers, payload)
612
+ end
613
+
614
+ def delete(uri, extra_headers={}) # :nodoc:
615
+ process(:delete, uri, extra_headers)
616
+ end
617
+
618
+ def process(method, uri, extra_headers={}, payload=nil)
619
+ headers = azuki_headers.merge(extra_headers)
620
+ args = [method, payload, headers].compact
621
+
622
+ resource_options = default_resource_options_for_uri(uri)
623
+
624
+ begin
625
+ response = resource(uri, resource_options).send(*args)
626
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError
627
+ host = URI.parse(realize_full_uri(uri)).host
628
+ error "Unable to connect to #{host}"
629
+ rescue RestClient::SSLCertificateNotVerified => ex
630
+ host = URI.parse(realize_full_uri(uri)).host
631
+ error "WARNING: Unable to verify SSL certificate for #{host}\nTo disable SSL verification, run with AZUKI_SSL_VERIFY=disable"
632
+ end
633
+
634
+ extract_warning(response)
635
+ response
636
+ end
637
+
638
+ def extract_warning(response)
639
+ return unless response
640
+ if response.headers[:x_azuki_warning] && @warning_callback
641
+ warning = response.headers[:x_azuki_warning]
642
+ @displayed_warnings ||= {}
643
+ unless @displayed_warnings[warning]
644
+ @warning_callback.call(warning)
645
+ @displayed_warnings[warning] = true
646
+ end
647
+ end
648
+ end
649
+
650
+ def azuki_headers # :nodoc:
651
+ {
652
+ 'X-Azuki-API-Version' => '2',
653
+ 'User-Agent' => Azuki.user_agent,
654
+ 'X-Ruby-Version' => RUBY_VERSION,
655
+ 'X-Ruby-Platform' => RUBY_PLATFORM
656
+ }
657
+ end
658
+
659
+ def xml(raw) # :nodoc:
660
+ REXML::Document.new(raw)
661
+ end
662
+
663
+ def escape(value) # :nodoc:
664
+ escaped = URI.escape(value.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
665
+ escaped.gsub('.', '%2E') # not covered by the previous URI.escape
666
+ end
667
+
668
+ module JSON
669
+ def self.parse(json)
670
+ json_decode(json)
671
+ end
672
+ end
673
+
674
+ private
675
+
676
+ def configure_addon(action, app_name, addon, config = {})
677
+ response = update_addon action,
678
+ addon_path(app_name, addon),
679
+ config
680
+
681
+ json_decode(response.to_s) unless response.to_s.empty?
682
+ end
683
+
684
+ def addon_path(app_name, addon)
685
+ "/apps/#{app_name}/addons/#{escape(addon)}"
686
+ end
687
+
688
+ def update_addon(action, path, config)
689
+ params = { :config => config }
690
+ app = params[:config].delete(:confirm)
691
+ headers = { :accept => 'application/json' }
692
+ params.merge!(:confirm => app) if app
693
+
694
+ case action
695
+ when :install
696
+ post path, params, headers
697
+ when :upgrade
698
+ put path, params, headers
699
+ when :uninstall
700
+ confirm = app ? "confirm=#{app}" : ''
701
+ delete "#{path}?#{confirm}", headers
702
+ end
703
+ end
704
+
705
+ def realize_full_uri(given)
706
+ full_host = (host =~ /^http/) ? host : "https://api.#{host}"
707
+ host = URI.parse(full_host)
708
+ uri = URI.parse(given)
709
+ uri.host ||= host.host
710
+ uri.scheme ||= host.scheme || "https"
711
+ uri.path = (uri.path[0..0] == "/") ? uri.path : "/#{uri.path}"
712
+ uri.port = host.port if full_host =~ /\:\d+/
713
+ uri.to_s
714
+ end
715
+
716
+ def default_resource_options_for_uri(uri)
717
+ if ENV["AZUKI_SSL_VERIFY"] == "disable"
718
+ {}
719
+ elsif realize_full_uri(uri) =~ %r|^https://api.azukiapp.com|
720
+ { :verify_ssl => OpenSSL::SSL::VERIFY_PEER, :ssl_ca_file => local_ca_file }
721
+ else
722
+ {}
723
+ end
724
+ end
725
+
726
+ def local_ca_file
727
+ File.expand_path("../../../data/cacert.pem", __FILE__)
728
+ end
729
+
730
+ def hash_from_xml_doc(elements)
731
+ elements.inject({}) do |hash, e|
732
+ next(hash) unless e.respond_to?(:children)
733
+ hash.update(e.name.gsub("-","_").to_sym => case e.children.length
734
+ when 0 then nil
735
+ when 1 then e.text
736
+ else hash_from_xml_doc(e.children)
737
+ end)
738
+ end
739
+ end
740
+
741
+ def http_proxy
742
+ proxy = ENV['HTTP_PROXY'] || ENV['http_proxy']
743
+ if proxy && !proxy.empty?
744
+ unless /^[^:]+:\/\// =~ proxy
745
+ proxy = "http://" + proxy
746
+ end
747
+ proxy
748
+ else
749
+ nil
750
+ end
751
+ end
752
+
753
+ def https_proxy
754
+ proxy = ENV['HTTPS_PROXY'] || ENV['https_proxy']
755
+ if proxy && !proxy.empty?
756
+ unless /^[^:]+:\/\// =~ proxy
757
+ proxy = "https://" + proxy
758
+ end
759
+ proxy
760
+ else
761
+ nil
762
+ end
763
+ end
764
+ end