elastic_beans 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 07dcf92670f011c2715d0ffd754bc856803f1434
4
- data.tar.gz: 8b483d5a53cb03898d44f8de16fcf4c5e180aa25
3
+ metadata.gz: '058ffb4aa22e205e19470449420643bc3e55298f'
4
+ data.tar.gz: 5dcd342a6962c0ee82dd672c2ee07a32fc7b1562
5
5
  SHA512:
6
- metadata.gz: c2758059cb4a7351e4c6ebb1492d5211c2098b351d4e7fbd584f96e0ce266d085bb4d7633cf4aa8a8f34695585adf4581b34150ab4350d3eef958927f9cb99c1
7
- data.tar.gz: 2c2e12f9ee533a77d60b82b888b80bc708615ad12ae3f60b13c96b66b5ce89daecf5767c05770a65a9c3416a8e1b0b157a83cdc0f6487d77339653ec52d2035f
6
+ metadata.gz: 40f88906d991227878c85386c3fd79fba38e14bbddddfbb26c9101e3c4b1e01d7996f9dafef8cb1b0e3035f59548a664f41c4de94f46d0010a7bd9b653c517d8
7
+ data.tar.gz: 3aeebfe162e590b106c6e253af178aef7c447ef4d005179ed197e34c4357a5a350bcc864a1e8eed51ef9c1d9e425a6617fe6a06df4a540e2b7aea8470f79829c
data/Gemfile CHANGED
@@ -6,4 +6,6 @@ gemspec
6
6
  group :test do
7
7
  gem "net-ssh-gateway"
8
8
  gem "rails", "~> 5.0"
9
+ gem "rspec_junit_formatter"
10
+ gem "timecop"
9
11
  end
data/README.md CHANGED
@@ -26,7 +26,7 @@ As the SDK documentation suggests, using environment variables is recommended.
26
26
  beans configure -n myapp-networking -a myapp \
27
27
  -b SECRET_KEY_BASE -d DATABASE_URL -k KEYPAIR \
28
28
  -p INTERNAL_PUBLIC_KEY -s SSL_CERTIFICATE_ARN \
29
- [-i IMAGE_ID] [-t INSTANCE_TYPE] [-l LOGGING_HTTP_ENDPOINT]
29
+ [-i IMAGE_ID] [-t INSTANCE_TYPE]
30
30
 
31
31
  # Create a webserver environment with a pretty DNS name at myapp.TLD (managed by Route53)
32
32
  beans create -a myapp [-d myapp.TLD] [--tags=Environment:production Team:Unicorn] webserver
@@ -50,14 +50,14 @@ As the SDK documentation suggests, using environment variables is recommended.
50
50
  # Then deploy that version to each running environment.
51
51
  beans deploy -a myapp
52
52
 
53
- # Run one-off tasks and upload logs to the LOGGING_HTTP_ENDPOINT from `configure`
53
+ # Run one-off tasks
54
54
  beans exec -a myapp rake db:migrate
55
55
 
56
56
  # Update all existing environments and configuration
57
57
  beans configure -n myapp-networking -a myapp \
58
58
  [-b SECRET_KEY_BASE] [-d DATABASE_URL] [-k KEYPAIR] \
59
59
  [-p INTERNAL_PUBLIC_KEY] [-s SSL_CERTIFICATE_ARN] \
60
- [-i IMAGE_ID] [-t INSTANCE_TYPE] [-l LOGGING_HTTP_ENDPOINT]
60
+ [-i IMAGE_ID] [-t INSTANCE_TYPE]
61
61
 
62
62
  ### API
63
63
 
@@ -88,8 +88,8 @@ In `config/initializers/elastic_beans.rb`, add the middleware into your stack, b
88
88
  cloudformation: Aws::CloudFormation::Client.new,
89
89
  elastic_beanstalk: Aws::ElasticBeanstalk::Client.new,
90
90
  s3: Aws::S3::Client.new,
91
+ sqs: Aws::SQS::Client.new,
91
92
  ),
92
- sqs: Aws::SQS::Client.new,
93
93
  logger: Rails.logger,
94
94
  )
95
95
  else
@@ -100,8 +100,8 @@ In `config/initializers/elastic_beans.rb`, add the middleware into your stack, b
100
100
  cloudformation: Aws::CloudFormation::Client.new,
101
101
  elastic_beanstalk: Aws::ElasticBeanstalk::Client.new,
102
102
  s3: Aws::S3::Client.new,
103
+ sqs: Aws::SQS::Client.new,
103
104
  ),
104
- sqs: Aws::SQS::Client.new,
105
105
  logger: Rails.logger,
106
106
  )
107
107
  end
@@ -141,7 +141,6 @@ Before loading your application, the configuration is fetched and loaded.
141
141
  Elastic Beans supports the execution of one-off and periodic commands in an isolated environment.
142
142
  This way they can be computationally expensive without affecting application performance.
143
143
  Additionally, they are not limited to the visibility timeout of an application background job queue.
144
- Logs are persisted to an HTTPS endpoint upon command completion.
145
144
 
146
145
  ### Persistent configuration
147
146
 
@@ -209,9 +208,13 @@ Its details will be discovered from the following outputs:
209
208
  * `ExecQueueUrl`
210
209
  * `Worker[Name]QueueUrl`
211
210
 
212
- Where a separate worker environment will be configured for each queue `[Name]` that appears.
211
+ A separate worker environment will be configured for each queue `[Name]` that appears.
213
212
  A default worker queue, i.e. `WorkerDefaultQueueUrl`, must exist.
214
213
 
214
+ The `ExecQueueUrl` is used by `beans exec` to enqueue one-off commands.
215
+ It is also by `beans ps` to inspect scheduled commands that have not yet run.
216
+ Make sure that its redrive policy allows such inspection before considering a message failed.
217
+
215
218
  ### Code
216
219
 
217
220
  Your application must use the [active-elastic-job gem](https://github.com/tawan/active-elastic-job) for background job processing.
@@ -288,7 +291,9 @@ Or install it yourself as:
288
291
 
289
292
  ## Development
290
293
 
291
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
294
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run all the tests, including feature tests. This will take about 1.5–2 hours. To run only the unit tests, run `rake spec:unit`.
295
+
296
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
292
297
 
293
298
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
294
299
 
data/Rakefile CHANGED
@@ -1,6 +1,14 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
3
 
4
- RSpec::Core::RakeTask.new(:spec)
4
+ RSpec::Core::RakeTask.new(:spec) do |t|
5
+ t.rspec_opts = ENV['RSPEC_OPTS']
6
+ end
5
7
 
6
8
  task :default => :spec
9
+
10
+ namespace :spec do
11
+ RSpec::Core::RakeTask.new(:unit) do |t|
12
+ t.rspec_opts = "--tag ~feature #{ENV['RSPEC_OPTS']}"
13
+ end
14
+ end
data/circle.yml CHANGED
@@ -1,4 +1,8 @@
1
1
  ---
2
+ machine:
3
+ environment:
4
+ RSPEC_OPTS: "-r rspec_junit_formatter --format progress --format RspecJunitFormatter -o $CIRCLE_TEST_REPORTS/rspec/junit.xml"
5
+
2
6
  dependencies:
3
7
  override:
4
8
  - |
@@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
24
24
  spec.add_dependency "ruby-progressbar", "~> 1.2"
25
25
  spec.add_dependency "rubyzip", "~> 1.2"
26
26
  spec.add_dependency "thor", "~> 0.19.0"
27
+ spec.add_dependency "tty-table", "~> 0.7.0"
27
28
 
28
29
  spec.add_development_dependency "bundler", "~> 1.12"
29
30
  spec.add_development_dependency "rake", "~> 10.0"
data/lib/elastic_beans.rb CHANGED
@@ -6,5 +6,6 @@ require "elastic_beans/application_version"
6
6
  require "elastic_beans/configuration_template"
7
7
  require "elastic_beans/env_vars"
8
8
  require "elastic_beans/environment"
9
+ require "elastic_beans/exec"
9
10
  require "elastic_beans/network"
10
11
  require "elastic_beans/version"
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "timeout"
2
3
  require "aws-sdk"
3
4
  require "elastic_beans/aws/cloudformation_stack"
4
5
  require "elastic_beans/error"
@@ -10,10 +11,11 @@ module ElasticBeans
10
11
  class Application
11
12
  attr_reader :name
12
13
 
13
- def initialize(name:, cloudformation:, elastic_beanstalk:, s3:)
14
+ def initialize(name:, cloudformation:, elastic_beanstalk:, s3:, sqs: nil)
14
15
  @name = name
15
16
  @elastic_beanstalk = elastic_beanstalk
16
17
  @s3 = s3
18
+ @sqs = sqs
17
19
  @stack = ElasticBeans::Aws::CloudformationStack.new(name, cloudformation: cloudformation)
18
20
  end
19
21
 
@@ -96,25 +98,76 @@ module ElasticBeans
96
98
  @bucket_name = bucket.name
97
99
  end
98
100
 
99
- # Enqueues a one-off command to be run on the application's +exec+ environment.
101
+ # Enqueues a one-off Exec::Command to be run on the application's +exec+ environment.
100
102
  # Does not wait for action to be taken, but returns immediately after enqueuing the command.
101
103
  #
102
- # Raises an error if the exec environment cannot be found.
103
- def enqueue_command(command, sqs:)
104
+ # Raises an error if the exec environment or queue cannot be found.
105
+ def enqueue_command(command)
104
106
  if environments.none? { |environment| environment.is_a?(Environment::Exec) }
105
107
  raise MissingExecEnvironmentError
106
108
  end
107
109
 
110
+ if command.to_s == command
111
+ command = Exec::Command.new(command_string: command)
112
+ end
113
+ command.metadata[:bucket] = bucket_name
114
+ command.metadata[:key] = "#{command_key_prefix}#{command.id}.json"
115
+
108
116
  sqs.send_message(
109
117
  queue_url: exec_queue_url,
110
- message_body: exec_message(command),
118
+ message_body: command.to_json,
111
119
  )
112
120
  end
113
121
 
122
+ # Fetches commands enqueued in the +exec_queue_url+.
123
+ # Polls the queue for 5 seconds.
124
+ #
125
+ # If a large number of commands is enqueued they may not all be found.
126
+ #
127
+ # Raises an error if the exec queue cannot be found.
128
+ def enqueued_commands
129
+ messages = []
130
+ Timeout.timeout(5) do
131
+ loop do
132
+ messages += sqs.receive_message(
133
+ queue_url: exec_queue_url,
134
+ max_number_of_messages: 10,
135
+ visibility_timeout: 5,
136
+ wait_time_seconds: 5,
137
+ ).messages
138
+ end
139
+ end
140
+ rescue Timeout::Error
141
+ messages.map { |msg| Exec::Command.from_json(msg.body) }
142
+ end
143
+
114
144
  def exec_queue_url
115
145
  stack.stack_output("ExecQueueUrl")
116
146
  end
117
147
 
148
+ # Fetches commands running in the Environment::Exec environment.
149
+ # Commands are deserialized from metadata files in a well-known location in S3.
150
+ # The instances update the metadata when executing a command, and remove the metadata when they are done.
151
+ #
152
+ # Raises an error if the exec environment cannot be found.
153
+ def running_commands
154
+ # Ignoring truncation for simplicity; >100 commands will just have to suffer.
155
+ objects = s3.list_objects_v2(
156
+ bucket: bucket_name,
157
+ prefix: command_key_prefix,
158
+ max_keys: 100,
159
+ ).contents
160
+ objects.each_with_object([]) { |object, commands|
161
+ begin
162
+ response = s3.get_object(bucket: bucket_name, key: object.key)
163
+ commands << ElasticBeans::Exec::Command.from_json(response.body.read)
164
+ # skip finished or invalid commands
165
+ rescue ::Aws::S3::Errors::NoSuchKey
166
+ rescue JSON::ParserError
167
+ end
168
+ }
169
+ end
170
+
118
171
  # Returns an ElasticBeans::ApplicationVersion for each version of the Elastic Beanstalk application.
119
172
  def versions
120
173
  response = elastic_beanstalk.describe_application_versions(application_name: name)
@@ -146,10 +199,8 @@ module ElasticBeans
146
199
 
147
200
  attr_reader :elastic_beanstalk, :s3, :stack
148
201
 
149
- def exec_message(command)
150
- {
151
- command: command,
152
- }.to_json
202
+ def command_key_prefix
203
+ @command_key_prefix ||= "#{name}/exec/command/"
153
204
  end
154
205
 
155
206
  def exists?
@@ -159,6 +210,10 @@ module ElasticBeans
159
210
  retry
160
211
  end
161
212
 
213
+ def sqs
214
+ @sqs || raise("ElasticBeans::Application: Missing SQS client")
215
+ end
216
+
162
217
  # :nodoc: all
163
218
  # @!visibility private
164
219
  class MissingApplicationError < ElasticBeans::Error
@@ -129,6 +129,10 @@ module ElasticBeans
129
129
  ".elastic_beans/exec/init.rb",
130
130
  File.expand_path('../exec/init.rb', __FILE__),
131
131
  )
132
+ zip_file.add(
133
+ ".elastic_beans/exec/logrotate",
134
+ File.expand_path('../exec/logrotate', __FILE__),
135
+ )
132
136
  zip_file.add(
133
137
  ".elastic_beans/exec/run_command.sh",
134
138
  File.expand_path('../exec/run_command.sh', __FILE__),
@@ -20,7 +20,6 @@ class ElasticBeans::CLI < Thor
20
20
  option :image_id, aliases: %w(-i), desc: "A custom AMI to use instead of the default Ruby Elastic Beanstalk AMI"
21
21
  option :instance_type, aliases: %w(-t), desc: "A default instance type to use for all environments instead of c4.large"
22
22
  option :keypair, aliases: %w(-k), desc: "Required on first run. The EC2 keypair to use for Elastic Beanstalk instances"
23
- option :logging_endpoint, aliases: %w(-l), desc: "An HTTP endpoint that can receive logs from one-off commands"
24
23
  option :public_key, aliases: %w(-p), desc: "For end-to-end encryption. The public key of the SSL certificate the ELB will verify to communicate with your Rails app"
25
24
  option :secret_key_base, aliases: %w(-b), desc: "The SECRET_KEY_BASE for the Rails application"
26
25
  option :ssl_certificate_arn, aliases: %w(-s), desc: "The ARN of the SSL server certificate stored in IAM to attach to the ELB"
@@ -31,7 +30,6 @@ class ElasticBeans::CLI < Thor
31
30
  image_id: options[:image_id],
32
31
  instance_type: options[:instance_type],
33
32
  keypair: options[:keypair],
34
- logging_endpoint: options[:logging_endpoint],
35
33
  public_key: options[:public_key],
36
34
  secret_key_base: options[:secret_key_base],
37
35
  ssl_certificate_arn: options[:ssl_certificate_arn],
@@ -94,13 +92,27 @@ class ElasticBeans::CLI < Thor
94
92
  application: application(
95
93
  name: options[:application],
96
94
  ),
97
- sqs: sqs_client,
98
95
  ui: ui,
99
96
  ).run(*command_parts)
100
97
  rescue StandardError => e
101
98
  error(e)
102
99
  end
103
100
 
101
+ desc ElasticBeans::Command::Ps::USAGE, ElasticBeans::Command::Ps::DESC
102
+ long_desc ElasticBeans::Command::Ps::LONG_DESC
103
+ option :application, aliases: %w(-a), required: true, desc: APPLICATION_DESC
104
+ def ps
105
+ @verbose = options[:verbose]
106
+ ElasticBeans::Command::Ps.new(
107
+ application: application(
108
+ name: options[:application],
109
+ ),
110
+ ui: ui,
111
+ ).run
112
+ rescue StandardError => e
113
+ error(e)
114
+ end
115
+
104
116
  desc ElasticBeans::Command::Scale::USAGE, ElasticBeans::Command::Scale::DESC
105
117
  long_desc ElasticBeans::Command::Scale::LONG_DESC
106
118
  option :application, aliases: %w(-a), required: true, desc: APPLICATION_DESC
@@ -187,13 +199,15 @@ class ElasticBeans::CLI < Thor
187
199
  name:,
188
200
  cloudformation: cloudformation_client,
189
201
  elastic_beanstalk: elastic_beanstalk_client,
190
- s3: s3_client
202
+ s3: s3_client,
203
+ sqs: sqs_client
191
204
  )
192
205
  @application ||= ElasticBeans::Application.new(
193
206
  name: name,
194
207
  cloudformation: cloudformation,
195
208
  elastic_beanstalk: elastic_beanstalk,
196
209
  s3: s3,
210
+ sqs: sqs,
197
211
  )
198
212
  end
199
213
 
@@ -2,6 +2,7 @@ require "elastic_beans/command/configure"
2
2
  require "elastic_beans/command/create"
3
3
  require "elastic_beans/command/deploy"
4
4
  require "elastic_beans/command/exec"
5
+ require "elastic_beans/command/ps"
5
6
  require "elastic_beans/command/scale"
6
7
  require "elastic_beans/command/get_env"
7
8
  require "elastic_beans/command/set_env"
@@ -20,7 +20,6 @@ Requires AWS credentials to be set in the environment, i.e. AWS_ACCESS_KEY_ID an
20
20
  image_id:,
21
21
  instance_type:,
22
22
  keypair:,
23
- logging_endpoint:,
24
23
  public_key:,
25
24
  secret_key_base:,
26
25
  ssl_certificate_arn:,
@@ -34,7 +33,6 @@ Requires AWS credentials to be set in the environment, i.e. AWS_ACCESS_KEY_ID an
34
33
  @image_id = image_id
35
34
  @instance_type = instance_type
36
35
  @keypair = keypair
37
- @logging_endpoint = logging_endpoint
38
36
  @public_key = public_key
39
37
  @secret_key_base = secret_key_base
40
38
  @ssl_certificate_arn = ssl_certificate_arn
@@ -111,7 +109,6 @@ Requires AWS credentials to be set in the environment, i.e. AWS_ACCESS_KEY_ID an
111
109
  instance_type: instance_type,
112
110
  keypair: keypair,
113
111
  iam: iam,
114
- logging_endpoint: logging_endpoint,
115
112
  )
116
113
  exec_environment = exec_config.environment
117
114
  if exec_environment
@@ -182,7 +179,6 @@ Requires AWS credentials to be set in the environment, i.e. AWS_ACCESS_KEY_ID an
182
179
  :image_id,
183
180
  :instance_type,
184
181
  :keypair,
185
- :logging_endpoint,
186
182
  :network,
187
183
  :public_key,
188
184
  :secret_key_base,
@@ -10,28 +10,28 @@ module ElasticBeans
10
10
  Run an arbitrary command in the context of your application.
11
11
  The command is run in an "exec" environment, separate from your webserver or worker environments.
12
12
  You must create the exec environment prior to this command being run: `beans create exec -a APPLICATION`.
13
- Upload output from the command to an HTTP endpoint.
13
+ Output from the command is appended to /var/log/elastic_beans/exec/command.log.
14
14
 
15
15
  Requires AWS credentials to be set in the environment, i.e. AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
16
16
  LONG_DESC
17
17
 
18
- def initialize(application:, sqs:, ui:)
18
+ def initialize(application:, ui:)
19
19
  @application = application
20
- @sqs = sqs
21
20
  @ui = ui
22
21
  end
23
22
 
24
23
  def run(*command_parts)
25
24
  ui.info("Running `#{command_parts.join(" ")}' on #{application.name}...")
26
- application.enqueue_command(command(command_parts), sqs: sqs)
25
+ application.enqueue_command(command(command_parts))
27
26
  end
28
27
 
29
28
  private
30
29
 
31
- attr_reader :application, :sqs, :ui
30
+ attr_reader :application, :ui
32
31
 
33
32
  def command(command_parts)
34
- command_parts.map { |word| Shellwords.escape(word) }.join(" ")
33
+ command_string = command_parts.map { |word| Shellwords.escape(word) }.join(" ")
34
+ ::ElasticBeans::Exec::Command.new(command_string: command_string)
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,64 @@
1
+ module ElasticBeans
2
+ module Command
3
+ # :nodoc: all
4
+ class Ps
5
+ USAGE = "ps"
6
+ DESC = "List scheduled and running one-off commands run using `beans exec`"
7
+ LONG_DESC = <<-LONG_DESC
8
+ List scheduled and running one-off commands run using `beans exec`.
9
+ See `beans help exec` for more information about running one-off commands.
10
+
11
+ Requires AWS credentials to be set in the environment, i.e. AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
12
+ LONG_DESC
13
+
14
+ COLUMNS = {
15
+ "INSTANCE" => ->(command) { command.instance_id || SCHEDULED_INSTANCE },
16
+ "RUN TIME" => ->(command) {
17
+ if command.start_time
18
+ seconds = Time.now - command.start_time
19
+ hours, seconds = seconds / 3600, seconds % 3600
20
+ minutes, seconds = seconds / 60, seconds % 60
21
+ sprintf("%02d:%02d:%02d", hours, minutes, seconds)
22
+ else
23
+ SCHEDULED_RUN_TIME
24
+ end
25
+ },
26
+ "COMMAND" => ->(command) { command.command_string },
27
+ }
28
+ SCHEDULED_INSTANCE = "scheduled"
29
+ SCHEDULED_RUN_TIME = "00:00:00"
30
+
31
+ def initialize(application:, ui:)
32
+ @application = application
33
+ @ui = ui
34
+ end
35
+
36
+ def run
37
+ enqueued_commands = []
38
+ running_commands = []
39
+ ui.debug { "Fetching enqueued and running commands from #{application.name}..." }
40
+ threads = [
41
+ Thread.new do
42
+ enqueued_commands = application.enqueued_commands
43
+ end,
44
+ Thread.new do
45
+ running_commands = application.running_commands
46
+ end,
47
+ ]
48
+ threads.each(&:join)
49
+
50
+ running_commands.sort_by!(&:start_time)
51
+ commands = running_commands + enqueued_commands
52
+ if commands.any?
53
+ ui.table(level: :info, columns: COLUMNS, rows: running_commands + enqueued_commands)
54
+ else
55
+ ui.info("No commands enqueued or running")
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :application, :ui
62
+ end
63
+ end
64
+ end
@@ -1,10 +1,9 @@
1
- require "uri"
2
1
  require "elastic_beans/error"
3
2
 
4
3
  module ElasticBeans
5
4
  class ConfigurationTemplate
6
5
  # The "exec" configuration template stored in the Elastic Beanstalk application.
7
- # Settings for the exec environment are stored here, such as the exec queue URL and the logging endpoint.
6
+ # Settings for the exec environment are stored here, such as the exec queue URL.
8
7
  class Exec < ElasticBeans::ConfigurationTemplate::Base
9
8
  def initialize(**args)
10
9
  super(name: "exec", **args)
@@ -13,42 +12,13 @@ module ElasticBeans
13
12
  protected
14
13
 
15
14
  # Constructs the configuration for the exec environment.
16
- # +logging_endpoint+, if provided, must be a valid HTTPS URL.
17
- def build_option_settings(logging_endpoint: nil, **_)
18
- if logging_endpoint
19
- begin
20
- if URI(logging_endpoint).scheme != "https"
21
- raise InvalidLoggingEndpointError.new(logging_endpoint: logging_endpoint)
22
- end
23
- rescue URI::InvalidURIError
24
- raise InvalidLoggingEndpointError.new(logging_endpoint: logging_endpoint)
25
- end
26
-
27
- logging_endpoint_setting = template_option_setting(namespace: "aws:elasticbeanstalk:application:environment", option_name: "ELASTIC_BEANS_EXEC_LOGGING_ENDPOINT", override: logging_endpoint)
28
- end
29
-
30
- settings = [
15
+ def build_option_settings(**_)
16
+ super + [
31
17
  template_option_setting(namespace: "aws:elasticbeanstalk:application", option_name: "Application Healthcheck URL", default: "HTTP:80/"),
32
18
  template_option_setting(namespace: "aws:elasticbeanstalk:application:environment", option_name: "DISABLE_SQS_CONSUMER", override: "false"),
33
19
  template_option_setting(namespace: "aws:elasticbeanstalk:application:environment", option_name: "ELASTIC_BEANS_EXEC_QUEUE_URL", override: application.exec_queue_url),
34
20
  template_option_setting(namespace: "aws:elasticbeanstalk:application:environment", option_name: "RAILS_SKIP_MIGRATIONS", default: "true"),
35
21
  ]
36
- if logging_endpoint
37
- settings << logging_endpoint_setting
38
- end
39
- super + settings
40
- end
41
-
42
- # :nodoc: all
43
- # @!visibility private
44
- class InvalidLoggingEndpointError < ElasticBeans::Error
45
- def initialize(logging_endpoint:)
46
- @logging_endpoint = logging_endpoint
47
- end
48
-
49
- def message
50
- "Logging endpoint `#{@logging_endpoint}' must be a valid HTTPS URL."
51
- end
52
22
  end
53
23
  end
54
24
  end
@@ -0,0 +1,6 @@
1
+ module ElasticBeans
2
+ module Exec
3
+ end
4
+ end
5
+
6
+ require "elastic_beans/exec/command"
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "time"
3
+ require "securerandom"
4
+
5
+ module ElasticBeans
6
+ module Exec
7
+ # A command enqueued or running on an exec instance in this Application, from +beans exec+.
8
+ class Command
9
+ # A unique ID to identify this command later on.
10
+ attr_reader :id
11
+
12
+ # The command string that is executed; the string passed to +beans exec+
13
+ attr_reader :command_string
14
+
15
+ # The instance ID this command is being executed on.
16
+ attr_reader :instance_id
17
+
18
+ # The time this command started execution.
19
+ attr_reader :start_time
20
+
21
+ # Command metadata, such as where to store execution data in S3.
22
+ attr_reader :metadata
23
+
24
+ def initialize(command_string:, id: nil, instance_id: nil, start_time: nil, metadata: nil)
25
+ @id = id || SecureRandom.uuid
26
+ @command_string = command_string
27
+ @instance_id = instance_id
28
+ @start_time = start_time
29
+ @metadata = metadata || {}
30
+ end
31
+
32
+ def ==(other)
33
+ id == other.id
34
+ end
35
+
36
+ def to_json
37
+ attributes = {}
38
+ attributes[:id] = id
39
+ attributes[:command] = command_string
40
+ attributes[:instance_id] = instance_id if instance_id
41
+ attributes[:start_time] = start_time.iso8601 if start_time
42
+ attributes[:metadata] = metadata
43
+ attributes.to_json
44
+ end
45
+
46
+ def self.from_json(json)
47
+ attributes = JSON.parse(json)
48
+ new(
49
+ id: attributes["id"],
50
+ command_string: attributes["command"],
51
+ instance_id: attributes["instance_id"],
52
+ start_time: attributes["start_time"] ? Time.iso8601(attributes["start_time"]) : nil,
53
+ metadata: attributes["metadata"],
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
@@ -3,8 +3,12 @@ container_commands:
3
3
  command: "mkdir -vp /opt/elastic_beans && cp -vR /var/app/ondeck/.elastic_beans/exec /opt/elastic_beans/"
4
4
  01_permissions:
5
5
  command: "chmod 755 /opt/elastic_beans/exec/run_command.sh"
6
+ 09_logrotate:
7
+ command: "cp -v /opt/elastic_beans/exec/logrotate /etc/logrotate.d/elastic_beans_exec"
8
+ test: "/opt/elasticbeanstalk/bin/get-config meta -k sqsdconfig --output YAML | grep -q '^environment_name: .*-exec$'"
6
9
  09_upstart:
7
10
  command: "cp -v /opt/elastic_beans/exec/elastic_beans_exec.conf /etc/init/"
11
+ test: "/opt/elasticbeanstalk/bin/get-config meta -k sqsdconfig --output YAML | grep -q '^environment_name: .*-exec$'"
8
12
  10_start_elastic_beans_exec:
9
13
  command: stop elastic_beans_exec; start elastic_beans_exec
10
14
  test: "/opt/elasticbeanstalk/bin/get-config meta -k sqsdconfig --output YAML | grep -q '^environment_name: .*-exec$'"
@@ -6,10 +6,11 @@ require "uri"
6
6
  # :nodoc: all
7
7
  # @!visibility private
8
8
  class Init
9
- LOGFILE = "/var/log/elastic_beans_exec.log"
9
+ LOGDIR = "/var/log/elastic_beans/exec"
10
10
  PIDFILE = "/var/run/elastic_beans_exec.pid"
11
11
  AZ_PATTERN = /\A(?<region>.+-\d+)\w+\z/
12
12
  AZ_URI = URI("http://169.254.169.254/latest/meta-data/placement/availability-zone")
13
+ INSTANCE_ID_URI = URI("http://169.254.169.254/latest/meta-data/instance-id")
13
14
 
14
15
  def self.run(command)
15
16
  init = new
@@ -20,17 +21,18 @@ class Init
20
21
  end
21
22
 
22
23
  def start
23
- logging_endpoint = ENV['ELASTIC_BEANS_EXEC_LOGGING_ENDPOINT']
24
24
  queue_url = ENV['ELASTIC_BEANS_EXEC_QUEUE_URL']
25
25
  if queue_url.nil?
26
26
  raise "ELASTIC_BEANS_EXEC_QUEUE_URL not set; please re-run `beans configure`."
27
27
  end
28
+ s3_client = ::Aws::S3::Client.new(region: region)
28
29
  sqs_client = ::Aws::SQS::Client.new(region: region)
29
30
  require File.expand_path("../sqs_consumer", __FILE__)
30
31
  consumer = SQSConsumer.new(
31
- logfile: LOGFILE,
32
- logging_endpoint: logging_endpoint,
32
+ instance_id: instance_id,
33
+ logdir: LOGDIR,
33
34
  queue_url: queue_url,
35
+ s3: s3_client,
34
36
  sqs: sqs_client,
35
37
  )
36
38
  consumer.run
@@ -38,6 +40,10 @@ class Init
38
40
 
39
41
  private
40
42
 
43
+ def instance_id
44
+ @instance_id ||= Net::HTTP.get(INSTANCE_ID_URI)
45
+ end
46
+
41
47
  def region
42
48
  return @region if @region
43
49
  az = Net::HTTP.get(AZ_URI)
@@ -0,0 +1,8 @@
1
+ /var/log/elastic_beans/exec/*.log {
2
+ daily
3
+ rotate 10
4
+ missingok
5
+ notifempty
6
+ compress
7
+ copytruncate
8
+ }
@@ -1,77 +1,137 @@
1
+ require "fileutils"
1
2
  require "json"
2
3
  require "net/http"
3
- require "uri"
4
+ require "time"
4
5
 
5
6
  # :nodoc: all
6
7
  # @!visibility private
7
8
  class SQSConsumer
8
- def initialize(logfile:, queue_url:, sqs:, logging_endpoint: nil)
9
- @logfile = logfile
9
+ def initialize(instance_id:, logdir:, queue_url:, s3:, sqs:)
10
+ @instance_id = instance_id
11
+ @logdir = logdir
12
+ @command_logfile = File.join(logdir, "command.log")
13
+ @consumer_logfile = File.join(logdir, "sqs_consumer.log")
10
14
  @queue_url = queue_url
15
+ @s3 = s3
11
16
  @sqs = sqs
12
- @logging_uri = URI(logging_endpoint) if logging_endpoint
13
17
  @command_script = File.expand_path('../run_command.sh', __FILE__)
14
18
  end
15
19
 
16
20
  def run
17
21
  trap("TERM", &method(:stop))
22
+ FileUtils.mkdir_p(logdir)
23
+ log("Starting message receive loop")
18
24
  loop do
19
- sleep 1
20
- response = sqs.receive_message(
21
- queue_url: queue_url,
22
- max_number_of_messages: 1,
23
- )
24
- message = response.messages[0]
25
- if message
26
- command = JSON.parse(message.body)
25
+ begin
26
+ sleep 1
27
+ if (message = receive_message)
28
+ command = command_from_message(message)
29
+ log_command("Executing command ID=#{command['id']} `#{command['command']}' on host #{`hostname`.chomp} pid #{command_pid}...")
30
+ start_time = Time.now
31
+ @command_pid = Process.spawn(
32
+ "#{command_script} #{command['command']}",
33
+ out: [command_logfile, "a"],
34
+ err: [command_logfile, "a"],
35
+ )
36
+ delete_message(message)
37
+ update_metadata(command: command, start_time: start_time)
38
+ _, status = Process.wait2(command_pid)
39
+ log_command("Command ID=#{command['id']} `#{command['command']}' exited on host #{`hostname`.chomp}: #{status}")
27
40
 
28
- @command_pid = Process.spawn("#{command_script} #{command['command']}", out: logfile, err: logfile)
29
- sqs.delete_message(
30
- queue_url: queue_url,
31
- receipt_handle: message.receipt_handle,
32
- )
33
- upload_log("Executing command `#{command['command']}' on host #{`hostname`.chomp} pid #{command_pid}...")
34
- _, status = Process.wait2(command_pid)
35
- File.open(logfile, "a") do |file|
36
- file.puts "Command `#{command['command']}' exited on host #{`hostname`.chomp}: #{status}"
41
+ @command_pid = nil
42
+ remove_metadata(command: command)
37
43
  end
38
-
39
- @command_pid = nil
40
- upload_logfile
44
+ rescue StandardError => e
45
+ log("[loop] #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}")
46
+ next
41
47
  end
42
48
  end
43
49
  end
44
50
 
45
51
  private
46
52
 
47
- attr_reader :command_pid, :command_script, :logfile, :logging_uri, :queue_url, :sqs
53
+ attr_reader :consumer_logfile, :command_logfile, :command_script, :instance_id, :logdir, :queue_url, :s3, :sqs
54
+ # Transient attributes
55
+ attr_reader :command_pid
48
56
 
49
57
  def stop(signal = nil)
58
+ log("Terminating message receiving process")
50
59
  signal ||= "TERM"
51
60
  Process.kill(signal, command_pid) if command_pid
52
61
  exit
53
62
  rescue Errno::ESRCH
54
63
  end
55
64
 
56
- def upload_log(logs)
57
- if logging_uri
58
- Net::HTTP.start(logging_uri.host, logging_uri.port, use_ssl: true) do |http|
59
- request = Net::HTTP::Post.new(logging_uri)
60
- request.body = logs
61
- request.content_type = "text/plain"
62
- http.request(request)
63
- end
65
+ def command_from_message(message)
66
+ JSON.parse(message.body)
67
+ rescue JSON::ParserError => e
68
+ log("[command_from_message] #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}")
69
+ raise e
70
+ end
71
+
72
+ def log(message)
73
+ File.open(consumer_logfile, "a") do |file|
74
+ file.puts("[#{Time.now.iso8601}] [elastic_beans/exec] #{message}")
64
75
  end
76
+ rescue StandardError => e
77
+ $stdout.puts message
78
+ $stderr.puts "#{e.class.name}: #{e.message}"
79
+ $stderr.puts e.backtrace.join("\n")
65
80
  end
66
81
 
67
- def upload_logfile
68
- if logging_uri
69
- Net::HTTP.start(logging_uri.host, logging_uri.port, use_ssl: true) do |http|
70
- request = Net::HTTP::Post.new(logging_uri, "Transfer-Encoding" => "chunked")
71
- request.body_stream = File.open(logfile, "rb")
72
- request.content_type = "text/plain"
73
- http.request(request)
74
- end
82
+ def log_command(message)
83
+ File.open(command_logfile, "a") do |file|
84
+ file.puts("[#{Time.now.iso8601}] [elastic_beans/exec] #{message}")
75
85
  end
86
+ rescue StandardError => e
87
+ $stdout.puts message
88
+ $stderr.puts "#{e.class.name}: #{e.message}"
89
+ $stderr.puts e.backtrace.join("\n")
90
+ end
91
+
92
+ def receive_message
93
+ sqs.receive_message(
94
+ queue_url: queue_url,
95
+ max_number_of_messages: 1,
96
+ ).messages[0]
97
+ rescue StandardError => e
98
+ log("[receive_message] #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}")
76
99
  end
100
+
101
+ def delete_message(message)
102
+ sqs.delete_message(
103
+ queue_url: queue_url,
104
+ receipt_handle: message.receipt_handle,
105
+ )
106
+ rescue StandardError => e
107
+ log("[delete_message] #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}")
108
+ end
109
+
110
+ def update_metadata(command:, start_time:)
111
+ metadata = command['metadata']
112
+ if metadata && metadata['bucket'] && metadata['key']
113
+ s3.put_object(
114
+ bucket: metadata['bucket'],
115
+ key: metadata['key'],
116
+ body: {
117
+ instance_id: instance_id,
118
+ start_time: start_time.iso8601,
119
+ }.merge(command).to_json,
120
+ )
121
+ end
122
+ rescue StandardError => e
123
+ log("[update_metadata] #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}")
124
+ end
125
+
126
+ def remove_metadata(command:)
127
+ metadata = command['metadata']
128
+ if metadata && metadata['bucket'] && metadata['key']
129
+ s3.delete_object(
130
+ bucket: metadata['bucket'],
131
+ key: metadata['key'],
132
+ )
77
133
  end
134
+ rescue StandardError => e
135
+ log("[remove_metadata] #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}")
136
+ end
137
+ end
@@ -17,7 +17,6 @@ module ElasticBeans
17
17
  def initialize(app, args={})
18
18
  @app = app
19
19
  @application = args.fetch(:application)
20
- @sqs = args.fetch(:sqs)
21
20
  @logger = args[:logger] || Logger.new("/dev/null")
22
21
  end
23
22
 
@@ -27,7 +26,7 @@ module ElasticBeans
27
26
  command = command_from_request(request)
28
27
  logger.info("[elastic_beans] Executing command: `#{command}'")
29
28
  begin
30
- application.enqueue_command(command, sqs: sqs)
29
+ application.enqueue_command(command)
31
30
  rescue => e
32
31
  logger.info("[elastic_beans] Enqueue failed: #{e.class.name}: #{e.message}\n#{e.backtrace.join("; ")}")
33
32
  return FAILURE
@@ -42,7 +41,7 @@ module ElasticBeans
42
41
 
43
42
  private
44
43
 
45
- attr_reader :app, :application, :logger, :sqs
44
+ attr_reader :app, :application, :logger
46
45
 
47
46
  def aws_sqsd?(request)
48
47
  current_user_agent = request.headers['User-Agent'.freeze]
@@ -52,7 +51,8 @@ module ElasticBeans
52
51
  end
53
52
 
54
53
  def command_from_request(request)
55
- URI.unescape(request.path.sub(%r{\A/exec/}, ''))
54
+ command_string = URI.unescape(request.path.sub(%r{\A/exec/}, ''))
55
+ ::ElasticBeans::Exec::Command.new(command_string: command_string)
56
56
  end
57
57
 
58
58
  def cron_request?(request)
@@ -1,3 +1,5 @@
1
+ require 'tty-table'
2
+
1
3
  module ElasticBeans
2
4
  # :nodoc: all
3
5
  # @!visibility private
@@ -24,6 +26,34 @@ module ElasticBeans
24
26
  stdout.puts message
25
27
  end
26
28
 
29
+ def table(level:, columns:, rows:)
30
+ raise "ElasticBeans::UI#table: No such level '#{level}'" unless %w(debug error info).include?(level.to_s)
31
+ raise "ElasticBeans::UI#table: Missing 'columns' argument" if columns.empty?
32
+
33
+ msg = Proc.new do
34
+ # Produce an array for each row, containing strings for each column
35
+ if rows.any?
36
+ table_rows = rows.map { |item|
37
+ columns.map { |_, item_proc|
38
+ item_proc.call(item)
39
+ }
40
+ }
41
+ else
42
+ table_rows = [columns.map { "" }]
43
+ end
44
+ TTY::Table.new(
45
+ header: columns.keys,
46
+ rows: table_rows,
47
+ ).render(:basic)
48
+ end
49
+
50
+ if level.to_s == "debug"
51
+ debug(&msg)
52
+ else
53
+ send(level, msg.call)
54
+ end
55
+ end
56
+
27
57
  private
28
58
 
29
59
  def verbose?
@@ -1,3 +1,3 @@
1
1
  module ElasticBeans
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: elastic_beans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Stegman
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-01-30 00:00:00.000000000 Z
11
+ date: 2017-02-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: 0.19.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: tty-table
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 0.7.0
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 0.7.0
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: bundler
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -168,6 +182,7 @@ files:
168
182
  - lib/elastic_beans/command/deploy.rb
169
183
  - lib/elastic_beans/command/exec.rb
170
184
  - lib/elastic_beans/command/get_env.rb
185
+ - lib/elastic_beans/command/ps.rb
171
186
  - lib/elastic_beans/command/scale.rb
172
187
  - lib/elastic_beans/command/set_env.rb
173
188
  - lib/elastic_beans/command/talk.rb
@@ -189,9 +204,12 @@ files:
189
204
  - lib/elastic_beans/environment/worker.rb
190
205
  - lib/elastic_beans/error.rb
191
206
  - lib/elastic_beans/error/environments_not_ready.rb
207
+ - lib/elastic_beans/exec.rb
208
+ - lib/elastic_beans/exec/command.rb
192
209
  - lib/elastic_beans/exec/ebextension.yml
193
210
  - lib/elastic_beans/exec/elastic_beans_exec.conf
194
211
  - lib/elastic_beans/exec/init.rb
212
+ - lib/elastic_beans/exec/logrotate
195
213
  - lib/elastic_beans/exec/run_command.sh
196
214
  - lib/elastic_beans/exec/sqs_consumer.rb
197
215
  - lib/elastic_beans/network.rb
@@ -218,7 +236,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
218
236
  version: '0'
219
237
  requirements: []
220
238
  rubyforge_project:
221
- rubygems_version: 2.5.1
239
+ rubygems_version: 2.5.2
222
240
  signing_key:
223
241
  specification_version: 4
224
242
  summary: Elastic Beanstalk environment orchestration for a Rails app