broadside 2.0.0 → 3.0.0.pre.prerelease

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.
@@ -1,12 +1,9 @@
1
- require 'active_support/core_ext/hash'
2
- require 'active_support/core_ext/array'
3
-
4
1
  module Broadside
5
2
  class EcsManager
6
3
  DEFAULT_DESIRED_COUNT = 0
7
4
 
8
5
  class << self
9
- include Utils
6
+ include LoggingUtils
10
7
 
11
8
  def ecs
12
9
  @ecs_client ||= Aws::ECS::Client.new(
@@ -15,14 +12,14 @@ module Broadside
15
12
  )
16
13
  end
17
14
 
18
- def create_service(cluster, name, options = {})
15
+ def create_service(cluster, name, service_config = {})
19
16
  ecs.create_service(
20
17
  {
21
18
  cluster: cluster,
22
19
  desired_count: DEFAULT_DESIRED_COUNT,
23
20
  service_name: name,
24
21
  task_definition: name
25
- }.deep_merge(options)
22
+ }.deep_merge(service_config)
26
23
  )
27
24
  end
28
25
 
@@ -35,42 +32,61 @@ module Broadside
35
32
  end
36
33
 
37
34
  def get_latest_task_definition(name)
38
- return nil unless get_latest_task_definition_arn(name)
39
- ecs.describe_task_definition(task_definition: get_latest_task_definition_arn(name)).task_definition.to_h
35
+ return nil unless (arn = get_latest_task_definition_arn(name))
36
+ ecs.describe_task_definition(task_definition: arn).task_definition.to_h
40
37
  end
41
38
 
42
39
  def get_latest_task_definition_arn(name)
43
40
  get_task_definition_arns(name).last
44
41
  end
45
42
 
43
+ def get_running_instance_ips!(cluster, family, task_arns = nil)
44
+ ips = get_running_instance_ips(cluster, family, task_arns)
45
+ raise Error, "No running tasks found for '#{family}' on cluster '#{cluster}'!" if ips.empty?
46
+ ips
47
+ end
48
+
46
49
  def get_running_instance_ips(cluster, family, task_arns = nil)
47
50
  task_arns = task_arns ? Array.wrap(task_arns) : get_task_arns(cluster, family)
48
- exception "No running tasks found for '#{family}' on cluster '#{cluster}'!" if task_arns.empty?
51
+ return [] if task_arns.empty?
49
52
 
50
53
  tasks = ecs.describe_tasks(cluster: cluster, tasks: task_arns).tasks
51
54
  container_instances = ecs.describe_container_instances(
52
55
  cluster: cluster,
53
- container_instances: tasks.map(&:container_instance_arn),
56
+ container_instances: tasks.map(&:container_instance_arn)
54
57
  ).container_instances
55
- ec2_instance_ids = container_instances.map(&:ec2_instance_id)
56
58
 
59
+ ec2_instance_ids = container_instances.map(&:ec2_instance_id)
57
60
  reservations = ec2_client.describe_instances(instance_ids: ec2_instance_ids).reservations
58
- instances = reservations.map(&:instances).flatten
59
- instances.map(&:private_ip_address)
61
+
62
+ reservations.map(&:instances).flatten.map(&:private_ip_address)
60
63
  end
61
64
 
62
- def get_task_arns(cluster, family)
63
- all_results(:list_tasks, :task_arns, { cluster: cluster, family: family })
65
+ def get_task_arns(cluster, family, filter = {})
66
+ options = {
67
+ cluster: cluster,
68
+ # Strange AWS restriction requires absence of family if service_name specified
69
+ family: filter[:service_name] ? nil : family,
70
+ desired_status: filter[:desired_status],
71
+ service_name: filter[:service_name],
72
+ started_by: filter[:started_by]
73
+ }.reject { |_, v| v.nil? }
74
+
75
+ all_results(:list_tasks, :task_arns, options)
64
76
  end
65
77
 
66
78
  def get_task_definition_arns(family)
67
79
  all_results(:list_task_definitions, :task_definition_arns, { family_prefix: family })
68
80
  end
69
81
 
70
- def get_task_exit_code(cluster, task_arn, name)
71
- task = ecs.describe_tasks({ cluster: cluster, tasks: [task_arn] }).tasks.first
82
+ def get_task_exit_status(cluster, task_arn, name)
83
+ task = ecs.describe_tasks(cluster: cluster, tasks: [task_arn]).tasks.first
72
84
  container = task.containers.select { |c| c.name == name }.first
73
- container.exit_code
85
+
86
+ {
87
+ exit_code: container.exit_code,
88
+ reason: container.reason
89
+ }
74
90
  end
75
91
 
76
92
  def list_task_definition_families
@@ -81,10 +97,10 @@ module Broadside
81
97
  all_results(:list_services, :service_arns, { cluster: cluster })
82
98
  end
83
99
 
84
- def run_task(cluster, name, command)
85
- fail ArgumentError, "#{command} must be an array" unless command.is_a?(Array)
100
+ def run_task(cluster, name, command, options = {})
101
+ raise ArgumentError, "command: '#{command}' must be an array" unless command.is_a?(Array)
86
102
 
87
- ecs.run_task(
103
+ response = ecs.run_task(
88
104
  cluster: cluster,
89
105
  task_definition: get_latest_task_definition_arn(name),
90
106
  overrides: {
@@ -96,8 +112,14 @@ module Broadside
96
112
  ]
97
113
  },
98
114
  count: 1,
99
- started_by: "before_deploy:#{command.join(' ')}"[0...36]
115
+ started_by: ((options[:started_by] ? "#{options[:started_by]}:" : '') + command.join(' '))[0...36]
100
116
  )
117
+
118
+ unless response.successful? && response.tasks.try(:[], 0)
119
+ raise EcsError, "Failed to run task '#{command.join(' ')}'\n#{response.pretty_inspect}"
120
+ end
121
+
122
+ response
101
123
  end
102
124
 
103
125
  def service_exists?(cluster, family)
@@ -105,6 +127,28 @@ module Broadside
105
127
  services.failures.empty? && services.services.any?
106
128
  end
107
129
 
130
+ def check_service_and_task_definition_state!(target)
131
+ check_task_definition_state!(target)
132
+ check_service_state!(target)
133
+ end
134
+
135
+ def check_task_definition_state!(target)
136
+ unless get_latest_task_definition_arn(target.family)
137
+ raise Error, "No task definition for '#{target.family}'! Please bootstrap or manually configure one."
138
+ end
139
+ end
140
+
141
+ def check_service_state!(target)
142
+ unless service_exists?(target.cluster, target.family)
143
+ raise Error, "No service for '#{target.family}'! Please bootstrap or manually configure one."
144
+ end
145
+ end
146
+
147
+ def current_service_scale(target)
148
+ check_service_state!(target)
149
+ EcsManager.ecs.describe_services(cluster: target.cluster, services: [target.family]).services.first[:desired_count]
150
+ end
151
+
108
152
  private
109
153
 
110
154
  def all_results(method, key, args = {})
@@ -121,8 +165,8 @@ module Broadside
121
165
 
122
166
  def ec2_client
123
167
  @ec2_client ||= Aws::EC2::Client.new(
124
- region: config.aws.region,
125
- credentials: config.aws.credentials
168
+ region: Broadside.config.aws.region,
169
+ credentials: Broadside.config.aws.credentials
126
170
  )
127
171
  end
128
172
  end
@@ -1,10 +1,5 @@
1
1
  module Broadside
2
- class MissingVariableError < StandardError
3
- end
4
-
5
- class Error < StandardError
6
- def initialize(msg = 'Broadside encountered an error !')
7
- super
8
- end
9
- end
2
+ class ConfigurationError < ArgumentError; end
3
+ class EcsError < StandardError; end
4
+ class Error < StandardError; end
10
5
  end
@@ -0,0 +1,147 @@
1
+ def add_tag_flag(cmd)
2
+ cmd.desc 'Docker tag for application container'
3
+ cmd.arg_name 'TAG'
4
+ cmd.flag [:tag]
5
+ end
6
+
7
+ def add_target_flag(cmd)
8
+ cmd.desc 'Deployment target to use, e.g. production_web'
9
+ cmd.arg_name 'TARGET'
10
+ cmd.flag [:t, :target], type: Symbol, required: true
11
+ end
12
+
13
+ def add_instance_flag(cmd)
14
+ cmd.desc '0-based index into the array of running instances'
15
+ cmd.default_value 0
16
+ cmd.arg_name 'INSTANCE'
17
+ cmd.flag [:n, :instance], type: Fixnum
18
+ end
19
+
20
+ def add_command_flags(cmd)
21
+ add_instance_flag(cmd)
22
+ add_target_flag(cmd)
23
+ end
24
+
25
+ desc 'Bootstrap your service and task definition from the configured definition.'
26
+ command :bootstrap do |bootstrap|
27
+ add_tag_flag(bootstrap)
28
+ add_target_flag(bootstrap)
29
+
30
+ bootstrap.action do |_, options, _|
31
+ Broadside::EcsDeploy.new(options).bootstrap
32
+ end
33
+ end
34
+
35
+ desc 'Gives an overview of all of the deploy targets'
36
+ command :targets do |targets|
37
+ targets.action do |_, options, _|
38
+ Broadside::Command.targets
39
+ end
40
+ end
41
+
42
+ desc 'Gets information about what is currently deployed.'
43
+ command :status do |status|
44
+ status.desc 'Additionally displays service and task information'
45
+ status.switch :verbose, negatable: false
46
+
47
+ add_target_flag(status)
48
+
49
+ status.action do |_, options, _|
50
+ Broadside::Command.status(options)
51
+ end
52
+ end
53
+
54
+ desc 'Creates a single instance of the application to run a command.'
55
+ command :run do |run|
56
+ run.desc 'Broadside::Command to run (wrap argument in quotes)'
57
+ run.arg_name 'COMMAND'
58
+ run.flag [:command], type: Array
59
+
60
+ add_tag_flag(run)
61
+ add_command_flags(run)
62
+
63
+ run.action do |_, options, _|
64
+ EcsDeploy.new(options).run_commands([options[:command]], started_by: 'run')
65
+ end
66
+ end
67
+
68
+ desc 'Tail the logs inside a running container.'
69
+ command :logtail do |logtail|
70
+ logtail.desc 'Number of lines to tail'
71
+ logtail.default_value Broadside::Command::DEFAULT_TAIL_LINES
72
+ logtail.arg_name 'TAIL_LINES'
73
+ logtail.flag [:l, :lines], type: Fixnum
74
+
75
+ add_command_flags(logtail)
76
+
77
+ logtail.action do |_, options, _|
78
+ Broadside::Command.logtail(options)
79
+ end
80
+ end
81
+
82
+ desc 'Establish a secure shell on an instance running the container.'
83
+ command :ssh do |ssh|
84
+ add_command_flags(ssh)
85
+
86
+ ssh.action do |_, options, _|
87
+ Broadside::Command.ssh(options)
88
+ end
89
+ end
90
+
91
+ desc 'Establish a shell inside a running container.'
92
+ command :bash do |bash|
93
+ add_command_flags(bash)
94
+
95
+ bash.action do |_, options, _|
96
+ Broadside::Command.bash(options)
97
+ end
98
+ end
99
+
100
+ desc 'Deploy your application.'
101
+ command :deploy do |d|
102
+ d.desc 'Deploys WITHOUT running predeploy commands'
103
+ d.command :short do |short|
104
+ add_tag_flag(short)
105
+ add_target_flag(short)
106
+
107
+ short.action do |_, options, _|
108
+ Broadside::EcsDeploy.new(options).short
109
+ end
110
+ end
111
+
112
+ d.desc 'Deploys WITH running predeploy commands'
113
+ d.command :full do |full|
114
+ add_tag_flag(full)
115
+ add_target_flag(full)
116
+
117
+ full.action do |_, options, _|
118
+ Broadside::EcsDeploy.new(options).full
119
+ end
120
+ end
121
+
122
+ d.desc 'Scales application to a given count'
123
+ d.command :scale do |scale|
124
+ scale.desc 'Specify a new scale for application'
125
+ scale.arg_name 'NUM'
126
+ scale.flag [:s, :scale], type: Fixnum
127
+
128
+ add_target_flag(scale)
129
+
130
+ scale.action do |_, options, _|
131
+ Broadside::EcsDeploy.new(options).scale(options)
132
+ end
133
+ end
134
+
135
+ d.desc 'Rolls back n releases and deploys'
136
+ d.command :rollback do |rollback|
137
+ rollback.desc 'Number of releases to rollback'
138
+ rollback.arg_name 'COUNT'
139
+ rollback.flag [:r, :rollback], type: Fixnum
140
+
141
+ add_target_flag(rollback)
142
+
143
+ rollback.action do |_, options, _|
144
+ Broadside::EcsDeploy.new(options).rollback(options)
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,72 @@
1
+ # GLI type coercions
2
+ accept Symbol do |val|
3
+ val.to_sym
4
+ end
5
+ accept Array do |val|
6
+ val.split(' ')
7
+ end
8
+ accept Fixnum do |val|
9
+ val.to_i
10
+ end
11
+
12
+ desc 'Configuration file to use.'
13
+ default_value 'config/broadside.conf.rb'
14
+ arg_name 'FILE'
15
+ flag [:c, :config]
16
+
17
+ desc 'Enables debug mode'
18
+ switch [:D, :debug], negatable: false
19
+
20
+ desc 'Log level output'
21
+ arg_name 'LOGLEVEL'
22
+ flag [:l, :loglevel], must_match: %w(debug info warn error fatal)
23
+
24
+ def call_hook(type, command, options, args)
25
+ hook = Broadside.config.public_send(type)
26
+ return if hook.nil?
27
+ raise "#{type} hook is not a callable proc" unless hook.is_a?(Proc)
28
+
29
+ hook_args = {
30
+ options: options,
31
+ args: args
32
+ }
33
+
34
+ if command.parent.is_a?(GLI::Command)
35
+ hook_args[:command] = command.parent.name
36
+ hook_args[:subcommand] = command.name
37
+ else
38
+ hook_args[:command] = command.name
39
+ end
40
+
41
+ debug "Calling #{type} with args '#{hook_args}'"
42
+ hook.call(hook_args)
43
+ end
44
+
45
+ pre do |global, command, options, args|
46
+ Broadside.load_config_file(global[:config])
47
+
48
+ if global[:debug]
49
+ Broadside.config.logger.level = ::Logger::DEBUG
50
+ ENV['GLI_DEBUG'] = 'true'
51
+ elsif global[:loglevel]
52
+ Broadside.config.logger.level = ::Logger.const_get(global[:loglevel].upcase)
53
+ end
54
+
55
+ call_hook(:prehook, command, options, args)
56
+ true
57
+ end
58
+
59
+ post do |global, command, options, args|
60
+ call_hook(:posthook, command, options, args)
61
+ true
62
+ end
63
+
64
+ on_error do |exception|
65
+ case exception
66
+ when Broadside::ConfigurationError
67
+ error exception.message, "Run your last command with --help for more information."
68
+ false # false skips default error handling
69
+ else
70
+ true
71
+ end
72
+ end
@@ -0,0 +1,9 @@
1
+ module Broadside
2
+ module LoggingUtils
3
+ %w(debug info warn error fatal).each do |log_level|
4
+ define_method(log_level) do |*args|
5
+ Broadside.config.logger.public_send(log_level.to_sym, args.join(' '))
6
+ end
7
+ end
8
+ end
9
+ end
@@ -3,15 +3,14 @@ require 'pathname'
3
3
 
4
4
  module Broadside
5
5
  class Target
6
- include VerifyInstanceVariables
7
- include Utils
6
+ include ActiveModel::Model
7
+ include LoggingUtils
8
8
 
9
- attr_accessor(
9
+ attr_reader(
10
10
  :bootstrap_commands,
11
+ :cluster,
11
12
  :command,
12
- :env_files,
13
- :env_vars,
14
- :instance,
13
+ :docker_image,
15
14
  :name,
16
15
  :predeploy_commands,
17
16
  :scale,
@@ -20,91 +19,78 @@ module Broadside
20
19
  :task_definition_config
21
20
  )
22
21
 
23
- DEFAULT_INSTANCE = 0
22
+ validates :cluster, :docker_image, :name, presence: true
23
+ validates :scale, numericality: { only_integer: true }
24
24
 
25
- TARGET_ATTRIBUTE_VALIDATIONS = {
26
- bootstrap_commands: ->(target_attribute) { validate_commands(target_attribute) },
27
- command: ->(target_attribute) { validate_types([Array, NilClass], target_attribute) },
28
- env_files: ->(target_attribute) { validate_types([String, Array], target_attribute) },
29
- predeploy_commands: ->(target_attribute) { validate_commands(target_attribute) },
30
- scale: ->(target_attribute) { validate_types([Integer], target_attribute) },
31
- service_config: ->(target_attribute) { validate_types([Hash, NilClass], target_attribute) },
32
- task_definition_config: ->(target_attribute) { validate_types([Hash, NilClass], target_attribute) }
33
- }
34
-
35
- def initialize(name, options = {})
36
- @name = name
37
- @config = options
38
-
39
- @bootstrap_commands = @config[:bootstrap_commands] || []
40
- @cluster = @config[:cluster]
41
- @command = @config[:command]
42
- _env_files = @config[:env_files] || @config[:env_file]
43
- @env_files = _env_files ? [*_env_files] : nil
44
- @env_vars = {}
45
- @instance = DEFAULT_INSTANCE || @config[:instance]
46
- @predeploy_commands = @config[:predeploy_commands]
47
- @scale = @config[:scale]
48
- @service_config = @config[:service_config]
49
- @tag = @config[:tag]
50
- @task_definition_config = @config[:task_definition_config]
51
-
52
- validate!
53
- load_env_vars!
25
+ validates_each(:bootstrap_commands, :predeploy_commands, allow_nil: true) do |record, attr, val|
26
+ record.errors.add(attr, 'is not array of arrays') unless val.is_a?(Array) && val.all? { |v| v.is_a?(Array) }
54
27
  end
55
28
 
56
- def cluster
57
- @cluster || config.ecs.cluster
29
+ validates_each(:service_config, allow_nil: true) do |record, attr, val|
30
+ record.errors.add(attr, 'is not a hash') unless val.is_a?(Hash)
58
31
  end
59
32
 
60
- private
61
-
62
- def validate!
63
- invalid_messages = TARGET_ATTRIBUTE_VALIDATIONS.map do |var, validation|
64
- message = validation.call(instance_variable_get('@' + var.to_s))
65
- message.nil? ? nil : "Deploy target '#{@name}' parameter '#{var}' is invalid: #{message}"
66
- end.compact
67
-
68
- unless invalid_messages.empty?
69
- raise ArgumentError, invalid_messages.join("\n")
33
+ validates_each(:task_definition_config, allow_nil: true) do |record, attr, val|
34
+ if val.is_a?(Hash)
35
+ if val[:container_definitions] && val[:container_definitions].size > 1
36
+ record.errors.add(attr, 'specifies > 1 container definition but this is not supported yet')
37
+ end
38
+ else
39
+ record.errors.add(attr, 'is not a hash')
70
40
  end
71
41
  end
72
42
 
73
- def load_env_vars!
74
- @env_files.flatten.each do |env_path|
75
- env_file = Pathname.new(env_path)
43
+ validates_each(:command, allow_nil: true) do |record, attr, val|
44
+ record.errors.add(attr, 'is not an array of strings') unless val.is_a?(Array) && val.all? { |v| v.is_a?(String) }
45
+ end
76
46
 
77
- unless env_file.absolute?
78
- dir = config.file.nil? ? Dir.pwd : Pathname.new(config.file).dirname
79
- env_file = env_file.expand_path(dir)
80
- end
47
+ def initialize(name, options = {})
48
+ @name = name
81
49
 
82
- if env_file.exist?
83
- vars = Dotenv.load(env_file)
84
- @env_vars.merge!(vars)
85
- else
86
- raise ArgumentError, "Could not find file '#{env_file}' for loading environment variables !"
87
- end
50
+ config = options.deep_dup
51
+ @bootstrap_commands = config.delete(:bootstrap_commands)
52
+ @cluster = config.delete(:cluster) || Broadside.config.aws.ecs_default_cluster
53
+ @command = config.delete(:command)
54
+ @docker_image = config.delete(:docker_image) || Broadside.config.default_docker_image
55
+ @predeploy_commands = config.delete(:predeploy_commands)
56
+ @scale = config.delete(:scale)
57
+ @service_config = config.delete(:service_config)
58
+ @task_definition_config = config.delete(:task_definition_config)
59
+
60
+ @env_files = Array.wrap(config.delete(:env_files) || config.delete(:env_file)).map do |env_path|
61
+ env_file = Pathname.new(env_path)
62
+ next env_file if env_file.absolute?
63
+
64
+ dir = Broadside.config.config_file ? Pathname.new(Broadside.config.config_file).dirname : Dir.pwd
65
+ env_file.expand_path(dir)
88
66
  end
89
67
 
90
- # convert env vars to format ecs expects
91
- @env_vars = @env_vars.map { |k, v| { 'name' => k, 'value' => v } }
68
+ raise ConfigurationError, errors.full_messages unless valid?
69
+ warn "Target #{@name} was configured with invalid/unused options: #{config}" unless config.empty?
92
70
  end
93
71
 
94
- def self.validate_types(types, target_attribute)
95
- return nil if types.any? { |type| target_attribute.is_a?(type) }
72
+ def ecs_env_vars
73
+ @env_vars ||= @env_files.inject({}) do |env_variables, env_file|
74
+ raise ConfigurationError, "Specified env_file: '#{env_file}' does not exist!" unless env_file.exist?
96
75
 
97
- "'#{target_attribute}' must be of type [#{types.join('|')}], got '#{target_attribute.class}' !"
76
+ begin
77
+ env_variables.merge(Dotenv.load(env_file))
78
+ rescue Dotenv::FormatError => e
79
+ raise e.class, "Error parsing #{env_file}: #{e.message}", e.backtrace
80
+ end
81
+ end.map { |k, v| { 'name' => k, 'value' => v } }
98
82
  end
99
83
 
100
- def self.validate_commands(commands)
101
- return nil if commands.nil?
102
- return 'predeploy_commands must be an array' unless commands.is_a?(Array)
84
+ def family
85
+ "#{Broadside.config.application}_#{@name}"
86
+ end
103
87
 
104
- messages = commands.reject { |cmd| cmd.is_a?(Array) }.map do |command|
105
- "predeploy_command '#{command}' must be an array" unless command.is_a?(Array)
106
- end
107
- messages.empty? ? nil : messages.join(', ')
88
+ def to_h
89
+ {
90
+ Target: @name,
91
+ Image: "#{@docker_image}:#{@tag || 'no_tag_configured'}",
92
+ Cluster: @cluster
93
+ }
108
94
  end
109
95
  end
110
96
  end