heroku-rds 0.4.0

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.
data/.gemspec ADDED
@@ -0,0 +1,18 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require 'rubygems' unless Object.const_defined?(:Gem)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "heroku-rds"
6
+ s.version = '0.4.0'
7
+ s.authors = ["Jonathan Dance"]
8
+ s.email = "rubygems@wuputah.com"
9
+ s.homepage = "http://github.com/wegowise/heroku-rds"
10
+ s.summary = "Heroku plugin to aid working with RDS databases"
11
+ s.description = "Heroku plugin to aid working with RDS databases"
12
+ s.required_rubygems_version = ">= 1.3.6"
13
+ s.add_dependency 'heroku', '~> 2.0'
14
+ s.add_dependency 'fog', '>= 0.7.0'
15
+ s.files = Dir.glob('lib/**/*.rb') + %w[README.md .gemspec init.rb]
16
+ s.extra_rdoc_files = ["README.md", "LICENSE.txt"]
17
+ s.license = 'MIT'
18
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT LICENSE
2
+
3
+ Copyright (c) 2011 Jonathan Dance
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ ## Installation
2
+
3
+ Forewarning: Heroku RDS is not designed to work on Windows.
4
+
5
+ ### Gem-based install (recommended)
6
+
7
+ Gem-based plugins are new to the Heroku ecosystem, and require first
8
+ installing the [Herogems](https://github.com/hone/herogems) plugin:
9
+
10
+ heroku plugins:install http://github.com/hone/herogems.git
11
+
12
+ Now, simply install the gem and enable the plugin:
13
+
14
+ gem install heroku-rds
15
+ heroku herogems:enable heroku-rds
16
+
17
+ You can update to new releases of heroku-rds by running `gem update
18
+ heroku-rds`.
19
+
20
+ ### Traditional install
21
+
22
+ heroku plugins:install http://github.com/wegowise/heroku-rds.git
23
+ gem install fog
24
+
25
+ To update, you must re-install the plugin using `heroku
26
+ plugins:install`.
27
+
28
+ ### Optional Packages
29
+
30
+ Commands involving data transfer support a progress bar using `pv`.
31
+ Install `pv` to see the awesome. Most package managers have a pv
32
+ package:
33
+
34
+ brew install pv # OS X
35
+ apt-get install pv # linux/fink
36
+ port install pv # BSD/macports
37
+
38
+ ## Usage
39
+
40
+ Access the command list at any time by typing `heroku help rds`:
41
+
42
+ Usage: heroku rds
43
+
44
+ Opens a MySQL console connected to the current database. Ingress access
45
+ is required to run this command (use rds:ingress to grant access).
46
+
47
+ Additional commands, type "heroku help COMMAND" for more details:
48
+
49
+ rds:access # displays current ingress access settings
50
+ rds:dump [FILE] # Download a database dump, bzipped and saved locally
51
+ rds:ingress [IP] [SECURITY GROUP] # Authorize ingress access to a particular IP
52
+ rds:pull [RAILS_ENV or DATABASE_URL] # downloads the remote database into a local database
53
+ rds:revoke [IP] [SECURITY GROUP] # Revokes previously-granted ingress access from a particular IP
54
+
55
+ ## Planned features
56
+
57
+ ### Short term
58
+
59
+ * rds:import - load a local database dump into the remote database
60
+ * rds:push - export a local database into the remote database
61
+
62
+ ### Lower priority
63
+
64
+ * rds:snapshot - capture a snapshot
65
+ * rds:restore - restore from a snapshot
66
+ * rds:reboot - reboot instance
67
+ * rds:describe - show all RDS instances you have access to
68
+ * rds:scale - change instance size
69
+
70
+ These commands are not ingress related so the target of the command
71
+ cannot be inferred from DATABASE\_URL. This functionality is also
72
+ readily available from the RDS dashboard, so implementing them is not a
73
+ priority at this time.
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'heroku/rds'
@@ -0,0 +1,283 @@
1
+ require 'fog'
2
+ require 'uri'
3
+
4
+ module Heroku::Command
5
+
6
+ # manage Amazon RDS instances
7
+ #
8
+ class Rds < BaseWithApp
9
+
10
+ # rds
11
+ #
12
+ # Opens a MySQL console connected to the current database. Ingress access
13
+ # is required to run this command (use rds:ingress to grant access).
14
+ #
15
+ def index
16
+ check_dependencies('mysql')
17
+ exec *(['mysql', '--compress'] + mysql_args(database_uri))
18
+ end
19
+
20
+ # rds:dump [FILE]
21
+ #
22
+ # Download a database dump, bzipped and saved locally
23
+ #
24
+ # -f, --force # allow overwriting existing file
25
+ #
26
+ # if no FILE is specified, appname-date.sql.bz2 is used by default.
27
+ # if name of FILE does not end in .sql.bz2, it will be added automatically.
28
+ #
29
+ def dump
30
+ check_dependencies('mysqldump', 'bzip2', '/bin/sh')
31
+ options = {}
32
+ while arg = args.shift
33
+ case arg
34
+ when '-f', '--force'
35
+ options[:force] = true
36
+ when /^[^-]/
37
+ raise CommandFailed, "too many arguments passed" if options[:filename]
38
+ options[:filename] = arg
39
+ else
40
+ raise CommandFailed, "unsupported option: #{arg}"
41
+ end
42
+ end
43
+
44
+ options[:filename] ||= "#{app}-#{Time.now.strftime('%Y-%m-%d')}.sql.bz2"
45
+ options[:filename] += '.sql.bz2' unless options[:filename] =~ /\.sql(\.bz2)?$/
46
+ options[:filename] += '.bz2' unless options[:filename] =~ /\.bz2$/
47
+
48
+ if File.exists?(options[:filename]) && !options[:force]
49
+ raise CommandFailed, "file already exists. use --force to override."
50
+ end
51
+
52
+ exec('/bin/sh', '-c',
53
+ "mysqldump --compress --single-transaction #{args_to_s(mysql_args(database_uri))}" +
54
+ pv_pipe +
55
+ %{| bzip2 > '#{options[:filename]}'})
56
+ end
57
+
58
+ # rds:ingress [IP] [SECURITY GROUP]
59
+ #
60
+ # Authorize ingress access to a particular IP
61
+ #
62
+ # * if IP is not specified, your current IP will be used
63
+ # * if SECURITY GROUP is not specified, 'default' will be used
64
+ # * IP can also be a CIDR range
65
+ #
66
+ def ingress
67
+ ip, security_group = parse_security_group_and_ip_from_args
68
+ rds.authorize_db_security_group_ingress(security_group, 'CIDRIP' => ip)
69
+ self.access
70
+ end
71
+
72
+ # rds:revoke [IP] [SECURITY GROUP]
73
+ #
74
+ # Revokes previously-granted ingress access from a particular IP
75
+ #
76
+ # * if IP is not specified, your current IP will be used
77
+ # * if SECURITY GROUP is not specified, 'default' will be used
78
+ # * IP can also be a CIDR range
79
+ #
80
+ def revoke
81
+ ip, security_group = parse_security_group_and_ip_from_args
82
+ rds.revoke_db_security_group_ingress(security_group, 'CIDRIP' => ip)
83
+ self.access
84
+ end
85
+
86
+ # rds:access
87
+ #
88
+ # displays current ingress access settings
89
+ #
90
+ def access
91
+ data = rds.security_groups.all.map do |group|
92
+ group.ec2_security_groups.map do |group_access|
93
+ [group.id, group_access['EC2SecurityGroupName'] + ' @ ' + group_access['EC2SecurityGroupOwnerId'], group_access['Status']]
94
+ end +
95
+ group.ip_ranges.map do |ip_range|
96
+ [group.id, ip_range['CIDRIP'], ip_range['Status']]
97
+ end
98
+ end.flatten(1)
99
+ data.unshift ['SECURITY GROUP', 'IP RANGE / SECURITY GROUP', 'STATUS']
100
+ lengths = (0..2).map { |i| data.map { |d| d[i].length }.max }
101
+ puts data.map { |d| '%-*s %-*s %-*s' % [lengths[0], d[0], lengths[1], d[1], lengths[2], d[2]] }.join("\n")
102
+ end
103
+
104
+ # rds:pull [RAILS_ENV or DATABASE_URL]
105
+ #
106
+ # downloads the remote database into a local database
107
+ #
108
+ # If a RAILS_ENV or DATABASE_URL is not specified, the current development environment
109
+ # is used (as read from config/database.yml). This command will confirm before executing
110
+ # the transfer.
111
+ #
112
+ def pull
113
+ check_dependencies('mysqldump', 'mysql')
114
+
115
+ target = args.shift || 'development'
116
+ if target =~ %r{://}
117
+ target = uri_to_hash(validate_db(URI.parse(target)))
118
+ else
119
+ raise CommandFailed, "config/database.yml not found" unless File.readable?("config/database.yml")
120
+ db_config = YAML.load(File.open("config/database.yml"))
121
+ raise CommandFailed, "environment #{target.inspect} not found in config/database.yml" unless
122
+ db_config.has_key?(target)
123
+ target = validate_db(db_config[target], target)
124
+ end
125
+
126
+ display "This will erase all data in the #{target['database'].inspect} database" +
127
+ (target['host'].empty? ? '' : " on #{target['host']}") + "!"
128
+ exit unless ask("Are you sure you wish to continue? [yN] ").downcase == 'y'
129
+
130
+ exec('/bin/sh', '-c',
131
+ 'mysqldump --compress --single-transaction ' + args_to_s(mysql_args(database_uri)) +
132
+ pv_pipe +
133
+ %{| mysql --compress } + args_to_s(mysql_args(target)))
134
+ end
135
+
136
+ private
137
+
138
+ def current_ip
139
+ # simple rack app which returns your external IP
140
+ RestClient::Resource.new("http://ip4.heroku.com")['/'].get.strip
141
+ end
142
+
143
+ def ask(prompt = nil)
144
+ Readline.readline(prompt)
145
+ end
146
+
147
+ def parse_database_uri
148
+ URI.parse(heroku.config_vars(app)['DATABASE_URL'])
149
+ end
150
+
151
+ def mysql_args(creds)
152
+ creds = uri_to_hash(creds) if creds.is_a?(URI)
153
+ args = []
154
+ args.concat(['-u', creds['username']]) if creds['username'] && !creds['username'].empty?
155
+ args << "-p#{creds['password']}" if creds['password'] && !creds['password'].empty?
156
+ args.concat(['-h', creds['host']]) if creds['host'] && !creds['host'].empty?
157
+ args.concat(['-P', creds['port']]) if creds['port'] && !creds['port'].empty?
158
+ args.concat(['-S', creds['socket']]) if creds['socket'] && !creds['socket'].empty?
159
+ args << creds['database']
160
+ args
161
+ end
162
+
163
+ def args_to_s(args)
164
+ "'" + args.collect { |s| s.gsub("'", "\\'") }.join("' '") + "'"
165
+ end
166
+
167
+ def exec(*args)
168
+ ENV['DEBUG'] ? puts("exec(): #{args.inspect}") && exit : super
169
+ end
170
+
171
+ def system(*args)
172
+ exec = ENV['DEBUG'].nil?
173
+ unless exec
174
+ puts("system(): #{args.inspect}")
175
+ exec = ask("execute? [yN] ") == 'y'
176
+ end
177
+ if exec
178
+ super or raise CommandFailed, "command failed [code=#{$?.exitstatus}]: " + args.join(' ')
179
+ end
180
+ end
181
+
182
+ def validate_db(creds, name = nil)
183
+ if creds.is_a?(URI)
184
+ raise CommandFailed, "#{name || creds.to_s} is not a MySQL server" unless creds.scheme =~ /^mysql/
185
+ else
186
+ raise CommandFailed, "#{name || creds.inspect} is not a MySQL server" unless creds['adapter'] =~ /^mysql/
187
+ end
188
+ creds
189
+ end
190
+
191
+ def check_dependencies(*commands)
192
+ options = commands.last.is_a?(Hash) ? commands.pop : {}
193
+ results = commands.collect do |cmd|
194
+ path = `which #{cmd}`.strip
195
+ if !options[:optional]
196
+ raise CommandFailed, "#{cmd}: not found in path" if path.empty?
197
+ raise CommandFailed, "#{cmd}: not executable" unless File.executable?(path)
198
+ else
199
+ !path.empty? && File.executable?(path)
200
+ end
201
+ end
202
+ results.inject { |a, b| a && b }
203
+ end
204
+
205
+ def uri_to_hash(uri)
206
+ { 'username' => uri.user,
207
+ 'password' => uri.password,
208
+ 'host' => uri.host,
209
+ 'port' => uri.port,
210
+ 'database' => uri.path.sub('/', '') }
211
+ end
212
+
213
+ def pv_installed?
214
+ check_dependencies('pv', :optional => true)
215
+ end
216
+
217
+ def pv_pipe
218
+ pv_installed? ? '| pv ' : ''
219
+ end
220
+
221
+ def database_uri
222
+ @database_uri ||= validate_db(parse_database_uri)
223
+ end
224
+
225
+ def aws_access_key_id
226
+ @aws_access_key_id ||= read_or_prompt_git_config('herokurds.accessKeyID', 'Please enter your AWS Access Key ID: ', 20)
227
+ end
228
+
229
+ def aws_secret_access_key
230
+ @aws_secret_access_key ||= read_or_prompt_git_config('herokurds.secretAccessKey', 'Please enter your AWS Secret Access Key: ', 40)
231
+ end
232
+
233
+ def read_or_prompt_git_config(config_var, prompt, length)
234
+ value = `git config #{config_var}`.strip
235
+ if value.empty?
236
+ value = ask(prompt)
237
+ unless value.length == length
238
+ puts "That is not valid; the value should be #{length} characters long."
239
+ return read_or_prompt_git_config(config_var, prompt, length)
240
+ end
241
+ system('git', 'config', '--add', config_var, value)
242
+ end
243
+ value
244
+ end
245
+
246
+ def rds
247
+ @rds ||= RdsProxy.new(:aws_access_key_id => aws_access_key_id, :aws_secret_access_key => aws_secret_access_key)
248
+ end
249
+
250
+ def parse_security_group_and_ip_from_args
251
+ ip = security_group = nil
252
+ while arg = args.shift
253
+ if arg =~ /^(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?$/
254
+ raise CommandFailed, "too many arguments passed" if ip
255
+ ip = arg
256
+ else
257
+ raise CommandFailed, "IP not in correct format or too many arguments passed" if security_group
258
+ security_group = arg
259
+ end
260
+ end
261
+ ip ||= current_ip + '/32'
262
+ ip += '/32' unless ip =~ /\/\d{1,2}$/
263
+ security_group ||= 'default'
264
+ [ip, security_group]
265
+ end
266
+
267
+ class RdsProxy
268
+ def initialize(*args)
269
+ @instance = Fog::AWS::RDS.new(*args)
270
+ end
271
+
272
+ private
273
+ def method_missing(sym, *args, &block)
274
+ begin
275
+ @instance.send(sym, *args, &block)
276
+ rescue Excon::Errors::HTTPStatusError => error
277
+ raise CommandFailed, Nokogiri::XML.parse(error.response.body).css('Message').first.content
278
+ end
279
+ end
280
+ end
281
+ end
282
+
283
+ end
data/lib/heroku/rds.rb ADDED
@@ -0,0 +1,8 @@
1
+ # ghetto heroku gem dependency management
2
+ if Heroku::VERSION < '2.0.0'
3
+ puts "Please upgrade your Heroku gem"
4
+ elsif Heroku::VERSION >= '3.0.0'
5
+ puts "Please update your heroku-rds plugin (or find a new maintainer)"
6
+ else
7
+ require 'heroku/commands/rds'
8
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: heroku-rds
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.4.0
6
+ platform: ruby
7
+ authors:
8
+ - Jonathan Dance
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2011-06-01 00:00:00 -04:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: heroku
18
+ prerelease: false
19
+ requirement: &id001 !ruby/object:Gem::Requirement
20
+ none: false
21
+ requirements:
22
+ - - ~>
23
+ - !ruby/object:Gem::Version
24
+ version: "2.0"
25
+ type: :runtime
26
+ version_requirements: *id001
27
+ - !ruby/object:Gem::Dependency
28
+ name: fog
29
+ prerelease: false
30
+ requirement: &id002 !ruby/object:Gem::Requirement
31
+ none: false
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 0.7.0
36
+ type: :runtime
37
+ version_requirements: *id002
38
+ description: Heroku plugin to aid working with RDS databases
39
+ email: rubygems@wuputah.com
40
+ executables: []
41
+
42
+ extensions: []
43
+
44
+ extra_rdoc_files:
45
+ - README.md
46
+ - LICENSE.txt
47
+ files:
48
+ - lib/heroku/commands/rds.rb
49
+ - lib/heroku/rds.rb
50
+ - README.md
51
+ - .gemspec
52
+ - init.rb
53
+ - LICENSE.txt
54
+ has_rdoc: true
55
+ homepage: http://github.com/wegowise/heroku-rds
56
+ licenses:
57
+ - MIT
58
+ post_install_message:
59
+ rdoc_options: []
60
+
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ none: false
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: "0"
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ none: false
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 1.3.6
75
+ requirements: []
76
+
77
+ rubyforge_project:
78
+ rubygems_version: 1.6.2
79
+ signing_key:
80
+ specification_version: 3
81
+ summary: Heroku plugin to aid working with RDS databases
82
+ test_files: []
83
+