├── .cane ├── .github └── workflows │ └── unit_test.yml ├── .gitignore ├── .rspec ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── aerosol.gemspec ├── bin └── aerosol ├── img ├── aerosol.pdf └── aerosol.png ├── lib ├── aerosol.rb └── aerosol │ ├── auto_scaling.rb │ ├── aws.rb │ ├── aws_model.rb │ ├── cli.rb │ ├── connection.rb │ ├── deploy.rb │ ├── env.rb │ ├── instance.rb │ ├── launch_configuration.rb │ ├── launch_template.rb │ ├── rake_task.rb │ ├── runner.rb │ ├── util.rb │ └── version.rb └── spec ├── aerosol ├── auto_scaling_spec.rb ├── aws_spec.rb ├── cli_spec.rb ├── connection_spec.rb ├── deploy_spec.rb ├── env_spec.rb ├── instance_spec.rb ├── launch_configuration_spec.rb ├── launch_template_spec.rb ├── rake_task_spec.rb └── runner_spec.rb ├── aerosol_spec.rb ├── spec_helper.rb └── support └── vcr.rb /.cane: -------------------------------------------------------------------------------- 1 | --no-doc 2 | --abc-max 30 3 | --style-measure 120 4 | -------------------------------------------------------------------------------- /.github/workflows/unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: [push, pull_request] 3 | jobs: 4 | docker-rspec: 5 | runs-on: 6 | - ubuntu-18.04 7 | strategy: 8 | matrix: 9 | ruby: 10 | - 2.7 11 | - 2.2 12 | - 2.1 13 | fail-fast: true 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby }} 21 | - name: install bundler 22 | run: | 23 | gem install bundler -v '~> 1.17.3' 24 | bundle update 25 | - name: install rpm 26 | run: | 27 | set -x 28 | sudo apt-get update -y 29 | sudo apt-get install -y rpm 30 | - name: spec tests 31 | run: CI=true bundle exec rake 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # bundler 2 | .bundle 3 | 4 | # OSX 5 | .DS_Store 6 | 7 | # Vim 8 | *.swp 9 | 10 | Gemfile.lock 11 | *.gem 12 | build/ 13 | dist/ 14 | 15 | .vscode 16 | 17 | docker-export-ubuntu-latest.tar.gz 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --profile 3 | --format documentation 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'net-ssh', '~> 2.0' 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Swipely, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/aerosol.png)](http://badge.fury.io/rb/aerosol) 2 | 3 | ![Aerosol](https://raw.github.com/upserve/aerosol/master/img/aerosol.png) 4 | ========================================================================= 5 | 6 | Aerosol orchestrates instance-based-deploys. Start new EC2 instances running your app every release, using auto scaling groups to orchestrate the startup of the new version and the graceful shutdown of the old version. 7 | 8 | Getting Started 9 | --------------- 10 | 11 | Add it to your Gemfile: 12 | 13 | ```ruby 14 | gem 'aerosol' 15 | ``` 16 | 17 | And build an aerosol.rb 18 | 19 | If you're not using Rails, add it to your Rakefile: 20 | 21 | ```ruby 22 | require 'aerosol' 23 | ``` 24 | 25 | Usage 26 | ----- 27 | 28 | ### Rake Tasks 29 | 30 | The deploy tasks are within the `aerosol:deploy_name` namespace where deploy_name is based upon the deploy section detailed below. 31 | 32 | #### Full deploy tasks 33 | 34 | `all` - Full serial deployment: Run migration, create auto scaling group, wait for instances, stop old application, destroy old auto scaling groups and run the post deploy command 35 | 36 | `all_asynch` - Same as `all` but runs the migration and creates auto scaling groups in parallel 37 | 38 | #### The separate deploy rake tasks 39 | 40 | `run_migration` - Runs the ActiveRecord migration through the SSH connection given 41 | 42 | `create_auto_scaling_group` - Creates a new auto scaling group for the current git hash 43 | 44 | `wait_for_new_instances` - Waits for instances of the new autoscaling groups to start up 45 | 46 | `stop_old_app` - Runs command to shut down the application on the old instances instead of just terminating 47 | 48 | `destroy_old_auto_scaling_groups` - Terminates instances with the current tag and different git hash 49 | 50 | `destroy_new_auto_scaling_groups` - Terminates instances with the current tag and same git hash 51 | 52 | `run_post_deploy` - Runs a post deploy command 53 | 54 | #### Non-deploy rake tasks 55 | 56 | `aerosol:ssh:deploy_name` - Prints out ssh command to all instances of the latest deploy 57 | 58 | ### CLI 59 | 60 | `aerosol ssh deploy_name` - Same as aerosol:ssh:deploy_name 61 | 62 | `aerosol ssh -r deploy_name` - Runs the ssh command to the first instance available (still prints out the others) 63 | 64 | Demo 65 | ---- 66 | 67 | ```ruby 68 | namespace :test 69 | 70 | launch_configuration :launch_config do 71 | ami 'ami-1715317e' # Ubuntu 13.04 US-East-1 ebs amd64 72 | instance_type 'm1.small' 73 | iam_role 'role-app' 74 | key_name 'app' 75 | 76 | user_data ERB.new(File.read('startup.sh.erb')).result(binding) 77 | end 78 | 79 | auto_scaling :auto_scaling_group do 80 | availability_zones ['us-east-1a'] 81 | max_size 1 82 | min_size 1 83 | launch_configuration :launch_config 84 | end 85 | 86 | ssh :ssh do 87 | user 'ubuntu' 88 | end 89 | 90 | ssh :migration do 91 | user 'ubuntu' 92 | host 'dbserver.example.com' 93 | end 94 | 95 | ssh :local do 96 | jump :user => 'ubuntu', :host => 'jump.example.com' 97 | end 98 | 99 | deploy :deploy do 100 | ssh :ssh 101 | migration_ssh :migration 102 | local_ssh :local 103 | auto_scaling :auto_scaling_group 104 | stop_command 'sudo stop app' 105 | live_check '/version' 106 | app_port 443 107 | post_deploy_command 'bundle exec rake postdeploycommand' 108 | end 109 | ``` 110 | 111 | The DSL 112 | ------- 113 | 114 | The DSL is broken down into multiple objects, all of which conform to a specific format. Each object starts with the name of the section, 115 | followed by a name for the object you're creating, and a block for configuration. 116 | 117 | ```ruby 118 | auto_scaling :test_auto_scaling do 119 | # code here 120 | end 121 | ``` 122 | 123 | Each object has an enumeration of valid attributes. The following code sets the `max_size` attribute in a `auto_scaling` group called `test_auto_scaling`: 124 | 125 | ```ruby 126 | auto_scaling :test_auto_scaling do 127 | max_size 1 128 | end 129 | ``` 130 | 131 | Finally, each object has zero or more valid references to other DSL objects. The following code sets `auto_scaling` that references a `launch_configuration`: 132 | 133 | ```ruby 134 | launch_configuration :my_launch_config do 135 | max_size 1 136 | end 137 | 138 | auto_scaling :my_auto_scaling do 139 | launch_configuration :my_launch_config 140 | end 141 | ``` 142 | 143 | Below is an alternative syntax that accomplishes the same thing: 144 | 145 | ```ruby 146 | auto_scaling :my_auto_scaling do 147 | launch_configuration do 148 | max_size 1 149 | end 150 | end 151 | ``` 152 | 153 | Network Design 154 | -------------- 155 | 156 | A simplified version of the AWS network design that Aerosol manages is: auto scaling groups control running instances, and determine how those instances start up. 157 | 158 | Aerosol defines a way to deploy to an auto scaling group, with the correct launch configuration and properly brings up the instances. 159 | 160 | Controlling SSH connections 161 | --------------------------- 162 | 163 | SSH connections are used when connecting for migrations, checking if the instances are live and running local connections. 164 | 165 | Options: 166 | 167 | - user - The user to connect with 168 | - host - Where to connect to (will be filled in for instance and local connections) 169 | - jump - A hash of a user and host to connect through. For secure/VPN connections. 170 | 171 | ### Examples 172 | 173 | Minimal SSH connection for local connections: 174 | 175 | ```ruby 176 | ssh :local_ssh do 177 | end 178 | ``` 179 | 180 | Since a machine could start up with your user and not need a jump server, this could be all you need. 181 | 182 | SSH connection with a jump server: 183 | 184 | ```ruby 185 | ssh :jump_ssh do 186 | jump :user => 'ec2_user', :host => 'jump.example.com' 187 | end 188 | ``` 189 | 190 | A lot of network structures use a middle jump server before connecting to the internal network. Supplying either a user, host or both, you can establish a jump server to connect through. 191 | 192 | SSH connection with a user and host: 193 | 194 | ```ruby 195 | ssh :migration_ssh do 196 | user 'db_user' 197 | host 'dbserver.example.com' 198 | end 199 | ``` 200 | 201 | When using ActiveRecord, you'll need to supply a connection to pass through. Supply your database server credentials like this (you can also use a jump server here). 202 | 203 | Controlling instance definition 204 | ------------------------------- 205 | 206 | Launch configurations define the EC2 instance startup. All the features entered via the EC2 wizard are available. 207 | 208 | Options: 209 | 210 | - ami - The Amazon Machine Image that designates what operating system and possible tiers to run on. 211 | - instance_type - The tier to run the instance on (m1.large, etc.) 212 | - security_groups - A list of security groups the instances are paired with. 213 | - user_data - The bash script that is run as the instance comes live 214 | - iam_role - the Identity and Access Management role the instances should startup with 215 | - kernel_id - The kernel ID configurable for the EC2 instance (best found through the EC2 wizard) 216 | - key_name - The name of the EC2 key pair for initial connections to the instance 217 | - spot_price - The max hourly price to be paid for any spot prices 218 | 219 | ### Examples 220 | 221 | Launch configurations must specify an AMI and instance type. Everything else can be ignored and the instance will start up: 222 | 223 | ```ruby 224 | launch_configuration :minimal_lc do 225 | ami 'ami-1715317e' # Ubuntu 13.04 US-East-1 ebs amd64 226 | instance_type 'm1.small' 227 | end 228 | ``` 229 | 230 | This instance will startup and do nothing, with almost no way to connect to it though. 231 | 232 | Adding a script into the user_data section sets how the instance will start up. Either use plain text, read from a file, or use ERB: 233 | 234 | Text: 235 | 236 | ```ruby 237 | launch_configuration :startup_lc do 238 | ami 'ami-1715317e' # Ubuntu 13.04 US-East-1 ebs amd64 239 | instance_type 'm1.small' 240 | user_data "#!/bin/bash\n# Get file here" 241 | end 242 | ``` 243 | 244 | File: 245 | 246 | ```ruby 247 | launch_configuration :startup_lc do 248 | ami 'ami-1715317e' # Ubuntu 13.04 US-East-1 ebs amd64 249 | instance_type 'm1.small' 250 | user_data File.read('startup.sh') 251 | end 252 | ``` 253 | 254 | ERB: 255 | 256 | ```ruby 257 | launch_configuration :startup_lc do 258 | ami 'ami-1715317e' # Ubuntu 13.04 US-East-1 ebs amd64 259 | instance_type 'm1.small' 260 | 261 | thing = "google.com" # This is accessible within the ERB file! 262 | 263 | user_data ERB.new(File.read('startup.sh.erb')).result(binding) 264 | end 265 | ``` 266 | 267 | Using an ERB file is likely the most powerful especially since anything defined within the launch configurations context is available, as well as any other gems required before this. 268 | 269 | Controlling auto scaling groups 270 | ------------------------------- 271 | 272 | Auto scaling groups define how your network expands as needed and spans multiple availability zones. 273 | 274 | Options: 275 | 276 | - availability_zones - A list of availability zones 277 | - min_size/max_size - The min and max number of instances for the auto scaling group 278 | - default_cooldown - The number of seconds after a scaling activity completes before any further trigger-related scaling activities can start 279 | - desired_capacity - The number of instances that should be running in the group 280 | - health_check_grace_period - The number of seconds AWS waits before it befores a health check 281 | - health_check_type - The type of health check to perform, `'ELB'` and `'EC2'` are valid options 282 | - load_balancer_names - A list of the names of desired load balancers 283 | - placement_group - Physical location of your cluster placement group created in EC2 284 | - tag - A hash of tags for the instances in the group 285 | - launch_configuration - A reference to the launch configuration used by this auto scaling group 286 | - vpc_zone_identifier - A comma-separated list of subnet identifiers of Amazon Virtual Private Clouds 287 | 288 | Auto scaling groups only require an availability zone, a min and max size, and a launch configuration. 289 | 290 | ```ruby 291 | auto_scaling :minimal_asg do 292 | launch_configuration :minimal_lc 293 | availability_zones ['us-east-1a'] 294 | min_size 1 295 | max_size 1 296 | end 297 | ``` 298 | 299 | This will start up a single instance using our most basic launch configuration in US East 1A 300 | 301 | Adding tags can help identify the instances (two are already made for you: Deploy and GitSha) 302 | 303 | ```ruby 304 | auto_scaling :tagged_asg do 305 | launch_configuration :minimal_lc 306 | availability_zones ['us-east-1a'] 307 | min_size 1 308 | max_size 1 309 | 310 | tag 'Name' => 'tagged_asg' 311 | tag 'NextTag' => 'NewValue' 312 | # or 313 | tag 'Name' => 'tagged_asg', 'NextTag' => 'NewValue' 314 | end 315 | ``` 316 | 317 | Configuring your deploy 318 | ----------------------- 319 | 320 | A deploy ties the auto scaling groups and possible SSH connections together for application logic 321 | 322 | Options: 323 | 324 | - auto_scaling - Auto scaling group for this deployment 325 | - ssh - SSH connection from deployment computer to new instance 326 | - migration_ssh - SSH connection from deployment computer to database connection instance (optional: only needed if deployment computer cannot connect to database directly) 327 | - do_not_migrate! - Do not try and do a migration (for non-ActiveRecord deploys) 328 | - local_ssh - SSH connection from local computer to running instances 329 | - stop_command - This command is run before an auto scaling group is run to stop your application gracefully 330 | - live_check - 'Local path to query on the instance to verify the application is running' 331 | - instance_live_grace_period - The number of seconds to wait for an instance to be live (default: 30 minutes - 1800 seconds) 332 | - db_config_path - relative path of your database config file (default: 'config/database.yml') 333 | - app_port - which port the application is running on 334 | - stop_app_retries - The number of times to retry stopping the application (default: 2) 335 | - continue_if_stop_app_fails - When true, will ignore a failure when stopping the application (default: 'false') 336 | - post_deploy_command - Command run after the deploy has finished 337 | - sleep_before_termination - Time to wait after the new instance spawns and before terminating the old instance (allow time for external load balancers to start working) 338 | - ssl - Boolean that enables/disables ssl when doing the live check (default: 'false'). 339 | - log_files - Paths to files on each instance to tail during deploy (default: '/var/log/syslog') 340 | - tail_logs - Boolean that will enable/disable tailing log files (default: 'false') 341 | 342 | A lot of the options for deployment are required, but we've defined some sane defaults. 343 | 344 | A basic deployment could look like: 345 | 346 | ```ruby 347 | deploy :quick_app do 348 | ssh :jump_ssh 349 | do_not_migrate! 350 | auto_scaling :minimal_asg 351 | stop_command 'sudo stop app' 352 | live_check '/version' 353 | app_port 80 354 | end 355 | ``` 356 | 357 | This won't perform a migration, will stop the application with 'sudo stop app' after checking the instance is live at 'localhost:80/version' after SSHing to the instance through jump_ssh. 358 | It will use the default of waiting 30 minutes for a deploy before failing, and fail if the previous application does not stop correctly. 359 | 360 | If the server you're deploying from is not behind the same restrictions a development machine is, add a local SSH connection. The local connection will be used when generating commands for connecting to the instances. 361 | 362 | Environments 363 | ------------ 364 | 365 | An environment is a set of deploys that may be run in parallel. 366 | This can be useful if you, for example, want to deploy a load balancer at the same time you deploy your backend application. 367 | 368 | Options: 369 | 370 | - deploy - Add a deploy to this environment. 371 | 372 | A basic environment (assuming there is already a deploy named `:quick_app` and another named `:quick_app_worker`): 373 | 374 | ```ruby 375 | env :staging do 376 | deploy :quick_app 377 | deploy :quick_app_worker 378 | end 379 | ``` 380 | 381 | To run these deploys, the following task can be run: 382 | 383 | ```shell 384 | $ bundle exec rake aerosol:env:staging 385 | ``` 386 | 387 | Namespace 388 | --------- 389 | 390 | A namespace will enable branches of a main project to be deployed separately without interfering with auto scaling groups, launch configurations or elastic IPs 391 | 392 | ```ruby 393 | namespace :test 394 | ``` 395 | 396 | This will prefix all auto scaling groups and launch configurations with `test-` and also the `Deploy` tag that is on each auto scaling group by default. 397 | 398 | Copyright (c) 2013 Swipely, Inc. See LICENSE.txt for further details. 399 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Copyright Swipely, Inc. All rights reserved. 2 | 3 | $LOAD_PATH.unshift( File.join( File.dirname(__FILE__), 'lib' ) ) 4 | 5 | require 'rake' 6 | require 'aerosol' 7 | require 'rspec/core/rake_task' 8 | require 'cane/rake_task' 9 | 10 | task :default => [:spec, :quality] 11 | 12 | RSpec::Core::RakeTask.new do |t| 13 | t.pattern = 'spec/**/*_spec.rb' 14 | end 15 | 16 | Cane::RakeTask.new(:quality) do |cane| 17 | cane.canefile = '.cane' 18 | end 19 | -------------------------------------------------------------------------------- /aerosol.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/aerosol/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Swipely, Inc."] 6 | gem.email = %w{tomhulihan@swipely.com bright@swipely.com toddlunter@swipely.com} 7 | gem.description = %q{Instance-based deploys made easy} 8 | gem.summary = %q{Instance-based deploys made easy} 9 | gem.homepage = "https://github.com/upserve/aerosol" 10 | gem.license = 'MIT' 11 | 12 | gem.files = `git ls-files`.split($\) 13 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 14 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 15 | gem.name = "aerosol" 16 | gem.require_paths = %w{lib} 17 | gem.version = Aerosol::VERSION 18 | gem.add_dependency 'activerecord', '>= 3.2.0' 19 | gem.add_dependency 'clamp', '~> 1' 20 | gem.add_dependency 'excon' 21 | gem.add_dependency 'aws-sdk-core', '~> 3' 22 | gem.add_dependency 'aws-sdk-s3', '~> 1' 23 | gem.add_dependency 'aws-sdk-ec2', '~> 1' 24 | gem.add_dependency 'aws-sdk-autoscaling', '~> 1' 25 | gem.add_dependency 'minigit', '~> 0.0.4' 26 | gem.add_dependency 'net-ssh' 27 | gem.add_dependency 'net-ssh-gateway' 28 | gem.add_dependency 'dockly-util', '~> 0.1.0' 29 | gem.add_development_dependency 'cane' 30 | gem.add_development_dependency 'pry' 31 | gem.add_development_dependency 'rake', '~> 10.0' 32 | gem.add_development_dependency 'rspec', '~> 3' 33 | gem.add_development_dependency 'webmock' 34 | gem.add_development_dependency 'vcr' 35 | end 36 | -------------------------------------------------------------------------------- /bin/aerosol: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | $: << File.join(File.dirname(__FILE__), "..", "lib") 5 | require 'aerosol' 6 | require 'aerosol/cli' 7 | 8 | Aerosol::Cli.run 9 | -------------------------------------------------------------------------------- /img/aerosol.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upserve/aerosol/f024d7e5686936ebb640241fbe1b2700707d195a/img/aerosol.pdf -------------------------------------------------------------------------------- /img/aerosol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upserve/aerosol/f024d7e5686936ebb640241fbe1b2700707d195a/img/aerosol.png -------------------------------------------------------------------------------- /lib/aerosol.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'aws-sdk-autoscaling' 3 | require 'aws-sdk-core' 4 | require 'aws-sdk-s3' 5 | require 'aws-sdk-ec2' 6 | require 'dockly/util' 7 | require 'base64' 8 | 9 | Aws.config.update({ region: 'us-east-1' }) if Aws.config[:region].nil? 10 | 11 | module Aerosol 12 | require 'aerosol/aws' 13 | require 'aerosol/util' 14 | require 'aerosol/aws_model' 15 | require 'aerosol/launch_configuration' 16 | require 'aerosol/launch_template' 17 | require 'aerosol/auto_scaling' 18 | require 'aerosol/instance' 19 | require 'aerosol/connection' 20 | require 'aerosol/deploy' 21 | require 'aerosol/env' 22 | 23 | attr_reader :deploy, :instance, :git_sha, :namespace 24 | attr_writer :load_file 25 | 26 | LOAD_FILE = 'aerosol.rb' 27 | 28 | def load_file 29 | @load_file || LOAD_FILE 30 | end 31 | 32 | def inst 33 | @instance ||= load_inst 34 | end 35 | 36 | def load_inst 37 | setup.tap do |state| 38 | if File.exists?(load_file) 39 | instance_eval(IO.read(load_file), load_file) 40 | end 41 | end 42 | end 43 | 44 | def namespace(value = nil) 45 | if value.nil? 46 | @namespace 47 | else 48 | @namespace = value 49 | end 50 | end 51 | 52 | def region(value = nil) 53 | if value.nil? 54 | Aws.config[:region] 55 | else 56 | Aws.config.update(region: value) 57 | end 58 | end 59 | 60 | def setup 61 | { 62 | :auto_scalings => Aerosol::AutoScaling.instances, 63 | :deploys => Aerosol::Deploy.instances, 64 | :launch_configurations => Aerosol::LaunchConfiguration.instances, 65 | :launch_templates => Aerosol::LaunchTemplate.instances, 66 | :sshs => Aerosol::Connection.instances, 67 | :envs => Aerosol::Env.instances 68 | } 69 | end 70 | 71 | { 72 | :auto_scaling => Aerosol::AutoScaling, 73 | :deploy => Aerosol::Deploy, 74 | :launch_configuration => Aerosol::LaunchConfiguration, 75 | :launch_template => Aerosol::LaunchTemplate, 76 | :ssh => Aerosol::Connection, 77 | :env => Aerosol::Env 78 | }.each do |method, klass| 79 | define_method(method) do |sym, &block| 80 | if block.nil? 81 | inst[:"#{method}s"][sym] 82 | else 83 | klass.new!(:name => sym, &block) 84 | end 85 | end 86 | end 87 | 88 | [:auto_scalings, :deploys, :launch_configurations, :launch_templates, :sshs, :envs].each do |method| 89 | define_method(method) do 90 | inst[method] 91 | end 92 | end 93 | 94 | module_function :inst, :load_inst, :setup, :load_file, :load_file=, 95 | :auto_scaling, :launch_configuration, :launch_template, :deploy, :ssh, :git_sha, 96 | :auto_scalings, :launch_configurations, :launch_templates, :deploys, :sshs, 97 | :namespace, :env, :envs, :region 98 | end 99 | 100 | require 'aerosol/runner' 101 | require 'aerosol/rake_task' 102 | -------------------------------------------------------------------------------- /lib/aerosol/auto_scaling.rb: -------------------------------------------------------------------------------- 1 | class Aerosol::AutoScaling 2 | include Aerosol::AWSModel 3 | include Dockly::Util::Logger::Mixin 4 | 5 | logger_prefix '[aerosol auto_scaling]' 6 | aws_attribute :auto_scaling_group_name, :availability_zones, :min_size, :max_size, :default_cooldown, 7 | :desired_capacity, :health_check_grace_period, :health_check_type, :load_balancer_names, 8 | :placement_group, :tags, :created_time, :vpc_zone_identifier, :target_group_arns 9 | aws_class_attribute :launch_configuration, Aerosol::LaunchConfiguration 10 | aws_class_attribute( 11 | :launch_template, 12 | Aerosol::LaunchTemplate, 13 | proc { |argument| argument.fetch(:launch_template, {})[:launch_template_name] } 14 | ) 15 | 16 | primary_key :auto_scaling_group_name 17 | 18 | def initialize(options={}, &block) 19 | tag = options.delete(:tag) 20 | super(options, &block) 21 | 22 | tags.merge!(tag) unless tag.nil? 23 | 24 | tags["GitSha"] ||= Aerosol::Util.git_sha 25 | tags["Deploy"] ||= namespaced_name 26 | end 27 | 28 | def auto_scaling_group_name(arg = nil) 29 | if arg 30 | raise "You cannot set the auto_scaling_group_name directly" unless from_aws 31 | @auto_scaling_group_name = arg 32 | else 33 | @auto_scaling_group_name || default_identifier 34 | end 35 | end 36 | 37 | def exists? 38 | info "auto_scaling: needed?: #{namespaced_name}: " + 39 | "checking for auto scaling group: #{auto_scaling_group_name}" 40 | exists = super 41 | info "auto scaling: needed?: #{namespaced_name}: " + 42 | "#{exists ? 'found' : 'did not find'} auto scaling group: #{auto_scaling_group_name}" 43 | exists 44 | end 45 | 46 | def create! 47 | ensure_present! :max_size, :min_size 48 | raise 'availability_zones or vpc_zone_identifier must be set' if [availability_zones, vpc_zone_identifier].none? 49 | raise 'launch_configuration or launch_template must be set' unless launch_details 50 | 51 | info "creating auto scaling group" 52 | launch_details = create_launch_details 53 | 54 | info self.inspect 55 | 56 | conn.create_auto_scaling_group({ 57 | auto_scaling_group_name: auto_scaling_group_name, 58 | availability_zones: [*availability_zones], 59 | max_size: max_size, 60 | min_size: min_size 61 | } 62 | .merge(create_options) 63 | .merge(launch_details) 64 | ) 65 | 66 | conn.wait_until(:group_in_service, auto_scaling_group_names: [auto_scaling_group_name]) do |waiter| 67 | waiter.before_wait do |attempt_count, _response| 68 | info "Wait count #{attempt_count} of #{waiter.max_attempts} for #{auto_scaling_group_name} to be in service" 69 | end 70 | end 71 | end 72 | 73 | def destroy! 74 | info self.inspect 75 | conn.delete_auto_scaling_group(auto_scaling_group_name: auto_scaling_group_name, force_delete: true) 76 | begin 77 | (0..2).each { break if deleting?; sleep 1 } 78 | launch_details.destroy 79 | rescue => ex 80 | info "Launch Details: #{launch_details} for #{auto_scaling_group_name} were not deleted." 81 | info ex.message 82 | end 83 | end 84 | 85 | def deleting? 86 | asgs = conn.describe_auto_scaling_groups(auto_scaling_group_names: [auto_scaling_group_name]).auto_scaling_groups 87 | 88 | return true if asgs.empty? 89 | 90 | asgs.first.status.to_s.include?('Delete') 91 | end 92 | 93 | def all_instances 94 | conn.describe_auto_scaling_groups(auto_scaling_group_names: [*auto_scaling_group_name]) 95 | .auto_scaling_groups.first 96 | .instances.map { |instance| Aerosol::Instance.from_hash(instance.to_hash) } 97 | end 98 | 99 | def launch_details 100 | launch_configuration || launch_template 101 | end 102 | 103 | def tag(val) 104 | tags.merge!(val) 105 | end 106 | 107 | def tags(ary=nil) 108 | if !ary.nil? 109 | if ary.is_a? Hash 110 | ary.each do |key, value| 111 | tag key => value 112 | end 113 | else 114 | ary.each do |struct| 115 | tag struct[:key] => struct[:value] 116 | end 117 | end 118 | else 119 | @tags ||= {} 120 | end 121 | end 122 | 123 | def self.request_all_for_token(next_token) 124 | options = next_token.nil? ? {} : { next_token: next_token } 125 | Aerosol::AWS.auto_scaling.describe_auto_scaling_groups(options) 126 | end 127 | 128 | def self.request_all 129 | next_token = nil 130 | asgs = [] 131 | 132 | begin 133 | new_asgs = request_all_for_token(next_token) 134 | asgs.concat(new_asgs.auto_scaling_groups) 135 | next_token = new_asgs.next_token 136 | end until next_token.nil? 137 | 138 | asgs 139 | end 140 | 141 | def self.latest_for_tag(key, value) 142 | all.select { |group| group.tags[key] == value } 143 | .sort_by { |group| group.created_time }.last 144 | end 145 | 146 | def to_s 147 | %{Aerosol::AutoScaling { \ 148 | "auto_scaling_group_name" => "#{auto_scaling_group_name}", \ 149 | "availability_zones" => "#{availability_zones}", \ 150 | "min_size" => "#{min_size}", \ 151 | "max_size" => "#{max_size}", \ 152 | "default_cooldown" => "#{default_cooldown}", \ 153 | "desired_capacity" => "#{desired_capacity}", \ 154 | "health_check_grace_period" => "#{health_check_grace_period}", \ 155 | "health_check_type" => "#{health_check_type}", \ 156 | "load_balancer_names" => "#{load_balancer_names}", \ 157 | "placement_group" => "#{placement_group}", \ 158 | "tags" => #{tags.to_s}, \ 159 | "created_time" => "#{created_time}" \ 160 | "target_group_arns" => "#{target_group_arns}" \ 161 | }} 162 | end 163 | 164 | private 165 | def conn 166 | Aerosol::AWS.auto_scaling 167 | end 168 | 169 | def create_launch_details 170 | if launch_configuration 171 | launch_configuration.create 172 | { launch_configuration_name: launch_configuration.launch_configuration_name } 173 | elsif launch_template 174 | launch_template.create 175 | { 176 | launch_template: 177 | { 178 | launch_template_name: launch_template.launch_template_name, 179 | version: '$Latest' 180 | } 181 | } 182 | end 183 | end 184 | 185 | def create_options 186 | { 187 | default_cooldown: default_cooldown, 188 | desired_capacity: desired_capacity, 189 | health_check_grace_period: health_check_grace_period, 190 | health_check_type: health_check_type, 191 | load_balancer_names: load_balancer_names, 192 | placement_group: placement_group, 193 | tags: tags_to_array, 194 | vpc_zone_identifier: vpc_zone_identifier, 195 | target_group_arns: target_group_arns 196 | }.reject { |k, v| v.nil? } 197 | end 198 | 199 | def tags_to_array 200 | tags.map do |key, value| 201 | { key: key, value: value } 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/aerosol/aws.rb: -------------------------------------------------------------------------------- 1 | # This module holds the connections for all AWS services used by the gem. 2 | module Aerosol::AWS 3 | extend self 4 | 5 | def service(name, klass) 6 | define_method name do 7 | if val = instance_variable_get(:"@#{name}") 8 | val 9 | else 10 | instance = klass.new(creds) 11 | instance_variable_set(:"@#{name}", instance) 12 | end 13 | end 14 | services << name 15 | end 16 | 17 | def services 18 | @services ||= [] 19 | end 20 | 21 | def env_attr(*names) 22 | names.each do |name| 23 | define_method name do 24 | instance_variable_get(:"@#{name}") || ENV[name.to_s.upcase] 25 | end 26 | 27 | define_method :"#{name}=" do |val| 28 | reset_cache! 29 | instance_variable_set(:"@#{name}", val) 30 | end 31 | 32 | env_attrs << name 33 | end 34 | end 35 | 36 | def env_attrs 37 | @env_attrs ||= [] 38 | end 39 | 40 | def creds 41 | Hash[env_attrs.map { |attr| [attr, public_send(attr)] }].reject { |k, v| v.nil? } 42 | end 43 | 44 | def reset_cache! 45 | services.each { |service| instance_variable_set(:"@#{service}", nil) } 46 | end 47 | 48 | service :sts, Aws::STS::Client 49 | service :s3, Aws::S3::Client 50 | service :compute, Aws::EC2::Client 51 | service :auto_scaling, Aws::AutoScaling::Client 52 | env_attr :credentials, :stub_responses 53 | end 54 | -------------------------------------------------------------------------------- /lib/aerosol/aws_model.rb: -------------------------------------------------------------------------------- 1 | module Aerosol::AWSModel 2 | def self.included(base) 3 | base.instance_eval do 4 | include Dockly::Util::DSL 5 | extend ClassMethods 6 | 7 | attr_accessor :from_aws 8 | end 9 | end 10 | 11 | def initialize(hash={}, &block) 12 | self.from_aws = false 13 | super 14 | end 15 | 16 | def namespaced_name 17 | Aerosol.namespace ? "#{Aerosol.namespace}-#{name}" : name.to_s 18 | end 19 | 20 | def default_identifier 21 | "#{namespaced_name}-#{Aerosol::Util.git_sha}" 22 | end 23 | 24 | def create 25 | raise '#create! must be defined to use #create' unless respond_to?(:create!) 26 | create! unless exists? 27 | end 28 | 29 | def destroy 30 | raise '#destroy! must be defined to use #destroy' unless respond_to?(:destroy!) 31 | destroy! if exists? 32 | end 33 | 34 | def exists? 35 | primary_value = send(self.class.primary_key) 36 | self.class.exists?(primary_value) 37 | end 38 | 39 | module ClassMethods 40 | def primary_key(attr = nil) 41 | @primary_key = attr unless attr.nil? 42 | @primary_key 43 | end 44 | 45 | def aws_attribute(*attrs) 46 | dsl_attribute(*attrs) 47 | aws_attributes.merge(attrs) 48 | end 49 | 50 | def aws_class_attribute(name, klass, primary_key_proc = nil) 51 | unless klass.ancestors.include?(Aerosol::AWSModel) || (klass == self) 52 | raise '.aws_class_attribute requires a Aerosol::AWSModel that is not the current class.' 53 | end 54 | 55 | primary_key_proc ||= proc { |hash| hash[klass.primary_key] } 56 | 57 | dsl_class_attribute(name, klass) 58 | aws_class_attributes.merge!({ name => [klass, primary_key_proc] }) 59 | end 60 | 61 | def exists?(key) 62 | all.map { |inst| inst.send(primary_key) }.include?(key) 63 | end 64 | 65 | def all 66 | raise 'Please define .request_all to use .all' unless respond_to?(:request_all) 67 | request_all.map { |struct| from_hash(struct.to_hash) } 68 | end 69 | 70 | def from_hash(hash) 71 | raise 'To use .from_hash, you must specify a primary_key' if primary_key.nil? 72 | refs = Hash[aws_class_attributes.map do |name, (klass, primary_key_proc)| 73 | value = klass.instances.values.find do |inst| 74 | inst.send(klass.primary_key).to_s == primary_key_proc.call(hash).to_s unless inst.send(klass.primary_key).nil? 75 | end 76 | [name, value] 77 | end].reject { |k, v| v.nil? } 78 | 79 | instance = new! 80 | instance.from_aws = true 81 | 82 | aws_attributes.each { |attr| instance.send(attr, hash[attr]) unless hash[attr].nil? } 83 | refs.each { |name, inst| instance.send(name, inst.name) } 84 | instance 85 | end 86 | 87 | def aws_attributes 88 | @aws_attributes ||= Set.new 89 | end 90 | 91 | def aws_class_attributes 92 | @aws_class_attributes ||= {} 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/aerosol/cli.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'aerosol' 3 | require 'clamp' 4 | 5 | class Aerosol::AbstractCommand < Clamp::Command 6 | option ['-f', '--file'], 'FILE', 'aerosol file to read', :default => 'aerosol.rb', :attribute_name => :file 7 | 8 | def execute 9 | if File.exist?(file) 10 | Aerosol.load_file = file 11 | else 12 | raise 'Could not find an aerosol file!' 13 | end 14 | end 15 | end 16 | 17 | class Aerosol::DeployCommand < Aerosol::AbstractCommand 18 | parameter 'DEPLOY', 'the deploy to run (can also be an environment name) for', :attribute_name => :deploy_name 19 | 20 | def execute 21 | super 22 | 23 | if Aerosol.deploy(deploy_name.to_sym) 24 | Rake::Task["aerosol:#{deploy_name}:all"].invoke 25 | elsif Aerosol.env(deploy_name.to_sym) 26 | Rake::Task["aerosol:env:#{deploy_name}"].invoke 27 | end 28 | end 29 | end 30 | 31 | class Aerosol::SshCommand < Aerosol::AbstractCommand 32 | option ['-r', '--run'], :flag, 'run first ssh command', :attribute_name => :run_first 33 | parameter 'DEPLOY', 'the deploy to list commands for', :attribute_name => :deploy_name 34 | 35 | def execute 36 | super 37 | if deploy = Aerosol.deploy(deploy_name.to_sym) 38 | ssh_commands = deploy.generate_ssh_commands 39 | raise 'No instances to ssh too!' if ssh_commands.empty? 40 | 41 | ssh_commands.each do |ssh_command| 42 | puts ssh_command 43 | end 44 | 45 | if run_first? 46 | system(ssh_commands.first) 47 | end 48 | end 49 | end 50 | end 51 | 52 | class Aerosol::Cli < Aerosol::AbstractCommand 53 | subcommand ['ssh', 's'], 'Print ssh commands for latest running instances', Aerosol::SshCommand 54 | subcommand ['deploy', 'd'], 'Run a deploy', Aerosol::DeployCommand 55 | end 56 | 57 | -------------------------------------------------------------------------------- /lib/aerosol/connection.rb: -------------------------------------------------------------------------------- 1 | require 'net/ssh' 2 | require 'net/ssh/gateway' 3 | 4 | class Aerosol::Connection 5 | include Dockly::Util::DSL 6 | include Dockly::Util::Logger::Mixin 7 | 8 | logger_prefix '[aerosol connection]' 9 | dsl_attribute :user, :host, :jump 10 | 11 | def with_connection(overridden_host=nil, &block) 12 | actual_host = overridden_host || host 13 | unless actual_host.is_a?(String) 14 | actual_host = actual_host.address 15 | end 16 | 17 | if jump 18 | info "connecting to gateway #{jump[:user] || user}@#{jump[:host]}" 19 | gateway = nil 20 | Timeout.timeout(20) do 21 | gateway = Net::SSH::Gateway.new(jump[:host], jump[:user] || user, :forward_agent => true) 22 | end 23 | 24 | begin 25 | info "connecting to #{user}@#{actual_host} through gateway" 26 | gateway.ssh(actual_host, user, &block) 27 | ensure 28 | info "shutting down gateway connection" 29 | gateway.shutdown! 30 | end 31 | else 32 | info "connecting to #{user}@#{actual_host}" 33 | Net::SSH.start(actual_host, user, &block) 34 | end 35 | rescue Timeout::Error => ex 36 | error "Timeout error #{ex.message}" 37 | error ex.backtrace.join("\n") 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/aerosol/deploy.rb: -------------------------------------------------------------------------------- 1 | class Aerosol::Deploy 2 | include Dockly::Util::DSL 3 | include Dockly::Util::Logger::Mixin 4 | 5 | logger_prefix '[aerosol deploy]' 6 | dsl_attribute :stop_command, :db_config_path, 7 | :instance_live_grace_period, :app_port, 8 | :continue_if_stop_app_fails, :stop_app_retries, 9 | :sleep_before_termination, :post_deploy_command, 10 | :ssl, :log_files, :tail_logs, :assume_role 11 | 12 | dsl_class_attribute :ssh, Aerosol::Connection 13 | dsl_class_attribute :migration_ssh, Aerosol::Connection 14 | dsl_class_attribute :local_ssh, Aerosol::Connection 15 | dsl_class_attribute :auto_scaling, Aerosol::AutoScaling 16 | 17 | default_value :db_config_path, 'config/database.yml' 18 | default_value :instance_live_grace_period, 30 * 60 # 30 Minutes 19 | default_value :continue_if_stop_app_fails, false 20 | default_value :stop_app_retries, 2 21 | default_value :sleep_before_termination, 20 22 | default_value :ssl, false 23 | default_value :tail_logs, false 24 | default_value :log_files, ['/var/log/syslog'] 25 | default_value :assume_role, nil 26 | 27 | def live_check(arg = nil) 28 | case 29 | when arg.nil? 30 | @live_check 31 | when arg.start_with?('/') 32 | @live_check = arg 33 | else 34 | @live_check = "/#{arg}" 35 | end 36 | @live_check 37 | end 38 | 39 | def is_alive?(command = nil, &block) 40 | fail 'Command and block specified' if command && block 41 | @is_alive = block if block 42 | @is_alive = command if command 43 | @is_alive 44 | end 45 | 46 | def live_check_url 47 | [ssl ? 'https' : 'http', '://localhost:', app_port, live_check].join 48 | end 49 | 50 | def do_not_migrate! 51 | self.instance_variable_set(:@db_config_path, nil) 52 | end 53 | 54 | def migration(opts = {}) 55 | self.db_config_path(opts[:db_config_path]) 56 | end 57 | 58 | def migrate? 59 | !!db_config_path 60 | end 61 | 62 | def local_ssh_ref 63 | local_ssh || ssh 64 | end 65 | 66 | def perform_role_assumption 67 | return if assume_role.nil? 68 | Aws.config.update( 69 | credentials: Aws::AssumeRoleCredentials.new( 70 | role_arn: assume_role, 71 | role_session_name: "aerosol-#{name}", 72 | client: Aerosol::AWS.sts 73 | ) 74 | ) 75 | end 76 | 77 | def run_post_deploy 78 | return if post_deploy_command.nil? 79 | info "running post deploy: #{post_deploy_command}" 80 | if system(post_deploy_command) 81 | info "post deploy ran successfully" 82 | true 83 | else 84 | raise "post deploy failed" 85 | end 86 | end 87 | 88 | def generate_ssh_command(instance) 89 | ssh_command = "ssh -o 'UserKnownHostsFile=/dev/null' -o 'StrictHostKeyChecking=no' " 90 | unless local_ssh_ref.nil? 91 | unless local_ssh_ref.jump.nil? || local_ssh_ref.jump.empty? 92 | ssh_command << "-o 'ProxyCommand=ssh -W %h:%p " 93 | ssh_command << "#{local_ssh_ref.jump[:user]}@" if local_ssh_ref.jump[:user] 94 | ssh_command << "#{local_ssh_ref.jump[:host]}' " 95 | end 96 | ssh_command << "#{local_ssh_ref.user}@" unless local_ssh_ref.user.nil? 97 | end 98 | ssh_command << "#{instance.address}" 99 | end 100 | 101 | def generate_ssh_commands 102 | group = Aerosol::AutoScaling.latest_for_tag('Deploy', auto_scaling.namespaced_name) 103 | raise "Could not find any auto scaling groups for this deploy (#{name})." if group.nil? 104 | 105 | ssh_commands = [] 106 | 107 | with_prefix("[#{name}]") do |logger| 108 | logger.info "found group: #{group.auto_scaling_group_name}" 109 | instances = group.all_instances 110 | raise "Could not find any instances for auto scaling group #{group.namespaced_name}" if instances.empty? 111 | instances.each do |instance| 112 | logger.info "printing ssh command for #{instance.address}" 113 | ssh_commands << generate_ssh_command(instance) 114 | end 115 | end 116 | 117 | return ssh_commands 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/aerosol/env.rb: -------------------------------------------------------------------------------- 1 | # An environment is a set of deploys. 2 | class Aerosol::Env 3 | include Dockly::Util::DSL 4 | 5 | dsl_attribute :assume_role 6 | dsl_class_attribute :deploy, Aerosol::Deploy, type: Array 7 | 8 | default_value :assume_role, nil 9 | 10 | def perform_role_assumption 11 | return if assume_role.nil? 12 | Aws.config.update( 13 | credentials: Aws::AssumeRoleCredentials.new( 14 | role_arn: assume_role, 15 | role_session_name: "aerosol-#{name}", 16 | client: Aerosol::AWS.sts 17 | ) 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/aerosol/instance.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/object/blank' 2 | 3 | class Aerosol::Instance 4 | include Aerosol::AWSModel 5 | 6 | aws_attribute :availability_zone, :health_status, :instance_id, :lifecycle_state 7 | aws_class_attribute :launch_configuration, Aerosol::LaunchConfiguration 8 | aws_class_attribute( 9 | :launch_template, 10 | Aerosol::LaunchTemplate, 11 | proc { |argument| argument.fetch(:launch_template, {})[:launch_template_name] } 12 | ) 13 | primary_key :instance_id 14 | 15 | def live? 16 | describe_again 17 | instance_state_name == 'running' 18 | end 19 | 20 | def instance_state_name 21 | description[:state][:name] 22 | end 23 | 24 | def public_hostname 25 | description[:public_dns_name] 26 | end 27 | 28 | def private_ip_address 29 | description[:private_ip_address] 30 | end 31 | 32 | def address 33 | if public_hostname.blank? 34 | private_ip_address 35 | else 36 | public_hostname 37 | end 38 | end 39 | 40 | def image_id 41 | description[:image_id] 42 | end 43 | 44 | def description 45 | @description ||= describe! 46 | end 47 | 48 | def self.request_all_for_token(next_token) 49 | options = next_token.nil? ? {} : { next_token: next_token } 50 | Aerosol::AWS.auto_scaling.describe_auto_scaling_instances(options) 51 | end 52 | 53 | def self.request_all 54 | next_token = nil 55 | instances = [] 56 | 57 | begin 58 | new_instances = request_all_for_token(next_token) 59 | instances.concat(new_instances.auto_scaling_instances) 60 | next_token = new_instances.next_token 61 | end until next_token.nil? 62 | 63 | instances 64 | end 65 | 66 | private 67 | def describe! 68 | ensure_present! :instance_id 69 | result = Aerosol::AWS.compute.describe_instances(instance_ids: [instance_id]) 70 | result.reservations.first.instances.first.to_h rescue nil 71 | end 72 | 73 | def describe_again 74 | @description = nil 75 | description 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/aerosol/launch_configuration.rb: -------------------------------------------------------------------------------- 1 | class Aerosol::LaunchConfiguration 2 | include Aerosol::AWSModel 3 | include Dockly::Util::Logger::Mixin 4 | 5 | logger_prefix '[aerosol launch_configuration]' 6 | aws_attribute :launch_configuration_name, :image_id, :instance_type, :security_groups, :user_data, 7 | :iam_instance_profile, :kernel_id, :key_name, :spot_price, :created_time, 8 | :associate_public_ip_address, :block_device_mappings, :ebs_optimized 9 | dsl_attribute :meta_data 10 | 11 | primary_key :launch_configuration_name 12 | default_value(:security_groups) { [] } 13 | default_value(:meta_data) { {} } 14 | 15 | def launch_configuration_name(arg = nil) 16 | if arg 17 | raise "You cannot set the launch_configuration_name directly" unless from_aws 18 | @launch_configuration_name = arg 19 | else 20 | @launch_configuration_name || default_identifier 21 | end 22 | end 23 | 24 | def ami(name=nil) 25 | warn 'Warning: Use `image_id` instead `ami` for a launch configuration' 26 | image_id(name) 27 | end 28 | 29 | def iam_role(name=nil) 30 | warn 'Warning: Use `iam_instance_profile` instead `iam_role` for a launch configuration' 31 | iam_instance_profile(name) 32 | end 33 | 34 | def security_group(group) 35 | security_groups << group 36 | end 37 | 38 | def create! 39 | ensure_present! :image_id, :instance_type 40 | 41 | info self.to_s 42 | conn.create_launch_configuration({ 43 | image_id: image_id, 44 | instance_type: instance_type, 45 | launch_configuration_name: launch_configuration_name, 46 | }.merge(create_options)) 47 | sleep 10 # TODO: switch to fog models and .wait_for { ready? } 48 | end 49 | 50 | def destroy! 51 | info self.to_s 52 | conn.delete_launch_configuration(launch_configuration_name: launch_configuration_name) 53 | end 54 | 55 | def all_instances 56 | Aerosol::Instance.all.select { |instance| 57 | !instance.launch_configuration.nil? && 58 | (instance.launch_configuration.launch_configuration_name == launch_configuration_name) 59 | }.each(&:description) 60 | end 61 | 62 | def self.request_all_for_token(next_token) 63 | options = next_token.nil? ? {} : { next_token: next_token } 64 | Aerosol::AWS.auto_scaling.describe_launch_configurations(options) 65 | end 66 | 67 | def self.request_all 68 | next_token = nil 69 | lcs = [] 70 | 71 | begin 72 | new_lcs = request_all_for_token(next_token) 73 | lcs.concat(new_lcs.launch_configurations) 74 | next_token = new_lcs.next_token 75 | end until next_token.nil? 76 | 77 | lcs 78 | end 79 | 80 | def to_s 81 | %{Aerosol::LaunchConfiguration { \ 82 | "launch_configuration_name" => "#{launch_configuration_name}", \ 83 | "image_id" => "#{image_id}", \ 84 | "instance_type" => "#{instance_type}", \ 85 | "security_groups" => #{security_groups.to_s}, \ 86 | "user_data" => "#{user_data}", \ 87 | "iam_instance_profile" => "#{iam_instance_profile}", \ 88 | "kernel_id" => "#{kernel_id}", \ 89 | "key_name" => "#{key_name}", \ 90 | "spot_price" => "#{spot_price}", \ 91 | "created_time" => "#{created_time}", \ 92 | "block_device_mappings" => #{block_device_mappings}", \ 93 | "ebs_optimized" => #{ebs_optimized} \ 94 | }} 95 | end 96 | 97 | def corrected_user_data 98 | realized_user_data = user_data.is_a?(Proc) ? user_data.call : user_data 99 | 100 | Base64.encode64(Aerosol::Util.strip_heredoc(realized_user_data || '')) 101 | end 102 | 103 | private 104 | def create_options 105 | { 106 | iam_instance_profile: iam_instance_profile, 107 | kernel_id: kernel_id, 108 | key_name: key_name, 109 | security_groups: security_groups, 110 | spot_price: spot_price, 111 | user_data: corrected_user_data, 112 | associate_public_ip_address: associate_public_ip_address, 113 | block_device_mappings: block_device_mappings, 114 | ebs_optimized: ebs_optimized 115 | }.reject { |k, v| v.nil? } 116 | end 117 | 118 | def conn 119 | Aerosol::AWS.auto_scaling 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/aerosol/launch_template.rb: -------------------------------------------------------------------------------- 1 | class Aerosol::LaunchTemplate 2 | include Aerosol::AWSModel 3 | include Dockly::Util::Logger::Mixin 4 | 5 | logger_prefix '[aerosol launch_template]' 6 | aws_attribute :launch_template_name, :launch_template_id, :latest_version_number, 7 | :image_id, :instance_type, :security_groups, :user_data, 8 | :iam_instance_profile, :kernel_id, :key_name, :spot_price, :created_time, 9 | :network_interfaces, :block_device_mappings, :ebs_optimized 10 | dsl_attribute :meta_data 11 | 12 | primary_key :launch_template_name 13 | default_value(:security_groups) { [] } 14 | default_value(:meta_data) { {} } 15 | 16 | def launch_template_name(arg = nil) 17 | if arg 18 | raise "You cannot set the launch_template_name directly" unless from_aws 19 | @launch_template_name = arg 20 | else 21 | @launch_template_name || default_identifier 22 | end 23 | end 24 | 25 | def security_group(group) 26 | security_groups << group 27 | end 28 | 29 | def create! 30 | ensure_present! :image_id, :instance_type 31 | 32 | info "creating launch template" 33 | conn.create_launch_template( 34 | launch_template_name: launch_template_name, 35 | launch_template_data: { 36 | image_id: image_id, 37 | instance_type: instance_type, 38 | monitoring: { 39 | enabled: true 40 | }, 41 | }.merge(create_options) 42 | ) 43 | 44 | debug self.inspect 45 | end 46 | 47 | def destroy! 48 | info self.to_s 49 | raise StandardError.new('No launch_template_name found') unless launch_template_name 50 | conn.delete_launch_template(launch_template_name: launch_template_name) 51 | end 52 | 53 | def all_instances 54 | Aerosol::Instance.all.select { |instance| 55 | !instance.launch_template.nil? && 56 | (instance.launch_template.launch_template_name == launch_template_name) 57 | }.each(&:description) 58 | end 59 | 60 | def self.request_all_for_token(next_token) 61 | options = next_token.nil? ? {} : { next_token: next_token } 62 | Aerosol::AWS.compute.describe_launch_templates(options) 63 | end 64 | 65 | def self.request_all 66 | next_token = nil 67 | lts = [] 68 | 69 | begin 70 | new_lts = request_all_for_token(next_token) 71 | lts.concat(new_lts.launch_templates) 72 | next_token = new_lts.next_token 73 | end until next_token.nil? 74 | lts 75 | end 76 | 77 | def to_s 78 | %{Aerosol::LaunchTemplate { \ 79 | "launch_template_name" => "#{launch_template_name}", \ 80 | "launch_template_id" => "#{launch_template_id}", \ 81 | "latest_version_number" => "#{latest_version_number}", \ 82 | "image_id" => "#{image_id}", \ 83 | "instance_type" => "#{instance_type}", \ 84 | "security_group_ids" => #{security_groups.to_s}, \ 85 | "user_data" => "#{user_data}", \ 86 | "iam_instance_profile" => "#{iam_instance_profile}", \ 87 | "kernel_id" => "#{kernel_id}", \ 88 | "key_name" => "#{key_name}", \ 89 | "spot_price" => "#{spot_price}", \ 90 | "created_time" => "#{created_time}", \ 91 | "block_device_mappings" => #{block_device_mappings}", \ 92 | "ebs_optimized" => #{ebs_optimized} \ 93 | }} 94 | end 95 | 96 | def corrected_user_data 97 | realized_user_data = user_data.is_a?(Proc) ? user_data.call : user_data 98 | 99 | Base64.encode64(Aerosol::Util.strip_heredoc(realized_user_data || '')) 100 | end 101 | 102 | private 103 | def create_options 104 | instance_market_options = { 105 | market_type: 'spot', 106 | spot_options: { 107 | max_price: spot_price 108 | } 109 | } if spot_price 110 | 111 | { 112 | iam_instance_profile: { name: iam_instance_profile }, 113 | kernel_id: kernel_id, 114 | key_name: key_name, 115 | security_group_ids: security_groups, 116 | instance_market_options: instance_market_options, 117 | user_data: corrected_user_data, 118 | network_interfaces: network_interfaces, 119 | block_device_mappings: block_device_mappings, 120 | ebs_optimized: ebs_optimized, 121 | }.reject { |k, v| v.nil? } 122 | end 123 | 124 | def conn 125 | Aerosol::AWS.compute 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/aerosol/rake_task.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'aerosol' 3 | 4 | class Rake::AutoScalingTask < Rake::Task 5 | def needed? 6 | !auto_scaling.exists? 7 | end 8 | 9 | def auto_scaling 10 | Aerosol.auto_scaling(name.split(':').last.to_sym) 11 | end 12 | end 13 | 14 | module Rake::DSL 15 | def auto_scaling(*args, &block) 16 | Rake::AutoScalingTask.define_task(*args, &block) 17 | end 18 | end 19 | 20 | namespace :aerosol do 21 | desc "Verify an aerosol.rb file exists" 22 | task :load do 23 | raise "No aerosol.rb found!" unless File.exist?('aerosol.rb') 24 | end 25 | 26 | namespace :auto_scaling do 27 | Aerosol.auto_scalings.values.reject(&:from_aws).each do |inst| 28 | auto_scaling inst.name => 'aerosol:load' do |name| 29 | Thread.current[:rake_task] = name 30 | inst.create 31 | end 32 | end 33 | end 34 | 35 | namespace :ssh do 36 | Aerosol.deploys.values.each do |inst| 37 | desc "Prints out ssh command to all instances of the latest deploy of #{inst.name}" 38 | task inst.name do |name| 39 | Thread.current[:rake_task] = name 40 | inst.generate_ssh_commands.each do |ssh_command| 41 | puts ssh_command 42 | end 43 | end 44 | end 45 | end 46 | 47 | all_deploy_tasks = [] 48 | all_asynch_deploy_tasks = [] 49 | 50 | namespace :env do 51 | Aerosol.envs.values.each do |env| 52 | namespace env.name do 53 | desc "Assumes a role if necessary for #{env.name}" 54 | task :assume_role => 'aerosol:load' do |name| 55 | Thread.current[:rake_task] = name 56 | env.perform_role_assumption 57 | end 58 | 59 | desc "Run all of the deploys for #{env.name} in parallel" 60 | multitask :run => env.deploy.map { |dep| "aerosol:#{dep.name}:all" } 61 | end 62 | 63 | task env.name => ["aerosol:env:#{env.name}:assume_role", "aerosol:env:#{env.name}:run"] 64 | end 65 | end 66 | 67 | 68 | Aerosol.deploys.values.each do |inst| 69 | namespace :"#{inst.name}" do 70 | desc "Assumes a role if necessary" 71 | task :assume_role => 'aerosol:load' do |name| 72 | Thread.current[:rake_task] = name 73 | inst.perform_role_assumption 74 | end 75 | 76 | desc "Runs the ActiveRecord migration through the SSH connection given" 77 | task :run_migration => "aerosol:#{inst.name}:assume_role" do |name| 78 | Thread.current[:rake_task] = name 79 | Aerosol::Runner.new.with_deploy(inst.name) do |runner| 80 | runner.run_migration 81 | end 82 | end 83 | 84 | desc "Creates a new auto scaling group for the current git hash" 85 | task :create_auto_scaling_group => "aerosol:auto_scaling:#{inst.auto_scaling.name}" 86 | 87 | desc "Waits for instances of the new autoscaling groups to start up" 88 | task :wait_for_new_instances => "aerosol:#{inst.name}:assume_role" do |name| 89 | Thread.current[:rake_task] = name 90 | Aerosol::Runner.new.with_deploy(inst.name) do |runner| 91 | runner.wait_for_new_instances 92 | end 93 | end 94 | 95 | desc "Runs command to shut down the application on the old instances instead of just terminating" 96 | task :stop_old_app => "aerosol:#{inst.name}:assume_role" do |name| 97 | Thread.current[:rake_task] = name 98 | Aerosol::Runner.new.with_deploy(inst.name) do |runner| 99 | runner.stop_app 100 | end 101 | end 102 | 103 | desc "Terminates instances with the current tag and different git hash" 104 | task :destroy_old_auto_scaling_groups => "aerosol:#{inst.name}:assume_role" do |name| 105 | Thread.current[:rake_task] = name 106 | Aerosol::Runner.new.with_deploy(inst.name) do |runner| 107 | runner.destroy_old_auto_scaling_groups 108 | end 109 | end 110 | 111 | desc "Terminates instances with the current tag and current git hash" 112 | task :destroy_new_auto_scaling_groups => "aerosol:#{inst.name}:assume_role" do |name| 113 | Thread.current[:rake_task] = name 114 | Aerosol::Runner.new.with_deploy(inst.name) do |runner| 115 | runner.destroy_new_auto_scaling_groups 116 | end 117 | end 118 | 119 | desc "Runs a post deploy command" 120 | task :run_post_deploy => "aerosol:#{inst.name}:assume_role" do |name| 121 | Thread.current[:rake_task] = name 122 | inst.run_post_deploy 123 | end 124 | 125 | ## 126 | 127 | desc "Runs migration and creates auto scaling groups" 128 | task :all_prep => [:run_migration, :create_auto_scaling_group] 129 | 130 | desc "Waits for new instances, stops old application, destroys old auto scaling groups "\ 131 | "and runs the post deploy command" 132 | task :all_release => [:wait_for_new_instances, :stop_old_app, :destroy_old_auto_scaling_groups, :run_post_deploy] 133 | 134 | desc "Run migration, create auto scaling group, wait for instances, stop old application, "\ 135 | "destroy old auto scaling groups and run the post deploy command" 136 | task :all => [:all_prep, :all_release] 137 | all_deploy_tasks << "aerosol:#{inst.name}:all" 138 | 139 | ## 140 | 141 | desc "Runs migration and creates auto scaling groups in parallel" 142 | multitask :all_asynch_prep => [:run_migration, :create_auto_scaling_group] 143 | 144 | desc "Same as `all` but runs the migration and creates auto scaling groups in parallel" 145 | task :all_asynch => [:all_asynch_prep, :all_release] 146 | all_asynch_deploy_tasks << "aerosol:#{inst.name}:all_asynch" 147 | end 148 | end 149 | 150 | desc "Runs all the all deploy tasks in the aerosol.rb" 151 | task :deploy_all => all_deploy_tasks 152 | 153 | desc "Runs all the all deploy tasks in the aerosol.rb in parallel" 154 | multitask :deploy_all_asynch => all_asynch_deploy_tasks 155 | end 156 | -------------------------------------------------------------------------------- /lib/aerosol/runner.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'active_record' 3 | require 'timeout' 4 | 5 | class Aerosol::Runner 6 | extend Dockly::Util::Delegate 7 | include Dockly::Util::Logger::Mixin 8 | 9 | logger_prefix '[aerosol runner]' 10 | attr_reader :deploy, :log_pids 11 | 12 | def initialize 13 | @log_pids = {} 14 | end 15 | 16 | def run_migration 17 | require_deploy! 18 | return unless deploy.migrate? 19 | raise 'To run a migration, $RAILS_ENV must be set.' if ENV['RAILS_ENV'].nil? 20 | 21 | info "running migration" 22 | begin 23 | info "loading config for env #{ENV['RAILS_ENV']}" 24 | original_config = YAML.load(ERB.new(File.read(db_config_path)).result)[ENV['RAILS_ENV']] 25 | debug "creating ssh tunnel" 26 | migration_ssh.with_connection do |session| 27 | # session.logger.sev_threshold=Logger::Severity::DEBUG 28 | debug "finding free port" 29 | port = random_open_port 30 | db_port = original_config['port'] || 3306 # TODO: get default port from DB driver 31 | host = original_config['host'] 32 | info "forwarding 127.0.0.1:#{port} --> #{host}:#{db_port}" 33 | session.forward.local(port, host, db_port) 34 | child = fork do 35 | GC.disable 36 | with_prefix('child:') do |logger| 37 | logger.debug "establishing connection" 38 | ActiveRecord::Base.establish_connection(original_config.merge( 39 | 'host' => '127.0.0.1', 40 | 'port' => port 41 | )) 42 | logger.info "running migration" 43 | ActiveRecord::Migrator.migrate(%w[db/migrate]) 44 | end 45 | end 46 | debug "waiting for child" 47 | exitstatus = nil 48 | session.loop(0.1) do 49 | pid = Process.waitpid(child, Process::WNOHANG) 50 | exitstatus = $?.exitstatus if !pid.nil? 51 | pid.nil? 52 | end 53 | raise "migration failed: #{exitstatus}" unless exitstatus == 0 54 | end 55 | info "complete" 56 | ensure 57 | ActiveRecord::Base.clear_all_connections! 58 | end 59 | info "migration ran" 60 | end 61 | 62 | def wait_for_new_instances 63 | require_deploy! 64 | info "waiting for new instances" 65 | 66 | live_instances = [] 67 | Timeout.timeout(instance_live_grace_period) do 68 | loop do 69 | current_instances = new_instances 70 | remaining_instances = current_instances - live_instances 71 | info "waiting for instances to be live (#{remaining_instances.count} remaining)" 72 | debug "current instances: #{current_instances.map(&:instance_id)}" 73 | debug "live instances: #{live_instances.map(&:instance_id)}" 74 | live_instances.concat(remaining_instances.select { |instance| healthy?(instance) }) 75 | break if (current_instances - live_instances).empty? 76 | debug 'sleeping for 10 seconds' 77 | sleep(10) 78 | end 79 | end 80 | 81 | info 'new instances are up' 82 | rescue Timeout::Error 83 | raise "[aerosol runner] site live check timed out after #{instance_live_grace_period} seconds" 84 | ensure 85 | log_pids.each do |instance_id, fork| 86 | debug "Killing tailing for #{instance_id}: #{Time.now}" 87 | Process.kill('HUP', fork) 88 | debug "Killed process for #{instance_id}: #{Time.now}" 89 | debug "Waiting for process to die" 90 | Process.wait(fork) 91 | debug "Process ended for #{instance_id}: #{Time.now}" 92 | end 93 | end 94 | 95 | def healthy?(instance) 96 | debug "Checking if #{instance.instance_id} is healthy" 97 | 98 | unless instance.live? 99 | debug "#{instance.instance_id} is not live" 100 | return false 101 | end 102 | 103 | debug "trying to SSH to #{instance.instance_id}" 104 | success = false 105 | ssh.with_connection(instance) do |session| 106 | start_tailing_logs(ssh, instance) if log_pids[instance.instance_id].nil? 107 | debug "checking if #{instance.instance_id} is healthy" 108 | success = 109 | case is_alive? 110 | when Proc 111 | debug 'Using custom site live check' 112 | is_alive?.call(session, self) 113 | when String 114 | debug "Using custom site live check: #{is_alive?}" 115 | check_live_with(session, is_alive?) 116 | else 117 | debug 'Using default site live check' 118 | check_site_live(session) 119 | end 120 | end 121 | 122 | if success 123 | debug "#{instance.instance_id} is healthy" 124 | else 125 | debug "#{instance.instance_id} is not healthy" 126 | end 127 | success 128 | rescue => ex 129 | debug "#{instance.instance_id} is not healthy: #{ex.message}" 130 | false 131 | end 132 | 133 | def check_site_live(session) 134 | command = [ 135 | 'wget', 136 | '-q', 137 | # Since we're hitting localhost, the cert will always be invalid, so don't try to check it. 138 | deploy.ssl ? '--no-check-certificate' : nil, 139 | "'#{deploy.live_check_url}'", 140 | '-O', 141 | '/dev/null' 142 | ].compact.join(' ') 143 | check_live_with(session, command) 144 | end 145 | 146 | def check_live_with(session, command) 147 | debug "running #{command}" 148 | ret = ssh_exec!(session, command) 149 | debug "finished running #{command}" 150 | ret[:exit_status].zero? 151 | end 152 | 153 | def start_tailing_logs(ssh, instance) 154 | if tail_logs && log_files.length > 0 155 | command = [ 156 | 'sudo', 'tail', '-f', *log_files 157 | ].join(' ') 158 | 159 | log_pids[instance.instance_id] ||= ssh_fork(command, ssh, instance) 160 | end 161 | end 162 | 163 | def ssh_fork(command, ssh, instance) 164 | debug 'starting ssh fork' 165 | fork do 166 | Signal.trap('HUP') do 167 | debug 'Killing tailing session' 168 | Process.exit! 169 | end 170 | debug 'starting tail' 171 | begin 172 | ssh.with_connection(instance) do |session| 173 | debug 'tailing session connected' 174 | buffer = '' 175 | ssh_exec!(session, command) do |stream, data| 176 | data.lines.each do |line| 177 | if line.end_with?($/) 178 | debug "[#{instance.instance_id}] #{stream}: #{buffer + line}" 179 | buffer = '' 180 | else 181 | buffer = line 182 | end 183 | end 184 | end 185 | end 186 | rescue => ex 187 | error "#{ex.class}: #{ex.message}" 188 | error "#{ex.backtrace.join("\n")}" 189 | ensure 190 | debug 'finished' 191 | end 192 | end 193 | end 194 | 195 | def stop_app 196 | info "stopping old app" 197 | to_stop = old_instances 198 | 199 | info "starting with #{to_stop.length} instances to stop" 200 | 201 | stop_app_retries.succ.times do |n| 202 | break if to_stop.empty? 203 | debug "stop app: #{to_stop.length} instances remaining" 204 | to_stop.reject! { |instance| stop_one_app(instance) } 205 | end 206 | 207 | if to_stop.length.zero? 208 | info "successfully stopped the app on each old instance" 209 | elsif !continue_if_stop_app_fails 210 | raise "Failed to stop app on #{to_stop.length} instances" 211 | end 212 | info "stopped old app" 213 | end 214 | 215 | def destroy_old_auto_scaling_groups 216 | require_deploy! 217 | info "destroying old autoscaling groups" 218 | sleep deploy.sleep_before_termination 219 | old_auto_scaling_groups.map(&:destroy) 220 | info "destroyed old autoscaling groups" 221 | end 222 | 223 | def destroy_new_auto_scaling_groups 224 | require_deploy! 225 | info "destroying autoscaling groups created for this sha" 226 | new_auto_scaling_groups.map(&:destroy) 227 | info "destroyed new autoscaling groups" 228 | end 229 | 230 | def old_instances 231 | require_deploy! 232 | old_auto_scaling_groups.map(&:launch_details).compact.map(&:all_instances).flatten.compact 233 | end 234 | 235 | def old_auto_scaling_groups 236 | select_auto_scaling_groups { |asg| asg.tags['GitSha'] != auto_scaling.tags['GitSha'] } 237 | end 238 | 239 | def new_auto_scaling_groups 240 | select_auto_scaling_groups { |asg| asg.tags['GitSha'] == auto_scaling.tags['GitSha'] } 241 | end 242 | 243 | def select_auto_scaling_groups(&block) 244 | require_deploy! 245 | Aerosol::LaunchConfiguration.all # load all of the launch configurations first 246 | Aerosol::LaunchTemplate.all 247 | Aerosol::AutoScaling.all.select { |asg| 248 | (asg.tags['Deploy'].to_s == auto_scaling.tags['Deploy']) && 249 | (block.nil? ? true : block.call(asg)) 250 | } 251 | end 252 | 253 | def new_instances 254 | require_deploy! 255 | 256 | while launch_details.all_instances.length < auto_scaling.min_size 257 | info "Waiting for instances to come up" 258 | sleep 10 259 | end 260 | 261 | launch_details.all_instances 262 | end 263 | 264 | def with_deploy(name) 265 | unless dep = Aerosol::Deploy[name] 266 | raise "No deploy named '#{name}'" 267 | end 268 | original = @deploy 269 | @deploy = dep 270 | yield self 271 | @deploy = original 272 | end 273 | 274 | def require_deploy! 275 | raise "@deploy must be present" if deploy.nil? 276 | end 277 | 278 | def git_sha 279 | @git_sha ||= Aerosol::Util.git_sha 280 | end 281 | 282 | delegate :ssh, :migration_ssh, :package, :auto_scaling, :stop_command, 283 | :live_check, :db_config_path, :instance_live_grace_period, 284 | :app_port, :continue_if_stop_app_fails, :stop_app_retries, 285 | :is_alive?, :log_files, :tail_logs, :to => :deploy 286 | delegate :launch_details, :to => :auto_scaling 287 | 288 | private 289 | 290 | def stop_one_app(instance) 291 | debug "attempting to stop app on: #{instance.address}" 292 | ssh.with_connection(instance) do |session| 293 | session.exec!(stop_command) 294 | session.loop 295 | end 296 | info "successfully stopped app on: #{instance.address}" 297 | true 298 | rescue => ex 299 | warn "stop app failed on #{instance.address} due to: #{ex}" 300 | false 301 | end 302 | 303 | # inspired by: http://stackoverflow.com/questions/3386233/how-to-get-exit-status-with-rubys-netssh-library 304 | def ssh_exec!(ssh, command, options = {}, &block) 305 | res = { :out => "", :err => "", :exit_status => nil } 306 | ssh.open_channel do |channel| 307 | if options[:tty] 308 | channel.request_pty do |ch, success| 309 | raise "could not start a pseudo-tty" unless success 310 | channel = ch 311 | end 312 | end 313 | 314 | channel.exec(command) do |ch, success| 315 | raise "unable to run remote cmd: #{command}" unless success 316 | 317 | channel.on_data do |_, data| 318 | block.call(:out, data) unless block.nil? 319 | res[:out] << data 320 | end 321 | channel.on_extended_data do |_, type, data| 322 | block.call(:err, data) unless block.nil? 323 | res[:err] << data 324 | end 325 | channel.on_request("exit-status") { |_, data| res[:exit_status] = data.read_long } 326 | end 327 | end 328 | ssh.loop 329 | res 330 | end 331 | 332 | def random_open_port 333 | socket = Socket.new(:INET, :STREAM, 0) 334 | socket.bind(Addrinfo.tcp("127.0.0.1", 0)) 335 | port = socket.local_address.ip_port 336 | socket.close 337 | port 338 | end 339 | end 340 | -------------------------------------------------------------------------------- /lib/aerosol/util.rb: -------------------------------------------------------------------------------- 1 | require 'minigit' 2 | 3 | module Aerosol::Util 4 | extend self 5 | 6 | def is_tar?(path) 7 | if File.size(path) < 262 8 | return false 9 | end 10 | magic = nil 11 | File.open(path, "r") do |f| 12 | f.read(257) 13 | magic = f.read(5) 14 | end 15 | magic == "ustar" 16 | end 17 | 18 | def is_gzip?(path) 19 | if File.size(path) < 2 20 | return false 21 | end 22 | magic = nil 23 | File.open(path, "r") do |f| 24 | magic = f.read(2) 25 | end 26 | magic = magic.unpack('H*')[0] 27 | magic == "1f8b" 28 | end 29 | 30 | def strip_heredoc(str) 31 | str.gsub(/^#{str[/\A\s*/]}/, '') 32 | end 33 | 34 | def git_repo 35 | @git_repo ||= MiniGit.new('.') 36 | end 37 | 38 | def git_sha 39 | @git_sha ||= git_repo.capturing.rev_parse('HEAD').chomp[0..6] rescue 'unknown' 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/aerosol/version.rb: -------------------------------------------------------------------------------- 1 | # Copyright Swipely, Inc. All rights reserved. 2 | 3 | module Aerosol 4 | VERSION = '1.10.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/aerosol/auto_scaling_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::AutoScaling do 4 | let(:launch_configuration_setup) do 5 | Aerosol::LaunchConfiguration.new! do 6 | name :my_launch_config_for_auto_scaling 7 | image_id 'ami :) :) :)' 8 | instance_type 'm1.large' 9 | stub(:sleep) 10 | end 11 | end 12 | 13 | let(:launch_configuration) do 14 | launch_configuration_setup.tap(&:create) 15 | end 16 | 17 | subject { described_class.new!(&block) } 18 | let(:previous_launch_configurations) { [] } 19 | let(:previous_auto_scaling_groups) { [] } 20 | 21 | before do 22 | subject.stub(:sleep) 23 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 24 | launch_configurations: previous_launch_configurations, next_token: nil 25 | }) 26 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_groups, { 27 | auto_scaling_groups: previous_auto_scaling_groups, next_token: nil 28 | }) 29 | end 30 | 31 | let(:block) { Proc.new { } } 32 | 33 | describe "#auto_scaling_group_name" do 34 | let(:block) { Proc.new { name :my_auto_scaling } } 35 | 36 | context "with no namespace set" do 37 | let(:identifier) { "my_auto_scaling-#{Aerosol::Util.git_sha}" } 38 | it "returns a normal identifier" do 39 | expect(subject.auto_scaling_group_name).to eq(identifier) 40 | end 41 | end 42 | 43 | context "with a namespace set" do 44 | let(:namespace) { "test" } 45 | let(:identifier) { "#{namespace}-my_auto_scaling-#{Aerosol::Util.git_sha}" } 46 | 47 | before { Aerosol.namespace namespace } 48 | after { Aerosol.instance_variable_set(:"@namespace", nil) } 49 | 50 | it "returns a namespaced identifier" do 51 | expect(subject.auto_scaling_group_name).to eq(identifier) 52 | end 53 | end 54 | end 55 | 56 | describe '#create!' do 57 | context 'when none of the required options are set' do 58 | it 'raises an error' do 59 | expect { subject.create! }.to raise_error 60 | end 61 | end 62 | 63 | context 'when some of the required options are set' do 64 | before { subject.max_size 101 } 65 | 66 | it 'raises an error' do 67 | expect { subject.create! }.to raise_error 68 | end 69 | end 70 | 71 | context 'when all of the required options are set' do 72 | let(:availability_zone) { 'US' } 73 | let(:min_size) { 1 } 74 | let(:max_size) { 10 } 75 | let(:options) { 76 | { 77 | :name => :my_group, 78 | :launch_configuration => launch_configuration.name, 79 | :availability_zones => [availability_zone], 80 | :min_size => 1, 81 | :max_size => 10, 82 | :vpc_zone_identifier => 'subnet-deadbeef,subnet-00112233' 83 | } 84 | } 85 | 86 | subject { Aerosol::AutoScaling.new!(options) } 87 | before { subject.tag :my_group => '1' } 88 | 89 | context 'when the launch configuration is not known' do 90 | before { subject.instance_variable_set(:@launch_configuration, nil) } 91 | it 'raises an error' do 92 | expect { subject.create! }.to raise_error 93 | end 94 | end 95 | 96 | context 'when the launch configuration is known' do 97 | it 'creates an auto-scaling group' do 98 | expect(subject.tags).to include('Deploy' => 'my_group') 99 | subject.create! 100 | end 101 | 102 | context "when there is a namespace" do 103 | subject do 104 | Aerosol.namespace "tags" 105 | Aerosol::AutoScaling.new!(options) 106 | end 107 | 108 | after { Aerosol.instance_variable_set(:"@namespace", nil) } 109 | 110 | it "includes the namespace" do 111 | expect(subject.tags).to include('Deploy' => 'tags-my_group') 112 | end 113 | end 114 | end 115 | end 116 | end 117 | 118 | describe '#destroy!' do 119 | let(:launch_configuration) { Aerosol::LaunchConfiguration.new!(name: 'test-lc') } 120 | subject { Aerosol::AutoScaling.new!(launch_configuration: launch_configuration.name) } 121 | 122 | context 'when there is no such auto-scaling group' do 123 | it 'raises an error' do 124 | Aerosol::AWS.auto_scaling.stub_responses( 125 | :delete_auto_scaling_group, 126 | Aws::AutoScaling::Errors::ValidationError.new(nil, nil) 127 | ) 128 | 129 | expect { subject.destroy! }.to raise_error(Aws::AutoScaling::Errors::ValidationError) 130 | end 131 | end 132 | 133 | context 'when the auto-scaling group exists' do 134 | it 'deletes the auto-scaling group' do 135 | Aerosol::AWS.auto_scaling.stub_responses(:delete_auto_scaling_group, {}) 136 | expect(subject.launch_configuration).to receive(:destroy) 137 | expect { subject.destroy! }.to_not raise_error 138 | end 139 | end 140 | end 141 | 142 | describe '#create' do 143 | context 'when the auto_scaling_group_name is nil' do 144 | subject { described_class.new!(:name => 'nonsense') } 145 | 146 | it 'raises an error' do 147 | expect { subject.create }.to raise_error 148 | end 149 | end 150 | 151 | context 'when the auto_scaling_group_name is present' do 152 | subject { described_class.new!(:name => 'nonsense2') } 153 | 154 | context 'when the model already exists' do 155 | before { described_class.stub(:exists?).and_return(true) } 156 | 157 | it 'does not create it' do 158 | subject.should_not_receive(:create!) 159 | subject.create 160 | end 161 | end 162 | 163 | context 'when the model does not already exist' do 164 | before { described_class.stub(:exists?).and_return(false) } 165 | 166 | it 'creates it' do 167 | subject.should_receive(:create!) 168 | subject.create 169 | end 170 | end 171 | end 172 | end 173 | 174 | describe '#destroy' do 175 | subject { described_class.new!(:name => 'nonsense2') } 176 | 177 | context 'when the model already exists' do 178 | before { described_class.stub(:exists?).and_return(true) } 179 | 180 | it 'destroys it' do 181 | subject.should_receive(:destroy!) 182 | subject.destroy 183 | end 184 | end 185 | 186 | context 'when the model does not exist' do 187 | before { described_class.stub(:exists?).and_return(false) } 188 | 189 | it 'does not destroy it' do 190 | subject.should_not_receive(:destroy!) 191 | subject.destroy 192 | end 193 | end 194 | end 195 | 196 | describe '.exists?' do 197 | subject { described_class } 198 | 199 | context 'when the argument exists' do 200 | it 'returns true' do 201 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_groups, { 202 | auto_scaling_groups: [{ 203 | auto_scaling_group_name: 'test', 204 | launch_configuration_name: 'test', 205 | min_size: 1, 206 | max_size: 1, 207 | desired_capacity: 1, 208 | default_cooldown: 300, 209 | availability_zones: ['us-east-1a'], 210 | health_check_type: 'EC2', 211 | created_time: Time.at(1) 212 | }], 213 | next_token: nil 214 | }) 215 | subject.exists?('test').should be true 216 | end 217 | end 218 | 219 | context 'when the argument does not exist' do 220 | before do 221 | described_class.new! do 222 | name :exists_test_name 223 | auto_scaling_group_name 'does-not-exist' 224 | stub(:sleep) 225 | end.destroy! rescue nil 226 | end 227 | 228 | it 'returns false' do 229 | subject.exists?('does-not-exist').should be false 230 | end 231 | end 232 | end 233 | 234 | describe '.request_all' do 235 | describe 'repeats until no NextToken' do 236 | it 'should include both autoscaling groups lists' do 237 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_groups, [ 238 | { 239 | auto_scaling_groups: [ 240 | { 241 | auto_scaling_group_name: '1', 242 | launch_configuration_name: '1', 243 | min_size: 1, 244 | max_size: 1, 245 | desired_capacity: 1, 246 | default_cooldown: 300, 247 | availability_zones: ['us-east-1a'], 248 | health_check_type: 'EC2', 249 | created_time: Time.at(1) 250 | }, { 251 | auto_scaling_group_name: '4', 252 | launch_configuration_name: '4', 253 | min_size: 1, 254 | max_size: 1, 255 | desired_capacity: 1, 256 | default_cooldown: 300, 257 | availability_zones: ['us-east-1a'], 258 | health_check_type: 'EC2', 259 | created_time: Time.at(1) 260 | } 261 | ], 262 | next_token: 'token' 263 | }, 264 | { 265 | auto_scaling_groups: [ 266 | { 267 | auto_scaling_group_name: '2', 268 | launch_configuration_name: '2', 269 | min_size: 1, 270 | max_size: 1, 271 | desired_capacity: 1, 272 | default_cooldown: 300, 273 | availability_zones: ['us-east-1a'], 274 | health_check_type: 'EC2', 275 | created_time: Time.at(1) 276 | }, { 277 | auto_scaling_group_name: '3', 278 | launch_configuration_name: '3', 279 | min_size: 1, 280 | max_size: 1, 281 | desired_capacity: 1, 282 | default_cooldown: 300, 283 | availability_zones: ['us-east-1a'], 284 | health_check_type: 'EC2', 285 | created_time: Time.at(1) 286 | } 287 | ], 288 | next_token: nil 289 | } 290 | ]) 291 | 292 | expect(Aerosol::AutoScaling.request_all.map(&:auto_scaling_group_name)).to eq(['1','4','2','3']) 293 | end 294 | end 295 | end 296 | 297 | describe '.all' do 298 | subject { described_class } 299 | 300 | context 'when there are no auto scaling groups' do 301 | before do 302 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_groups, [ 303 | { auto_scaling_groups: [], next_token: nil } 304 | ]) 305 | end 306 | it 'is empty' do 307 | expect(subject.all).to be_empty 308 | end 309 | end 310 | 311 | context 'when there are auto scaling groups' do 312 | let(:insts) { 313 | [ 314 | { 315 | auto_scaling_group_name: 'test', 316 | min_size: 1, 317 | max_size: 3, 318 | availability_zones: ['us-east-1'], 319 | launch_configuration_name: launch_configuration.name.to_s, 320 | desired_capacity: 1, 321 | default_cooldown: 300, 322 | health_check_type: 'EC2', 323 | created_time: Time.at(1) 324 | }, 325 | { 326 | auto_scaling_group_name: 'test2', 327 | min_size: 2, 328 | max_size: 4, 329 | availability_zones: ['us-east-2'], 330 | launch_configuration_name: launch_configuration.name.to_s, 331 | desired_capacity: 1, 332 | default_cooldown: 300, 333 | health_check_type: 'EC2', 334 | created_time: Time.at(1), 335 | tags: [{ key: 'my_tag', value: 'is_sweet' }] 336 | } 337 | ] 338 | } 339 | 340 | it 'returns each of them' do 341 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_groups, { 342 | auto_scaling_groups: insts, 343 | next_token: nil 344 | }) 345 | instances = subject.all 346 | instances.map(&:min_size).should == [1, 2] 347 | instances.map(&:max_size).should == [3, 4] 348 | instances.map(&:tags).should == [{ 349 | 'GitSha' => Aerosol::Util.git_sha, 350 | 'Deploy' => instances.first.name.to_s 351 | }, 352 | { 353 | 'GitSha' => Aerosol::Util.git_sha, 354 | 'Deploy' => instances.last.name.to_s, 355 | 'my_tag' => 'is_sweet' 356 | } 357 | ] 358 | end 359 | end 360 | end 361 | 362 | describe '.from_hash' do 363 | context 'when the auto scaling group has not been initialized' do 364 | let(:auto_scaling) { described_class.from_hash(hash) } 365 | 366 | let(:hash) do 367 | { 368 | auto_scaling_group_name: 'test-auto-scaling', 369 | availability_zones: ['us-east-1'], 370 | launch_configuration_name: launch_configuration.launch_configuration_name, 371 | min_size: 1, 372 | max_size: 2, 373 | desired_capacity: 1, 374 | default_cooldown: 300, 375 | health_check_type: 'EC2', 376 | created_time: Time.at(1), 377 | } 378 | end 379 | 380 | it 'creates a new auto scaling group with the specified values' do 381 | auto_scaling.auto_scaling_group_name.should == 'test-auto-scaling' 382 | auto_scaling.availability_zones.should == ['us-east-1'] 383 | auto_scaling.launch_configuration.should == launch_configuration 384 | auto_scaling.min_size.should == 1 385 | auto_scaling.max_size.should == 2 386 | end 387 | 388 | it 'generates a name' do 389 | auto_scaling.name.to_s.should start_with 'AutoScaling_' 390 | end 391 | end 392 | 393 | context 'when the auto scaling group has already been initialized' do 394 | let(:old_hash) do 395 | { 396 | auto_scaling_group_name: 'this-aws-id-abc-123', 397 | min_size: 16 398 | } 399 | end 400 | let(:new_hash) { old_hash.merge(max_size: 40) } 401 | let!(:existing) { described_class.from_hash(old_hash) } 402 | let(:new) { described_class.from_hash(new_hash) } 403 | 404 | it 'makes a new instance' do 405 | expect { new }.to change { described_class.instances.length }.by(1) 406 | new.auto_scaling_group_name.should == 'this-aws-id-abc-123' 407 | new.min_size.should == 16 408 | new.max_size.should == 40 409 | end 410 | end 411 | end 412 | 413 | describe '.latest_for_tag' do 414 | subject { Aerosol::AutoScaling } 415 | 416 | before { subject.stub(:all).and_return(groups) } 417 | 418 | context 'when there are no groups' do 419 | let(:groups) { [] } 420 | 421 | it 'returns nil' do 422 | subject.latest_for_tag('Deploy', 'my-deploy').should be_nil 423 | end 424 | end 425 | 426 | context 'when there is at least one group' do 427 | context 'but none of the groups satisfy the query' do 428 | let(:group1) { double(:tags => { 'Deploy' => 'not-the-correct-deploy' }) } 429 | let(:group2) { double(:tags => {}) } 430 | let(:group3) { double(:tags => { 'deploy' => 'my-deploy' }) } 431 | let(:groups) { [group1, group2, group3] } 432 | 433 | it 'returns nil' do 434 | subject.latest_for_tag('Deploy', 'my-deploy').should be_nil 435 | end 436 | end 437 | 438 | context 'and one group satisfies the query' do 439 | let(:group1) { double(:tags => { 'Deploy' => 'my-deploy' }, 440 | :created_time => Time.parse('01-01-2013')) } 441 | let(:group2) { double(:tags => { 'Non' => 'Sense'}) } 442 | let(:groups) { [group1, group2] } 443 | 444 | it 'returns that group' do 445 | subject.latest_for_tag('Deploy', 'my-deploy').should == group1 446 | end 447 | end 448 | 449 | context 'and many groups satisfy the query' do 450 | let(:group1) { double(:tags => { 'Deploy' => 'my-deploy' }, 451 | :created_time => Time.parse('01-01-2013')) } 452 | let(:group2) { double(:tags => { 'Deploy' => 'my-deploy' }, 453 | :created_time => Time.parse('02-01-2013')) } 454 | let(:group3) { double(:tags => { 'Non' => 'Sense'}) } 455 | let(:groups) { [group1, group2, group3] } 456 | 457 | it 'returns the group that was created last' do 458 | subject.latest_for_tag('Deploy', 'my-deploy').should == group2 459 | end 460 | end 461 | end 462 | end 463 | 464 | describe '#all_instances' do 465 | let(:auto_scaling) { 466 | described_class.new( 467 | :name => :all_instances_asg, 468 | :availability_zones => [], 469 | :max_size => 10, 470 | :min_size => 4, 471 | :launch_configuration => launch_configuration.name 472 | ) 473 | } 474 | let(:previous_auto_scaling_groups) { 475 | [{ 476 | auto_scaling_group_name: 'all_instances_asg', 477 | availability_zones: ['us-east-1'], 478 | launch_configuration_name: launch_configuration.name.to_s, 479 | min_size: 1, 480 | max_size: 2, 481 | desired_capacity: 1, 482 | default_cooldown: 300, 483 | health_check_type: 'EC2', 484 | created_time: Time.at(1), 485 | instances: [{ 486 | instance_id: 'i-1239013', 487 | availability_zone: 'us-east-1a', 488 | lifecycle_state: 'InService', 489 | health_status: 'GOOD', 490 | launch_configuration_name: launch_configuration.name.to_s, 491 | protected_from_scale_in: false 492 | }, { 493 | instance_id: 'i-1239014', 494 | availability_zone: 'us-east-1a', 495 | lifecycle_state: 'InService', 496 | health_status: 'GOOD', 497 | launch_configuration_name: launch_configuration.name.to_s, 498 | protected_from_scale_in: false 499 | }, { 500 | instance_id: 'i-1239015', 501 | availability_zone: 'us-east-1a', 502 | lifecycle_state: 'InService', 503 | health_status: 'GOOD', 504 | launch_configuration_name: launch_configuration.name.to_s, 505 | protected_from_scale_in: false 506 | }, { 507 | instance_id: 'i-1239016', 508 | availability_zone: 'us-east-1a', 509 | lifecycle_state: 'InService', 510 | health_status: 'GOOD', 511 | launch_configuration_name: launch_configuration.name.to_s, 512 | protected_from_scale_in: false 513 | }] 514 | }] 515 | } 516 | 517 | it 'returns a list of instances associated with the group' do 518 | auto_scaling.all_instances.length.should == 4 519 | auto_scaling.all_instances.should be_all { |inst| inst.is_a?(Aerosol::Instance) } 520 | end 521 | end 522 | end 523 | -------------------------------------------------------------------------------- /spec/aerosol/aws_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::AWS do 4 | subject { Aerosol::AWS } 5 | 6 | describe '#reset_cache!' do 7 | before do 8 | subject.instance_variable_set(:@auto_scaling, double) 9 | subject.instance_variable_set(:@compute, double) 10 | end 11 | 12 | it 'sets @auto_scaling to nil' do 13 | expect { subject.reset_cache! } 14 | .to change { subject.instance_variable_get(:@auto_scaling) } 15 | .to(nil) 16 | end 17 | 18 | it 'sets @compute to nil' do 19 | expect { subject.reset_cache! } 20 | .to change { subject.instance_variable_get(:@compute) } 21 | .to(nil) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/aerosol/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Aerosol CLI" do 4 | describe "running the most basic command" do 5 | let(:command) { "./bin/aerosol" } 6 | it "should exit with 0" do 7 | expect(system(command)).to be true 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/aerosol/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::Connection do 4 | describe '#with_connection' do 5 | context 'when at least one of the required fields is missing' do 6 | it 'raises an error' do 7 | expect { subject.with_connection }.to raise_error 8 | end 9 | end 10 | 11 | context 'when all of the required fields are present' do 12 | subject do 13 | Aerosol::Connection.new do 14 | name :lil_joey_pumpkins 15 | host 'www.aol.com' 16 | user 'steve_case' 17 | end 18 | end 19 | 20 | context 'when the jump host is nil' do 21 | it 'logs in directly' do 22 | Net::SSH.should_receive(:start) 23 | subject.with_connection 24 | end 25 | end 26 | 27 | context 'when the jump host is present' do 28 | let(:gateway) { double(:gateway) } 29 | before do 30 | subject.jump :host => 'my-jump-host', :user => 'my-user' 31 | end 32 | 33 | it 'goes through the jump host' do 34 | Net::SSH::Gateway.stub(:new).and_return(gateway) 35 | gateway.should_receive(:ssh) 36 | gateway.should_receive(:shutdown!) 37 | subject.with_connection 38 | end 39 | end 40 | 41 | context 'when the host is an instance' do 42 | let(:public_hostname) { nil } 43 | let(:host) { Aerosol::Instance.new } 44 | subject do 45 | Aerosol::Connection.new.tap do |inst| 46 | inst.name :connection_for_tim_horton 47 | inst.user 'tim_horton' 48 | inst.host host 49 | end 50 | end 51 | 52 | before do 53 | allow(host).to receive(:public_hostname).and_return(public_hostname) 54 | allow(host).to receive(:private_ip_address).and_return('152.60.94.125') 55 | end 56 | 57 | context 'when the public_hostname is present' do 58 | let(:public_hostname) { 'example.com' } 59 | it 'returns the public_hostname' do 60 | Net::SSH.should_receive(:start).with(public_hostname, 'tim_horton') 61 | subject.with_connection 62 | end 63 | end 64 | 65 | context 'when the public_hostname is nil' do 66 | it 'returns the private_ip_address' do 67 | Net::SSH.should_receive(:start).with('152.60.94.125', 'tim_horton') 68 | subject.with_connection 69 | end 70 | end 71 | end 72 | 73 | context 'when the host is passed into with_connection' do 74 | let(:public_hostname) { nil } 75 | let(:host) { Aerosol::Instance.new } 76 | subject do 77 | Aerosol::Connection.new.tap do |inst| 78 | inst.name :connection_for_tim_horton 79 | inst.user 'tim_horton' 80 | end 81 | end 82 | 83 | before do 84 | allow(host).to receive(:public_hostname).and_return(public_hostname) 85 | allow(host).to receive(:private_ip_address).and_return('152.60.94.125') 86 | end 87 | 88 | context 'when the public_hostname is present' do 89 | let(:public_hostname) { 'example.com' } 90 | it 'returns the public_hostname' do 91 | Net::SSH.should_receive(:start).with(public_hostname, 'tim_horton') 92 | subject.with_connection(host) 93 | end 94 | end 95 | 96 | context 'when the public_hostname is an empty string' do 97 | let(:public_hostname) { '' } 98 | it 'returns the private_ip_address' do 99 | Net::SSH.should_receive(:start).with('152.60.94.125', 'tim_horton') 100 | subject.with_connection(host) 101 | end 102 | end 103 | 104 | context 'when the public_hostname is nil' do 105 | it 'returns the private_ip_address' do 106 | Net::SSH.should_receive(:start).with('152.60.94.125', 'tim_horton') 107 | subject.with_connection(host) 108 | end 109 | end 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/aerosol/deploy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::Deploy do 4 | let!(:ssh) { double(:ssh) } 5 | subject { described_class.new(:name => :test) } 6 | 7 | before do 8 | subject.stub(:ssh).and_return(ssh) 9 | end 10 | 11 | describe '#migration' do 12 | context 'by default' do 13 | it 'is true' do 14 | expect(subject.migrate?).to be true 15 | end 16 | end 17 | 18 | context 'when do_not_migrate! has been called' do 19 | before { subject.do_not_migrate! } 20 | it 'is false' do 21 | expect(subject.migrate?).to be false 22 | end 23 | end 24 | end 25 | 26 | describe '#perform_role_assumption' do 27 | context 'when assume_role is nil' do 28 | it 'does not change the aws config' do 29 | expect(Aws).to_not receive(:config) 30 | end 31 | end 32 | 33 | context 'when assume_role exists' do 34 | let(:assume_role) { 'arn:aws:sts::123456789123:role/role-aerosol' } 35 | 36 | before do 37 | Aerosol::AWS.sts.stub_responses( 38 | :assume_role, 39 | credentials: { 40 | access_key_id: '123', 41 | secret_access_key: '456', 42 | session_token: '789', 43 | expiration: Time.new + 60 44 | } 45 | ) 46 | end 47 | 48 | after do 49 | Aws.config.update(credentials: nil) 50 | end 51 | 52 | it 'should set the Aws.config[:credentials]' do 53 | subject.assume_role(assume_role) 54 | expect { subject.perform_role_assumption } 55 | .to change { Aws.config[:credentials] } 56 | end 57 | end 58 | end 59 | 60 | describe '#run_post_deploy' do 61 | context 'with no post_deploy_command' do 62 | before do 63 | subject.stub(:post_deploy_command) 64 | end 65 | 66 | it "doesn't raises an error" do 67 | expect { subject.run_post_deploy }.to_not raise_error 68 | end 69 | 70 | it "returns nil" do 71 | expect(subject.run_post_deploy).to be_nil 72 | end 73 | end 74 | 75 | context 'with post_deploy_command' do 76 | context 'and post_deploy_command runs correctly' do 77 | before do 78 | subject.stub(:post_deploy_command).and_return('true') 79 | end 80 | 81 | it "doesn't raises an error" do 82 | expect { subject.run_post_deploy }.to_not raise_error 83 | end 84 | 85 | it "returns true" do 86 | expect(subject.run_post_deploy).to be true 87 | end 88 | end 89 | 90 | context 'and post_deploy_command runs incorrectly' do 91 | before do 92 | subject.stub(:post_deploy_command).and_return('false') 93 | end 94 | 95 | it 'raises an error' do 96 | expect { subject.run_post_deploy }.to raise_error 97 | end 98 | end 99 | end 100 | end 101 | 102 | describe '#local_ssh_ref' do 103 | context 'when there is no local_ssh' do 104 | it 'is ssh' do 105 | expect(subject.local_ssh_ref).to eq(ssh) 106 | end 107 | end 108 | 109 | context 'when there is a local_ssh' do 110 | let!(:local_ssh) { double(:local_ssh) } 111 | before do 112 | subject.stub(:local_ssh).and_return(local_ssh) 113 | end 114 | 115 | it 'is ssh' do 116 | expect(subject.local_ssh_ref).to eq(local_ssh) 117 | end 118 | end 119 | end 120 | 121 | describe '#generate_ssh_command' do 122 | let(:ssh_ref) { double(:ssh_ref) } 123 | let(:instance) { Aerosol::Instance.new } 124 | let(:ssh_command) { subject.generate_ssh_command(instance) } 125 | 126 | before do 127 | allow(instance).to receive(:public_hostname).and_return('hostname.com') 128 | allow(subject).to receive(:local_ssh_ref).and_return(ssh_ref) 129 | end 130 | 131 | context 'with a user' do 132 | before do 133 | ssh_ref.stub(:user).and_return('ubuntu') 134 | end 135 | 136 | context 'without a jump server' do 137 | before do 138 | ssh_ref.stub(:jump) 139 | end 140 | 141 | it 'responds with no jump server' do 142 | expect(ssh_command).to be =~ /ssh .* ubuntu@hostname.com/ 143 | end 144 | end 145 | 146 | context 'with a jump server' do 147 | before do 148 | ssh_ref.stub(:jump).and_return(:user => 'candle', :host => 'example.org') 149 | end 150 | 151 | it 'responds with a jump server' do 152 | expect(ssh_command).to be =~ /ssh .* -o 'ProxyCommand=ssh -W %h:%p candle@example\.org' ubuntu@hostname\.com/ 153 | end 154 | end 155 | end 156 | 157 | context 'without a user' do 158 | before do 159 | ssh_ref.stub(:user) 160 | end 161 | 162 | context 'without a jump server' do 163 | before do 164 | ssh_ref.stub(:jump) 165 | end 166 | 167 | it 'responds with no user and no jump' do 168 | expect(ssh_command).to be =~ /ssh .* hostname.com/ 169 | end 170 | end 171 | 172 | context 'with a jump server' do 173 | before do 174 | ssh_ref.stub(:jump).and_return(:user => 'candle', :host => 'example.org') 175 | end 176 | 177 | it 'responds with no user and a jump server' do 178 | expect(ssh_command).to be =~ /ssh .* -o 'ProxyCommand=ssh -W %h:%p candle@example\.org' hostname\.com/ 179 | end 180 | end 181 | end 182 | end 183 | 184 | describe '#live_check_url' do 185 | context 'when SSL is not enabled' do 186 | subject { 187 | Aerosol::Deploy.new do 188 | app_port 5000 189 | live_check '/test' 190 | end 191 | } 192 | 193 | it 'returns an http url' do 194 | expect(subject.live_check_url).to eq('http://localhost:5000/test') 195 | end 196 | end 197 | 198 | context 'when SSL is enabled' do 199 | subject { 200 | Aerosol::Deploy.new do 201 | app_port 4000 202 | live_check 'check' 203 | ssl true 204 | end 205 | } 206 | 207 | it 'returns an https url' do 208 | expect(subject.live_check_url).to eq('https://localhost:4000/check') 209 | end 210 | end 211 | end 212 | 213 | describe '#is_alive?' do 214 | let(:check) { proc { true } } 215 | 216 | context 'when no argument is given' do 217 | before { subject.is_alive?(&check) } 218 | 219 | it 'returns the current value of is_alive?' do 220 | expect(subject.is_alive?).to eq(check) 221 | end 222 | end 223 | 224 | context 'when a command and block are given' do 225 | it 'fails' do 226 | expect { subject.is_alive?('true', &check) }.to raise_error 227 | end 228 | end 229 | 230 | context 'when a command is given' do 231 | let(:command) { 'bash -lc "[[ -e /tmp/up ]]"' } 232 | 233 | it 'sets is_alive? to that value' do 234 | expect { subject.is_alive?(command) } 235 | .to change { subject.is_alive? } 236 | .from(nil) 237 | .to(command) 238 | end 239 | end 240 | 241 | context 'when a block is given' do 242 | it 'sets is_alive? to that value' do 243 | expect { subject.is_alive?(&check) } 244 | .to change { subject.is_alive? } 245 | .from(nil) 246 | .to(check) 247 | end 248 | end 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /spec/aerosol/env_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::Env do 4 | describe '#deploy' do 5 | let(:name) { "unique_name_#{Time.now.to_i}".to_sym } 6 | let!(:deploy) { Aerosol.deploy(name) { } } 7 | 8 | it 'adds a deploy to the list of deploys' do 9 | expect { subject.deploy(name) } 10 | .to change { subject.deploy } 11 | .from(nil) 12 | .to([deploy]) 13 | end 14 | end 15 | 16 | describe '#perform_role_assumption' do 17 | context 'when assume_role is nil' do 18 | it 'does not change the aws config' do 19 | expect(Aws).to_not receive(:config) 20 | end 21 | end 22 | 23 | context 'when assume_role exists' do 24 | let(:assume_role) { 'arn:aws:sts::123456789123:role/role-aerosol' } 25 | 26 | before do 27 | Aerosol::AWS.sts.stub_responses( 28 | :assume_role, 29 | credentials: { 30 | access_key_id: '123', 31 | secret_access_key: '456', 32 | session_token: '789', 33 | expiration: Time.new + 60 34 | } 35 | ) 36 | end 37 | 38 | after do 39 | Aws.config.update(credentials: nil) 40 | end 41 | 42 | it 'should set the Aws.config[:credentials]' do 43 | subject.assume_role(assume_role) 44 | expect { subject.perform_role_assumption } 45 | .to change { Aws.config[:credentials] } 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/aerosol/instance_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::Instance do 4 | let(:launch_configuration) do 5 | Aerosol::LaunchConfiguration.new!({ 6 | name: 'launch_config_for_instances', 7 | image_id: 'ami-123-abc', 8 | instance_type: 'm1.large' 9 | }) 10 | end 11 | 12 | describe '.all' do 13 | subject { described_class.all } 14 | 15 | context 'when there are no instances' do 16 | it { should be_empty } 17 | end 18 | 19 | context 'when there are instances' do 20 | it 'materializes each of them into an object' do 21 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_instances, { 22 | auto_scaling_instances: 10.times.map do |i| 23 | { 24 | instance_id: "i-#{1239013 + i}", 25 | availability_zone: 'us-east-2', 26 | lifecycle_state: 'InService', 27 | health_status: 'GOOD', 28 | launch_configuration_name: launch_configuration.launch_configuration_name.to_s, 29 | auto_scaling_group_name: "test-#{i}", 30 | protected_from_scale_in: false 31 | } 32 | end 33 | }) 34 | subject.length.should == 10 35 | subject.should be_all { |inst| inst.launch_configuration == launch_configuration } 36 | subject.should be_all { |inst| inst.availability_zone == 'us-east-2' } 37 | end 38 | end 39 | end 40 | 41 | describe '.description' do 42 | subject { described_class.all.first } 43 | 44 | it 'returns additional information about the instance' do 45 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_instances, { 46 | auto_scaling_instances: [ 47 | { 48 | instance_id: 'i-1239013', 49 | availability_zone: 'us-east-2', 50 | lifecycle_state: 'InService', 51 | health_status: 'GOOD', 52 | launch_configuration_name: launch_configuration.launch_configuration_name.to_s, 53 | auto_scaling_group_name: 'test', 54 | protected_from_scale_in: false 55 | } 56 | ] 57 | }) 58 | Aerosol::AWS.compute.stub_responses(:describe_instances, { 59 | reservations: [{ 60 | instances: [{ 61 | instance_id: 'i-1239013', 62 | image_id: launch_configuration.image_id, 63 | instance_type: launch_configuration.instance_type, 64 | public_dns_name: 'ec2-dns.aws.amazon.com', 65 | private_ip_address: '10.0.0.1', 66 | state: { 67 | code: 99, 68 | name: 'running' 69 | } 70 | }] 71 | }] 72 | }) 73 | 74 | expect(subject.image_id).to eq(launch_configuration.image_id) 75 | expect(subject.description[:instance_type]).to eq(launch_configuration.instance_type) 76 | expect(subject.public_hostname).to_not be_nil 77 | expect(subject.private_ip_address).to_not be_nil 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/aerosol/launch_configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::LaunchConfiguration do 4 | subject do 5 | described_class.new do 6 | name :my_launch_config 7 | image_id 'ami-123' 8 | instance_type 'super-cool-instance-type' 9 | user_data <<-END_OF_STRING 10 | #!/bin/bash 11 | rm -rf / 12 | END_OF_STRING 13 | end 14 | end 15 | before { subject.stub(:sleep) } 16 | 17 | describe "#launch_configuration_name" do 18 | context "with no namespace set" do 19 | let(:identifier) { "my_launch_config-#{Aerosol::Util.git_sha}" } 20 | it "returns a normal identifier" do 21 | expect(subject.launch_configuration_name).to eq(identifier) 22 | end 23 | end 24 | 25 | context "with a namespace set" do 26 | let(:namespace) { "test" } 27 | let(:identifier) { "#{namespace}-my_launch_config-#{Aerosol::Util.git_sha}" } 28 | 29 | before { Aerosol.namespace namespace } 30 | after { Aerosol.instance_variable_set(:"@namespace", nil) } 31 | 32 | it "returns a namespaced identifier" do 33 | expect(subject.launch_configuration_name).to eq(identifier) 34 | end 35 | end 36 | end 37 | 38 | describe '#security_group' do 39 | subject { described_class.new!(:name => 'conf-conf-conf') } 40 | 41 | it 'adds the argument to the list of security groups' do 42 | expect { subject.security_group 'my group' } 43 | .to change { subject.security_groups.length } 44 | .by 1 45 | end 46 | 47 | it 'does not the default security group' do 48 | expect { subject.security_group 'other test' } 49 | .to_not change { described_class.default_values[:security_groups] } 50 | end 51 | end 52 | 53 | describe '#create!' do 54 | context 'when some required fields are nil' do 55 | before { subject.instance_variable_set(:@image_id, nil) } 56 | 57 | it 'raises an error' do 58 | expect { subject.create! }.to raise_error 59 | end 60 | end 61 | 62 | context 'when everything is present' do 63 | context 'and the launch configuration already exists' do 64 | it 'raises an error' do 65 | Aerosol::AWS.auto_scaling.stub_responses( 66 | :create_launch_configuration, 67 | Aws::AutoScaling::Errors::AlreadyExists 68 | ) 69 | expect { subject.create! }.to raise_error 70 | end 71 | end 72 | 73 | context 'and the launch configuration does not exist yet' do 74 | after { subject.destroy! rescue nil } 75 | 76 | it 'creates the launch configuration group' do 77 | Aerosol::AWS.auto_scaling.stub_responses(:create_launch_configuration, []) 78 | expect { subject.create! }.to_not raise_error 79 | end 80 | end 81 | end 82 | end 83 | 84 | describe '#destroy!' do 85 | context 'when the launch_configuration_name is nil' do 86 | 87 | it 'raises an error' do 88 | allow(subject).to receive(:launch_configuration_name).and_return(nil) 89 | Aerosol::AWS.auto_scaling.stub_responses(:delete_launch_configuration, []) 90 | expect { subject.destroy! }.to raise_error 91 | end 92 | end 93 | 94 | context 'when the launch_configuration_name is present' do 95 | context 'but the launch configuration does not exist' do 96 | it 'raises an error' do 97 | Aerosol::AWS.auto_scaling.stub_responses( 98 | :delete_launch_configuration, 99 | Aws::AutoScaling::Errors::ValidationError 100 | ) 101 | expect { subject.destroy! }.to raise_error 102 | end 103 | end 104 | 105 | context 'and the launch configuration exists' do 106 | it 'deletes the launch configuration' do 107 | Aerosol::AWS.auto_scaling.stub_responses(:delete_launch_configuration, []) 108 | expect { subject.destroy! }.to_not raise_error 109 | end 110 | end 111 | end 112 | end 113 | 114 | describe '#create' do 115 | context 'when the launch_configuration_name is nil' do 116 | subject do 117 | described_class.new! do 118 | name :random_test_name 119 | image_id 'test-ami-who-even-cares-really' 120 | instance_type 'm1.large' 121 | end 122 | end 123 | 124 | it 'raises an error' do 125 | allow(subject).to receive(:launch_configuration_name).and_return(nil) 126 | Aerosol::AWS.auto_scaling.stub_responses(:create_launch_configuration, []) 127 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 128 | launch_configurations: [], next_token: nil 129 | }) 130 | expect { subject.create }.to raise_error 131 | end 132 | end 133 | 134 | context 'when the launch_configuration_name is present' do 135 | subject do 136 | described_class.new! do 137 | name :random_test_name_2 138 | image_id 'test-ami-who-even-cares-really' 139 | instance_type 'm1.large' 140 | end 141 | end 142 | 143 | context 'but the launch configuration already exists' do 144 | it 'does not call #create!' do 145 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 146 | launch_configurations: [{ 147 | launch_configuration_name: subject.launch_configuration_name, 148 | image_id: 'ami-1235535', 149 | instance_type: 'm3.large', 150 | created_time: Time.at(1) 151 | }], 152 | next_token: nil 153 | }) 154 | expect(subject).to_not receive(:create!) 155 | subject.create 156 | end 157 | end 158 | 159 | context 'and the launch configuration does not yet exist' do 160 | it 'creates it' do 161 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 162 | launch_configurations: [], 163 | next_token: nil 164 | }) 165 | subject.should_receive(:create!) 166 | subject.create 167 | end 168 | end 169 | end 170 | end 171 | 172 | describe '#destroy' do 173 | subject do 174 | described_class.new! do 175 | name :random_test_name_3 176 | image_id 'awesome-ami' 177 | instance_type 'm1.large' 178 | end 179 | end 180 | 181 | context 'when the launch_configuration_name is nil' do 182 | it 'raises an error' do 183 | allow(subject).to receive(:launch_configuration_name).and_return(nil) 184 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 185 | launch_configurations: [], 186 | next_token: nil 187 | }) 188 | expect { subject.create }.to raise_error(ArgumentError) 189 | end 190 | end 191 | 192 | context 'when the launch_configuration_name is present' do 193 | context 'and the launch configuration already exists' do 194 | 195 | it 'calls #destroy!' do 196 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 197 | launch_configurations: [{ 198 | launch_configuration_name: subject.launch_configuration_name, 199 | image_id: 'ami-1235535', 200 | instance_type: 'm3.large', 201 | created_time: Time.at(1) 202 | }], 203 | next_token: nil 204 | }) 205 | subject.should_receive(:destroy!) 206 | subject.destroy 207 | end 208 | end 209 | 210 | context 'but the launch configuration does not yet exist' do 211 | it 'does not call #destroy!' do 212 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 213 | launch_configurations: [], 214 | next_token: nil 215 | }) 216 | subject.should_not_receive(:destroy!) 217 | subject.destroy 218 | end 219 | end 220 | end 221 | end 222 | 223 | describe '.exists?' do 224 | subject { described_class } 225 | let(:instance) do 226 | subject.new! do 227 | name :exists_test_name 228 | image_id 'ami123' 229 | instance_type 'm1.large' 230 | stub(:sleep) 231 | end 232 | end 233 | 234 | context 'when the argument exists' do 235 | it 'returns true' do 236 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 237 | launch_configurations: [{ 238 | launch_configuration_name: instance.launch_configuration_name, 239 | image_id: 'ami-1235535', 240 | instance_type: 'm3.large', 241 | created_time: Time.at(1) 242 | }], 243 | next_token: nil 244 | }) 245 | subject.exists?(instance.launch_configuration_name).should be true 246 | end 247 | end 248 | 249 | context 'when the argument does not exist' do 250 | let(:instance) { described_class.new! } 251 | 252 | it 'returns false' do 253 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 254 | launch_configurations: [], 255 | next_token: nil 256 | }) 257 | subject.exists?(instance.launch_configuration_name).should be false 258 | end 259 | end 260 | end 261 | 262 | describe '.request_all' do 263 | describe 'repeats until no NextToken' do 264 | it 'should include both autoscaling groups lists' do 265 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, [ 266 | { 267 | launch_configurations: [ 268 | { 269 | launch_configuration_name: '1', 270 | image_id: 'ami-1235535', 271 | instance_type: 'm3.large', 272 | created_time: Time.at(1) 273 | }, { 274 | launch_configuration_name: '4', 275 | image_id: 'ami-1235535', 276 | instance_type: 'm3.large', 277 | created_time: Time.at(1) 278 | } 279 | ], 280 | next_token: 'yes' 281 | }, 282 | { 283 | launch_configurations: [ 284 | { 285 | launch_configuration_name: '2', 286 | image_id: 'ami-1235535', 287 | instance_type: 'm3.large', 288 | created_time: Time.at(1) 289 | }, { 290 | launch_configuration_name: '3', 291 | image_id: 'ami-1235535', 292 | instance_type: 'm3.large', 293 | created_time: Time.at(1) 294 | } 295 | ], 296 | next_token: nil 297 | } 298 | ]) 299 | expect(Aerosol::LaunchConfiguration.request_all.map(&:launch_configuration_name)).to eq(['1','4','2','3']) 300 | end 301 | end 302 | end 303 | 304 | describe '.all' do 305 | subject { described_class } 306 | 307 | context 'when there are no launch configurations' do 308 | before do 309 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, [ 310 | { launch_configurations: [], next_token: nil } 311 | ]) 312 | end 313 | it "is empty" do 314 | expect(subject.all).to be_empty 315 | end 316 | end 317 | 318 | context 'when there are launch configurations' do 319 | let(:insts) { 320 | [ 321 | { 322 | launch_configuration_name: 'test', 323 | image_id: 'ami1', 324 | instance_type: 'm1.large', 325 | created_time: Time.at(1) 326 | }, 327 | { 328 | launch_configuration_name: 'test2', 329 | image_id: 'ami2', 330 | instance_type: 'm1.large', 331 | created_time: Time.at(1) 332 | } 333 | ] 334 | } 335 | 336 | it 'returns each of them' do 337 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 338 | launch_configurations: insts, 339 | next_token: nil 340 | }) 341 | subject.all.map(&:image_id).should == %w[ami1 ami2] 342 | subject.all.map(&:instance_type).should == %w[m1.large m1.large] 343 | end 344 | end 345 | end 346 | 347 | describe '.from_hash' do 348 | context 'when the launch configuration has not been initialized' do 349 | subject { described_class.from_hash(hash) } 350 | let(:hash) do 351 | { 352 | launch_configuration_name: '~test-launch-config~', 353 | image_id: 'ami-123', 354 | instance_type: 'm1.large', 355 | security_groups: [], 356 | user_data: 'echo hi', 357 | iam_instance_profile: nil, 358 | kernel_id: 'kernel-id', 359 | key_name: 'key-name', 360 | spot_price: '0.04', 361 | } 362 | end 363 | 364 | it 'creates a new launch configuration with the specified values' do 365 | subject.launch_configuration_name.should == '~test-launch-config~' 366 | subject.image_id.should == 'ami-123' 367 | subject.instance_type.should == 'm1.large' 368 | subject.security_groups.should be_empty 369 | subject.user_data.should == 'echo hi' 370 | subject.iam_instance_profile.should be_nil 371 | subject.kernel_id.should == 'kernel-id' 372 | subject.spot_price.should == '0.04' 373 | subject.from_aws = true 374 | end 375 | 376 | it 'generates a name' do 377 | subject.name.to_s.should start_with 'LaunchConfiguration_' 378 | end 379 | end 380 | 381 | context 'when the launch configuration has already been initialized' do 382 | let(:old_hash) do 383 | { 384 | launch_configuration_name: 'this-aws-id-abc-123', 385 | image_id: 'ami-456', 386 | } 387 | end 388 | let(:new_hash) { old_hash.merge(instance_type: 'm1.large') } 389 | let!(:existing) { described_class.from_hash(old_hash) } 390 | let(:new) { described_class.from_hash(new_hash) } 391 | 392 | it 'makes a new instance' do 393 | expect { new }.to change { described_class.instances.length }.by(1) 394 | new.launch_configuration_name.should == 'this-aws-id-abc-123' 395 | new.image_id.should == 'ami-456' 396 | end 397 | end 398 | end 399 | 400 | describe '#corrected_user_data' do 401 | let(:encoded_user_data_string) { Base64.encode64('test') } 402 | 403 | context 'when the user_data is a String' do 404 | subject do 405 | described_class.new do 406 | name :corrected_user_data 407 | user_data 'test' 408 | end 409 | end 410 | 411 | it 'correctly encodes to base64' do 412 | expect(subject.corrected_user_data).to eq(encoded_user_data_string) 413 | end 414 | end 415 | 416 | context 'when the user_data is a Proc' do 417 | subject do 418 | described_class.new do 419 | name :corrected_user_data_2 420 | user_data { 'test' } 421 | end 422 | end 423 | 424 | it 'correctly encodes to base64' do 425 | expect(subject.corrected_user_data).to eq(encoded_user_data_string) 426 | end 427 | end 428 | end 429 | 430 | describe '#meta_data' do 431 | subject do 432 | described_class.new do 433 | name :my_launch_config 434 | meta_data('Test' => '1') 435 | end 436 | end 437 | 438 | it 'returns the hash' do 439 | expect(subject.meta_data['Test']).to eq('1') 440 | end 441 | end 442 | end 443 | -------------------------------------------------------------------------------- /spec/aerosol/launch_template_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::LaunchTemplate do 4 | subject do 5 | described_class.new do 6 | name :my_launch_template 7 | image_id 'ami-123' 8 | instance_type 'super-cool-instance-type' 9 | user_data <<-END_OF_STRING 10 | #!/bin/bash 11 | rm -rf / 12 | END_OF_STRING 13 | end 14 | end 15 | before { subject.stub(:sleep) } 16 | 17 | describe "#launch_template_name" do 18 | context "with no namespace set" do 19 | let(:identifier) { "my_launch_template-#{Aerosol::Util.git_sha}" } 20 | it "returns a normal identifier" do 21 | expect(subject.launch_template_name).to eq(identifier) 22 | end 23 | end 24 | 25 | context "with a namespace set" do 26 | let(:namespace) { "test" } 27 | let(:identifier) { "#{namespace}-my_launch_template-#{Aerosol::Util.git_sha}" } 28 | 29 | before { Aerosol.namespace namespace } 30 | after { Aerosol.instance_variable_set(:"@namespace", nil) } 31 | 32 | it "returns a namespaced identifier" do 33 | expect(subject.launch_template_name).to eq(identifier) 34 | end 35 | end 36 | end 37 | 38 | describe '#security_group' do 39 | subject { described_class.new!(:name => 'conf-conf-conf') } 40 | 41 | it 'adds the argument to the list of security groups' do 42 | expect { subject.security_group 'my group' } 43 | .to change { subject.security_groups.length } 44 | .by 1 45 | end 46 | 47 | it 'does not the default security group' do 48 | expect { subject.security_group 'other test' } 49 | .to_not change { described_class.default_values[:security_groups] } 50 | end 51 | end 52 | 53 | describe '#create!' do 54 | context 'when some required fields are nil' do 55 | before { subject.instance_variable_set(:@image_id, nil) } 56 | 57 | it 'raises an error' do 58 | expect { subject.create! }.to raise_error 59 | end 60 | end 61 | 62 | context 'when everything is present' do 63 | context 'and the launch template already exists' do 64 | it 'raises an error' do 65 | Aerosol::AWS.compute.stub_responses( 66 | :create_launch_template, 67 | Aws::EC2::Errors::AlreadyExists 68 | ) 69 | expect { subject.create! }.to raise_error 70 | end 71 | end 72 | 73 | context 'and the launch template does not exist yet' do 74 | after { subject.destroy! rescue nil } 75 | 76 | it 'creates the launch template group' do 77 | Aerosol::AWS.compute.stub_responses(:create_launch_template, []) 78 | expect { subject.create! }.to_not raise_error 79 | end 80 | end 81 | end 82 | end 83 | 84 | describe '#destroy!' do 85 | context 'when the launch_template_name is nil' do 86 | 87 | it 'raises an error' do 88 | allow(subject).to receive(:launch_template_name).and_return(nil) 89 | Aerosol::AWS.compute.stub_responses(:delete_launch_template, []) 90 | expect { subject.destroy! }.to raise_error 91 | end 92 | end 93 | 94 | context 'when the launch_template_name is present' do 95 | context 'but the launch template does not exist' do 96 | it 'raises an error' do 97 | Aerosol::AWS.compute.stub_responses( 98 | :delete_launch_template, 99 | Aws::EC2::Errors::ValidationError 100 | ) 101 | expect { subject.destroy! }.to raise_error 102 | end 103 | end 104 | 105 | context 'and the launch template exists' do 106 | it 'deletes the launch template' do 107 | Aerosol::AWS.compute.stub_responses(:delete_launch_template, []) 108 | expect { subject.destroy! }.to_not raise_error 109 | end 110 | end 111 | end 112 | end 113 | 114 | describe '#create' do 115 | context 'when the launch_template_name is nil' do 116 | subject do 117 | described_class.new! do 118 | name :random_test_name 119 | image_id 'test-ami-who-even-cares-really' 120 | instance_type 'm1.large' 121 | end 122 | end 123 | 124 | it 'raises an error' do 125 | allow(subject).to receive(:launch_template_name).and_return(nil) 126 | Aerosol::AWS.compute.stub_responses(:create_launch_template, []) 127 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 128 | launch_templates: [], next_token: nil 129 | }) 130 | expect { subject.create }.to raise_error 131 | end 132 | end 133 | 134 | context 'when the launch_template_name is present' do 135 | subject do 136 | described_class.new! do 137 | name :random_test_name_2 138 | image_id 'test-ami-who-even-cares-really' 139 | instance_type 'm1.large' 140 | end 141 | end 142 | 143 | context 'but the launch template already exists' do 144 | it 'does not call #create!' do 145 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 146 | launch_templates: [{ 147 | launch_template_name: subject.launch_template_name, 148 | }], 149 | next_token: nil 150 | }) 151 | expect(subject).to_not receive(:create!) 152 | subject.create 153 | end 154 | end 155 | 156 | context 'and the launch template does not yet exist' do 157 | it 'creates it' do 158 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 159 | launch_templates: [], 160 | next_token: nil 161 | }) 162 | subject.should_receive(:create!) 163 | subject.create 164 | end 165 | end 166 | end 167 | end 168 | 169 | describe '#destroy' do 170 | subject do 171 | described_class.new! do 172 | name :random_test_name_3 173 | image_id 'awesome-ami' 174 | instance_type 'm1.large' 175 | end 176 | end 177 | 178 | context 'when the launch_template_name is nil' do 179 | it 'raises an error' do 180 | allow(subject).to receive(:launch_template_name).and_return(nil) 181 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 182 | launch_templates: [], 183 | next_token: nil 184 | }) 185 | expect { subject.create }.to raise_error(ArgumentError) 186 | end 187 | end 188 | 189 | context 'when the launch_template_name is present' do 190 | context 'and the launch template already exists' do 191 | 192 | it 'calls #destroy!' do 193 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 194 | launch_templates: [{ 195 | launch_template_name: subject.launch_template_name 196 | }], 197 | next_token: nil 198 | }) 199 | subject.should_receive(:destroy!) 200 | subject.destroy 201 | end 202 | end 203 | 204 | context 'but the launch template does not yet exist' do 205 | it 'does not call #destroy!' do 206 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 207 | launch_templates: [], 208 | next_token: nil 209 | }) 210 | subject.should_not_receive(:destroy!) 211 | subject.destroy 212 | end 213 | end 214 | end 215 | end 216 | 217 | describe '.exists?' do 218 | subject { described_class } 219 | let(:instance) do 220 | subject.new! do 221 | name :exists_test_name 222 | image_id 'ami123' 223 | instance_type 'm1.large' 224 | stub(:sleep) 225 | end 226 | end 227 | 228 | context 'when the argument exists' do 229 | it 'returns true' do 230 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 231 | launch_templates: [{ 232 | launch_template_name: instance.launch_template_name, 233 | }], 234 | next_token: nil 235 | }) 236 | subject.exists?(instance.launch_template_name).should be true 237 | end 238 | end 239 | 240 | context 'when the argument does not exist' do 241 | let(:instance) { described_class.new! } 242 | 243 | it 'returns false' do 244 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 245 | launch_templates: [], 246 | next_token: nil 247 | }) 248 | subject.exists?(instance.launch_template_name).should be false 249 | end 250 | end 251 | end 252 | 253 | describe '.request_all' do 254 | describe 'repeats until no NextToken' do 255 | it 'should include both autoscaling groups lists' do 256 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, [ 257 | { 258 | launch_templates: [ 259 | { launch_template_name: '1' }, 260 | { launch_template_name: '4' } 261 | ], 262 | next_token: 'yes' 263 | }, 264 | { 265 | launch_templates: [ 266 | { launch_template_name: '2' }, 267 | { launch_template_name: '3' } 268 | ], 269 | next_token: nil 270 | } 271 | ]) 272 | expect(Aerosol::LaunchTemplate.request_all.map(&:launch_template_name)).to eq(['1','4','2','3']) 273 | end 274 | end 275 | end 276 | 277 | describe '.all' do 278 | subject { described_class } 279 | 280 | context 'when there are no launch templates' do 281 | before do 282 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, [ 283 | { launch_templates: [], next_token: nil } 284 | ]) 285 | end 286 | it 'is empty' do 287 | expect(subject.all).to be_empty 288 | end 289 | end 290 | 291 | context 'when there are launch templates' do 292 | let(:insts) { 293 | [ 294 | { launch_template_name: 'test' }, 295 | { launch_template_name: 'test2' } 296 | ] 297 | } 298 | 299 | it 'returns each of them' do 300 | Aerosol::AWS.compute.stub_responses(:describe_launch_templates, { 301 | launch_templates: insts, 302 | next_token: nil 303 | }) 304 | subject.all.map(&:launch_template_name).should == %w[test test2] 305 | end 306 | end 307 | end 308 | 309 | describe '.from_hash' do 310 | context 'when the launch template has not been initialized' do 311 | subject { described_class.from_hash(hash) } 312 | let(:hash) do 313 | { 314 | launch_template_name: '~test-launch-config~', 315 | image_id: 'ami-123', 316 | instance_type: 'm1.large', 317 | security_groups: [], 318 | user_data: 'echo hi', 319 | iam_instance_profile: nil, 320 | kernel_id: 'kernel-id', 321 | key_name: 'key-name', 322 | spot_price: '0.04', 323 | } 324 | end 325 | 326 | it 'creates a new launch template with the specified values' do 327 | subject.launch_template_name.should == '~test-launch-config~' 328 | subject.image_id.should == 'ami-123' 329 | subject.instance_type.should == 'm1.large' 330 | subject.security_groups.should be_empty 331 | subject.user_data.should == 'echo hi' 332 | subject.iam_instance_profile.should be_nil 333 | subject.kernel_id.should == 'kernel-id' 334 | subject.spot_price.should == '0.04' 335 | subject.from_aws = true 336 | end 337 | 338 | it 'generates a name' do 339 | subject.name.to_s.should start_with 'LaunchTemplate_' 340 | end 341 | end 342 | 343 | context 'when the launch template has already been initialized' do 344 | let(:old_hash) do 345 | { 346 | launch_template_name: 'this-aws-id-abc-123', 347 | image_id: 'ami-456', 348 | } 349 | end 350 | let(:new_hash) { old_hash.merge(instance_type: 'm1.large') } 351 | let!(:existing) { described_class.from_hash(old_hash) } 352 | let(:new) { described_class.from_hash(new_hash) } 353 | 354 | it 'makes a new instance' do 355 | expect { new }.to change { described_class.instances.length }.by(1) 356 | new.launch_template_name.should == 'this-aws-id-abc-123' 357 | new.image_id.should == 'ami-456' 358 | end 359 | end 360 | end 361 | 362 | describe '#corrected_user_data' do 363 | let(:encoded_user_data_string) { Base64.encode64('test') } 364 | 365 | context 'when the user_data is a String' do 366 | subject do 367 | described_class.new do 368 | name :corrected_user_data 369 | user_data 'test' 370 | end 371 | end 372 | 373 | it 'correctly encodes to base64' do 374 | expect(subject.corrected_user_data).to eq(encoded_user_data_string) 375 | end 376 | end 377 | 378 | context 'when the user_data is a Proc' do 379 | subject do 380 | described_class.new do 381 | name :corrected_user_data_2 382 | user_data { 'test' } 383 | end 384 | end 385 | 386 | it 'correctly encodes to base64' do 387 | expect(subject.corrected_user_data).to eq(encoded_user_data_string) 388 | end 389 | end 390 | end 391 | 392 | describe '#meta_data' do 393 | subject do 394 | described_class.new do 395 | name :my_launch_template 396 | meta_data('Test' => '1') 397 | end 398 | end 399 | 400 | it 'returns the hash' do 401 | expect(subject.meta_data['Test']).to eq('1') 402 | end 403 | end 404 | end 405 | -------------------------------------------------------------------------------- /spec/aerosol/rake_task_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Rake' do 4 | describe 'load' do 5 | before do 6 | Aerosol::Util.stub(:git_sha) 7 | end 8 | 9 | context 'when the aerosol.rb file does not exist' do 10 | before do 11 | File.stub(:exist?).and_return(false) 12 | end 13 | 14 | it 'raises an error' do 15 | lambda { Rake::Task['aerosol:load'].invoke }.should raise_error(RuntimeError, 'No aerosol.rb found!') 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/aerosol/runner_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Aerosol::Runner do 4 | describe '#with_deploy' do 5 | before { subject.instance_variable_set(:@deploy, :original_deploy) } 6 | 7 | context 'when the name is not one of the listed deploys' do 8 | it 'raises an error and does not change the @deploy variable' do 9 | subject.deploy.should == :original_deploy 10 | expect { subject.with_deploy(:not_a_real_deploy) {} }.to raise_error 11 | subject.deploy.should == :original_deploy 12 | end 13 | end 14 | 15 | context 'when the name is a valid deploy' do 16 | before do 17 | Aerosol::Deploy.new!(:name => :my_deploy) 18 | end 19 | 20 | it 'sets @deploy to that deploy' do 21 | subject.with_deploy(:my_deploy) do 22 | subject.deploy.should be_a Aerosol::Deploy 23 | subject.deploy.name.should == :my_deploy 24 | end 25 | end 26 | 27 | it 'changes @deploy back after' do 28 | expect { subject.with_deploy(:my_deploy) {} }.to_not change { subject.deploy } 29 | end 30 | end 31 | end 32 | 33 | describe '#run_migration' do 34 | let(:db_conn) { double(:db_conn) } 35 | 36 | before do 37 | ENV['RAILS_ENV'] = 'production' 38 | subject.stub(:db_conn).and_return(db_conn) 39 | end 40 | 41 | context 'when the deploy is nil' do 42 | before { subject.instance_variable_set(:@deploy, nil) } 43 | 44 | it 'raises an error' do 45 | expect { subject.run_migration }.to raise_error 46 | end 47 | end 48 | 49 | context 'context when the deploy is present' do 50 | let!(:connection) { Aerosol::Connection.new!(:name => :run_migration_conn) } 51 | let!(:deploy) { Aerosol::Deploy.new!(:name => :run_migration_deploy, :ssh => :run_migration_conn) } 52 | 53 | before { subject.instance_variable_set(:@deploy, deploy) } 54 | 55 | context 'and #do_not_migrate! has been called on it' do 56 | before { subject.deploy.do_not_migrate! } 57 | 58 | it 'does nothing' do 59 | connection.should_not_receive(:with_connection) 60 | subject.run_migration 61 | end 62 | end 63 | 64 | context 'and #do_not_migrate! has not been called on it' do 65 | context 'but the rails env is nil' do 66 | before { ENV['RAILS_ENV'] = nil } 67 | 68 | it 'raises an error' do 69 | expect { subject.run_migration }.to raise_error 70 | end 71 | end 72 | 73 | context 'and the rails env is set' do 74 | let(:session) { double(:session) } 75 | let(:port) { 50127 } 76 | let(:conf) do 77 | { 78 | ENV['RAILS_ENV'] => 79 | { 80 | 'database' => 'daddybase', 81 | 'host' => 'http://www.geocities.com/spunk1111/', 82 | 'port' => 8675309 83 | } 84 | } 85 | end 86 | 87 | before do 88 | subject.stub(:random_open_port).and_return(port) 89 | File.stub(:read) 90 | ERB.stub_chain(:new, :result) 91 | YAML.stub(:load).and_return(conf) 92 | deploy.stub_chain(:migration_ssh, :with_connection).and_yield(session) 93 | Process.stub(:waitpid).and_return(1) 94 | Process::Status.any_instance.stub(:exitstatus) { 0 } 95 | session.stub(:loop).and_yield 96 | end 97 | 98 | it 'forwards the database connection and runs the migration' do 99 | session.stub_chain(:forward, :local) 100 | .with(port, 101 | conf['production']['host'], 102 | conf['production']['port']) 103 | ActiveRecord::Base.stub(:establish_connection) 104 | ActiveRecord::Migrator.stub(:migrate) 105 | .with(%w[db/migrate]) 106 | subject.run_migration 107 | end 108 | end 109 | end 110 | end 111 | end 112 | 113 | describe '#old_instances' do 114 | before do 115 | allow(Aerosol::Util).to receive(:git_sha).and_return('1') 116 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 117 | launch_configurations: [ 118 | { 119 | launch_configuration_name: 'launch_config-1', 120 | image_id: 'ami-1234567', 121 | instance_type: 'm1.large', 122 | created_time: Time.at(1) 123 | }, 124 | { 125 | launch_configuration_name: 'launch_config-2', 126 | image_id: 'ami-1234567', 127 | instance_type: 'm1.large', 128 | created_time: Time.at(1) 129 | }, 130 | { 131 | launch_configuration_name: 'launch_config-3', 132 | image_id: 'ami-1234567', 133 | instance_type: 'm1.large', 134 | created_time: Time.at(1) 135 | } 136 | ], 137 | next_token: nil 138 | }) 139 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_groups, { 140 | auto_scaling_groups: [ 141 | { 142 | auto_scaling_group_name: 'auto_scaling_group-1', 143 | launch_configuration_name: 'launch_config-1', 144 | min_size: 1, 145 | max_size: 1, 146 | desired_capacity: 1, 147 | default_cooldown: 300, 148 | availability_zones: ['us-east-1a'], 149 | health_check_type: 'EC2', 150 | created_time: Time.new(2015, 01, 01, 01, 01, 01), 151 | tags: [{ key: 'Deploy', value: 'auto_scaling_group'}, { key: 'GitSha', value: '1' }] 152 | }, 153 | { 154 | auto_scaling_group_name: 'auto_scaling_group-2', 155 | launch_configuration_name: 'launch_config-2', 156 | min_size: 1, 157 | max_size: 1, 158 | desired_capacity: 1, 159 | default_cooldown: 300, 160 | availability_zones: ['us-east-1a'], 161 | health_check_type: 'EC2', 162 | created_time: Time.new(2015, 01, 01, 01, 01, 01), 163 | tags: [{ key: 'Deploy', value: 'auto_scaling_group'}, { key: 'GitSha', value: '2'}] 164 | }, 165 | { 166 | auto_scaling_group_name: 'auto_scaling_group-3', 167 | launch_configuration_name: 'launch_config-3', 168 | min_size: 1, 169 | max_size: 1, 170 | desired_capacity: 1, 171 | default_cooldown: 300, 172 | availability_zones: ['us-east-1a'], 173 | health_check_type: 'EC2', 174 | created_time: Time.new(2015, 01, 01, 01, 01, 01), 175 | tags: [{ key: 'Deploy', value: 'auto_scaling_group'}, { key: 'GitSha', value: '3'}] 176 | } 177 | ], 178 | next_token: nil 179 | }) 180 | 181 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_instances, { 182 | auto_scaling_instances: 3.times.map do |i| 183 | { 184 | instance_id: "i-#{123456+i}", 185 | auto_scaling_group_name: "auto_scaling_group-#{i+1}", 186 | launch_configuration_name: "launch_config-#{i+1}", 187 | availability_zone: 'us-east-1a', 188 | lifecycle_state: 'InService', 189 | health_status: 'Healthy', 190 | protected_from_scale_in: false 191 | } 192 | end, 193 | next_token: nil 194 | }) 195 | 196 | Aerosol::Deploy.new! do 197 | name :old_instances_deploy 198 | auto_scaling do 199 | name :auto_scaling_group 200 | min_size 1 201 | max_size 1 202 | 203 | launch_configuration do 204 | name :launch_config 205 | image_id 'fake-ami' 206 | instance_type 'm1.large' 207 | end 208 | end 209 | end 210 | end 211 | 212 | let!(:all_lcs) { Aerosol::LaunchConfiguration.all } 213 | let!(:all_asgs) { Aerosol::AutoScaling.all } 214 | 215 | let(:all_instance_ids) { Aerosol::Instance.all.map(&:instance_id).sort } 216 | let(:old_instance_ids) { subject.old_instances.map(&:instance_id).sort } 217 | 218 | let(:asg1) { all_asgs[0] } 219 | let(:asg2) { all_asgs[1] } 220 | let(:asg3) { all_asgs[2] } 221 | let(:asg1_instances) { asg1.launch_configuration.all_instances.map(&:instance_id) } 222 | let(:combined_instances) { 223 | asg2.launch_configuration.all_instances + asg3.launch_configuration.all_instances 224 | } 225 | let(:combined_instance_ids) { combined_instances.map(&:instance_id).sort } 226 | 227 | let(:all_asgs_instances) { 228 | [asg1, asg2, asg3].map(&:launch_configuration).map(&:all_instances).flatten 229 | } 230 | let(:all_asgs_instance_ids) { all_asgs_instances.map(&:instance_id).sort } 231 | 232 | it 'returns each instance that is not a member of the current auto scaling group' do 233 | subject.with_deploy :old_instances_deploy do 234 | expect(old_instance_ids).to eq(combined_instance_ids) 235 | subject.old_instances.length.should == 2 236 | end 237 | end 238 | 239 | it 'does not include any of the current auto scaling group\'s instances' do 240 | subject.with_deploy :old_instances_deploy do 241 | asg1.launch_configuration.all_instances.should be_none { |inst| 242 | old_instance_ids.include?(inst.instance_id) 243 | } 244 | end 245 | end 246 | 247 | it 'does not modify the existing instances' do 248 | expect(all_instance_ids).to eq(all_asgs_instance_ids) 249 | subject.with_deploy :old_instances_deploy do 250 | expect(subject.new_instances.map(&:instance_id).sort).to eq(asg1_instances.sort) 251 | end 252 | end 253 | end 254 | 255 | describe '#new_instances' do 256 | context 'With a launch template' do 257 | let!(:lt) do 258 | Aerosol::LaunchTemplate.new! do 259 | name :lt 260 | image_id 'fake-ami-how-scandalous' 261 | instance_type 'm1.large' 262 | end 263 | end 264 | let!(:asg_lt) do 265 | Aerosol::AutoScaling.new! do 266 | name :asg_lt 267 | availability_zones 'us-east-1' 268 | min_size 0 269 | max_size 3 270 | launch_template :lt 271 | stub(:sleep) 272 | end 273 | end 274 | let!(:instance1) do 275 | Aerosol::Instance.from_hash( 276 | { 277 | instance_id: 'z0', 278 | launch_template: { launch_template_name: lt.launch_template_name } 279 | } 280 | ) 281 | end 282 | let!(:instance2) do 283 | double( 284 | :launch_template => double(:launch_template_name => 'lc7-8891022'), 285 | :launch_configuration => nil 286 | ) 287 | end 288 | let!(:instance3) do 289 | double( 290 | :launch_template => nil, 291 | :launch_configuration => double(:launch_configuration_name => 'lc0-8891022') 292 | ) 293 | end 294 | 295 | before do 296 | Aerosol::Instance.stub(:all).and_return([instance1, instance2, instance3]) 297 | end 298 | 299 | it 'returns each instance that is a member of the current launch template' do 300 | deploy = Aerosol::Deploy.new!(name: :lt_deploy, auto_scaling: :asg_lt) 301 | subject.with_deploy(:lt_deploy) do 302 | subject.new_instances.should == [instance1] 303 | end 304 | end 305 | end 306 | 307 | let!(:lc7) do 308 | Aerosol::LaunchConfiguration.new! do 309 | name :lc7 310 | image_id 'fake-ami-how-scandalous' 311 | instance_type 'm1.large' 312 | stub(:sleep) 313 | end 314 | end 315 | let!(:asg7) do 316 | Aerosol::AutoScaling.new! do 317 | name :asg7 318 | availability_zones 'us-east-1' 319 | min_size 0 320 | max_size 3 321 | launch_configuration :lc7 322 | stub(:sleep) 323 | end 324 | end 325 | let!(:instance1) do 326 | Aerosol::Instance.from_hash( 327 | { 328 | instance_id: 'z0', 329 | launch_configuration_name: lc7.launch_configuration_name 330 | } 331 | ) 332 | end 333 | let!(:instance2) do 334 | double(:launch_configuration => double(:launch_configuration_name => 'lc7-8891022')) 335 | end 336 | let!(:instance3) do 337 | double(:launch_configuration => double(:launch_configuration_name => 'lc0-8891022')) 338 | end 339 | 340 | let!(:deploy) do 341 | Aerosol::Deploy.new! do 342 | name :new_instances_deploy 343 | auto_scaling :asg7 344 | end 345 | end 346 | 347 | before do 348 | Aerosol::Instance.stub(:all).and_return([instance1, instance2, instance3]) 349 | end 350 | 351 | it 'returns each instance that is a member of the current launch config' do 352 | subject.with_deploy :new_instances_deploy do 353 | subject.new_instances.should == [instance1] 354 | end 355 | end 356 | end 357 | 358 | describe '#wait_for_new_instances' do 359 | let(:instances) do 360 | 3.times.map do |i| 361 | double(:instance, 362 | :public_hostname => 'not-a-real-hostname', 363 | :instance_id => "test#{i}") 364 | end 365 | end 366 | let(:timeout_length) { 0.01 } 367 | let!(:deploy) do 368 | timeout = timeout_length 369 | Aerosol::Deploy.new! do 370 | name :wait_for_new_instances_deploy 371 | is_alive? { is_site_live } 372 | instance_live_grace_period timeout 373 | stub(:sleep) 374 | end 375 | end 376 | let(:action) do 377 | subject.with_deploy(:wait_for_new_instances_deploy) { subject.wait_for_new_instances } 378 | end 379 | 380 | before do 381 | subject.stub(:healthy?).and_return(healthy) 382 | subject.stub(:sleep) 383 | subject.stub(:new_instances).and_return(instances) 384 | end 385 | 386 | context 'when all of the new instances eventually return a 200' do 387 | let(:timeout_length) { 1 } 388 | let(:healthy) { true } 389 | let(:is_site_live) { true } 390 | 391 | it 'does nothing' do 392 | expect { action }.to_not raise_error 393 | end 394 | end 395 | 396 | context 'when at least one of the instances never returns a 200' do 397 | let(:healthy) { false } 398 | let(:is_site_live) { false } 399 | 400 | it 'raises an error' do 401 | expect { action }.to raise_error 402 | end 403 | end 404 | 405 | context 'when getting new instances takes too long' do 406 | let(:healthy) { true } 407 | let(:is_site_live) { false } 408 | before do 409 | allow(subject).to receive(:new_instances) { sleep 10 } 410 | end 411 | 412 | it 'raises an error' do 413 | expect { action }.to raise_error 414 | end 415 | end 416 | end 417 | 418 | describe '#start_tailing_logs' do 419 | let(:ssh) { double(:ssh) } 420 | let(:instance) { double(Aerosol::Instance, instance_id: '2') } 421 | let(:command) { 'sudo tail -f /var/log/syslog' } 422 | let(:tail_logs) { true } 423 | let(:log_files) { ['/var/log/syslog'] } 424 | 425 | before do 426 | allow(subject).to receive(:tail_logs).and_return(tail_logs) 427 | allow(subject).to receive(:log_files).and_return(log_files) 428 | end 429 | 430 | context 'when there are log_files' do 431 | context 'when a log fork is already made' do 432 | let(:old_log_fork) { double(:old_log_fork) } 433 | 434 | it 'keeps the old one' do 435 | subject.log_pids[instance.instance_id] = old_log_fork 436 | expect(subject.start_tailing_logs(ssh, instance)).to be(old_log_fork) 437 | end 438 | end 439 | 440 | context 'when no log fork exists' do 441 | let(:new_log_fork) { double(:new_log_fork) } 442 | 443 | it 'makes a new one' do 444 | expect(subject).to receive(:ssh_fork).with(command, ssh, instance) { 445 | new_log_fork 446 | } 447 | expect(subject.start_tailing_logs(ssh, instance)).to be(new_log_fork) 448 | end 449 | end 450 | end 451 | 452 | context 'when there is no log_files' do 453 | let(:log_files) { [] } 454 | 455 | it 'does not call ssh_fork' do 456 | expect(subject).to_not receive(:ssh_fork) 457 | end 458 | end 459 | 460 | context 'when tail_logs is false' do 461 | let(:tail_logs) { false } 462 | 463 | it 'does not call ssh_fork' do 464 | expect(subject).to_not receive(:ssh_fork) 465 | end 466 | end 467 | end 468 | 469 | describe '#ssh_fork', :local do 470 | let(:ssh) { Aerosol::Connection.new(user: `whoami`.strip, host: 'www.doesntusethis.com') } 471 | let(:instance) { double(Aerosol::Instance, instance_id: '1', address: 'localhost') } 472 | let(:ssh_fork) { 473 | subject.ssh_fork(command, ssh, instance) 474 | } 475 | context 'when no error is raised' do 476 | let(:command) { 'echo "hello"; echo "bye"' } 477 | 478 | it 'should make a new fork that SSHs and runs a command' do 479 | expect(subject).to receive(:fork).and_yield do |&block| 480 | expect(subject).to receive(:debug).exactly(5).times 481 | block.call 482 | end 483 | ssh_fork 484 | end 485 | end 486 | 487 | context 'when an error is raised' do 488 | let(:command) { ['test','ing'] } 489 | 490 | it 'logs the errors' do 491 | expect(subject).to receive(:fork).and_yield do |&block| 492 | expect(subject).to receive(:error).twice 493 | block.call 494 | end 495 | ssh_fork 496 | end 497 | end 498 | end 499 | 500 | describe '#stop_app' do 501 | let!(:lc) do 502 | Aerosol::LaunchConfiguration.new! do 503 | name :stop_app_launch_config 504 | image_id 'stop-app-ami-123' 505 | instance_type 'm1.large' 506 | stub(:sleep) 507 | end 508 | end 509 | let!(:asg) do 510 | Aerosol::AutoScaling.new! do 511 | name :stop_app_auto_scaling_group 512 | availability_zones 'us-east-1' 513 | min_size 5 514 | max_size 5 515 | launch_configuration :stop_app_launch_config 516 | stub(:sleep) 517 | end 518 | end 519 | let!(:session) { double(:session) } 520 | let!(:deploy) do 521 | s = session 522 | Aerosol::Deploy.new! do 523 | auto_scaling :stop_app_auto_scaling_group 524 | name :stop_app_deploy 525 | ssh :stop_app_ssh do 526 | user 'dad' 527 | stub(:with_connection).and_yield(s) 528 | end 529 | stop_command 'mkdir lol' 530 | end 531 | end 532 | 533 | it 'sshs into each old instance and calls the stop command' do 534 | Aerosol::AWS.auto_scaling.stub_responses(:describe_launch_configurations, { 535 | launch_configurations: [ 536 | { 537 | launch_configuration_name: 'stop_app_launch_config-123456', 538 | image_id: 'stop-app-ami-123', 539 | instance_type: 'm1.large', 540 | created_time: Time.at(1) 541 | } 542 | ], 543 | next_token: nil 544 | }) 545 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_groups, { 546 | auto_scaling_groups: [ 547 | { 548 | auto_scaling_group_name: 'stop_app_auto_scaling_group-123456', 549 | min_size: 5, 550 | max_size: 5, 551 | desired_capacity: 5, 552 | default_cooldown: 300, 553 | availability_zones: ['us-east-1a'], 554 | health_check_type: 'EC2', 555 | created_time: Time.at(1), 556 | launch_configuration_name: 'stop_app_launch_config-123456', 557 | tags: [ 558 | { 559 | key: 'GitSha', 560 | value: '123456', 561 | }, 562 | { 563 | key: 'Deploy', 564 | value: 'stop_app_auto_scaling_group' 565 | } 566 | ] 567 | } 568 | ], 569 | next_token: nil 570 | }) 571 | Aerosol::AWS.auto_scaling.stub_responses(:describe_auto_scaling_instances, { 572 | auto_scaling_instances: 5.times.map do |n| 573 | { 574 | launch_configuration_name: 'stop_app_launch_config-123456', 575 | instance_id: "i-#{1234567+n}", 576 | auto_scaling_group_name: 'stop_app_launch_config-123456', 577 | availability_zone: 'us-east-1a', 578 | lifecycle_state: 'InService', 579 | health_status: 'Running', 580 | protected_from_scale_in: false 581 | } 582 | end 583 | }) 584 | Aerosol::AWS.compute.stub_responses(:describe_instances, { 585 | reservations: [ 586 | { 587 | instances: [ 588 | { 589 | public_dns_name: 'test' 590 | } 591 | ] 592 | } 593 | ] 594 | }) 595 | session.should_receive(:exec!).with(deploy.stop_command).exactly(5).times 596 | session.should_receive(:loop).exactly(5).times 597 | subject.with_deploy :stop_app_deploy do 598 | subject.stop_app 599 | end 600 | end 601 | end 602 | 603 | describe '#old_auto_scaling_groups/#new_auto_scaling_groups' do 604 | let!(:asg1) do 605 | Aerosol::AutoScaling.new! do 606 | name :destroy_old_asgs_auto_scaling_group_1 607 | availability_zones 'us-east-1' 608 | min_size 0 609 | max_size 3 610 | tag 'Deploy' => 'destroy_old_asgs_deploy', 'GitSha' => '1e7b3cd' 611 | stub(:sleep) 612 | stub(:aws_identifier).and_return(1) 613 | end 614 | end 615 | let!(:asg2) do 616 | Aerosol::AutoScaling.new! do 617 | name :destroy_old_asgs_auto_scaling_group_2 618 | availability_zones 'us-east-1' 619 | min_size 0 620 | max_size 3 621 | tag 'Deploy' => 'destroy_old_asgs_deploy', 'GitSha' => '1234567' 622 | stub(:sleep) 623 | stub(:aws_identifier).and_return(2) 624 | end 625 | end 626 | let!(:asg3) do 627 | Aerosol::AutoScaling.new! do 628 | name :destroy_old_asgs_auto_scaling_group_3 629 | availability_zones 'us-east-1' 630 | min_size 0 631 | max_size 5 632 | tag 'Deploy' => 'not-part-of-this-app', 'GitSha' => '1e7b3cd' 633 | stub(:sleep) 634 | stub(:aws_identifier).and_return(3) 635 | end 636 | end 637 | 638 | let!(:deploy) do 639 | Aerosol::Deploy.new! do 640 | name :destroy_old_asgs_deploy 641 | auto_scaling :destroy_old_asgs_auto_scaling_group_1 642 | end 643 | end 644 | 645 | before do 646 | subject.instance_variable_set(:@deploy, deploy) 647 | Aerosol::AutoScaling.stub(:all).and_return([asg1, asg2, asg3]) 648 | end 649 | 650 | it 'returns the old and new groups from this app' do 651 | subject.old_auto_scaling_groups.should == [asg2] 652 | subject.new_auto_scaling_groups.should == [asg1] 653 | end 654 | end 655 | end 656 | -------------------------------------------------------------------------------- /spec/aerosol_spec.rb: -------------------------------------------------------------------------------- 1 | # Copyright Swipely, Inc. All rights reserved. 2 | 3 | require 'spec_helper' 4 | 5 | describe Aerosol do 6 | subject { Aerosol } 7 | 8 | { 9 | :auto_scaling => Aerosol::AutoScaling, 10 | :deploy => Aerosol::Deploy, 11 | :launch_configuration => Aerosol::LaunchConfiguration, 12 | :ssh => Aerosol::Connection 13 | }.each do |name, klass| 14 | describe ".#{name}" do 15 | before { subject.send(name, :"runner_test_#{name}") { } } 16 | 17 | it "creates a new #{klass}" do 18 | expect(klass.instances.keys).to include(:"runner_test_#{name}") 19 | end 20 | 21 | it "accessible via #{klass} without a block " do 22 | expect(subject.send("#{name}s").keys).to include(:"runner_test_#{name}") 23 | end 24 | end 25 | end 26 | 27 | it { should be_an_instance_of(Module) } 28 | 29 | describe ".namespace" do 30 | let(:namespace) { "test" } 31 | before { subject.namespace namespace } 32 | 33 | it "sets the namespace" do 34 | expect(subject.instance_variable_get(:"@namespace")).to eq(namespace) 35 | end 36 | 37 | it "returns the namespace after being set" do 38 | expect(subject.namespace).to eq(namespace) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 3 | 4 | require 'rspec' 5 | require 'pry' 6 | require 'aerosol' 7 | # Requires supporting files with custom matchers and macros, etc, in ./support/ 8 | # and its subdirectories. 9 | #Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 10 | 11 | Aerosol::AWS.credentials = Aws::Credentials.new('MOCK_KEY', 'MOCK_SECRET') 12 | Aerosol::AWS.stub_responses = true 13 | Dockly::Util::Logger.disable! unless ENV['ENABLE_LOGGER'] == 'true' 14 | 15 | RSpec.configure do |config| 16 | config.mock_with :rspec 17 | config.treat_symbols_as_metadata_keys_with_true_values = true 18 | config.tty = true 19 | config.filter_run_excluding local: true if ENV['CI'] 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/vcr.rb: -------------------------------------------------------------------------------- 1 | require 'webmock' 2 | require 'vcr' 3 | 4 | WebMock.disable_net_connect! 5 | 6 | VCR.configure do |c| 7 | c.allow_http_connections_when_no_cassette = true 8 | c.hook_into :webmock 9 | c.cassette_library_dir = File.join(File.dirname(File.dirname(__FILE__)), 'vcr') 10 | c.configure_rspec_metadata! 11 | end 12 | --------------------------------------------------------------------------------