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.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +297 -0
- data/Rakefile +2 -0
- data/athlete.gemspec +27 -0
- data/bin/athlete +5 -0
- data/lib/athlete.rb +20 -0
- data/lib/athlete/build.rb +130 -0
- data/lib/athlete/cli.rb +118 -0
- data/lib/athlete/deployment.rb +285 -0
- data/lib/athlete/logging.rb +34 -0
- data/lib/athlete/utils.rb +34 -0
- data/lib/athlete/version.rb +3 -0
- data/lib/marathon/LICENSE.txt +22 -0
- data/lib/marathon/client.rb +130 -0
- data/lib/marathon/response.rb +54 -0
- metadata +134 -0
data/lib/athlete/cli.rb
ADDED
@@ -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
|