broadside 2.0.0 → 3.0.0.pre.prerelease

Sign up to get free protection for your applications and to get access to all the features.
@@ -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