qops 1.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5a359bd27c24df1c4c124864fdc9d409bf43e453
4
+ data.tar.gz: 3d42a3143c38fde591eae2969d8abe4d0be36b20
5
+ SHA512:
6
+ metadata.gz: 4ca05c0d395bbcadfc399ad7a52649f19ebf68520c09acd37bb56c0d86bb9c77cfe0c65dd7639a3c1e6b43c2f9b6f460158a9d94374129806f6832b03b1df78d
7
+ data.tar.gz: cfb7726f54f320898ba5c955babb1d1a17bdbcc5695612a740dbb347f743bd49a82fe51264b59184bd638d89484b73423ea7e101372752a821b3deed301eb93c
@@ -0,0 +1,103 @@
1
+ # Qops : Quandl Operations Helper
2
+
3
+ ## Configuring Qops in your project
4
+
5
+ 1. In your repo's config directory create a file called opsworks.yml. See sample.
6
+ 2. Install gem `qops` to your system. Be sure to use gem install `qops`. Do not include it as part of the bundle of your repo as it is mean't to be run outside of the scope of your project.
7
+ 3. Run `qops list` to get a list of commands you can run.
8
+ 4. Run `qops help <command>` for more information on command argument options
9
+
10
+ ## FAQ:
11
+
12
+ ### Q: The `qops` gem is currently not public. How do I access it?
13
+
14
+ Please add your personal gemfury source to the gem path to install it.
15
+
16
+ ### Q: For the `qops qops:instance:run_command` command, it provides two options: one is run commands against all instances of the stack all in once, one is run commands on each instances of the stack one by one randomly. How do I use this?
17
+
18
+ When running commands one by one, between each execution of the command, there will be a delay. The delay is config by wait_deploy. By default it is 180 seconds when it is not defined. For now, run_command command will only support commends `setup` `configure` `install_dependencies` `update_dependencies`, since commands `update_custom_cookbooks` `deploy` was implemented before.
19
+
20
+ ### Q: How do I use QOPS to override env variables on my opsworks CHEF 11 stack?
21
+
22
+ You can use the custom json flag for this. Example:
23
+
24
+ ```qops qops:instance:up -e staging -j '{ "deploy" : { "wikiposit" : { "environment_variables" : { "ENV_VARIABLE_TO_OVERIDE": "X" } } } }'```
25
+
26
+ In this case the `wikiposit` is the opsworks app wikiposit. You will need to change this to whichever app you are deploying.
27
+
28
+ See also: http://docs.aws.amazon.com/opsworks/latest/userguide/apps-environment-vars.html
29
+
30
+ ## Sample Config (with all options)
31
+
32
+ ```
33
+ _daily_schedule: &daily_schedule
34
+ '13': 'on'
35
+ '14': 'on'
36
+ '15': 'on'
37
+ '16': 'on'
38
+ '17': 'on'
39
+ '18': 'on'
40
+ '19': 'on'
41
+ '20': 'on'
42
+ '21': 'on'
43
+ '22': 'on'
44
+
45
+ _weekly_schedule: &weekly_schedule
46
+ monday: *daily_schedule
47
+ tuesday: *daily_schedule
48
+ wednesday: *daily_schedule
49
+ thursday: *daily_schedule
50
+ friday: *daily_schedule
51
+
52
+ _default: &default
53
+ wait_iterations: 600 # Optional
54
+ command_log_lines: 100 # Optional
55
+ autoscale_type: ~ # Optional
56
+ region: us-east-1
57
+ app_name: 'wikiposit'
58
+ instance_type: 't2.small'
59
+ max_instance_duration: 86400 # Optional
60
+ clean_commands_to_ignore: ['configure', 'shutdown] # Optional: A list of opsworks commands to ignore when calculating that last run time for the clean command. Ignores `configure` and `shutdown` commands by default.
61
+ cookbook_dir: cookbooks
62
+ cookbook_name: wikiposit
63
+ cookbook_version: "<%= IO.read(File.join(Dir.pwd, 'cookbooks/VERSION')).strip %>"
64
+ cookbook_s3_bucket: quandl-cookbooks
65
+
66
+ staging:
67
+ <<: *default
68
+ deploy_type: :staging
69
+ stack_id: 1aec9354-e1bc-4f31-8627-2208f2382dcb
70
+ layer_id: 622555c4-fc07-4ff1-ba79-0a63fbd233f5
71
+ application_id: 15904509-ace3-4d78-923e-ea10b3b2d433 # Optional. Deploy command will not run without application ID.
72
+ subnet: subnet-0cde5d27
73
+ cookbook_s3_path: staging/my-app
74
+ ```
75
+
76
+ ## Sample Slack.yml
77
+
78
+ If you create a `config/quandl/slack.yml` file as so slack messages can be enabled for deployments under various environments.
79
+
80
+ ```
81
+ defaults: &defaults
82
+ webhook_url: https://hooks.slack.com/services/......
83
+ notifiers: &default_notifiers
84
+ cookbook:
85
+ channel: '#releases'
86
+ username: My App Cookbooks
87
+ icon_emoji: ':book:'
88
+ release:
89
+ channel: '#releases'
90
+ username: My App
91
+ icon_emoji: ':rocket:'
92
+ instance_up:
93
+ channel: '#releases'
94
+ username: My App
95
+ icon_emoji: ':chart_with_upwards_trend:'
96
+ instance_down:
97
+ channel: '#releases'
98
+ username: My App
99
+ icon_emoji: ':chart_with_downwards_trend:'
100
+
101
+ development:
102
+ <<: *defaults
103
+ ```
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ $LOAD_PATH.unshift(File.dirname(File.realpath(__FILE__)) + '/../lib')
5
+
6
+ require 'thor/runner'
7
+ require 'qops'
8
+
9
+ $thor_runner = true
10
+ Thor::Runner.start
@@ -0,0 +1,32 @@
1
+ require 'thor'
2
+ require 'thor/group'
3
+ require 'aws-sdk'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require 'active_support/all'
7
+ require 'pp'
8
+ require 'optparse'
9
+ require 'erb'
10
+ require 'rainbow'
11
+
12
+ require 'quandl/slack'
13
+
14
+ require_relative 'qops/environment'
15
+ require_relative 'qops/helpers'
16
+ require_relative 'qops/deployment/helpers'
17
+ require_relative 'qops/deployment/app'
18
+ require_relative 'qops/deployment/instances'
19
+ require_relative 'qops/cookbook/cookbook'
20
+
21
+ # Migrate this into quandl config project
22
+ module Quandl
23
+ class Config < ::OpenStruct
24
+ cattr_accessor :environment
25
+
26
+ private
27
+
28
+ def project_environment
29
+ @_environment ||= @@environment || (defined?(Rails) ? ::Rails.env : nil) || ENV['RAILS_ENV'] || ENV['RAKE_ENV'] || ENV['QUANDL_ENV'] || 'default'
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,160 @@
1
+ class Qops::Cookbook < Thor
2
+ include Qops::Helpers
3
+
4
+ desc 'vendor', 'Generate vendor directory to contain the cookbooks'
5
+ def vendor
6
+ initialize_run
7
+ cleanup
8
+ Dir.chdir(config.cookbook_dir) do
9
+ system('berks vendor vendor -e opsworks')
10
+ end
11
+ end
12
+
13
+ desc 'package', 'Package the cookbooks into a zip file in vendor'
14
+ def package
15
+ initialize_run
16
+ Dir.chdir(config.cookbook_dir) do
17
+ remove_zip_files
18
+ system("zip -r #{artifact_name} vendor/*")
19
+ end
20
+ end
21
+
22
+ desc 'upload', 'Uploads cookbooks to s3'
23
+ def upload
24
+ initialize_run
25
+ s3.put_object(
26
+ bucket: config.cookbook_s3_bucket,
27
+ acl: 'private',
28
+ key: remote_artifact_file,
29
+ body: IO.read(local_artifact_file)
30
+ )
31
+ end
32
+
33
+ desc 'update_custom_json', 'Upload custom json to stack'
34
+ def update_custom_json
35
+ initialize_run
36
+ raw_json = File.read(File.join(config.cookbook_dir, config.cookbook_json))
37
+ json = JSON.parse(raw_json)
38
+
39
+ say(JSON.pretty_generate(json), :yellow)
40
+ if yes?("Are you sure you want to update the custom JSON for opsworks stack #{config.stack_id}?", :yellow)
41
+ config.opsworks.update_stack(
42
+ stack_id: config.stack_id,
43
+ custom_json: JSON.pretty_generate(json)
44
+ )
45
+ say('Updated!', :green)
46
+ else
47
+ say('You said no, so we\'re done here.', :yellow)
48
+ end
49
+ rescue JSON::ParserError
50
+ say('Check your JSON for errors!', :red)
51
+ end
52
+
53
+ desc 'update_stack_cookbooks', 'Runs the opsworks command to update custom cookbooks.'
54
+ def update_stack_cookbooks
55
+ initialize_run
56
+ if yes?("Are you sure you want to run the 'Update Custom Cookbooks' command on stack #{config.stack_id}?", :yellow)
57
+ run_opsworks_command(
58
+ stack_id: config.stack_id,
59
+ command: {
60
+ name: 'update_custom_cookbooks'
61
+ }
62
+ )
63
+ say('Updated!', :green)
64
+ else
65
+ say('You said no, so we\'re done here.', :yellow)
66
+ exit(-1)
67
+ end
68
+ end
69
+
70
+ desc 'release', 'Zip, package and update a new cookbook as a release.'
71
+ def release
72
+ vendor && package && upload && update_custom_cookbooks && update_stack_cookbooks
73
+
74
+ ping_slack('Quandl::Slack::Cookbook', 'Cookbook updated', 'success',
75
+ command: 'opsworks cookbook release',
76
+ status: 'success',
77
+ name: config.cookbook_name,
78
+ version: config.cookbook_version,
79
+ stack: config.stack_id
80
+ )
81
+
82
+ say('Released!', :green)
83
+ end
84
+
85
+ desc 'update_custom_cookbooks', 'Update the stack with the custom cookbooks!'
86
+ def update_custom_cookbooks
87
+ if yes?("Are you sure you want to update the custom the custom cookbook for opsworks stack #{config.stack_id}?", :yellow)
88
+ config.opsworks.update_stack(
89
+ stack_id: config.stack_id,
90
+ use_custom_cookbooks: true,
91
+ custom_cookbooks_source: {
92
+ type: 's3',
93
+ url: "https://s3.amazonaws.com/#{config.cookbook_s3_bucket}/#{remote_artifact_file}"
94
+ })
95
+ say('Cookbooks updated', :green)
96
+ else
97
+ say('You said no, so we\'re done here.', :yellow)
98
+ exit(-1)
99
+ end
100
+ end
101
+
102
+ desc 'cleanup', 'Cleanup all temporary files from the cookbook directory'
103
+ def cleanup
104
+ Dir.chdir(config.cookbook_dir) do
105
+ remove_zip_files
106
+ FileUtils.remove_dir('vendor') if File.directory?('vendor')
107
+ say("Cleaned up directory '#{config.cookbook_dir}/vendor'", :green)
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def initialize_run
114
+ super
115
+ config
116
+ end
117
+
118
+ def config
119
+ return @_config if @_config
120
+
121
+ @_config ||= Qops::Environment.new
122
+
123
+ %w(cookbook_dir cookbook_s3_bucket cookbook_s3_path cookbook_name cookbook_version).each do |var|
124
+ fail ArgumentError.new("Must specify a '#{var}' in the config") if !@_config.respond_to?(var) && !@_config.configuration.respond_to?(var)
125
+ end
126
+
127
+ fail ArgumentError.new("Cannot find/do not have access to cookbook directory: #{@_config.cookbook_dir}") unless Dir.exist?(@_config.cookbook_dir)
128
+
129
+ @_config
130
+ end
131
+
132
+ def s3
133
+ @s3 ||= Aws::S3::Client.new(
134
+ region: 'us-east-1',
135
+ access_key_id: config.opsworks.config.credentials.access_key_id,
136
+ secret_access_key: config.opsworks.config.credentials.secret_access_key
137
+ )
138
+ end
139
+
140
+ def local_artifact_file
141
+ File.join(config.cookbook_dir, artifact_name)
142
+ end
143
+
144
+ def remote_artifact_file
145
+ File.join(config.cookbook_s3_path, artifact_name)
146
+ end
147
+
148
+ def artifact_name
149
+ "#{config.cookbook_name}-#{config.cookbook_version}.zip"
150
+ end
151
+
152
+ def vendor_dir
153
+ File.join(config.cookbook_dir, 'vendor')
154
+ end
155
+
156
+ def remove_zip_files
157
+ FileUtils.rm Dir.glob("#{config.cookbook_name}*.zip")
158
+ say("Cleaned up directory '#{config.cookbook_dir}/*.zip'", :green)
159
+ end
160
+ end
@@ -0,0 +1,117 @@
1
+ class Qops::Deploy < Thor
2
+ include Qops::DeployHelpers
3
+
4
+ class_option :custom_json, type: :string, aliases: '-j', desc: 'A custom json that will be used during a deployment of the app. ex: \'{ "custom_attrs": "are awesome!"}\''
5
+
6
+ desc 'app', 'Deploy the latest version of the app'
7
+ def app
8
+ initialize_run
9
+
10
+ instances = if config.deploy_type == 'staging'
11
+ [retrieve_instance].compact
12
+ else
13
+ retrieve_instances
14
+ end
15
+ online_instances = instances.select { |instance| instance.status == 'online' }
16
+
17
+ if online_instances.count == 0
18
+ raise 'Could not find any running instance(s) to deploy to. Perhaps you need to run "qops:instance:up" first'
19
+ end
20
+
21
+ if config.deploy_type == 'staging'
22
+ puts "Preparing to deploy branch #{default_revision} to instance #{online_instances.first.hostname}"
23
+ else
24
+ puts "Preparing to deploy default branch to all (online) servers (#{online_instances.map(&:hostname).join(', ')})"
25
+ end
26
+
27
+ base_deployment_params = {
28
+ stack_id: config.stack_id,
29
+ command: { name: 'deploy' }
30
+ }
31
+
32
+ if !config.application_id
33
+ Qops::Environment.print_with_colour('No application specified. Exiting without application deployment.')
34
+ exit(0)
35
+ else
36
+ base_deployment_params[:app_id] = config.application_id
37
+ end
38
+
39
+ if config.deploy_type != 'production'
40
+ base_deployment_params[:custom_json] = custom_json.to_json
41
+ end
42
+
43
+ manifest = { environment: config.deploy_type }
44
+
45
+ # Deploy the first instance with migration on
46
+ first_instance = online_instances.first
47
+ print "Migrating and deploying first instance (#{first_instance.hostname}) ..."
48
+ deployment_params = base_deployment_params.deep_dup
49
+ should_migrate = !config.option?(:migrate) || config.option?(:migrate) && config.migrate == true
50
+ deployment_params[:command][:args] = { migrate: ['true'] } if should_migrate
51
+ run_opsworks_command(deployment_params, [first_instance.instance_id])
52
+ ping_slack(
53
+ 'Quandl::Slack::Release',
54
+ "Deployed and migrated instance '#{first_instance.hostname}'",
55
+ 'success',
56
+ manifest.merge(
57
+ app_name: config.app_name,
58
+ command: 'deploy + migrate',
59
+ migrate: should_migrate.to_s,
60
+ completed: Time.now,
61
+ hostname: first_instance.hostname,
62
+ instance_id: first_instance.instance_id
63
+ )
64
+ )
65
+
66
+ # Deploy any remaining instances with migration off for production
67
+ return unless config.deploy_type == 'production' && online_instances.count > 1
68
+
69
+ print 'Deploying remaining instances ...'
70
+ deployment_params = base_deployment_params.deep_dup
71
+ run_opsworks_command(deployment_params)
72
+ ping_slack(
73
+ 'Quandl::Slack::Release',
74
+ 'Deployed All Instances',
75
+ 'success',
76
+ manifest.merge(
77
+ app_name: config.app_name,
78
+ command: 'deploy',
79
+ migrate: false,
80
+ completed: Time.now,
81
+ hostname: online_instances.map(&:hostname),
82
+ instance_id: online_instances.map(&:instance_id)
83
+ )
84
+ )
85
+ end
86
+
87
+ private
88
+
89
+ def custom_json
90
+ return @_custom_json if @_custom_json
91
+
92
+ @_custom_json = {}
93
+
94
+ if config.application_id
95
+ application_name = config.opsworks.describe_apps(app_ids: [config.application_id]).apps.first.name
96
+
97
+ @_custom_json[:deploy] = {
98
+ application_name => {
99
+ scm: {
100
+ revision: default_revision
101
+ }
102
+ }
103
+ }
104
+ end
105
+
106
+ if options[:custom_json].present?
107
+ your_json = JSON.parse(options[:custom_json])
108
+ @_custom_json.merge!(your_json)
109
+ puts "Using custom json:\n#{JSON.pretty_generate(@_custom_json)}"
110
+ end
111
+
112
+ @_custom_json
113
+ rescue JSON::ParserError
114
+ say('Your custom json has invalid syntax. Failing ...', :red)
115
+ exit(-1)
116
+ end
117
+ end
@@ -0,0 +1,74 @@
1
+ module Qops::DeployHelpers
2
+ extend ActiveSupport::Concern
3
+
4
+ include Qops::Helpers
5
+
6
+ included do
7
+ class_option :branch, type: :string, aliases: '-b', desc: 'The branch to use when deploying to staging type environments'
8
+ end
9
+
10
+ private
11
+
12
+ def config
13
+ return @_config if @_config
14
+
15
+ Qops::Environment.notifiers
16
+ @_config ||= Qops::Environment.new
17
+
18
+ fail "Invalid configure deploy_type detected: #{@_config.deploy_type}" unless %w(staging production).include?(@_config.deploy_type)
19
+
20
+ @_config
21
+ end
22
+
23
+ def retrieve_instances(options = {})
24
+ # Describe and create instances as necessary
25
+ instances_results = config.opsworks.describe_instances({ layer_id: config.layer_id }.merge(options))
26
+
27
+ # Determine if instance exists.
28
+ instances_results.data.instances
29
+ end
30
+
31
+ def retrieve_instance(instance_id = nil)
32
+ # Retrieve a specific instance as necessary
33
+ if instance_id
34
+ instances_results = config.opsworks.describe_instances(instance_ids: [instance_id])
35
+ return instances_results.data.instances.first
36
+ end
37
+
38
+ # Get instance based on hostname
39
+ instances_results = config.opsworks.describe_instances(layer_id: config.layer_id)
40
+
41
+ # Determine if instance exists.
42
+ instances = instances_results.data.instances
43
+
44
+ return unless instances.map(&:hostname).include?(requested_hostname)
45
+
46
+ instances.find { |k| k.hostname == requested_hostname }
47
+ end
48
+
49
+ def requested_hostname
50
+ return @requested_hostname if @requested_hostname
51
+ if config.deploy_type == 'staging'
52
+ @requested_hostname = default_revision.parameterize
53
+ elsif config.deploy_type == 'production'
54
+ @requested_hostname = config.app_name
55
+ existing_hostnames = retrieve_instances.map(&:hostname)
56
+ @requested_hostname += "-#{existing_hostnames.sort.last.to_s.split('-').last.to_i + 1}"
57
+ end
58
+
59
+ @requested_hostname = @requested_hostname.gsub(/[^A-Za-z0-9\-]+/, '-').gsub(/-+/, '-')
60
+ @requested_hostname = @requested_hostname[0..62]
61
+ @requested_hostname = @requested_hostname.match(/^([A-Za-z0-9\-]+).*$/)[1]
62
+ end
63
+
64
+ def default_revision
65
+ return 'master' unless config.deploy_type == 'staging'
66
+ if options[:branch].present?
67
+ options[:branch]
68
+ elsif `git --version` # rubocop:disable Lint/LiteralInCondition
69
+ `git symbolic-ref --short HEAD`.strip
70
+ else
71
+ 'master'
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,351 @@
1
+ class Qops::Instance < Thor # rubocop:disable Metrics/ClassLength
2
+ include Qops::DeployHelpers
3
+
4
+ desc 'up', 'Deploy the current branch to new or existing instance(s)'
5
+ def up
6
+ initialize_run
7
+
8
+ # Get the instance(s) to work with if they exist. In production we always create a new instacne
9
+ instance = retrieve_instance if config.deploy_type == 'staging'
10
+
11
+ # Create the instance if necessary
12
+ if instance
13
+ instance_id = instance.instance_id
14
+ puts "Existing instance #{requested_hostname}"
15
+ else
16
+ params = {
17
+ stack_id: config.stack_id,
18
+ layer_ids: [config.layer_id],
19
+ instance_type: config.instance_type,
20
+ os: 'Ubuntu 14.04 LTS',
21
+ hostname: requested_hostname,
22
+ subnet_id: config.subnet,
23
+ auto_scaling_type: config.autoscale_type,
24
+ architecture: 'x86_64',
25
+ root_device_type: 'ebs',
26
+ block_device_mappings: [
27
+ {
28
+ device_name: 'ROOT_DEVICE',
29
+ ebs: {
30
+ volume_size: config.root_volume_size,
31
+ volume_type: 'gp2',
32
+ delete_on_termination: true
33
+ }
34
+ }
35
+ ],
36
+ ebs_optimized: config.deploy_type =~ /production/
37
+ }
38
+ puts 'Creating instance with params: ' + params.inspect
39
+ instance_id = config.opsworks.create_instance(params).data.instance_id
40
+ creating_instance = true
41
+ end
42
+
43
+ instance_results = config.opsworks.describe_instances(instance_ids: [instance_id])
44
+ instance = instance_results.data.instances.first
45
+
46
+ # Set up the automatic boot scheduler
47
+ if config.autoscale_type == 'timer'
48
+ print 'Setting up weekly schedule ...'
49
+ config.opsworks.set_time_based_auto_scaling(instance_id: instance_id, auto_scaling_schedule: config.schedule)
50
+ print "done\n"
51
+ end
52
+
53
+ # Record the initial instance before doing anything.
54
+ initial_instance_state = instance
55
+
56
+ # Start the instance if necessary
57
+ print 'Booting instance ...'
58
+ unless %w(online booting).include?(instance.status)
59
+ config.opsworks.start_instance(instance_id: instance_id)
60
+ end
61
+
62
+ manifest = {
63
+ environment: config.deploy_type,
64
+ app_name: config.app_name,
65
+ command: 'add instance'
66
+ }
67
+
68
+ # Boot the instance
69
+ iterator(manifest) do |i|
70
+ instance_results = config.opsworks.describe_instances(instance_ids: [instance_id])
71
+ instance = instance_results.data.instances.first
72
+
73
+ if %w(booting requested pending).include?(instance.status)
74
+ print '.'
75
+ print " #{instance.status} :" if been_a_minute?(i)
76
+ else
77
+ puts ' ' + instance.status
78
+ true
79
+ end
80
+ end
81
+
82
+ puts "Public IP: #{instance.public_ip}"
83
+ puts "Private IP: #{instance.private_ip}"
84
+
85
+ setup_instance(instance, initial_instance_state, manifest)
86
+
87
+ if creating_instance
88
+ ping_slack(
89
+ 'Quandl::Slack::InstanceUp',
90
+ 'Created another instance',
91
+ 'success',
92
+ manifest.merge(
93
+ completed: Time.now,
94
+ hostname: instance.hostname,
95
+ instance_id: instance.instance_id,
96
+ private_ip: instance.private_ip,
97
+ public_ip: instance.public_ip.blank? ? 'N/A' : instance.public_ip
98
+ )
99
+ )
100
+ end
101
+
102
+ # For Elasticsearch cluster, register with public elb
103
+ if config.option?(:public_search_elb)
104
+ print "Register instance #{instance.ec2_instance_id} to elb #{config.public_search_elb}"
105
+ elb.register_instances_with_load_balancer(load_balancer_name: config.public_search_elb.to_s,
106
+ instances: [{ instance_id: instance.ec2_instance_id.to_s }])
107
+ end
108
+
109
+ # Deploy the latest code to instance
110
+ Qops::Deploy.new([], options).app
111
+ end
112
+
113
+ desc 'down', 'Remove the instance associated with the given branch'
114
+ def down
115
+ initialize_run
116
+
117
+ # Get the instance to shutdown
118
+ if config.deploy_type == 'staging'
119
+ instance = retrieve_instance
120
+ elsif config.deploy_type == 'production'
121
+ instance = retrieve_instances.first
122
+ end
123
+
124
+ if instance.nil?
125
+ puts 'No instance available to shutdown'
126
+ exit(0)
127
+ else
128
+ instance_id = instance.instance_id
129
+ end
130
+
131
+ terminate_instance(instance_id)
132
+
133
+ puts 'Success'
134
+ end
135
+
136
+ desc 'rebuild', 'Runs the down then up command to rebuild an instance.'
137
+ def rebuild
138
+ down
139
+ up
140
+ end
141
+
142
+ desc 'clean', 'Cleans up old instances in staging type environments'
143
+ def clean
144
+ initialize_run
145
+
146
+ if config.deploy_type == 'production'
147
+ fail "Cannot clean instances in a #{config.deploy_type} environment"
148
+ end
149
+
150
+ terminated_instances = []
151
+
152
+ # Find all instances to be destroyed
153
+ retrieve_instances.each do |instance|
154
+ next if instance.hostname == 'master'
155
+
156
+ # Find the latest command since the instance was deployed
157
+ latest_command = Time.parse(instance.created_at)
158
+ config.opsworks.describe_commands(instance_id: instance.instance_id).commands.each do |command|
159
+ next if config.clean_commands_to_ignore.include?(command.type)
160
+ completed_at = Time.parse(command.completed_at || command.acknowledged_at || command.created_at)
161
+ latest_command = completed_at if completed_at > latest_command
162
+ end
163
+
164
+ # If the latest deployment is greater than the maximum alive time allowed remove the instance.
165
+ if Time.now.to_i - latest_command.to_i > config.max_instance_duration
166
+ terminate_instance(instance.instance_id)
167
+ terminated_instances << instance
168
+ end
169
+ end
170
+
171
+ if terminated_instances.any?
172
+ puts "Terminated instances: #{terminated_instances.map(&:hostname).join("\n")}"
173
+ else
174
+ puts 'No unused instances old enough to terminate.'
175
+ end
176
+ end
177
+
178
+ desc 'run_command', 'Run command on existing instance(s) at once or each one by one'
179
+ def run_command
180
+ initialize_run
181
+ instances = retrieve_instances
182
+
183
+ puts "Preparing to run command to all servers (#{instances.map(&:hostname).join(', ')})"
184
+
185
+ command = ask('Which command you want to execute?', limited_to: %w(setup configure install_dependencies update_dependencies))
186
+
187
+ option = ask('Which command you want to execute?', limited_to: %w(all_in_once one_by_one))
188
+
189
+ base_deployment_params = {
190
+ stack_id: config.stack_id,
191
+ command: { name: command.to_s }
192
+ }
193
+
194
+ manifest = { environment: config.deploy_type }
195
+
196
+ case option
197
+ when 'all_in_once'
198
+ print "Run command #{command} on all instances at once ..."
199
+ deployment_params = base_deployment_params.deep_dup
200
+ run_opsworks_command(deployment_params)
201
+ ping_slack(
202
+ 'Quandl::Slack::Release',
203
+ "Run command: `#{command}` on all instances",
204
+ 'success',
205
+ manifest.merge(
206
+ app_name: config.app_name,
207
+ command: 'deploy',
208
+ migrate: false,
209
+ completed: Time.now,
210
+ hostname: instances.map(&:hostname),
211
+ instance_id: instances.map(&:instance_id)
212
+ )
213
+ )
214
+ else
215
+ instances.each do |instance|
216
+ print "Run command #{command} on instance #{instance.ec2_instance_id}"
217
+
218
+ run_opsworks_command(base_deployment_params, [instance.instance_id])
219
+
220
+ ping_slack('Quandl::Slack::InstanceDown', "Run command: `#{command}` on existing instance", 'success',
221
+ manifest.merge(
222
+ completed: Time.now,
223
+ hostname: instance.hostname,
224
+ instance_id: instance.instance_id,
225
+ private_ip: instance.private_ip,
226
+ public_ip: instance.public_ip
227
+ )
228
+ )
229
+ puts 'Success'
230
+ break if instance.instance_id == instances.last.instance_id
231
+ delay = config.wait_deploy
232
+ puts "wait for #{delay / 60.0} mintues"
233
+ sleep delay
234
+ end
235
+ end
236
+ end
237
+
238
+ private
239
+
240
+ def elb
241
+ @elb ||= Aws::ElasticLoadBalancing::Client.new(
242
+ region: 'us-east-1',
243
+ access_key_id: config.opsworks.config.credentials.access_key_id,
244
+ secret_access_key: config.opsworks.config.credentials.secret_access_key)
245
+ end
246
+
247
+ def setup_instance(instance, initial_instance_state, manifest)
248
+ # If the previous instance setup failed then run the setup task again when trying to bring up the instance.
249
+ if initial_instance_state.status == 'setup_failed'
250
+ print 'Setup instance ...'
251
+ run_opsworks_command(
252
+ {
253
+ stack_id: config.stack_id,
254
+ command: {
255
+ name: 'setup'
256
+ }
257
+ },
258
+ [instance.instance_id]
259
+ )
260
+
261
+ # Monitor the existing instance setup.
262
+ else
263
+ print 'Setup instance ...'
264
+ iterator(manifest) do |i|
265
+ instance_results = config.opsworks.describe_instances(instance_ids: [instance.instance_id])
266
+ instance = instance_results.data.instances.first
267
+
268
+ if %w(online).include?(instance.status)
269
+ puts ' ' + instance.status
270
+ true
271
+ elsif %w(setup_failed).include?(instance.status)
272
+ puts ' ' + instance.status
273
+ read_failure_log(
274
+ { instance_id: instance.instance_id },
275
+ last_only: true,
276
+ manifest: manifest.merge(
277
+ hostname: instance.hostname,
278
+ instance_id: instance.instance_id,
279
+ private_ip: instance.private_ip,
280
+ public_ip: instance.public_ip
281
+ )
282
+ )
283
+ exit(-1)
284
+ else
285
+ print '.'
286
+ print " #{instance.status} :" if been_a_minute?(i)
287
+ end
288
+ end
289
+ end
290
+ end
291
+
292
+ def terminate_instance(instance_id)
293
+ # Remove schedule if time based instance
294
+ if config.autoscale_type == 'timer'
295
+ config.opsworks.set_time_based_auto_scaling(instance_id: instance_id, auto_scaling_schedule: {})
296
+ end
297
+
298
+ # Get the instance from the id
299
+ instance = retrieve_instance(instance_id)
300
+
301
+ # For Elasticsearch cluster, remove from from public elb
302
+ if config.option?(:public_search_elb)
303
+ elb.deregister_instances_from_load_balancer(
304
+ load_balancer_name: config.public_search_elb.to_s,
305
+ instances: [{ instance_id: instance.ec2_instance_id.to_s }]
306
+ )
307
+ end
308
+
309
+ # Attempt to shutdown the instance
310
+ print "Attempting instance #{instance_id}-#{instance.hostname} shutdown ..."
311
+ unless instance.status == 'stopped'
312
+ config.opsworks.stop_instance(instance_id: instance_id)
313
+ end
314
+
315
+ manifest = {
316
+ environment: config.deploy_type,
317
+ app_name: config.app_name,
318
+ command: 'remove instance'
319
+ }
320
+
321
+ iterator(manifest) do |i|
322
+ instance_results = config.opsworks.describe_instances(instance_ids: [instance_id])
323
+ instance = instance_results.data.instances.first
324
+
325
+ if instance.status == 'stopped'
326
+ puts ' ' + instance.status
327
+ true
328
+ else
329
+ print '.'
330
+ print " #{instance.status} :" if been_a_minute?(i)
331
+ end
332
+ end
333
+
334
+ # Terminate the instance
335
+ puts "Terminating instance #{instance_id}"
336
+ config.opsworks.delete_instance(instance_id: instance_id, delete_volumes: true)
337
+
338
+ ping_slack(
339
+ 'Quandl::Slack::InstanceDown',
340
+ 'Remove existing instance',
341
+ 'success',
342
+ manifest.merge(
343
+ completed: Time.now,
344
+ hostname: instance.hostname,
345
+ instance_id: instance.instance_id,
346
+ private_ip: instance.private_ip,
347
+ public_ip: instance.public_ip
348
+ )
349
+ )
350
+ end
351
+ end
@@ -0,0 +1,110 @@
1
+ require 'quandl/config'
2
+
3
+ module Qops
4
+ class Environment
5
+ include Quandl::Configurable
6
+
7
+ def self.file_name
8
+ 'opsworks'
9
+ end
10
+
11
+ def self.notifiers
12
+ return @_notifiers unless @_notifiers.nil?
13
+ if File.exist?('config/quandl/slack.yml')
14
+ @_notifiers ||= Quandl::Slack.autogenerate_notifiers
15
+ else
16
+ @_notifiers = false
17
+ print_with_colour('Slack notifications disabled. Could not find slack configuration at: config/quandl/slack.yml', :warning)
18
+ end
19
+ rescue NoMethodError => e
20
+ print_with_colour("Slack notifications disabled due to an error. #{e}", :warning)
21
+ end
22
+
23
+ def self.print_with_colour(message, level = :normal)
24
+ case level
25
+ when :error
26
+ puts Rainbow(message).bg(:black).red
27
+ when :warning
28
+ puts Rainbow(message).bg(:black).yellow
29
+ else
30
+ puts message
31
+ end
32
+ end
33
+
34
+ def initialize
35
+ %w(deploy_type region stack_id app_name).each do |v|
36
+ fail "Please configure #{v} before continuing." unless option?(v)
37
+ end
38
+
39
+ begin
40
+ opsworks.config.credentials.access_key_id
41
+ opsworks.config.credentials.secret_access_key
42
+ rescue => e
43
+ raise "There may be a problem with your aws credentials. Please correct with `aws configure`. Error: #{e}"
44
+ end
45
+ end
46
+
47
+ def application_id
48
+ configuration.application_id unless configuration.application_id.blank?
49
+ end
50
+
51
+ def deploy_type
52
+ configuration.deploy_type.to_s
53
+ end
54
+
55
+ def command_log_lines
56
+ configuration.command_log_lines || 100
57
+ end
58
+
59
+ def wait_iterations
60
+ configuration.wait_iterations || 600
61
+ end
62
+
63
+ def wait_deploy
64
+ configuration.wait_deploy || 180
65
+ end
66
+
67
+ def autoscale_type
68
+ configuration.autoscale_type || nil
69
+ end
70
+
71
+ # Default 1 days
72
+ def max_instance_duration
73
+ configuration.max_instance_duration || 86_400
74
+ end
75
+
76
+ def clean_commands_to_ignore
77
+ configuration.clean_commands_to_ignore.present? ? configuration.clean_commands_to_ignore : %w(configure shutdown)
78
+ end
79
+
80
+ def file_name
81
+ self.class.file_name
82
+ end
83
+
84
+ def opsworks
85
+ @_opsworks ||= Aws::OpsWorks::Client.new(region: configuration.region)
86
+ end
87
+
88
+ def cookbook_json
89
+ configuration.cookbook_json || 'custom.json'
90
+ end
91
+
92
+ def option?(key)
93
+ respond_to?(key.to_sym) || configuration.instance_variable_get(:@table).keys.include?(key.to_sym)
94
+ end
95
+
96
+ def root_volume_size
97
+ configuration.root_volume_size || 30
98
+ end
99
+
100
+ private
101
+
102
+ def method_missing(method_sym, *arguments, &block)
103
+ if configuration.respond_to?(method_sym)
104
+ configuration.send(method_sym, *arguments, &block)
105
+ else
106
+ super
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,153 @@
1
+ module Qops::Helpers
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ class_option :environment, aliases: '-e', desc: 'The environment to use when running commands.'
6
+ end
7
+
8
+ private
9
+
10
+ def initialize_run
11
+ return if @_run_initialized
12
+
13
+ @_run_initialized = true
14
+ verify_opsworks_config
15
+ verify_environment_selection
16
+ Qops::Environment.notifiers
17
+ end
18
+
19
+ def been_a_minute?(i)
20
+ i > 1 && i % 60 == 0
21
+ end
22
+
23
+ def iterator(options)
24
+ config.wait_iterations.times.each do |i|
25
+ result = yield(i)
26
+ break if result
27
+
28
+ if i + 1 == config.wait_iterations
29
+ puts " failed to complete within #{config.wait_iterations} seconds"
30
+ ping_slack('Quandl::Slack::Release', 'Command timeout', 'failure',
31
+ options.merge(failed_at: Time.now)
32
+ )
33
+ exit(-1)
34
+ elsif been_a_minute?(i)
35
+ print " #{i / 60} minute(s) "
36
+ end
37
+
38
+ sleep(1)
39
+ end
40
+ end
41
+
42
+ def ping_slack(notifier, message, status, manifest)
43
+ fields = manifest.keys
44
+ fields.map! { |field| { title: field, value: manifest[field], short: true } }
45
+
46
+ if Object.const_defined?(notifier)
47
+ notifier_class = notifier.constantize
48
+
49
+ notifier_class.ping("#{config.app_name}: #{message}",
50
+ attachments: [{ color: status, mrkdwn_in: ['text'], fallback: 'Details', fields: fields }]
51
+ )
52
+ else
53
+ puts "#{config.app_name}: #{message}"
54
+ pp fields
55
+ end
56
+ end
57
+
58
+ def run_opsworks_command(deployment_params, instance_ids = [])
59
+ deployment_params[:instance_ids] = instance_ids unless instance_ids.empty?
60
+
61
+ # Create the deployment
62
+ deployment_results = config.opsworks.create_deployment(deployment_params)
63
+ deployment_id = deployment_results.data.deployment_id
64
+
65
+ iterator(deployment_params) do |i|
66
+ deployment_results = config.opsworks.describe_deployments(deployment_ids: [deployment_id])
67
+ deployment = deployment_results.data.deployments.first
68
+
69
+ if deployment.completed_at
70
+ puts ' ' + deployment.status
71
+
72
+ if deployment.status != 'successful'
73
+ read_failure_log(deployment_id: deployment.deployment_id)
74
+ exit(-1)
75
+ end
76
+
77
+ true
78
+ else
79
+ print '.'
80
+ if been_a_minute?(i)
81
+ print " #{retrieve_instance.status} :" if config.deploy_type == :staging && instance_ids.any?
82
+ print " #{deployment.status} :" if config.deploy_type == :production
83
+ end
84
+ end
85
+ end
86
+
87
+ print "\n"
88
+ end
89
+
90
+ def read_failure_log(opsworks_options, options = {})
91
+ results = config.opsworks.describe_commands(opsworks_options)
92
+ results.commands.each do |command|
93
+ if command.log_url
94
+ puts "\nReading last 100 lines from #{command.log_url}\n"
95
+ lines = open(command.log_url).read.split("\n")
96
+ num_lines = lines.count < config.command_log_lines ? lines.count : config.command_log_lines
97
+ puts open(command.log_url).read.split("\n")[-1 * num_lines..-1].join("\n")
98
+ puts "\nLog file at: #{command.log_url}"
99
+ end
100
+
101
+ ping_slack(
102
+ 'Quandl::Slack::Release',
103
+ 'Deployment failure',
104
+ 'failure',
105
+ (options[:manifest] || {}).merge(
106
+ command: command.type,
107
+ status: command.status
108
+ )
109
+ )
110
+
111
+ exit(-1) if options[:last_only]
112
+ end
113
+
114
+ exit(-1)
115
+ end
116
+
117
+ def verify_opsworks_config
118
+ return if File.exist?("config/#{Qops::Environment.file_name}.yml")
119
+ raise "Could not find configuration file: config/#{Qops::Environment.file_name}.yml"
120
+ end
121
+
122
+ def verify_environment_selection
123
+ return if Quandl::Config.environment
124
+
125
+ project_root = Pathname.new(Quandl::ProjectRoot.root)
126
+ file_path = project_root.join('config', "#{Qops::Environment.file_name}.yml")
127
+
128
+ if File.exist?(file_path) # rubocop:disable Style/GuardClause
129
+ raw_config = File.read(file_path)
130
+ erb_config = ERB.new(raw_config).result
131
+ configs = YAML.load(erb_config)
132
+
133
+ env = options[:environment]
134
+
135
+ msg = 'Run command using config environment:'
136
+ msg = "Invalid config environment '#{env}'. Switch to:" if env && !configs.keys.include?(env)
137
+
138
+ unless env && configs.keys.include?(env)
139
+ env = Thor::Shell::Color.new.ask(
140
+ msg,
141
+ :yellow,
142
+ limited_to: configs.keys.reject { |g| g.start_with?('_') },
143
+ echo: false
144
+ )
145
+ end
146
+
147
+ Quandl::Config.environment = env
148
+ puts "\nRunning commands with config environment: #{env}"
149
+ else
150
+ raise "Not a qops compatible project. Please be sure to add a config/opsworks.yml file as described in the readme. Path: #{file_path}"
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Qops
3
+ VERSION = '1.0.0'.freeze
4
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: qops
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Basset
8
+ - Clemeny Leung
9
+ - Jason Byck
10
+ - Jun Li
11
+ autorequire:
12
+ bindir: bin
13
+ cert_chain: []
14
+ date: 2016-04-04 00:00:00.000000000 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: thor
18
+ requirement: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 0.19.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 0.19.1
30
+ - !ruby/object:Gem::Dependency
31
+ name: aws-sdk
32
+ requirement: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 2.0.41
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 2.0.41
44
+ - !ruby/object:Gem::Dependency
45
+ name: quandl-config
46
+ requirement: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 0.1.0
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 0.1.0
58
+ - !ruby/object:Gem::Dependency
59
+ name: quandl-slack
60
+ requirement: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ type: :runtime
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: activesupport
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 4.2.1
79
+ type: :runtime
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 4.2.1
86
+ - !ruby/object:Gem::Dependency
87
+ name: rainbow
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - "~>"
91
+ - !ruby/object:Gem::Version
92
+ version: 2.0.0
93
+ type: :runtime
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: 2.0.0
100
+ description: Help to automate opsworks project deployments with single commands.
101
+ email:
102
+ - support@quandl.com
103
+ executables:
104
+ - qops
105
+ extensions: []
106
+ extra_rdoc_files: []
107
+ files:
108
+ - README.md
109
+ - bin/qops
110
+ - lib/qops.rb
111
+ - lib/qops/cookbook/cookbook.rb
112
+ - lib/qops/deployment/app.rb
113
+ - lib/qops/deployment/helpers.rb
114
+ - lib/qops/deployment/instances.rb
115
+ - lib/qops/environment.rb
116
+ - lib/qops/helpers.rb
117
+ - lib/qops/version.rb
118
+ homepage: https://github.com/quandl/opsworks_commands
119
+ licenses:
120
+ - MIT
121
+ metadata: {}
122
+ post_install_message:
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 2.2.4
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubyforge_project:
138
+ rubygems_version: 2.5.1
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: Helper commands for deployment of opsworks projects.
142
+ test_files: []
143
+ has_rdoc: