athlete 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ require 'thor'
2
+
3
+ module Athlete
4
+ class CLI < Thor
5
+ include Thor::Actions
6
+ include Logging
7
+
8
+ # Allow specifying a custom path to the config file
9
+ class_option :config, :desc => "Path to config file", :aliases => "-f"
10
+
11
+ # Verbose (turns loglevel to DEBUG)
12
+ class_option :verbose, :desc => "Output verbose logging", :aliases => "-v", :type => :boolean, :default => false
13
+
14
+ desc 'list [TYPE]', 'List all builds and/or deployments'
15
+ long_desc <<-LONGDESC
16
+ `athlete list` will show all builds and deployments.
17
+ `athlete list builds` will show only builds, and `athlete list deployments` will
18
+ list only deployments.
19
+ LONGDESC
20
+ def list(type = nil)
21
+ setup
22
+ output_builds if type.nil? || type == 'builds'
23
+ output_deployments if type.nil? || type == 'deployments'
24
+ end
25
+
26
+
27
+ desc 'build BUILD_NAME', 'Build and push the Docker image specified by BUILD_NAME'
28
+ long_desc <<-LONGDESC
29
+ `athlete build` will build the named Docker image(s) specified in the build section
30
+ of your Athlete configuration file. It will then push this image to the remote
31
+ registry you have specified, unless you specify the `--no-push` flag (`--push` is
32
+ the default).
33
+ LONGDESC
34
+ method_option :push, :desc => "Specify whether the image should be pushed to the configured registry", :type => :boolean, :default => true
35
+ def build(build_name)
36
+ setup
37
+
38
+ build = Athlete::Build.builds[build_name]
39
+ if build
40
+ do_build(build, options[:push])
41
+ else
42
+ fatal "Could not locate a build in the configuration named '#{build_name}'"
43
+ exit 1
44
+ end
45
+ end
46
+
47
+ desc 'deploy DEPLOYMENT_NAME', 'Run the deployment specified by DEPLOYMENT_NAME'
48
+ long_desc <<-LONGDESC
49
+ `athlete deploy` will deploy container(s) (to Marathon) of the Docker image(s) specified in the deployment
50
+ section of your Athlete configuration file.
51
+ LONGDESC
52
+ def deploy(deployment_name)
53
+ setup
54
+
55
+ deployment = Athlete::Deployment.deployments[deployment_name]
56
+ if deployment
57
+ do_deploy(deployment)
58
+ else
59
+ fatal "Could not locate a deployment in the configuration named '#{deployment_name}'"
60
+ exit 1
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def setup
67
+ handle_verbose
68
+ load_config
69
+ end
70
+
71
+ # Basic configuration loading and safety checking of the DSL
72
+ def load_config
73
+ config_file = options[:config] || 'config/athlete.rb'
74
+ if !File.exists?(config_file)
75
+ fatal "Config file '#{config_file}' does not exist or cannot be read"
76
+ exit 1
77
+ end
78
+ debug "Using configuration file at #{config_file}"
79
+ begin
80
+ load config_file
81
+ rescue Exception => e
82
+ fatal "Exception loading the config file - #{e.class}: #{e.message} at #{e.backtrace[0]}"
83
+ exit 1
84
+ end
85
+ end
86
+
87
+ def handle_verbose
88
+ options[:verbose] ? loglevel(Logger::DEBUG) : loglevel(Logger::INFO)
89
+ end
90
+
91
+ def do_build(build, should_push)
92
+ info "Beginning build of '#{build.name}'"
93
+ build.perform(should_push)
94
+ info "Build complete"
95
+ end
96
+
97
+ def do_deploy(deployment)
98
+ info "Beginning deployment of '#{deployment.name}'"
99
+ deployment.perform
100
+ info "Deployment complete"
101
+ end
102
+
103
+ def output_builds
104
+ puts "Builds"
105
+ Athlete::Build.builds.keys.sort.each do |name|
106
+ Athlete::Build.builds[name].readable_output
107
+ end
108
+ end
109
+
110
+ def output_deployments
111
+ puts "Deployments"
112
+ Athlete::Deployment.deployments.keys.sort.each do |name|
113
+ Athlete::Deployment.deployments[name].readable_output
114
+ end
115
+ end
116
+
117
+ end
118
+ end
@@ -0,0 +1,285 @@
1
+ module Athlete
2
+ class Deployment
3
+ include Logging
4
+
5
+ @deployments = {}
6
+
7
+ class << self
8
+ attr_accessor :deployments
9
+ end
10
+
11
+ # Define valid properties
12
+ @@valid_properties = %w{
13
+ name
14
+ marathon_url
15
+ build_name
16
+ image_name
17
+ command
18
+ arguments
19
+ cpus
20
+ memory
21
+ environment_variables
22
+ instances
23
+ minimum_health_capacity
24
+ }
25
+
26
+ # Define properties that cannot be overridden or inherited
27
+ @@locked_properties = %w{
28
+ name
29
+ marathon_url
30
+ build_name
31
+ image_name
32
+ command
33
+ arguments
34
+ environment_variables
35
+ }
36
+
37
+ def initialize
38
+ @inherit_properties = []
39
+ @override_properties = []
40
+ setup_dsl_methods
41
+ end
42
+
43
+ def setup_dsl_methods
44
+ @@valid_properties.each do |property|
45
+ self.class.class_eval {
46
+
47
+ # Define property settings methods for the DSL
48
+ define_method(property) do |property_value, override_or_inherit = nil|
49
+ instance_variable_set("@#{property}", property_value)
50
+ if not @@locked_properties.include?(property)
51
+ case override_or_inherit
52
+ when :override
53
+ @override_properties << property
54
+ when :inherit
55
+ @inherit_properties << property
56
+ else
57
+ raise Athlete::ConfigurationInvalidException,
58
+ "Property '#{property}' of deployment '#{@name}' specified behaviour as '#{override_or_inherit}', which is not one of :override or :inherit"
59
+ end
60
+ end
61
+ self.class.class_eval{attr_reader property.to_sym}
62
+ end
63
+
64
+ }
65
+ end
66
+ end
67
+
68
+ def self.define(name, &block)
69
+ deployment = Athlete::Deployment.new
70
+ deployment.name name
71
+ deployment.instance_eval(&block)
72
+ deployment.fill_default_values
73
+ deployment.validate
74
+ deployment.connect_to_marathon
75
+ @deployments[deployment.name] = deployment
76
+ end
77
+
78
+ def fill_default_values
79
+ if !@instances
80
+ @instances = 1
81
+ @inherit_properties << 'instances'
82
+ end
83
+ end
84
+
85
+ def validate
86
+ errors = []
87
+
88
+ # Must specify a Docker image (either from a build or some upstream source)
89
+ errors << "You must set one of image_name or build_name" unless @build_name || @image_name
90
+
91
+ # If a build name is specified, it must match something in the file
92
+ if @build_name && linked_build.nil?
93
+ errors << "Build name '#{@build_name}' doesn't match a build in the config file"
94
+ end
95
+
96
+ # Marathon URL is required
97
+ errors << "You must specify marathon_url" unless @marathon_url
98
+
99
+ # Environment variables must be a hash
100
+ errors << "environment_variables must be a hash" if @environment_variables && !@environment_variables.kind_of?(Hash)
101
+
102
+ # Can't supply both command and args
103
+ errors << "You must specify only one of command or arguments" if @command && @arguments
104
+
105
+ # Arguments must be in an array
106
+ error << "The arguments parameter must be specified as an array" if @arguments && !@arguments.kind_of?(Array)
107
+
108
+ unless errors.empty?
109
+ raise ConfigurationInvalidException, @errors
110
+ end
111
+ end
112
+
113
+ def connect_to_marathon
114
+ @marathon_client = Marathon::Client.new(@marathon_url)
115
+ end
116
+
117
+ def perform
118
+ response = deploy_or_update
119
+ @deploy_response = response.parsed_response
120
+ debug "Entire deployment response: #{response.inspect}"
121
+
122
+ # Check to see if the deployment actually happened
123
+ if response.code == 409
124
+ fatal "Deployment did not start; another deployment is in progress"
125
+ exit 1
126
+ end
127
+
128
+ info "Polling for deployment state"
129
+ state = poll_for_deploy_state
130
+ case state
131
+ when :retry_exceeded
132
+ fatal "App failed to start on Marathon; cancelling deploy"
133
+ exit 1
134
+ when :complete
135
+ info "App is running on Marathon; deployment complete"
136
+ else
137
+ fatal "App is in unknown state on Marathon"
138
+ exit 1
139
+ end
140
+ end
141
+
142
+ def deploy_or_update
143
+ if app_running?
144
+ debug "App is running in Marathon; performing a warm deploy"
145
+ prepare_for_warm_deploy
146
+ return @marathon_client.update(@name, marathon_json)
147
+ else
148
+ debug "App is not running in Marathon; performing a cold deploy"
149
+ prepare_for_cold_deploy
150
+ return @marathon_client.start(@name, marathon_json)
151
+ end
152
+ end
153
+
154
+ # Poll Marathon to see if the deploy has completed for the
155
+ # given deployed version
156
+ def poll_for_deploy_state
157
+ debug "Entering deploy state polling"
158
+ while (not deployment_completed?) && (not retry_exceeded?)
159
+ if has_task_failures?
160
+ warn "Task failures have occurred during the deploy attempt - this deploy may not succeed"
161
+ sleep 1
162
+ increment_retry
163
+ else
164
+ debug "Deploy still in progress with no task failures; sleeping and retrying"
165
+ sleep 1
166
+ increment_retry
167
+ end
168
+ end
169
+
170
+ # We bailed because we exceeded retry or the deploy completed, determine
171
+ # which of these states it is
172
+ deployment_completed? ? :complete : :retry_exceeded
173
+ end
174
+
175
+ def deployment_completed?
176
+ @marathon_client.find_deployment_by_name(@name) == nil
177
+ end
178
+
179
+ def retry_exceeded?
180
+ @retry_count == 10
181
+ end
182
+
183
+ def increment_retry
184
+ @retry_count ||= 0
185
+ @retry_count = @retry_count + 1
186
+ end
187
+
188
+ def has_task_failures?
189
+ app_config = @marathon_client.find(@name)
190
+ return false if app_config.parsed_response['app']['lastTaskFailure'].nil?
191
+ app_config.parsed_response['app']['lastTaskFailure']['version'] == @deploy_response['version']
192
+ end
193
+
194
+ # A 'warm' deploy is one where the app is running in Marathon and
195
+ # we're making changes to it. For each declared configuration property,
196
+ # determine whether it will be always inserted into the remote configuration
197
+ # (:override) or not (:inherit). Think of :override as "Athlete is
198
+ # authoritative for this property", and :inherit as "Marathon is
199
+ # authoritative for this property".
200
+ # The way this works in practice is we unset any instance variables
201
+ # that are specified as "inherit", so that when the Marathon JSON
202
+ # is generated by `to_marathon_json` they do not appear in the final
203
+ # deployment JSON.
204
+ def prepare_for_warm_deploy
205
+ @inherit_properties.each do |property|
206
+ debug "Property '#{property}' is specified as :inherit; not supplying to Marathon"
207
+ instance_variable_set("@#{property}", nil)
208
+ end
209
+ end
210
+
211
+ # A 'cold' deploy is one where the app is not running in Marathon.
212
+ # We have to do additional validation to ensure we can deploy the app, since
213
+ # we don't have a set of valid parameters in Marathon.
214
+ def prepare_for_cold_deploy
215
+ errors = []
216
+ errors << "You must specify the parameter 'cpus'" unless @cpus
217
+ errors << "You must specify the parameter 'memory'" unless @memory
218
+ unless errors.empty?
219
+ raise ConfigurationInvalidException, @errors
220
+ end
221
+ end
222
+
223
+ # Locate the linked build
224
+ def linked_build
225
+ @build_name ? Athlete::Build.builds[@build_name] : nil
226
+ end
227
+
228
+ # Find the app if it's already in Marathon (if it's not there, we get nil)
229
+ def get_running_config
230
+ if @running_config
231
+ return @running_config
232
+ else
233
+ response = @marathon_client.find(@name)
234
+ @running_config = response.error? ? nil : response.parsed_response
235
+ debug "Retrieved running Marathon configuration: #{@running_config}"
236
+ return @running_config
237
+ end
238
+ end
239
+
240
+ def app_running?
241
+ get_running_config != nil
242
+ end
243
+
244
+ def marathon_json
245
+ json = {}
246
+
247
+ json['id'] = @name
248
+ json['cmd'] = @command if @command
249
+ json['args'] = @arguments if @arguments
250
+ json['cpus'] = @cpus if @cpus
251
+ json['mem'] = @memory if @memory
252
+ json['env'] = @environment_variables if @environment_variables
253
+ json['instances'] = @instances if @instances
254
+ if @minimum_health_capacity
255
+ json['upgradeStrategy'] = {
256
+ 'minimumHealthCapacity' => @minimum_health_capacity
257
+ }
258
+ end
259
+
260
+ if @image_name || @build_name
261
+ image = @image_name || linked_build.final_image_name
262
+ json['container'] = {
263
+ 'type' => 'DOCKER',
264
+ 'docker' => {
265
+ 'image' => image,
266
+ 'network' => 'BRIDGE'
267
+ }
268
+ }
269
+ end
270
+ debug("Generated Marathon JSON: #{json.to_json}")
271
+ json
272
+ end
273
+
274
+ def readable_output
275
+ lines = []
276
+ lines << " Deployment name: #{@name}"
277
+ @@valid_properties.sort.each do |property|
278
+ next if property == 'name'
279
+ lines << sprintf(" %-26s: %s", property, instance_variable_get("@#{property}")) if instance_variable_get("@#{property}")
280
+ end
281
+ puts lines.join("\n")
282
+ end
283
+
284
+ end
285
+ end
@@ -0,0 +1,34 @@
1
+ module Athlete
2
+ module Logging
3
+
4
+ @@logger = Logger.new(STDOUT)
5
+ @@logger.formatter = proc do |severity, datetime, progname, msg|
6
+ "#{datetime} [#{severity}]: #{msg.to_s.chomp}\n"
7
+ end
8
+
9
+ def loglevel(level)
10
+ @@logger.level = level
11
+ end
12
+
13
+ def get_loglevel
14
+ @@logger.level
15
+ end
16
+
17
+ def info(msg)
18
+ @@logger.info(msg)
19
+ end
20
+
21
+ def warn(msg)
22
+ @@logger.warn(msg)
23
+ end
24
+
25
+ def fatal(msg)
26
+ @@logger.fatal(msg)
27
+ end
28
+
29
+ def debug(msg)
30
+ @@logger.debug(msg)
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ module Athlete
2
+ module Utils
3
+
4
+ # A moderately decent way of dealing with running commands
5
+ # and coping with stdout/stderr
6
+ class Subprocess
7
+ extend Logging
8
+ def self.run(cmd, &block)
9
+ info "Running command '#{cmd}'"
10
+ log_command = "#{cmd.split(' ')[0]}"
11
+ # see: http://stackoverflow.com/a/1162850/83386
12
+ Open3.popen3(cmd) do |stdin, stdout, stderr, thread|
13
+ # read each stream from a new thread
14
+ { :out => stdout, :err => stderr }.each do |key, stream|
15
+ Thread.new do
16
+ until (line = stream.gets).nil? do
17
+ # yield the block depending on the stream
18
+ if key == :out
19
+ debug("[#{log_command}] [stdout] #{line}") unless line.nil?
20
+ else
21
+ debug("[#{log_command}] [stderr] #{line}") unless line.nil?
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ thread.join # don't exit until the external process is done
28
+ return thread.value
29
+ end
30
+ end
31
+ end
32
+
33
+ end
34
+ end