slugforge 4.0.0

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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +316 -0
  3. data/bin/slugforge +9 -0
  4. data/lib/slugforge.rb +19 -0
  5. data/lib/slugforge/build.rb +4 -0
  6. data/lib/slugforge/build/build_project.rb +31 -0
  7. data/lib/slugforge/build/export_upstart.rb +85 -0
  8. data/lib/slugforge/build/package.rb +63 -0
  9. data/lib/slugforge/cli.rb +125 -0
  10. data/lib/slugforge/commands.rb +130 -0
  11. data/lib/slugforge/commands/build.rb +20 -0
  12. data/lib/slugforge/commands/config.rb +24 -0
  13. data/lib/slugforge/commands/deploy.rb +383 -0
  14. data/lib/slugforge/commands/project.rb +21 -0
  15. data/lib/slugforge/commands/tag.rb +148 -0
  16. data/lib/slugforge/commands/wrangler.rb +142 -0
  17. data/lib/slugforge/configuration.rb +125 -0
  18. data/lib/slugforge/helper.rb +186 -0
  19. data/lib/slugforge/helper/build.rb +46 -0
  20. data/lib/slugforge/helper/config.rb +37 -0
  21. data/lib/slugforge/helper/enumerable.rb +46 -0
  22. data/lib/slugforge/helper/fog.rb +90 -0
  23. data/lib/slugforge/helper/git.rb +89 -0
  24. data/lib/slugforge/helper/path.rb +76 -0
  25. data/lib/slugforge/helper/project.rb +86 -0
  26. data/lib/slugforge/models/host.rb +233 -0
  27. data/lib/slugforge/models/host/fog_host.rb +33 -0
  28. data/lib/slugforge/models/host/hostname_host.rb +9 -0
  29. data/lib/slugforge/models/host/ip_address_host.rb +9 -0
  30. data/lib/slugforge/models/host_group.rb +65 -0
  31. data/lib/slugforge/models/host_group/aws_tag_group.rb +22 -0
  32. data/lib/slugforge/models/host_group/ec2_instance_group.rb +21 -0
  33. data/lib/slugforge/models/host_group/hostname_group.rb +16 -0
  34. data/lib/slugforge/models/host_group/ip_address_group.rb +16 -0
  35. data/lib/slugforge/models/host_group/security_group_group.rb +20 -0
  36. data/lib/slugforge/models/logger.rb +36 -0
  37. data/lib/slugforge/models/tag_manager.rb +125 -0
  38. data/lib/slugforge/slugins.rb +125 -0
  39. data/lib/slugforge/version.rb +9 -0
  40. data/scripts/post-install.sh +143 -0
  41. data/scripts/unicorn-shepherd.sh +305 -0
  42. data/spec/fixtures/array.yaml +3 -0
  43. data/spec/fixtures/fog_credentials.yaml +4 -0
  44. data/spec/fixtures/invalid_syntax.yaml +1 -0
  45. data/spec/fixtures/one.yaml +3 -0
  46. data/spec/fixtures/two.yaml +3 -0
  47. data/spec/fixtures/valid.yaml +4 -0
  48. data/spec/slugforge/commands/deploy_spec.rb +72 -0
  49. data/spec/slugforge/commands_spec.rb +33 -0
  50. data/spec/slugforge/configuration_spec.rb +200 -0
  51. data/spec/slugforge/helper/fog_spec.rb +81 -0
  52. data/spec/slugforge/helper/git_spec.rb +152 -0
  53. data/spec/slugforge/models/host_group/aws_tag_group_spec.rb +54 -0
  54. data/spec/slugforge/models/host_group/ec2_instance_group_spec.rb +51 -0
  55. data/spec/slugforge/models/host_group/hostname_group_spec.rb +20 -0
  56. data/spec/slugforge/models/host_group/ip_address_group_spec.rb +54 -0
  57. data/spec/slugforge/models/host_group/security_group_group_spec.rb +52 -0
  58. data/spec/slugforge/models/tag_manager_spec.rb +75 -0
  59. data/spec/spec_helper.rb +37 -0
  60. data/spec/support/env.rb +3 -0
  61. data/spec/support/example_groups/configuration_writer.rb +24 -0
  62. data/spec/support/example_groups/helper_provider.rb +10 -0
  63. data/spec/support/factories.rb +18 -0
  64. data/spec/support/fog.rb +15 -0
  65. data/spec/support/helpers.rb +18 -0
  66. data/spec/support/mock_logger.rb +6 -0
  67. data/spec/support/ssh.rb +8 -0
  68. data/spec/support/streams.rb +13 -0
  69. data/templates/foreman/master.conf.erb +21 -0
  70. data/templates/foreman/process-master.conf.erb +2 -0
  71. data/templates/foreman/process.conf.erb +19 -0
  72. metadata +344 -0
@@ -0,0 +1,63 @@
1
+ module Slugforge
2
+ module Build
3
+ class Package < Slugforge::BuildCommand
4
+ desc :package, 'package the project'
5
+ def call
6
+ existing = Dir.glob('*.slug')
7
+
8
+ if options[:clean]
9
+ existing.each do |path|
10
+ logger.say_status :clean, path, :red
11
+ File.delete(path)
12
+ end
13
+ end
14
+
15
+ logger.say_status :execute, "fpm #{package_file_name}"
16
+ execute(fpm_command)
17
+
18
+ if options[:deploy]
19
+ invoke Slugforge::Commands::Deploy, [:file, package_file_name, options[:deploy]], []
20
+ end
21
+ end
22
+ default_task :call
23
+
24
+ private
25
+ def fpm_command
26
+ command = ['fpm']
27
+ command << '--verbose'
28
+ command << "--package #{package_file_name}"
29
+ command << "--maintainer=#{`whoami`.chomp}"
30
+ command << "-C #{project_root}"
31
+ command << '-s dir'
32
+ command << '-t sh'
33
+ command << "-n #{project_name}"
34
+ command << "-v #{date_stamp}"
35
+ command << '--template-scripts'
36
+ command << post_install_template_variables
37
+ command << "--after-install #{post_install_script_path}"
38
+ unless options[:'with-git']
39
+ command << "--exclude '.git'"
40
+ command << "--exclude '.git/**'"
41
+ end
42
+ command << "--exclude '*.slug'"
43
+ command << "--exclude 'log/**'"
44
+ command << "--exclude 'tmp/**'"
45
+ command << "--exclude 'vendor/bundle/ruby/1.9.1/cache/*'"
46
+ command << "." # package all the things
47
+ command.join(' ')
48
+ end
49
+
50
+ def post_install_script_path
51
+ scripts_dir('post-install.sh')
52
+ end
53
+
54
+ def post_install_template_variables
55
+ variables = {
56
+ 'release_id' => File.basename(package_file_name, ".*")
57
+ }.map do |key, value|
58
+ "--template-value #{key}=#{value}"
59
+ end.join(' ')
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,125 @@
1
+ require 'thor'
2
+ require 'slugforge/helper'
3
+ require 'slugforge/commands'
4
+
5
+ # Don't display warnings to the user
6
+ $VERBOSE = nil
7
+
8
+ $stdout.sync = true
9
+
10
+ module Slugforge
11
+ class Cli < Slugforge::Command
12
+
13
+ desc 'version', 'display the current version'
14
+ def version
15
+ logger.say_json :slugforge => Slugforge::VERSION
16
+ logger.say "slugforge #{Slugforge::VERSION}"
17
+ end
18
+
19
+ desc 'build [ARGS]', 'build a new slug (`slugforge build` for more help)'
20
+ option :ruby, :type => :string, :aliases => '-r',
21
+ :desc => 'Ruby version this package requires'
22
+ option :path, :type => :string, :default => Dir.pwd,
23
+ :desc => 'The path to the files being packaged'
24
+ option :clean, :type => :boolean,
25
+ :desc => 'Clean existing slugs from current directory before build'
26
+ option :deploy, :type => :string,
27
+ :desc => 'Deploy the slug if the build was successful'
28
+ option :'with-git', :type => :boolean,
29
+ :desc => 'include the .git folder in the slug'
30
+ def build
31
+ verify_procfile_exists!
32
+ invoke Slugforge::Commands::Build
33
+ end
34
+
35
+ desc 'deploy <command> [ARGS]', 'deploy a slug to a host (`slugforge deploy` for more help)'
36
+ subcommand 'deploy', Slugforge::Commands::Deploy
37
+
38
+ desc 'project <command> [ARGS]', 'manage projects (`slugforge project` for more help)'
39
+ subcommand 'project', Slugforge::Commands::Project
40
+
41
+ desc 'tag <command> [ARGS]', 'manage project tags (`slugforge tag` for more help)'
42
+ subcommand 'tag', Slugforge::Commands::Tag
43
+
44
+ desc 'wrangler <command> [ARGS]', 'list, push and delete slugs (`slugforge wrangler` for more help)'
45
+ subcommand 'wrangler', Slugforge::Commands::Wrangler
46
+
47
+ # subcommand method name is configuration to not conflict with the config method on all commands
48
+ desc 'config <command> [ARGS]', 'configure slugforge (`slugforge config` for more help)'
49
+ subcommand 'configuration', Slugforge::Commands::Config
50
+
51
+ def help(command = nil, subcommand = nil)
52
+ return super if command
53
+
54
+ self.class.help(shell, subcommand)
55
+
56
+ logger.say <<-HELP
57
+ Project Naming
58
+
59
+ The easiest way to name a project is to name it after a repository. The only reason you may have to use a different
60
+ name is for testing of some kind. That said, once you've named your project there are a few ways for slugforge to
61
+ determine what project you are attempting to run project-specific commands (such as build) against. There are two
62
+ basic ways of telling slugforge which project you are working with:
63
+
64
+ 1. With the provided configuration option (through a CLI flag, environment variable or config file)
65
+ 2. By running the slugforge command from inside the project's repository. slugforge will use the name of the
66
+ project's root folder (as defined by the location of .git), which generally matches the name of the repository
67
+ which should be the name of the project in slugforge.
68
+
69
+ Configuring the CLI
70
+
71
+ The configuration options above can all be configured through several candidate configuration files, environment
72
+ variables or the flags as shown. Precedence is by proximity to the command: flags trump environment, which trumps
73
+ configuration files. Slugforge will attempt to load configuration files in the following locations, listed in order
74
+ of priority highest to lowest:
75
+
76
+ .slugforge
77
+ ~/.slugforge
78
+ /etc/slugforge
79
+
80
+ Configuration files are written in yaml for simplicity. Below is a list of each option and the keys expected for each
81
+ type. File keys should be split on periods and expanded into hashes. AWS buckets accepts a comma seperated list,
82
+ which will be tried from first to last. There is an example config at the end of this screen.
83
+
84
+ HELP
85
+
86
+ rows = []
87
+ rows << %w(CLI Environment File)
88
+ Slugforge::Configuration.options.each do |name, config|
89
+ rows << [config[:option], config[:env], config[:key]]
90
+
91
+ unless rows.last.first.nil?
92
+ rows.last[0] = "--#{rows.last.first}"
93
+ end
94
+ end
95
+ print_table(rows, :indent => 4)
96
+
97
+ logger.say <<-HELP
98
+
99
+ Example configuration file
100
+
101
+ aws:
102
+ access_key: hashhashhashhashhash
103
+ secret_key: hashhashhashhashhashhashhashhashhashhash
104
+
105
+ HELP
106
+ end
107
+
108
+ if binding.respond_to?(:pry)
109
+ desc 'pry', 'start a pry session inside of a Thor command', :hide => true
110
+ option :path, :type => :string, :default => Dir.pwd,
111
+ :desc => 'The path to the files being packaged'
112
+ def pry
113
+ binding.pry
114
+ end
115
+ end
116
+
117
+ desc 'debug', 'run a test command inside slugforge', :hide => true
118
+ option :path, :type => :string, :default => Dir.pwd,
119
+ :desc => 'The path to the files being packaged'
120
+ def debug(cmd)
121
+ eval(cmd)
122
+ end
123
+ end
124
+ end
125
+
@@ -0,0 +1,130 @@
1
+ module Slugforge
2
+ module Commands
3
+ autoload :Build, 'slugforge/commands/build'
4
+ autoload :Config, 'slugforge/commands/config'
5
+ autoload :Deploy, 'slugforge/commands/deploy'
6
+ autoload :Project, 'slugforge/commands/project'
7
+ autoload :Tag, 'slugforge/commands/tag'
8
+ autoload :Tjs, 'slugforge/commands/tjs'
9
+ autoload :Wrangler, 'slugforge/commands/wrangler'
10
+ end
11
+
12
+ class JsonError < Thor::Error
13
+ def initialize(message)
14
+ super({error: message}.to_json)
15
+ end
16
+ end
17
+
18
+ class Command < Thor
19
+ include Thor::Actions
20
+ include Slugforge::Helper
21
+
22
+ check_unknown_options!
23
+
24
+ # Add Thor::Actions options
25
+ add_runtime_options!
26
+
27
+
28
+ class << self
29
+ # Parses the command and options from the given args, instantiate the class
30
+ # and invoke the command. This method is used when the arguments must be parsed
31
+ # from an array. If you are inside Ruby and want to use a Thor class, you
32
+ # can simply initialize it:
33
+ #
34
+ # script = MyScript.new(args, options, config)
35
+ # script.invoke(:command, first_arg, second_arg, third_arg)
36
+ #
37
+ def start(given_args=ARGV, config={})
38
+ # Loads enabled slugins. This must be done before the CLI is instantiated so that new commands
39
+ # will be found. Activation of slugins must be delayed until the command line options are parsed
40
+ # so that the full config will be available.
41
+ Configuration.new
42
+ super
43
+ end
44
+ end
45
+
46
+ # ==== Parameters
47
+ # args<Array[Object]>:: An array of objects. The objects are applied to their
48
+ # respective accessors declared with <tt>argument</tt>.
49
+ #
50
+ # options<Hash>:: Either an array of command-line options requiring parsing or
51
+ # a hash of pre-parsed options.
52
+ #
53
+ # config<Hash>:: Configuration for this Thor class.
54
+ #
55
+ def initialize(args=[], options=[], config={})
56
+ @command_start_time = Time.now()
57
+
58
+ super
59
+
60
+ # Configuration must be
61
+ # - created after command line is parsed (so not in #start)
62
+ # - inherited from parent commands
63
+ if config[:invoked_via_subcommand]
64
+ @config = config[:shell].base.config
65
+ else
66
+ @config = Configuration.new(self.options)
67
+ @config.activate_slugins
68
+ end
69
+ end
70
+
71
+ protected
72
+
73
+ def config
74
+ @config
75
+ end
76
+
77
+ def self.exit_on_failure?
78
+ true
79
+ end
80
+
81
+ def self.inherited(base)
82
+ base.source_root templates_dir
83
+ end
84
+
85
+ def publish(event, *args)
86
+ ActiveSupport::Notifications.publish(event, self, *args) if notifications_enabled?
87
+ rescue => e
88
+ clean_trace = e.backtrace.reject { |l| l =~ /active_support|thor|bin\/slugforge/ } # reject parts of the stack containing active_support, thor, or bin/slugforge
89
+ logger.say_status :error, "[notification #{args.first}] #{e.message}\n" + clean_trace.join("\n"), :red
90
+ end
91
+ end
92
+
93
+ # This class overrides #banner, forcing the subcommand parameter to be true by default. This gets around a bug in
94
+ # Thor, and causes subcommands to properly display their parent command in the help text for the subcommand.
95
+ class SubCommand < Command
96
+ def self.banner(command, namespace = nil, subcommand = true)
97
+ super
98
+ end
99
+ end
100
+
101
+ class BuildCommand < Command
102
+ class_option :ruby, :type => :string, :aliases => '-r',
103
+ :desc => 'Ruby version this package requires'
104
+ class_option :path, :type => :string, :default => Dir.pwd,
105
+ :desc => 'The path to the files being packaged'
106
+ class_option :clean, :type => :boolean,
107
+ :desc => 'Clean existing slugs from current directory before build'
108
+ class_option :deploy, :type => :string,
109
+ :desc => 'Deploy the slug if the build was successful'
110
+ class_option :'with-git', :type => :boolean,
111
+ :desc => 'include the .git folder in the slug'
112
+
113
+ def self.inherited(base)
114
+ base.source_root templates_dir
115
+ end
116
+ end
117
+
118
+ class Group < Thor::Group
119
+ include Thor::Actions
120
+ include Slugforge::Helper
121
+
122
+ # Add Thor::Actions options
123
+ add_runtime_options!
124
+
125
+ def self.inherited(base)
126
+ base.source_root templates_dir
127
+ end
128
+ end
129
+ end
130
+
@@ -0,0 +1,20 @@
1
+ require 'slugforge/build'
2
+
3
+ module Slugforge
4
+ module Commands
5
+ class Build < Slugforge::Group
6
+ def build_project
7
+ invoke Slugforge::Build::BuildProject
8
+ end
9
+
10
+ def export_upstart
11
+ invoke Slugforge::Build::ExportUpstart
12
+ end
13
+
14
+ def package
15
+ invoke Slugforge::Build::Package
16
+ end
17
+ end
18
+ end
19
+ end
20
+
@@ -0,0 +1,24 @@
1
+ module Slugforge
2
+ module Commands
3
+ class Config < Slugforge::SubCommand
4
+ desc 'show', 'Print current configuration options'
5
+ def show
6
+ if json?
7
+ logger.say_json config.values
8
+ else
9
+ logger.say "The following configuration options are in use:"
10
+ rows = config.values.map { |name, value| [name, value] }
11
+ print_table(rows, :indent => 4)
12
+
13
+ unless config.slugins.empty?
14
+ logger.say "Slugins detected:"
15
+ rows = config.slugins.map do |(name, slugin)|
16
+ [name, slugin.spec.version, slugin.enabled ? 'enabled' : 'disabled']
17
+ end
18
+ print_table(rows, :indent => 4)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,383 @@
1
+ require 'slugforge/models/host'
2
+ require 'slugforge/models/host_group'
3
+
4
+ module Slugforge
5
+ module Commands
6
+ class Deploy < Slugforge::SubCommand
7
+ class_option :identity, :type => :string, :aliases => '-i',
8
+ :desc => 'The identify (.pem) file to use for authentication'
9
+ class_option :deploy_dir, :type => :string, :aliases => '-d',
10
+ :desc => 'The directory to deploy to on the server'
11
+ class_option :owner, :type => :string, :aliases => '-o',
12
+ :desc => 'Account that the application will run with when deployed'
13
+ class_option :env, :type => :string, :aliases => '-e',
14
+ :desc => 'A quoted, space-delimited list of environment variables and values'
15
+ class_option :count, :type => :numeric, :aliases => '-c',
16
+ :desc => 'Only deploy to the specified number hosts in the expanded list'
17
+ class_option :percent, :type => :numeric,
18
+ :desc => 'Only deploy to the specified percent in the expanded list'
19
+ # AWS may throttle simultaneous downloads to a file
20
+ class_option :'batch-size', :type => :numeric, :default => 85,
21
+ :desc => 'Set the number of hosts per deployment batch to help slow your roll'
22
+ class_option :'batch-count', :type => :numeric,
23
+ :desc => 'Set the number of deployment batches to help slow your roll'
24
+ class_option :'batch-pause', :type => :numeric,
25
+ :desc => 'Set the amount of time (in seconds) to pause between deployment batches, to further slow your roll'
26
+ class_option :'no-stage', :type => :boolean, :default => false,
27
+ :desc => "Don't stage the slug files on the host group members that were not targeted for the deploy"
28
+ class_option :'yes', :type => :boolean, :default => false,
29
+ :desc => "Do not prompt to proceed with deploy (a more gentle --force)"
30
+
31
+ desc 'file <filename> <hosts...> [ARGS]', 'deploy a slug file to host(s)'
32
+ option :path, :copy_type => :string, :default => Dir.pwd,
33
+ :desc => 'The path to the files being packaged'
34
+ def file(filename, *hosts)
35
+ logger.say_status :deploy, "deploying local slug #{filename}", :green
36
+ slug_name = File.basename(filename)
37
+
38
+ deploy(hosts, slug_name, deploy_options(:copy_type => :scp, :filename => filename))
39
+ end
40
+
41
+ desc 'name <name_part> <hosts...> [ARGS]', 'deploy an S3 stored slug by name to host(s) (use `wrangler list` for slug names)'
42
+ def name(name_part, *hosts)
43
+ slug = find_slug(name_part)
44
+ slug_name = File.basename(slug.key)
45
+ logger.say_status :deploy, "deploying slug #{slug_name} from s3", :green
46
+
47
+ url = expiring_url(slug)
48
+ deploy(hosts, slug_name, deploy_options(:copy_type => :aws_cmd, :url => url, :aws_session => aws_session, :s3_url => "s3://#{aws_bucket}/#{slug.key}"))
49
+ end
50
+
51
+ desc 'rollback <tag> <hosts...> [ARGS]', 'deploy the previous slug for a tag to host(s)'
52
+ def rollback(tag, *hosts)
53
+ raise error_class, "There is no project found named '#{project_name}'. Try setting the project name with --project" unless tag_manager.projects.include?(project_name)
54
+ data = tag_manager.rollback_slug_for_tag(project_name, tag)
55
+ if data.nil?
56
+ raise error_class, "could not find tag '#{tag}' for project '#{project_name}'"
57
+ else
58
+ logger.say_status :deploy, "deploying slug #{data}", :green
59
+ slug = find_slug(data)
60
+ slug_name = File.basename(data)
61
+ url = expiring_url(slug)
62
+ end
63
+
64
+ raise error_class, "could not determine URL for tag" unless url
65
+ deploy(hosts, slug_name, deploy_options(:copy_type => :aws_cmd, :url => url, :aws_session => aws_session, :s3_url => "s3://#{aws_bucket}/#{data}"))
66
+ end
67
+
68
+ desc 'tag <tag> <hosts...> [ARGS]', 'deploy a slug by tag to host(s)'
69
+ def tag(tag, *hosts)
70
+ raise error_class, "There is no project found named '#{project_name}'. Try setting the project name with --project" unless tag_manager.projects.include?(project_name)
71
+ data = tag_manager.slug_for_tag(project_name, tag)
72
+ if data.nil?
73
+ raise error_class, "could not find tag '#{tag}' for project '#{project_name}'"
74
+ else
75
+ logger.say_status :deploy, "deploying slug #{data}", :green
76
+ slug = find_slug(data)
77
+ slug_name = File.basename(data)
78
+ url = expiring_url(slug)
79
+ end
80
+
81
+ raise error_class, "could not determine URL for tag" unless url
82
+
83
+ deploy(hosts, slug_name, deploy_options(:copy_type => :aws_cmd, :url => url, :aws_session => aws_session, :s3_url => "s3://#{aws_bucket}/#{data}"))
84
+ end
85
+
86
+ desc 'ssh <hosts...> [ARGS]', 'log in to a box for testing', :hide => true
87
+ def ssh(*hosts)
88
+ deploy hosts, nil, deploy_options(:copy_type => :ssh)
89
+ end
90
+
91
+ private
92
+ def deploy_options(opts={})
93
+ {
94
+ :username => config.ssh_username,
95
+ :deploy_dir => options[:deploy_dir] || self.deploy_dir,
96
+ :owner => options[:owner],
97
+ :identity => options[:identity],
98
+ :env => options[:env],
99
+ :force => force?,
100
+ :pretend => pretend?
101
+ }.merge(opts)
102
+ end
103
+
104
+ def deploy(host_patterns, slug_name, deploy_opts)
105
+ host_groups = determine_host_groups(host_patterns)
106
+ return unless confirm_deployment_start?(host_groups)
107
+
108
+ # Stage file on all other hosts in facets, unless told otherwise
109
+ unless options[:'no-stage']
110
+ host_groups.each do |group|
111
+ group.hosts.each { |host| host.add_action(:stage) unless host.install? }
112
+ end
113
+ end
114
+
115
+ logger.say_status :deploy, "beginning deployment", :green
116
+
117
+ # Organize the list of hosts to more evenly spread load across impacted facets
118
+ hosts = order_deploy(unique_hosts(host_groups))
119
+ batches = (batch_size(hosts.count) == 0) ? [hosts] : hosts.each_slice(batch_size(hosts.count)).to_a
120
+
121
+ partial_deploy_type = [:count, :percent].detect{ |s| options[s] }
122
+
123
+ publish('deploy.started', {
124
+ :partial_deploy_method => partial_deploy_type,
125
+ :partial_deploy_limit => options[partial_deploy_type],
126
+ :host_groups => host_groups,
127
+ :batch_size => batch_size(hosts.count),
128
+ :project => project_name,
129
+ :slug_name => slug_name
130
+ })
131
+
132
+ deploy_in_batches(batches, slug_name, deploy_opts)
133
+
134
+ logger.say_status :deploy, "deployment complete!", :green
135
+ say_deploy_status(host_groups, slug_name)
136
+
137
+
138
+ publish('deploy.finished', {
139
+ :success => hosts.all?(&:success?),
140
+ :partial_deploy_method => partial_deploy_type,
141
+ :partial_deploy_limit => options[:count] || options[:percent],
142
+ :host_groups => host_groups,
143
+ :batch_size => batch_size(hosts.count),
144
+ :project => project_name,
145
+ :slug_name => slug_name
146
+ })
147
+
148
+ host_groups
149
+ end
150
+
151
+ def deploy_in_batches(batches, slug_name, deploy_opts)
152
+ pause = options[:'batch-pause'].to_i
153
+ batches.each.with_index(1) do |batch, i|
154
+ logger.say "deploying batch #{i} of #{batches.count}", :magenta if batches.count > 1
155
+ threads = {}
156
+ batch.each do |host|
157
+ thread = Thread.new do
158
+ host.deploy(slug_name, logger, deploy_opts)
159
+ end
160
+ threads[host.ip] = thread
161
+ end
162
+ join_batch_threads(threads, batch, logger)
163
+ unless (batches.length == i || pause == 0)
164
+ logger.say "batch #{i} complete; pausing for #{pause} seconds", :magenta
165
+ sleep pause
166
+ end
167
+ end
168
+ end
169
+
170
+ def say_deploy_status(host_groups, slug_name)
171
+ return nil if host_groups.nil?
172
+ hosts = unique_hosts(host_groups)
173
+ return nil if hosts.empty?
174
+ total_count = hosts.count
175
+ successful = hosts.select { |h| h.success? }.count
176
+ overall_success = (total_count == successful)
177
+
178
+ if json?
179
+ logger.say_json :hosts => hosts.map(&:to_status), :success => overall_success
180
+ else
181
+ status_color = overall_success ? :green : :red
182
+ logger.say "\n#{'-'*22}\n| Deployment Summary |\n#{'-'*22}", status_color
183
+ logger.say "Deployed #{slug_name} to "
184
+ logger.say "#{successful} ", status_color
185
+ logger.say "of "
186
+ logger.say "#{total_count} ", status_color
187
+ logger.say "hosts in "
188
+ logger.say "#{elapsed_time}", :yellow
189
+
190
+ unless overall_success
191
+ indent = Math.log10(total_count - successful).round + 4
192
+ logger.say "\nFailures:", :red
193
+ count = 0
194
+ hosts.each do |host|
195
+ unless host.success?
196
+ logger.say ""
197
+ logger.say "%#{indent}s" % "#{count += 1}) ", :red
198
+ logger.say "#{host.name}", :red
199
+ print_wrapped host.output.join("\n"), :indent => indent
200
+ end
201
+ end
202
+ end
203
+ log_rollout_status(host_groups)
204
+ end
205
+ end
206
+
207
+ def determine_host_groups(host_patterns)
208
+ say_option_status host_patterns
209
+ logger.say_status :deploy, "determining deployment targets", :green
210
+ host_groups = partial_install_groups(host_groups_for_patterns(host_patterns))
211
+ end
212
+
213
+ def confirm_deployment_start?(host_groups)
214
+ say_predeploy_status(host_groups)
215
+ if !(force? || json? || options[:yes]) && (ask("Are you sure you wish to deploy? [yN]").downcase != 'y')
216
+ logger.say_status :deploy, "deployment aborted!", :red
217
+ return false
218
+ end
219
+ # Reset the start time for more useful reporting
220
+ @command_start_time = Time.now()
221
+ true
222
+ end
223
+
224
+ def unique_hosts(host_groups)
225
+ # If we're not staging the slug, return just the hosts being installed to
226
+ options[:'no-stage'] ? host_groups.collect {|host_group| host_group.hosts_for_action(:install)}.flatten.uniq { |h| h.name } : host_groups.map(&:hosts).flatten.uniq { |h| h.name }
227
+ end
228
+
229
+ def order_deploy(hosts)
230
+ # Percolate installations to the top, then stripe across batches
231
+ hosts.sort! {|a,b| a.install? ? -1 : 1}
232
+ results=[]
233
+ batches = hosts.count / batch_size(hosts.count)
234
+ hosts.each.with_index {|item, index| results[index % batches].nil? ? results[index % batches] = [item] : results[index % batches] << item }
235
+ results.flatten
236
+ end
237
+
238
+ def join_batch_threads(threads, hosts, logger)
239
+ joined = false
240
+ while !joined
241
+ begin
242
+ threads.map { |ip,thread| thread }.map(&:join)
243
+ joined = true
244
+ rescue Interrupt # Ctrl+C
245
+ logger.say "\nWe are #{elapsed_time} in. Stragglers for this batch:", :magenta
246
+ hosts.reject { |host| host.complete? }.each_with_index do |host, stripe|
247
+ logger.say " #{host.name} (Timeline: #{host.timeline})", stripe.odd? ? :cyan : :yellow
248
+ end
249
+ logger.say "Maybe you should give 'em them the clamps?", :magenta
250
+ case ask("(T)erminate stragglers, (F)ail stragglers, (?) for help, or anything else to keep waiting:").downcase
251
+ when 'f'
252
+ break
253
+ when 't'
254
+ logger.say "Gee, you think? You think that maybe I should use these clamps that I use every day at every opportunity? You're a freakin' genius, you idiot!", :magenta
255
+ hosts.each_with_index do |host, index|
256
+ next if host.complete?
257
+ if host.id.nil? || !host.is_autoscaled?
258
+ logger.say "Can't terminate #{host.name} as it is not part of an autoscaler"
259
+ else
260
+ logger.say "Terminating #{host.name} (#{['Clamp.','Clamp!','Clampity Clamp!'][index%3]})", index.odd? ? :cyan : :yellow
261
+ threads[host.ip].terminate
262
+ host.record_event(:terminated)
263
+ autoscaling.terminate_instance_in_auto_scaling_group(host.id, false)
264
+ end
265
+ end
266
+ when '?'
267
+ straggler_help
268
+ end
269
+ end
270
+ end
271
+ end
272
+
273
+ def straggler_help
274
+ logger.say <<-EOF
275
+
276
+ T) Attempt to terminate the stragglers and let their autoscaling group create new instances. The deploy will end if everyone who had not completed could be terminated.
277
+ F) Mark the remaining stragglers are failed and end the deployment
278
+ ?) Display this help and resume the deploy
279
+ EOF
280
+ end
281
+
282
+ def say_option_status(host_patterns)
283
+ subset_name = if options[:count]
284
+ "#{options[:count]} server#{(options[:count] == 1) ? '' : 's'}"
285
+ elsif options[:percent]
286
+ "#{options[:percent]}% of servers"
287
+ else
288
+ "all servers"
289
+ end
290
+ logger.say_status :deploy, "targeting #{subset_name} for: #{host_patterns.join(', ')}", :green
291
+ end
292
+
293
+ def say_predeploy_status(host_groups)
294
+ total_count = host_groups.inject(0) { |sum, host_group| sum += host_group.hosts.count }
295
+ install_count = 0
296
+ host_groups.each_with_index do |host_group, stripe|
297
+ raise error_class, "Host group #{host_group.name} was empty!" if host_group.hosts.nil?
298
+ install_hosts = host_group.hosts_for_action(:install)
299
+ install_count += install_hosts.count
300
+ install_hosts.each do |host|
301
+ unless json?
302
+ logger.say # Add a newline for cleaner paste into Flowdock
303
+ logger.say "#{host_group.name}: ", stripe.odd? ? :cyan : :yellow
304
+ logger.say "#{host.name}"
305
+ end
306
+ end
307
+ end
308
+ logger.say # Add a newline for cleaner paste into Flowdock
309
+ logger.say_status :deploy, "#{with_units(install_count, 'host')} targeted for installation out of #{with_units(total_count, 'host')} total", :green
310
+
311
+ batch_host_count = options[:'no-stage'] ? host_groups.inject(0) { |sum, host_group| sum += host_group.hosts_for_action(:install).count } : total_count
312
+ batches = (batch_host_count/batch_size(batch_host_count).to_f).ceil
313
+ logger.say_status :deploy, "using #{batches} batch#{batches == 1 ? '' : 'es'} of #{with_units(batch_size(batch_host_count), 'host')} for installation #{options[:'no-stage'] ? '' : 'and staging'} ", :green
314
+ end
315
+
316
+ def with_units(value, unit)
317
+ "#{value} #{unit}#{(value == 1) ? '' : 's'}"
318
+ end
319
+
320
+ def log_rollout_status(host_groups)
321
+ return if host_groups.nil?
322
+ result = { :environment => overall_status, :hostgroups => [] }
323
+ host_groups.each do |host_group|
324
+ host_group.hosts.each do |host|
325
+ result[:hostgroups] << { :group => host_group.name }.merge(host.to_status)
326
+ end
327
+ end
328
+ filename = "slugforge_status-#{date_stamp}.json"
329
+ logger.say "Writing full status report to #{filename}"
330
+ File.open(filename, "w") do |f|
331
+ f.write(JSON.pretty_generate(result))
332
+ end
333
+ purge_old_files 'slugforge_status-*.json'
334
+ end
335
+
336
+ def overall_status
337
+ {
338
+ :command_line => "#{$0} #{$*.join(' ')}",
339
+ :options => @options,
340
+ :ruby_version => RUBY_VERSION,
341
+ :ec2_access_key => @ec2_access_key,
342
+ :s3_access_key => @s3_access_key,
343
+ :git_info => git_info,
344
+ }
345
+ end
346
+
347
+ def purge_old_files(file_mask, keep_count = 10)
348
+ old_files = Dir.glob(file_mask).sort_by{ |f| File.ctime(f) }.reverse.slice(keep_count..-1)
349
+ File.delete(*old_files)
350
+ end
351
+
352
+ def host_groups_for_patterns(host_patterns)
353
+ host_groups = HostGroup.discover(host_patterns, compute)
354
+ raise error_class, "Unable to determine what host or group of hosts you meant with '#{pattern}'." unless host_groups
355
+ # determine unique hosts in each list, then sort by IP (alphabetically) to make partial deploys essentially deterministic
356
+ host_groups
357
+ end
358
+
359
+ def partial_install_groups(host_groups)
360
+ if options[:percent]
361
+ return host_groups.each { |host_group| host_group.install_percent_of_hosts(options[:percent]) }
362
+ elsif options[:count]
363
+ return host_groups.each { |host_group| host_group.install_number_of_hosts(options[:count]) }
364
+ end
365
+ host_groups.each { |host_group| host_group.install_all }
366
+ end
367
+
368
+ private
369
+ def batch_size(host_count = 1)
370
+ if options[:'batch-count'] && host_count >= options[:'batch-count'].to_i
371
+ batch_count = options[:'batch-count'] < 1 ? 1 : options[:'batch-count']
372
+ (host_count / batch_count.to_f).ceil
373
+ elsif options[:'batch-size'] && host_count > options[:'batch-size'].to_i
374
+ batch_size = options[:'batch-size'].to_i
375
+ batch_size = batch_size < 1 ? host_count : batch_size
376
+ else
377
+ host_count > 1 ? host_count : 1
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
383
+