├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── ami_spec.gemspec ├── bin └── ami_spec ├── lib ├── ami_spec.rb └── ami_spec │ ├── aws_default_vpc.rb │ ├── aws_instance.rb │ ├── aws_instance_options.rb │ ├── aws_key_pair.rb │ ├── aws_security_group.rb │ ├── server_spec.rb │ ├── server_spec_options.rb │ ├── version.rb │ ├── wait_for_cloud_init.rb │ ├── wait_for_rc.rb │ └── wait_for_ssh.rb └── spec ├── ami_spec ├── aws_default_vpc_spec.rb ├── aws_key_pair_spec.rb └── aws_security_group_spec.rb ├── ami_spec_spec.rb ├── aws_instance_spec.rb ├── containers ├── Dockerfile.amazon_linux ├── Dockerfile.trusty ├── Dockerfile.xenial ├── README.md ├── ami-spec ├── ami-spec.pub ├── docker-compose.yml ├── rc.conf └── sshd_config ├── spec_helper.rb ├── spec_support.rb ├── support └── serverspec.rb ├── wait_for_rc_spec.rb └── wait_for_ssh_spec.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | on: [ push, pull_request ] 4 | jobs: 5 | test: 6 | name: Test (Ruby ${{ matrix.ruby }}) 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: [ '2.3', '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4' ] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Ruby ${{ matrix.ruby }} 14 | uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: ${{ matrix.ruby }} 17 | bundler-cache: true 18 | - name: RSpec 19 | run: bundle exec rake spec 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | pkg/ 3 | *.gem 4 | .ruby-version 5 | spec/examples.txt 6 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require ami_spec 2 | --require spec_helper 3 | --require spec_support 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'rake' 6 | gem 'rexml' 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AmiSpec 2 | 3 | [![License MIT](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/envato/ami-spec/blob/master/LICENSE.txt) 4 | [![Gem Version](https://badge.fury.io/rb/ami_spec.svg)](https://badge.fury.io/rb/ami_spec) 5 | [![Build Status](https://github.com/envato/ami-spec/workflows/tests/badge.svg?branch=master)](https://github.com/envato/ami-spec/actions?query=branch%3Amaster+workflow%3Atests) 6 | 7 | Acceptance testing your AMIs. 8 | 9 | AmiSpec is a RubyGem used to launch an Amazon Machine Image (AMI) and run ServerSpecs against it. It wraps around the AWS API and ServerSpec to spin up, test and tear down instances. 10 | 11 | ## Project Goals 12 | 13 | 1. To decouple the building of AMIs from testing them. Other approaches to this problem involve copying ServerSpec tests to an EC2 instance before it's converted to an AMI and running the tests there. 14 | The problem with this approach is: 15 | 16 | - It does not test the instance in the state it will be in when it's actually in production. 17 | - It does makes it harder to replace the AMI builder software (i.e. [Packer](https://github.com/mitchellh/packer)). 18 | - The software required to test the AMI must exist in the AMI. 19 | 20 | 2. To run tests as fast as possible; this approach is slightly slower than the alternative listed above (about 1-2 minutes), but should not be onerous. 21 | 22 | ## Installation 23 | 24 | System-wide: gem install ami\_spec 25 | 26 | With bundler: 27 | 28 | Add `gem 'ami_spec'` to your Gemfile. 29 | Run `bundle install` 30 | 31 | ## CLI Usage 32 | 33 | ```cli 34 | $ bundle exec ami_spec --help 35 | Options: 36 | -r, --role= The role to test, this should map to a directory in the spec 37 | folder 38 | -a, --ami= The ami ID to run tests against 39 | -o, --role-ami-file= A file containing comma separated roles and amis. i.e. 40 | web_server,ami-id. 41 | -s, --specs= The directory to find ServerSpecs 42 | -u, --subnet-id= The subnet to start the instance in. If not provided a subnet 43 | will be chosen from the default VPC 44 | -k, --key-name= The SSH key name to assign to instances. If not provided a 45 | temporary key pair will be generated in AWS 46 | -e, --key-file= The SSH private key file associated to the key_name 47 | -h, --ssh-user= The user to ssh to the instance as 48 | -w, --aws-region= The AWS region, defaults to AWS_DEFAULT_REGION environment 49 | variable 50 | -i, --aws-instance-type= The ec2 instance type, defaults to t2.micro (default: 51 | t2.micro) 52 | -c, --aws-security-groups= Security groups IDs to associate to the launched instances. May be 53 | specified multiple times. If not provided a temporary security 54 | group will be generated in AWS 55 | -n, --allow-any-temporary-security-group The temporary security group will allow SSH connections 56 | from any IP address (0.0.0.0/0), otherwise allow the subnet's block 57 | -p, --aws-public-ip Launch instances with a public IP and use that IP for SSH 58 | -q, --associate-public-ip Launch instances with a public IP and use the Private IP for SSH 59 | -t, --ssh-retries= The number of times we should try sshing to the ec2 instance 60 | before giving up. Defaults to 30 (default: 30) 61 | -g, --tags= Additional tags to add to launched instances in the form of 62 | comma separated key=value pairs. i.e. Name=AmiSpec (default: ) 63 | -d, --debug Don't terminate instances on exit 64 | -b, --buildkite Output section separators for buildkite 65 | -f, --wait-for-rc Wait for oldschool SystemV scripts to run before conducting 66 | tests. Currently only supports Ubuntu with upstart 67 | -l, --user-data-file= File path for aws ec2 user data 68 | -m, --iam-instance-profile-arn= IAM instance profile to use 69 | --help Show this message 70 | 71 | ``` 72 | 73 | AmiSpec will launch an EC2 instance from the given AMI (`--ami`), in a subnet (`--subnet-id`) with a key-pair (`--key-name`) 74 | and try to SSH to it (`--ssh-user` and `--key-file`). 75 | When the instances becomes reachable it will run all Specs inside the role spec directory (`--role` i.e. `my_project/spec/web_server`). 76 | 77 | Alternative to the `--ami` and `--role` variables, a file of comma separated roles and AMIs (`ROLE,AMI\n`) can be supplied to `--role-ami-file`. 78 | 79 | ## ServerSpec test layout 80 | 81 | AmiSpec expects the usual ServerSpec configuration layout as generated by "serverspec-init": 82 | 83 | spec/ 84 | ├── webserver 85 | │   └── webserver_spec.rb 86 | └── spec_helper.rb 87 | 88 | The \*\_spec.rb files under the role (e.g. webserver) contain the ServerSpec 89 | tests that you want to run. The spec_helper.rb file can be very simple: 90 | 91 | require 'serverspec' 92 | 93 | set :backend, :ssh 94 | 95 | Note that the backend *needs* to be :ssh or ami_spec might run the tests on 96 | your local machine, not in EC2. 97 | 98 | ## Example usage 99 | 100 | To test a custom AMI using a pre-created security group that allows SSH from anywhere: 101 | 102 | ```cli 103 | ami_spec --role webserver\ 104 | --specs spec\ 105 | --aws-region us-east-1\ 106 | --ami ami-0123456789abcdef0\ 107 | --key-name default\ 108 | --key-file ~/.ssh/default.pem\ 109 | --ssh-user ubuntu\ 110 | --aws-public-ip\ 111 | --aws-security-groups sg-0123456789abcdef0 112 | ``` 113 | 114 | ## Known caveats 115 | 116 | ### RSpec conditions in examples 117 | 118 | [ServerSpecs advanced tips](http://serverspec.org/advanced_tips.html) provides a mechanism to conditionally apply tests based on server information. 119 | 120 | ```ruby 121 | describe file('/usr/lib64'), :if => os[:arch] == 'x86_64' do 122 | it { should be_directory } 123 | end 124 | ``` 125 | 126 | If these are used in shared examples, say loaded via a rspec helper, this doesn't work with AmiSpec, because the evaluation of `os[:arch] == 'x86_64'` is done when the spec is loaded not at run time. 127 | 128 | Working around this is tricky. We need to move the evaluation of `os[:arch]` to runtime not load time. Since RSpec example metadata can only be a bool, string or symbol we set a metadata key of `:os_arch` to the value we expect: 129 | 130 | ```ruby 131 | describe file('/usr/lib64'), :os_arch => 'x86_64' do 132 | it { should be_directory } 133 | end 134 | ``` 135 | 136 | We then have to set an RSpec exclusion of examples where the architecture does not match the host under test's architecture. This can be done in the `spec_helper` with a lambda function that tests this: 137 | 138 | ```ruby 139 | RSpec.configure do |c| 140 | c.filter_run_excluding :os_arch => lambda { |arch| arch if os[:arch] != arch } 141 | end 142 | ``` 143 | 144 | We are exluding any example with the metadata key :os_arch where the value does not match our architecture. Similar examples can be included for os family etc. 145 | 146 | ## Development Status 147 | 148 | Active and ready for Production 149 | 150 | ## Contributing 151 | 152 | For bug fixes, documentation changes, and small features: 153 | 1. Fork it ( https://github.com/envato/ami-spec/fork ) 154 | 2. Create your feature branch (git checkout -b my-new-feature) 155 | 3. Commit your changes (git commit -am 'Add some feature') 156 | 4. Push to the branch (git push origin my-new-feature) 157 | 5. Create a new Pull Request 158 | 159 | ## Running tests 160 | 161 | Use the following command to run non-integration tests: 162 | ``` 163 | bundle exec rake spec 164 | ``` 165 | 166 | If you're working on the `WaitForRC` feature you can run it's integration tests by first bringing up the containers, then executing the integration tests: 167 | ``` 168 | docker-compose -f spec/containers/docker-compose.yml up -d 169 | bundle exec rspec . --tag integration 170 | docker-compose -f spec/containers/docker-compose.yml down 171 | ``` 172 | 173 | ## Maintainers 174 | 175 | Patrick Robinson (@patrobinson) 176 | 177 | ## License 178 | 179 | AmiSpec uses the MIT license. See [LICENSE.txt](./LICENSE.txt) 180 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) do |t| 5 | t.rspec_opts = "--tag ~integration" 6 | end 7 | 8 | task :default => :spec 9 | -------------------------------------------------------------------------------- /ami_spec.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require 'ami_spec/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'ami_spec' 8 | gem.version = AmiSpec::VERSION 9 | gem.authors = ['Patrick Robinson', 'Martin Jagusch'] 10 | gem.email = [] 11 | gem.description = 'Acceptance testing your AMIs' 12 | gem.summary = gem.description 13 | gem.homepage = 'https://github.com/envato/ami-spec' 14 | 15 | gem.files = `git ls-files -z`.split("\x0").select do |f| 16 | f.match(%r{^(?:README|LICENSE|CHANGELOG|lib/|bin/)}) 17 | end 18 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 19 | gem.require_paths = ['lib'] 20 | 21 | gem.add_dependency 'aws-sdk-ec2', '~> 1' 22 | gem.add_dependency 'serverspec', '~> 2' 23 | gem.add_dependency 'specinfra', '>= 2.45' 24 | gem.add_dependency 'optimist', '~> 3' 25 | gem.add_dependency 'hashie' 26 | gem.add_dependency 'net-ssh', '>= 5' 27 | 28 | gem.required_ruby_version = '>= 2.2.6' 29 | end 30 | -------------------------------------------------------------------------------- /bin/ami_spec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'ami_spec' 4 | 5 | AmiSpec.invoke 6 | -------------------------------------------------------------------------------- /lib/ami_spec.rb: -------------------------------------------------------------------------------- 1 | require 'ami_spec/aws_instance' 2 | require 'ami_spec/aws_instance_options' 3 | require 'ami_spec/aws_default_vpc' 4 | require 'ami_spec/aws_key_pair' 5 | require 'ami_spec/aws_security_group' 6 | require 'ami_spec/server_spec' 7 | require 'ami_spec/server_spec_options' 8 | require 'ami_spec/wait_for_ssh' 9 | require 'ami_spec/wait_for_rc' 10 | require 'ami_spec/wait_for_cloud_init' 11 | require 'optimist' 12 | require 'logger' 13 | 14 | module AmiSpec 15 | class InstanceConnectionTimeout < StandardError; end 16 | # == Parameters: 17 | # amis:: 18 | # A hash of roles and amis in the format of: 19 | # {role => ami_id}. i.e. 20 | # {'web_server' => 'ami-abcd1234'} 21 | # specs:: 22 | # A string of the directory to find ServerSpecs. 23 | # There should be a folder in this directory for each role found in ::amis 24 | # subnet_id:: 25 | # The subnet_id to start instances in. 26 | # key_name:: 27 | # The SSH key name to assign to instances. If not provided a temporary key pair will be generated in AWS 28 | # key_file:: 29 | # The SSH key file to use to connect to the host. 30 | # aws_region:: 31 | # AWS region to connect to 32 | # Defaults to AWS_DEFAULT_REGION 33 | # aws_security_group_ids:: 34 | # AWS Security groups to assign to the instances 35 | # If not provided a temporary security group will be generated in AWS 36 | # allow_any_temporary_security_group:: 37 | # The temporary security group will allow SSH connections from any IP address (0.0.0.0/0) 38 | # aws_instance_type:: 39 | # AWS ec2 instance type 40 | # aws_public_ip:: 41 | # Should the instances get a public IP address and use that IP for SSH 42 | # associate_public_ip:: 43 | # Launch instances with a public IP but don't use that IP for SSH 44 | # ssh_user:: 45 | # The username to SSH to the AMI with. 46 | # ssh_retries:: 47 | # Set the maximum number of ssh retries while waiting for the instance to boot. 48 | # tags::: 49 | # Additional tags to add to launched instances in the form of comma separated key=value pairs 50 | # debug:: 51 | # Don't terminate the instances on exit 52 | # buildkite:: 53 | # Output section separators for buildkite 54 | # == Returns: 55 | # Boolean - The result of all the server specs. 56 | def self.run(options) 57 | logger = Logger.new(STDOUT, formatter: proc { |_sev, _time, _name, message| "#{message}\n" }) 58 | 59 | ec2 = Aws::EC2::Resource.new(options[:aws_region] ? {region: options[:aws_region]} : {}) 60 | 61 | if options[:subnet_id].nil? 62 | default_vpc_subnet = AwsDefaultVpc.find_subnet(ec2: ec2) 63 | raise 'No default VPC subnet found. Please specify a subnet id.' if default_vpc_subnet.nil? 64 | options[:subnet_id] = default_vpc_subnet.id 65 | logger.info("Using subnet #{options[:subnet_id]} from the default VPC") 66 | end 67 | 68 | unless options[:key_name] 69 | key_pair = AwsKeyPair.create(ec2: ec2, logger: logger) 70 | options[:key_name] = key_pair.key_name 71 | options[:key_file] = key_pair.key_file 72 | end 73 | 74 | if options[:aws_security_groups].nil? || options[:aws_security_groups].empty? 75 | temporary_security_group = AwsSecurityGroup.create( 76 | ec2: ec2, 77 | subnet_id: options[:subnet_id], 78 | allow_any_ip: options[:allow_any_temporary_security_group], 79 | logger: logger 80 | ) 81 | options[:aws_security_groups] = [temporary_security_group.group_id] 82 | end 83 | 84 | instances = [] 85 | options[:amis].each_pair do |role, ami| 86 | aws_instance_options = AwsInstanceOptions.new(options.merge(role: role, ami: ami, logger: logger)) 87 | instances << AwsInstance.start(aws_instance_options) 88 | end 89 | 90 | results = [] 91 | instances.each do |instance| 92 | ip_address = options[:aws_public_ip] ? instance.public_ip_address : instance.private_ip_address 93 | logger.info("Waiting for SSH…") 94 | WaitForSSH.wait(ip_address, options[:ssh_user], options[:key_file], options[:ssh_retries]) 95 | if options[:wait_for_rc] 96 | logger.info("Waiting for RC…") 97 | WaitForRC.wait(ip_address, options[:ssh_user], options[:key_file]) 98 | end 99 | if options[:wait_for_cloud_init] 100 | logger.info("Waiting for cloud init…") 101 | WaitForCloudInit.wait(ip_address, options[:ssh_user], options[:key_file]) 102 | end 103 | 104 | logger.info("Running serverspec…") 105 | server_spec_options = ServerSpecOptions.new(options.merge(instance: instance)) 106 | results << ServerSpec.new(server_spec_options).run 107 | end 108 | 109 | results.all? 110 | ensure 111 | stop_instances(instances, options[:debug]) 112 | key_pair.delete if key_pair 113 | temporary_security_group.delete if temporary_security_group 114 | end 115 | 116 | def self.stop_instances(instances, debug) 117 | instances && instances.each do |instance| 118 | begin 119 | if debug 120 | puts "EC2 instance ##{instance.instance_id} has not been stopped due to debug mode." 121 | else 122 | instance.terminate 123 | end 124 | rescue Aws::EC2::Errors::InvalidInstanceIDNotFound 125 | puts "Failed to stop EC2 instance ##{instance.instance_id}" 126 | end 127 | end 128 | end 129 | 130 | private_class_method :stop_instances 131 | 132 | def self.invoke 133 | options = Optimist::options do 134 | opt :role, 135 | 'The role to test, this should map to a directory in the spec folder', 136 | type: :string, short: :r 137 | opt :ami, 'The ami ID to run tests against', type: :string, short: :a 138 | opt :role_ami_file, 139 | 'A file containing comma separated roles and amis. i.e. 140 | web_server,ami-id.', 141 | type: :string, short: :o 142 | opt :specs, 'The directory to find ServerSpecs', 143 | type: :string, required: true, short: :s 144 | opt :subnet_id, 145 | 'The subnet to start the instance in. If not provided a subnet will be chosen from the default VPC', 146 | type: :string, short: :u 147 | opt :key_name, 'The SSH key name to assign to instances. If not provided a temporary key pair will be generated in AWS', 148 | type: :string, short: :k 149 | opt :key_file, 'The SSH private key file associated to the key_name', type: :string, short: :e 150 | opt :ssh_user, 'The user to ssh to the instance as', type: :string, required: true, short: :h 151 | opt :aws_region, 'The AWS region, defaults to AWS_DEFAULT_REGION environment variable', 152 | type: :string, short: :w 153 | opt :aws_instance_type, 'The ec2 instance type, defaults to t2.micro', 154 | type: :string, default: 't2.micro', short: :i 155 | opt :aws_security_groups, 156 | 'Security groups to associate to the launched instances. May be specified multiple times. If not provided a temporary security group will be generated in AWS', 157 | type: :string, default: nil, multi: true, short: :c 158 | opt :allow_any_temporary_security_group, 'The temporary security group will allow SSH connections from any IP address (0.0.0.0/0)', 159 | short: :n 160 | opt :aws_public_ip, 'Launch instances with a public IP and use that IP for SSH', short: :p 161 | opt :associate_public_ip, "Launch instances with a public IP but don't use that IP for SSH", short: :q 162 | opt :ssh_retries, 'The number of times we should try sshing to the ec2 instance before giving up. Defaults to 30', 163 | type: :int, default: 30, short: :t 164 | opt :tags, 'Additional tags to add to launched instances in the form of comma separated key=value pairs. i.e. Name=AmiSpec', 165 | type: :string, default: '', short: :g 166 | opt :debug, "Don't terminate instances on exit", short: :d 167 | opt :buildkite, 'Output section separators for buildkite', short: :b 168 | opt :wait_for_rc, 'Wait for oldschool SystemV scripts to run before conducting tests. Currently only supports Ubuntu with upstart', 169 | short: :f 170 | opt :wait_for_cloud_init, 'Wait for Cloud Init to complete before running tests' 171 | opt :user_data_file, 'File path for aws ec2 user data', type: :string, default: nil, short: :l 172 | opt :iam_instance_profile_arn, 'IAM instance profile to use', type: :string, short: :m 173 | end 174 | 175 | if options[:role] && options[:ami] 176 | options[:amis] = { options[:role] => options[:ami] } 177 | options.delete(:role) 178 | options.delete(:ami) 179 | elsif options[:role_ami_file] 180 | file_lines = File.read(options[:role_ami_file]).split("\n") 181 | file_array = file_lines.collect { |line| line.split(',') }.flatten 182 | options[:amis] = Hash[*file_array] 183 | options.delete(:role_ami_file) 184 | else 185 | fail "You must specify either role and ami or role_ami_file" 186 | end 187 | 188 | fail "Key file #{options[:key_file]} not found" if options[:key_name] && !File.exist?(options.fetch(:key_file)) 189 | 190 | if options[:user_data_file] and !File.exist? options[:user_data_file] 191 | fail "User Data file #{options[:user_data_file]} not found" 192 | end 193 | 194 | options[:tags] = parse_tags(options[:tags]) 195 | 196 | options[:amis].each_pair do |role, _| 197 | unless Dir.exist?("#{options[:specs]}/#{role}") 198 | fail "Role directory #{options[:specs]}/#{role} does not exist. If you'd like to skip the role '#{role}', create the directory but leave it empty (except for a .gitignore file)." 199 | end 200 | end 201 | 202 | exit run(options) 203 | end 204 | 205 | def self.parse_tags(tags) 206 | tag_pairs = tags.split(',') 207 | tag_key_values = tag_pairs.collect{ |pair| pair.split('=')}.flatten 208 | Hash[*tag_key_values] 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/ami_spec/aws_default_vpc.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-ec2' 2 | 3 | module AmiSpec 4 | class AwsDefaultVpc 5 | def self.find_subnet(ec2: Aws::EC2::Resource.new) 6 | default_vpc = ec2.vpcs(filters: [{name: 'isDefault', values: ['true']}]).first 7 | default_vpc && default_vpc.subnets.first 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/ami_spec/aws_instance.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-ec2' 2 | require 'forwardable' 3 | require 'base64' 4 | 5 | module AmiSpec 6 | class AwsInstance 7 | extend Forwardable 8 | 9 | def self.start(args) 10 | new(args).tap do |instance| 11 | instance.start 12 | end 13 | end 14 | 15 | def initialize(options) 16 | @role = options.fetch(:role) 17 | @ami = options.fetch(:ami) 18 | @subnet_id = options.fetch(:subnet_id) 19 | @key_name = options.fetch(:key_name) 20 | @instance_type = options.fetch(:aws_instance_type) 21 | @public_ip = options.fetch(:aws_public_ip) 22 | @associate_public_ip = options.fetch(:associate_public_ip) 23 | @region = options.fetch(:aws_region) 24 | @security_group_ids = options.fetch(:aws_security_groups) 25 | @tags = ec2ify_tags(options.fetch(:tags)) 26 | @user_data_file = options.fetch(:user_data_file, nil) 27 | @iam_instance_profile_arn = options.fetch(:iam_instance_profile_arn, nil) 28 | @logger = options.fetch(:logger) 29 | end 30 | 31 | def_delegators :@instance, :instance_id, :tags, :private_ip_address, :public_ip_address 32 | 33 | def start 34 | @logger.info "Creating AWS EC2 instance for #{@ami}" 35 | client = Aws::EC2::Client.new(client_options) 36 | placeholder_instance = client.run_instances(instances_options).instances.first 37 | 38 | @instance = Aws::EC2::Instance.new(placeholder_instance.instance_id, client_options) 39 | @logger.info "Waiting for AWS EC2 instance to start: #{@instance.id}" 40 | @instance.wait_until_running 41 | tag_instance 42 | @logger.info "AWS EC2 instance started: #{@instance.id}" 43 | end 44 | 45 | def terminate 46 | @logger.info "Terminating AWS EC2 instance: #{@instance.id}" 47 | @instance.terminate 48 | @instance.wait_until_terminated 49 | @logger.info "AWS EC2 instance terminated: #{@instance.id}" 50 | end 51 | 52 | private 53 | 54 | def client_options 55 | !@region.nil? ? {region: @region} : {} 56 | end 57 | 58 | def ec2ify_tags(tags) 59 | tags.map { |k,v| {key: k, value: v} } 60 | end 61 | 62 | def instances_options 63 | params = { 64 | image_id: @ami, 65 | min_count: 1, 66 | max_count: 1, 67 | instance_type: @instance_type, 68 | key_name: @key_name, 69 | network_interfaces: [{ 70 | device_index: 0, 71 | associate_public_ip_address: @public_ip || @associate_public_ip, 72 | subnet_id: @subnet_id, 73 | }] 74 | } 75 | 76 | params[:user_data] = Base64.encode64(File.read(@user_data_file)) unless @user_data_file.nil? 77 | 78 | unless @iam_instance_profile_arn.nil? 79 | params[:iam_instance_profile] = { 80 | arn: @iam_instance_profile_arn 81 | } 82 | end 83 | 84 | unless @security_group_ids.nil? 85 | params[:network_interfaces][0][:groups] = @security_group_ids 86 | end 87 | 88 | params 89 | end 90 | 91 | def tag_instance 92 | @instance.create_tags(tags: [{ key: 'AmiSpec', value: @role }] + @tags) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/ami_spec/aws_instance_options.rb: -------------------------------------------------------------------------------- 1 | require 'hashie' 2 | 3 | module AmiSpec 4 | class AwsInstanceOptions < Hashie::Dash 5 | include Hashie::Extensions::IgnoreUndeclared 6 | 7 | property :ami 8 | property :role 9 | property :subnet_id 10 | property :key_name 11 | property :aws_instance_type 12 | property :aws_public_ip 13 | property :aws_region 14 | property :aws_security_groups 15 | property :associate_public_ip 16 | property :tags 17 | property :user_data_file 18 | property :iam_instance_profile_arn 19 | property :logger 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ami_spec/aws_key_pair.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-ec2' 2 | require 'logger' 3 | require 'securerandom' 4 | require 'tempfile' 5 | require 'pathname' 6 | 7 | module AmiSpec 8 | class AwsKeyPair 9 | 10 | def self.create(**args) 11 | new(**args).tap(&:create) 12 | end 13 | 14 | def initialize(ec2: Aws::EC2::Resource.new, key_name_prefix: 'ami-spec-', logger: Logger.new(STDOUT)) 15 | @ec2 = ec2 16 | @key_name = "#{key_name_prefix}#{SecureRandom.uuid}" 17 | @logger = logger 18 | end 19 | 20 | attr_reader :key_name, :key_file 21 | 22 | def create 23 | @logger.info "Creating temporary AWS key pair: #{@key_name}" 24 | @key_pair = @ec2.create_key_pair(key_name: @key_name) 25 | @temp_file = Tempfile.new('key') 26 | @temp_file.write(@key_pair.key_material) 27 | @temp_file.close 28 | @key_file = Pathname.new(@temp_file.path) 29 | end 30 | 31 | def delete 32 | @logger.info "Deleting temporary AWS key pair: #{@key_name}" 33 | @key_pair.delete 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ami_spec/aws_security_group.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-ec2' 2 | require 'forwardable' 3 | require 'securerandom' 4 | 5 | module AmiSpec 6 | class AwsSecurityGroup 7 | extend Forwardable 8 | 9 | def self.create(**args) 10 | new(**args).tap(&:create) 11 | end 12 | 13 | def initialize(ec2: Aws::EC2::Resource.new, 14 | group_name_prefix: "ami-spec-", 15 | connection_port: 22, 16 | subnet_id:, 17 | allow_any_ip: false, 18 | logger: Logger.new(STDOUT)) 19 | @ec2 = ec2 20 | @group_name = "#{group_name_prefix}#{SecureRandom.uuid}" 21 | @connection_port = connection_port 22 | @subnet_id = subnet_id 23 | @allow_any_ip = allow_any_ip 24 | @logger = logger 25 | end 26 | 27 | def_delegators :@security_group, :group_id 28 | attr_reader :group_name 29 | 30 | def create 31 | @logger.info "Creating temporary AWS security group: #{@group_name}" 32 | create_security_group 33 | allow_ingress_via_connection_port 34 | end 35 | 36 | def delete 37 | @logger.info "Deleting temporary AWS security group: #{@group_name}" 38 | @security_group.delete 39 | end 40 | 41 | private 42 | 43 | def create_security_group 44 | @security_group = @ec2.create_security_group( 45 | group_name: @group_name, 46 | description: "A temporary security group for running AmiSpec", 47 | vpc_id: subnet.vpc_id, 48 | ) 49 | end 50 | 51 | def allow_ingress_via_connection_port 52 | @security_group.authorize_ingress( 53 | ip_permissions: [ 54 | { 55 | ip_protocol: "tcp", 56 | from_port: @connection_port, 57 | to_port: @connection_port, 58 | ip_ranges: [{cidr_ip: cidr_block}], 59 | }, 60 | ], 61 | ) 62 | end 63 | 64 | def cidr_block 65 | @allow_any_ip ? "0.0.0.0/0" : subnet.cidr_block 66 | end 67 | 68 | def subnet 69 | @subnet ||= @ec2.subnet(@subnet_id) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/ami_spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | # Loading serverspec first causes a weird error - stack level too deep 2 | # Requiring rspec first fixes that *shrug* 3 | require 'rspec' 4 | require 'serverspec' 5 | 6 | module AmiSpec 7 | class ServerSpec 8 | def initialize(options) 9 | instance = options.fetch(:instance) 10 | public_ip = options.fetch(:aws_public_ip) 11 | 12 | @debug = options.fetch(:debug) 13 | @ip = public_ip ? instance.public_ip_address : instance.private_ip_address 14 | @role = instance.tags.find{ |tag| tag.key == 'AmiSpec' }.value 15 | @spec = options.fetch(:specs) 16 | @user = options.fetch(:ssh_user) 17 | @key_file = options.fetch(:key_file) 18 | @buildkite = options.fetch(:buildkite) 19 | end 20 | 21 | def run 22 | if @buildkite 23 | puts "--- Running tests for #{@role}" 24 | else 25 | puts "Running tests for #{@role}" 26 | end 27 | 28 | $LOAD_PATH.unshift(@spec) unless $LOAD_PATH.include?(@spec) 29 | begin 30 | require File.join(@spec, 'spec_helper') 31 | rescue LoadError 32 | puts 'Spec Helper does not exist. Skipping!' 33 | end 34 | 35 | set :backend, :ssh 36 | set :host, @ip 37 | set :ssh_options, :user => @user, :keys => [@key_file], :verify_host_key => :never 38 | 39 | RSpec.configuration.fail_fast = true if @debug 40 | 41 | RSpec::Core::Runner.disable_autorun! 42 | result = RSpec::Core::Runner.run(Dir.glob("#{@spec}/#{@role}/*_spec.rb")) 43 | 44 | # We can't use Rspec.clear_examples here because it also clears the shared_examples. 45 | # As shared examples are loaded in via the spec_helper, we cannot reload them. 46 | RSpec.world.example_groups.clear 47 | 48 | Specinfra::Backend::Ssh.clear 49 | 50 | puts "^^^ +++" if @buildkite && !result.zero? 51 | result.zero? 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/ami_spec/server_spec_options.rb: -------------------------------------------------------------------------------- 1 | require 'hashie' 2 | 3 | module AmiSpec 4 | class ServerSpecOptions < Hashie::Dash 5 | include Hashie::Extensions::IgnoreUndeclared 6 | 7 | property :instance 8 | property :aws_public_ip 9 | property :debug 10 | property :key_file 11 | property :specs 12 | property :ssh_user 13 | property :buildkite 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/ami_spec/version.rb: -------------------------------------------------------------------------------- 1 | module AmiSpec 2 | VERSION = '1.8.2' 3 | end 4 | -------------------------------------------------------------------------------- /lib/ami_spec/wait_for_cloud_init.rb: -------------------------------------------------------------------------------- 1 | require 'net/ssh' 2 | 3 | module AmiSpec 4 | class WaitForCloudInit 5 | def self.wait(ip_address, user, key, port=22) 6 | Net::SSH.start(ip_address, user, keys: [key], :verify_host_key => :never, port: port) do |ssh| 7 | ssh.exec! '/usr/bin/cloud-init status --wait' 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/ami_spec/wait_for_rc.rb: -------------------------------------------------------------------------------- 1 | require 'net/ssh' 2 | 3 | module AmiSpec 4 | class WaitForRC 5 | def self.wait(ip_address, user, key, port=22) 6 | Net::SSH.start(ip_address, user, keys: [key], :verify_host_key => :never, port: port) do |ssh| 7 | distrib_stdout = "" 8 | # Determine the OS family 9 | ssh.exec!("source /etc/*release && echo -n $DISTRIB_ID && echo -n $ID") do |channel, stream, data| 10 | distrib_stdout << data if stream == :stdout 11 | end 12 | if distrib_stdout == "Ubuntu" 13 | codename_stdout = "" 14 | ssh.exec!("source /etc/*release && echo -n $DISTRIB_CODENAME") do |channel, stream, data| 15 | codename_stdout << data if stream == :stdout 16 | end 17 | if codename_stdout == "trusty" 18 | ssh.exec 'while /usr/sbin/service rc status | grep -q "^rc start/running, process"; do sleep 1; done' 19 | elsif codename_stdout == "xenial" 20 | ssh.exec 'while /usr/sbin/service rc status >/dev/null; do sleep 1; done' 21 | else 22 | puts "WARNING: Only Ubuntu trusty and xenial supported and we detected '#{codename_stdout}'. --wait-for-rc has no effect." 23 | end 24 | elsif distrib_stdout == "amzn" 25 | version_stdout = "" 26 | ssh.exec!("source /etc/*release && echo -n $VERSION_ID") do |channel, stream, data| 27 | version_stdout << data if stream == :stdout 28 | end 29 | if version_stdout =~ %r{201[0-9]{1}\.[0-9]+} 30 | ssh.exec 'while initctl status rc |grep -q "^rc start/running"; do sleep 1; done' 31 | else 32 | puts "WARNING: Only Amazon Linux 1 is supported and we detected '#{version_stdout}'. --wait-for-rc has no effect." 33 | end 34 | else 35 | puts "WARNING: Only Ubuntu and Amazon linux are supported and we detected '#{distrib_stdout}'. --wait-for-rc has no effect." 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/ami_spec/wait_for_ssh.rb: -------------------------------------------------------------------------------- 1 | require 'net/ssh' 2 | 3 | module AmiSpec 4 | class WaitForSSH 5 | def self.wait(ip_address, user, key, max_retries) 6 | last_error = nil 7 | retries = 0 8 | 9 | while retries < max_retries 10 | begin 11 | Net::SSH.start(ip_address, user, keys: [key], :verify_host_key => :never) { |ssh| ssh.exec 'echo boo!' } 12 | rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, Timeout::Error, Net::SSH::Exception => error 13 | last_error = error 14 | sleep 1 15 | else 16 | break 17 | end 18 | 19 | retries = retries + 1 20 | end 21 | 22 | if retries > max_retries - 1 23 | raise AmiSpec::InstanceConnectionTimeout.new("Timed out waiting for SSH to become available: #{last_error}") 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/ami_spec/aws_default_vpc_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AmiSpec::AwsDefaultVpc do 2 | subject(:aws_default_vpc) { described_class.create(ec2: ec2, logger: logger) } 3 | 4 | let(:ec2) { instance_spy(Aws::EC2::Resource, vpcs: vpcs) } 5 | 6 | describe '#find_subnet' do 7 | subject(:find_subnet) { described_class.find_subnet(ec2: ec2) } 8 | 9 | context 'given no default vpc' do 10 | let(:vpcs) { instance_spy(Aws::EC2::Vpc::Collection, first: nil) } 11 | 12 | it 'returns nil' do 13 | expect(find_subnet).to be_nil 14 | end 15 | end 16 | 17 | context 'given a default vpc' do 18 | let(:vpcs) { instance_spy(Aws::EC2::Vpc::Collection, first: default_vpc) } 19 | let(:default_vpc) { instance_spy(Aws::EC2::Vpc, subnets: subnets) } 20 | 21 | context 'given the default vpc has no subnets' do 22 | let(:subnets) { instance_spy(Aws::EC2::Subnet::Collection, first: nil) } 23 | 24 | it 'returns nil' do 25 | expect(find_subnet).to be_nil 26 | end 27 | end 28 | 29 | context 'given the default vpc has a subnet' do 30 | let(:subnets) { instance_spy(Aws::EC2::Subnet::Collection, first: subnet) } 31 | let(:subnet) { instance_spy(Aws::EC2::Subnet) } 32 | 33 | it 'returns the subnet' do 34 | expect(find_subnet).to eq(subnet) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/ami_spec/aws_key_pair_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AmiSpec::AwsKeyPair do 2 | subject(:aws_key_pair) { described_class.create(ec2: ec2, logger: logger) } 3 | 4 | let(:ec2) { instance_spy(Aws::EC2::Resource, create_key_pair: key_pair) } 5 | let(:key_pair) { instance_spy(Aws::EC2::KeyPair, key_material: key_material) } 6 | let(:key_material) { 'test-key-material' } 7 | let(:logger) { instance_spy(Logger) } 8 | 9 | describe '#create' do 10 | subject(:create) { aws_key_pair } 11 | 12 | it 'creates the key pair in AWS' do 13 | create 14 | expect(ec2).to have_received(:create_key_pair).with(key_name: aws_key_pair.key_name) 15 | end 16 | 17 | it 'logs the creation of the key pair in AWS' do 18 | create 19 | expect(logger).to have_received(:info).with "Creating temporary AWS key pair: #{aws_key_pair.key_name}" 20 | end 21 | end 22 | 23 | describe '#key_name' do 24 | subject(:key_name) { aws_key_pair.key_name } 25 | 26 | it { should start_with('ami-spec-') } 27 | end 28 | 29 | describe '#key_file' do 30 | subject(:key_file) { aws_key_pair.key_file } 31 | 32 | it { should exist } 33 | 34 | it 'should contain key material' do 35 | expect(key_file.read).to eq(key_material) 36 | end 37 | 38 | it { should be_a Pathname } 39 | end 40 | 41 | describe '#delete' do 42 | subject(:delete) { aws_key_pair.delete } 43 | 44 | it 'deletes the key pair in AWS' do 45 | delete 46 | expect(key_pair).to have_received(:delete) 47 | end 48 | 49 | it 'logs the deletion of the key pair in AWS' do 50 | delete 51 | expect(logger).to have_received(:info).with "Deleting temporary AWS key pair: #{aws_key_pair.key_name}" 52 | end 53 | end 54 | end 55 | 56 | -------------------------------------------------------------------------------- /spec/ami_spec/aws_security_group_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AmiSpec::AwsSecurityGroup do 2 | subject(:aws_security_group) do 3 | described_class.create( 4 | ec2: ec2, 5 | subnet_id: test_subnet_id, 6 | allow_any_ip: allow_any_ip, 7 | logger: logger 8 | ) 9 | end 10 | 11 | let(:ec2) { instance_spy(Aws::EC2::Resource, create_security_group: security_group, subnet: subnet) } 12 | let(:security_group) { instance_spy(Aws::EC2::SecurityGroup, group_id: test_group_id) } 13 | let(:subnet) { instance_spy(Aws::EC2::Subnet, vpc_id: test_vpc_id, cidr_block: test_cidr_block) } 14 | let(:test_subnet_id) { 'test-subnet-id' } 15 | let(:test_group_id) { 'test-group-id' } 16 | let(:test_vpc_id) { 'test-vpc-id' } 17 | let(:test_cidr_block) { '172.16.0.0/24' } 18 | let(:allow_any_ip) { false } 19 | let(:logger) { instance_spy(Logger) } 20 | 21 | describe '#create' do 22 | subject(:create) { aws_security_group } 23 | 24 | it 'creates the security group in AWS' do 25 | create 26 | expect(ec2).to have_received(:create_security_group).with(group_name: aws_security_group.group_name, vpc_id: test_vpc_id, description: anything) 27 | end 28 | 29 | context 'given allow_any_ip: true' do 30 | let(:allow_any_ip) { true } 31 | 32 | it 'adds the ingress rule for SSH, allowing any IP address' do 33 | create 34 | expect(security_group).to have_received(:authorize_ingress).with( 35 | ip_permissions: [{ip_protocol: "tcp", from_port: 22, to_port: 22, ip_ranges: [{cidr_ip: "0.0.0.0/0"}]}] 36 | ) 37 | end 38 | end 39 | 40 | context 'given allow_any_ip: false' do 41 | let(:allow_any_ip) { false } 42 | 43 | it 'adds the ingress rule for SSH, allowing only IP addresses from the subnet CIDR block' do 44 | create 45 | expect(security_group).to have_received(:authorize_ingress).with( 46 | ip_permissions: [{ip_protocol: "tcp", from_port: 22, to_port: 22, ip_ranges: [{cidr_ip: test_cidr_block}]}] 47 | ) 48 | end 49 | end 50 | 51 | it 'loads the subnet to find the vpc id' do 52 | create 53 | expect(ec2).to have_received(:subnet).with(test_subnet_id) 54 | end 55 | 56 | it 'logs the creation of the key pair in AWS' do 57 | create 58 | expect(logger).to have_received(:info).with "Creating temporary AWS security group: #{aws_security_group.group_name}" 59 | end 60 | end 61 | 62 | describe '#group_name' do 63 | subject(:key_name) { aws_security_group.group_name } 64 | 65 | it { should start_with('ami-spec-') } 66 | end 67 | 68 | describe '#group_id' do 69 | subject(:group_id) { aws_security_group.group_id } 70 | 71 | it 'is obtained from the AWS create-security-group API response' do 72 | expect(group_id).to eq test_group_id 73 | end 74 | end 75 | 76 | describe '#delete' do 77 | subject(:delete) { aws_security_group.delete } 78 | 79 | it 'deletes the security group in AWS' do 80 | delete 81 | expect(security_group).to have_received(:delete) 82 | end 83 | 84 | it 'logs the deletion of the key pair in AWS' do 85 | delete 86 | expect(logger).to have_received(:info).with "Deleting temporary AWS security group: #{aws_security_group.group_name}" 87 | end 88 | end 89 | end 90 | 91 | -------------------------------------------------------------------------------- /spec/ami_spec_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AmiSpec do 2 | let(:amis) { {'web_server' => 'ami-1234abcd', 'db_server' => 'ami-1234abcd'} } 3 | let(:ec2) { instance_spy(Aws::EC2::Resource) } 4 | let(:ec2_double) { instance_double(AmiSpec::AwsInstance) } 5 | let(:aws_key_pair) { instance_spy(AmiSpec::AwsKeyPair) } 6 | let(:aws_security_group) { instance_spy(AmiSpec::AwsSecurityGroup) } 7 | let(:logger) { instance_spy(Logger) } 8 | let(:state) { double(name: 'running') } 9 | let(:test_result) { true } 10 | let(:server_spec_double) { double(run: test_result) } 11 | let(:key_name) { 'key' } 12 | let(:security_groups) { ['sg-1234'] } 13 | let(:subnet_id) { 'subnet-1234abcd' } 14 | let(:allow_any_temporary_security_group) { false } 15 | subject do 16 | described_class.run( 17 | amis: amis, 18 | specs: '/tmp/foobar', 19 | subnet_id: subnet_id, 20 | key_name: key_name, 21 | key_file: 'key.pem', 22 | aws_public_ip: false, 23 | aws_instance_type: 't2.micro', 24 | ssh_user: 'ubuntu', 25 | debug: false, 26 | ssh_retries: 30, 27 | aws_security_groups: security_groups, 28 | allow_any_temporary_security_group: allow_any_temporary_security_group, 29 | ) 30 | end 31 | 32 | 33 | describe '#invoke' do 34 | context 'given no arguments' do 35 | it 'prints to STDERR and raises a system exit' do 36 | expect{ described_class.invoke }.to output.to_stderr.and raise_error(SystemExit) 37 | end 38 | end 39 | end 40 | 41 | describe '#run' do 42 | before do 43 | allow(AmiSpec::WaitForSSH).to receive(:wait).and_return(true) 44 | allow(AmiSpec::AwsInstance).to receive(:start).and_return(ec2_double) 45 | allow(AmiSpec::ServerSpec).to receive(:new).and_return(server_spec_double) 46 | allow(AmiSpec::AwsKeyPair).to receive(:create).and_return(aws_key_pair) 47 | allow(AmiSpec::AwsSecurityGroup).to receive(:create).and_return(aws_security_group) 48 | allow(Aws::EC2::Resource).to receive(:new).and_return(ec2) 49 | allow(ec2_double).to receive(:terminate).and_return(true) 50 | allow(ec2_double).to receive(:private_ip_address).and_return('127.0.0.1') 51 | allow_any_instance_of(Object).to receive(:sleep) 52 | allow(Logger).to receive(:new).and_return(logger) 53 | end 54 | 55 | context 'successful tests' do 56 | it 'calls aws instance for each ami' do 57 | expect(AmiSpec::AwsInstance).to receive(:start).with(hash_including(role: 'web_server')) 58 | expect(AmiSpec::AwsInstance).to receive(:start).with(hash_including(role: 'db_server')) 59 | subject 60 | end 61 | 62 | it 'returns true' do 63 | expect(subject).to be_truthy 64 | end 65 | end 66 | 67 | context 'failed tests' do 68 | let(:test_result) { false } 69 | 70 | it 'returns false' do 71 | expect(subject).to be_falsey 72 | end 73 | end 74 | 75 | context 'given a key name is not provided' do 76 | let(:key_name) { nil } 77 | 78 | it 'creates a key pair' do 79 | subject 80 | expect(AmiSpec::AwsKeyPair).to have_received(:create) 81 | end 82 | 83 | it 'deletes the key pair' do 84 | subject 85 | expect(aws_key_pair).to have_received(:delete) 86 | end 87 | end 88 | 89 | context 'given a key name is provided' do 90 | let(:key_name) { 'key' } 91 | 92 | it 'does not create a key pair' do 93 | subject 94 | expect(AmiSpec::AwsKeyPair).not_to have_received(:create) 95 | end 96 | end 97 | 98 | context 'given a security group id is not provided' do 99 | let(:security_groups) { [] } 100 | 101 | it 'creates a temporary security group' do 102 | subject 103 | expect(AmiSpec::AwsSecurityGroup).to have_received(:create) 104 | end 105 | 106 | it 'passes the subnet id' do 107 | subject 108 | expect(AmiSpec::AwsSecurityGroup).to have_received(:create) 109 | .with(a_hash_including(subnet_id: subnet_id)) 110 | end 111 | 112 | context 'given allow_any_temporary_security_group: true' do 113 | let(:allow_any_temporary_security_group) { true } 114 | 115 | it 'passes allow_any_ip: true' do 116 | subject 117 | expect(AmiSpec::AwsSecurityGroup).to have_received(:create) 118 | .with(a_hash_including(allow_any_ip: true)) 119 | end 120 | end 121 | 122 | context 'given allow_any_temporary_security_group: false' do 123 | let(:allow_any_temporary_security_group) { false } 124 | 125 | it 'passes allow_any_ip: true' do 126 | subject 127 | expect(AmiSpec::AwsSecurityGroup).to have_received(:create) 128 | .with(a_hash_including(allow_any_ip: false)) 129 | end 130 | end 131 | 132 | it 'deletes the temporary security group' do 133 | subject 134 | expect(aws_security_group).to have_received(:delete) 135 | end 136 | end 137 | 138 | context 'given a security group id is provided' do 139 | let(:security_groups) { ['sg-4321'] } 140 | 141 | it 'does not create a temporary security group' do 142 | subject 143 | expect(AmiSpec::AwsSecurityGroup).not_to have_received(:create) 144 | end 145 | end 146 | 147 | context 'given a subnet id is provided' do 148 | it 'launches the EC2 instances in the provided subnet' do 149 | subject 150 | expect(AmiSpec::AwsInstance).to have_received(:start).twice.with( 151 | an_instance_of(AmiSpec::AwsInstanceOptions) 152 | .and(having_attributes(subnet_id: subnet_id))) 153 | end 154 | end 155 | 156 | context 'given a subnet id is not provided' do 157 | let(:subnet_id) { nil } 158 | 159 | context 'given a default VPC subnet is found' do 160 | let(:default_subnet) { instance_spy(Aws::EC2::Subnet, id: default_subnet_id) } 161 | let(:default_subnet_id) { 'subnet-1234' } 162 | before do 163 | allow(AmiSpec::AwsDefaultVpc).to receive(:find_subnet).and_return(default_subnet) 164 | end 165 | 166 | it 'launches the EC2 instances in the default VPC subnet' do 167 | subject 168 | expect(AmiSpec::AwsInstance).to have_received(:start).twice.with( 169 | an_instance_of(AmiSpec::AwsInstanceOptions) 170 | .and(having_attributes(subnet_id: default_subnet_id))) 171 | end 172 | 173 | it 'logs which subnet is being used' do 174 | subject 175 | expect(logger).to have_received(:info).with( 176 | "Using subnet #{default_subnet_id} from the default VPC" 177 | ) 178 | end 179 | 180 | context 'given a security group id is not provided' do 181 | let(:security_groups) { [] } 182 | 183 | it 'creates the temporary security group in the default VPC subnet' do 184 | subject 185 | expect(AmiSpec::AwsSecurityGroup).to have_received(:create) 186 | .with(a_hash_including(subnet_id: default_subnet_id)) 187 | end 188 | end 189 | end 190 | 191 | context 'given a default VPC subnet is not found' do 192 | before do 193 | allow(AmiSpec::AwsDefaultVpc).to receive(:find_subnet).and_return(nil) 194 | end 195 | 196 | it 'raises an error, asking for a subnet_id to be provided' do 197 | expect { subject }.to raise_error('No default VPC subnet found. Please specify a subnet id.') 198 | end 199 | end 200 | end 201 | end 202 | 203 | describe '#parse_tags' do 204 | it 'parses a single key/value pair' do 205 | expect(described_class.parse_tags("Name=AmiSpec")).to eq( { "Name"=>"AmiSpec" } ) 206 | end 207 | 208 | it 'parses multiple key/value pairs' do 209 | expect(described_class.parse_tags("Name=AmiSpec,Owner=Me")).to eq( { "Name"=>"AmiSpec", "Owner"=>"Me" } ) 210 | end 211 | 212 | it 'parses an empty string' do 213 | expect(described_class.parse_tags("")).to eq({}) 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /spec/aws_instance_spec.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'tempfile' 3 | 4 | RSpec.describe AmiSpec::AwsInstance do 5 | let(:role) { 'web_server' } 6 | let(:sec_group_id) { nil } 7 | let(:region) { nil } 8 | let(:client_double) { instance_spy(Aws::EC2::Client) } 9 | let(:new_ec2_double) { instance_spy(Aws::EC2::Types::Instance) } 10 | let(:ec2_double) { instance_spy(Aws::EC2::Instance) } 11 | let(:tags) { {} } 12 | let(:iam_instance_profile_arn) { nil } 13 | let(:user_data_file) { nil } 14 | let(:aws_public_ip) { false } 15 | let(:associate_public_ip) { false } 16 | let(:logger) { instance_spy(Logger) } 17 | 18 | subject(:aws_instance) do 19 | described_class.new( 20 | role: role, 21 | ami: 'ami', 22 | subnet_id: 'subnet', 23 | key_name: 'key', 24 | aws_instance_type: 't2.micro', 25 | aws_public_ip: aws_public_ip, 26 | associate_public_ip: associate_public_ip, 27 | aws_security_groups: sec_group_id, 28 | aws_region: region, 29 | tags: tags, 30 | user_data_file: user_data_file, 31 | iam_instance_profile_arn: iam_instance_profile_arn, 32 | logger: logger, 33 | ) 34 | end 35 | 36 | before do 37 | allow(Aws::EC2::Client).to receive(:new).and_return(client_double) 38 | allow(client_double).to receive(:run_instances).and_return(double(instances: [new_ec2_double])) 39 | allow(ec2_double).to receive(:create_tags).and_return(double) 40 | allow(Aws::EC2::Instance).to receive(:new).and_return(ec2_double) 41 | end 42 | 43 | describe '#start' do 44 | subject(:start) { aws_instance.start } 45 | context 'without optional values' do 46 | it 'does not include the security group' do 47 | expect(client_double).to receive(:run_instances).with( 48 | hash_excluding(:network_interfaces=>array_including(hash_including(:groups))) 49 | ) 50 | start 51 | end 52 | 53 | it 'does include the region' do 54 | expect(Aws::EC2::Client).to receive(:new).with( 55 | hash_excluding(:region => region) 56 | ) 57 | start 58 | end 59 | end 60 | 61 | context 'with security group' do 62 | let(:sec_group_id) { ['1234'] } 63 | 64 | it 'does include security groups' do 65 | expect(client_double).to receive(:run_instances).with( 66 | hash_including(:network_interfaces=>array_including(hash_including(:groups))) 67 | ) 68 | start 69 | end 70 | end 71 | 72 | context 'with region' do 73 | let(:region) { 'us-east-1' } 74 | 75 | it 'does include the region in the intial connection' do 76 | expect(Aws::EC2::Client).to receive(:new).with( 77 | hash_including(:region => region) 78 | ) 79 | start 80 | end 81 | 82 | it 'does include the region in the subsequent connection' do 83 | expect(Aws::EC2::Instance).to receive(:new).with( 84 | anything, 85 | hash_including(:region => region) 86 | ) 87 | start 88 | end 89 | end 90 | 91 | context 'with tags' do 92 | let(:tags) { {"Name" => "AmiSpec"} } 93 | 94 | it 'tags the instance' do 95 | expect(ec2_double).to receive(:create_tags).with( 96 | {tags: [{ key: 'AmiSpec', value: role}, { key: "Name", value: "AmiSpec"}]} 97 | ) 98 | start 99 | end 100 | end 101 | 102 | context 'with user_data' do 103 | let(:user_data_file) { 104 | file = Tempfile.new('user_data.txt') 105 | file.write("my file\ncontent") 106 | file.close 107 | file.path 108 | } 109 | 110 | it 'does include user_data' do 111 | expect(client_double).to receive(:run_instances).with( 112 | hash_including(:user_data => Base64.encode64("my file\ncontent")) 113 | ) 114 | start 115 | end 116 | end 117 | 118 | context 'with iam_instance_profile_arn' do 119 | let(:iam_instance_profile_arn) { "my_arn" } 120 | 121 | it 'does include iam_instance_profile_arn' do 122 | expect(client_double).to receive(:run_instances).with( 123 | hash_including(:iam_instance_profile => { arn: 'my_arn'}) 124 | ) 125 | start 126 | end 127 | end 128 | 129 | context 'with aws_public_ip' do 130 | let(:aws_public_ip) { true } 131 | it 'sets associate public IP' do 132 | expect(client_double).to receive(:run_instances).with( 133 | hash_including( 134 | network_interfaces: [ 135 | { 136 | device_index: 0, 137 | associate_public_ip_address: true, 138 | subnet_id: 'subnet' 139 | } 140 | ] 141 | ) 142 | ) 143 | start 144 | end 145 | end 146 | 147 | context 'with associate_public_ip' do 148 | let(:associate_public_ip) { true } 149 | it 'sets associate public IP' do 150 | expect(client_double).to receive(:run_instances).with( 151 | hash_including( 152 | network_interfaces: [ 153 | { 154 | device_index: 0, 155 | associate_public_ip_address: true, 156 | subnet_id: 'subnet' 157 | } 158 | ] 159 | ) 160 | ) 161 | start 162 | end 163 | end 164 | 165 | it 'tags the instance with a role' do 166 | expect(ec2_double).to receive(:create_tags).with( 167 | hash_including(tags: [{ key: 'AmiSpec', value: role}]) 168 | ) 169 | start 170 | end 171 | 172 | it 'delegates some methods to the instance variable' do 173 | expect(ec2_double).to receive(:instance_id) 174 | start 175 | aws_instance.instance_id 176 | end 177 | 178 | it 'logs creation' do 179 | start 180 | expect(logger).to have_received(:info).with /^Creating AWS EC2 instance for ami/ 181 | expect(logger).to have_received(:info).with /^Waiting for AWS EC2 instance to start/ 182 | expect(logger).to have_received(:info).with /^AWS EC2 instance started/ 183 | end 184 | end 185 | 186 | describe '#terminate' do 187 | subject(:terminate) { aws_instance.terminate } 188 | 189 | before { aws_instance.start } 190 | 191 | it 'instructs the EC2 instance to terminate' do 192 | terminate 193 | expect(ec2_double).to have_received(:terminate) 194 | end 195 | 196 | it 'waits for the EC2 instance to terminate' do 197 | terminate 198 | expect(ec2_double).to have_received(:wait_until_terminated) 199 | end 200 | 201 | it 'logs termination' do 202 | terminate 203 | expect(logger).to have_received(:info).with /^Terminating AWS EC2 instance/ 204 | expect(logger).to have_received(:info).with /^AWS EC2 instance terminated/ 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /spec/containers/Dockerfile.amazon_linux: -------------------------------------------------------------------------------- 1 | FROM amazonlinux:1 2 | 3 | RUN yum install -y upstart openssh-server && yum clean all 4 | ADD rc.conf /etc/init/rc.conf 5 | 6 | COPY ami-spec.pub /root/.ssh/authorized_keys 7 | COPY sshd_config /etc/ssh/sshd_config 8 | 9 | CMD ["/bin/bash", "-c", "exec /sbin/init"] 10 | -------------------------------------------------------------------------------- /spec/containers/Dockerfile.trusty: -------------------------------------------------------------------------------- 1 | FROM ubuntu-upstart:trusty 2 | 3 | ENV DEBIAN_FRONTEND noninteractive 4 | RUN apt-get update && apt-get install -y openssh-server && apt-get clean 5 | 6 | COPY ami-spec.pub /root/.ssh/authorized_keys 7 | 8 | EXPOSE 22 9 | -------------------------------------------------------------------------------- /spec/containers/Dockerfile.xenial: -------------------------------------------------------------------------------- 1 | FROM ubuntu:xenial 2 | 3 | RUN cd /lib/systemd/system/sysinit.target.wants/; ls | grep -v systemd-tmpfiles-setup | xargs rm -f $1 \ 4 | rm -f /lib/systemd/system/multi-user.target.wants/*;\ 5 | rm -f /etc/systemd/system/*.wants/*;\ 6 | rm -f /lib/systemd/system/local-fs.target.wants/*; \ 7 | rm -f /lib/systemd/system/sockets.target.wants/*udev*; \ 8 | rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \ 9 | rm -f /lib/systemd/system/basic.target.wants/*;\ 10 | rm -f /lib/systemd/system/anaconda.target.wants/*; \ 11 | rm -f /lib/systemd/system/plymouth*; \ 12 | rm -f /lib/systemd/system/systemd-update-utmp*; 13 | 14 | RUN apt-get update && apt-get install -y openssh-server dbus && apt-get clean 15 | 16 | RUN systemctl set-default multi-user.target 17 | 18 | COPY ami-spec.pub /root/.ssh/authorized_keys 19 | 20 | EXPOSE 22 21 | 22 | CMD ["/bin/bash", "-c", "exec /sbin/init --log-target=journal 3>&1"] 23 | -------------------------------------------------------------------------------- /spec/containers/README.md: -------------------------------------------------------------------------------- 1 | ## Integration test containers 2 | 3 | This directory is used to create containers that can be used to test the `WaitForRC` class. Because they require upstart/systemd to exist we have to install and start the init environment. We also setup SSH so that we can simple call the `wait` function and have it SSH to our container to execute. 4 | 5 | Refer to the [README](../../README.md#running-tests) for how to execute them. 6 | -------------------------------------------------------------------------------- /spec/containers/ami-spec: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAwWn2++lylp8RcHzy7H9QpYli3nxLDh769DDbnb9cw2UDd9OH 3 | 6JZKaT3xe3IbMr39SmkGlOygkBmeH43VxAkiVJv3awDPRU0UvDyUvCsbaYj1/cOS 4 | 8Vxr7ENExoiKkengcg6k3mFj65ooJ1pf8RoXuj+0+YU0fgejuR/M4x6V8GKFCJhU 5 | wFmRs3mcoCx0EiJtTx40IW87uOQUruDX5HcgTUInRhyRxltNrXJaap1weMGpIA/o 6 | Bo8foOx1Os9o3YKQlkPF4iqk2AVJ4FZGbMay0cIq3075Jeig6bdlIhRpYA+w+SAI 7 | y/yT/K3U1ciQqKtPgahGEyihrh7Ks2F2FSLhdwIDAQABAoIBABWt/QNLrY54kgnb 8 | 15buxmlntu9dW0Rf8J1ChLtv4cP9JKBf05IcloapbNH7flT3utaGYzh6NZ0xYeoD 9 | ifyJUZHOUbNqydDozPQ0ji9xXYc81OX28Beh1m8LM0BVucKVRpVCUvSiUgLsqqeO 10 | l8Z8uEAmN/DoH3QpAw8TI3Ip0YC6OHA2aRV9PXuDnR5OTdBPOBj33Fdtf0rUAk41 11 | UFe/BHFyACfTK05+bcQz9DvRV/H+SnBeOCqDie1eNDnEgza4NS2cnBUCogKsaCrY 12 | gV06pivS2aHsK5CuNB1lcZi1tVf3DnDwPvFWqLLG9PIHaevPDpDURECirCrpCWJT 13 | VSHm7KECgYEA4K5jSna4Jzo9FlHzF+yGEju5QwEJTjnhunNw1FpcgPAddFQ4hs3w 14 | 0EhyPlZyf3vwhfdH4vBhTLjRTrOF2SIvSSPwrkWlAhaluVvpVRFd/ncYW4kAVwhQ 15 | 15/ZBtvu8OQnKeeztsLlkEi4ik3cKjeXyeDQReb2Guvc6IM4fr6ZrlkCgYEA3F/S 16 | uJr04UgzX0cQuNLX7uXz6oeyJupwFkTuAhvLcHDsDHFkP1M9zfFzg5aEcQungz/l 17 | 5s/vFJmfLBrzhSoYY1T9PDdLwEL/JKaxhKNEV9lExF4exMui6QPWdTMA8ndvB7r5 18 | Ur85X8scH1qJo99fsEmNmG5O72PGXmltOB0sNE8CgYEApeuCPYIweh+C7xGzkE5F 19 | r/9Uz4tbYN5TuMn5X4gfWcR4K+jqGXrJxDZLz4ctZMGVHIlBF/DmGa8+On1OccvR 20 | 2ZRl73xU35bz6U9bn0uE+x7d6PLiQmNMt/8+WNdfu5rw5PxLdcK1nnhldxUKak7F 21 | k/qmM4jc44Kcj0QgG1EL0nkCgYAFbV61KSvKuIp7WDazNo4W1hbxubHLf46PHdd2 22 | udSCymUl0U0UuioVflLH9NcCKbVQaCxzSL+slDP1VByXNPgwyhEKgJoe/Adokaph 23 | h9vRBgrJgz/ivNkgP/XyIPVvAz36xMILJaZ2E3x30TT+kiu7HbSdAmpzPtPN027b 24 | KOzDxQKBgEv2OvEtpvpv9DgPHs9Mq4haTh2o8c8JW7kwHqbbZOZjZ/4daEh89FhH 25 | gjvJV5NjaNhFqBWTnNfjSr4o09WFDoQyVwEUrWNJXXZmsjOHqMDT/kwVoAsld1tO 26 | N+JW6/4M+EMYvF39yWzdQn/U3A1gZIfzAC6S3HUCi9BgKLBMKEN3 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /spec/containers/ami-spec.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDBafb76XKWnxFwfPLsf1CliWLefEsOHvr0MNudv1zDZQN304folkppPfF7chsyvf1KaQaU7KCQGZ4fjdXECSJUm/drAM9FTRS8PJS8KxtpiPX9w5LxXGvsQ0TGiIqR6eByDqTeYWPrmignWl/xGhe6P7T5hTR+B6O5H8zjHpXwYoUImFTAWZGzeZygLHQSIm1PHjQhbzu45BSu4NfkdyBNQidGHJHGW02tclpqnXB4wakgD+gGjx+g7HU6z2jdgpCWQ8XiKqTYBUngVkZsxrLRwirfTvkl6KDpt2UiFGlgD7D5IAjL/JP8rdTVyJCoq0+BqEYTKKGuHsqzYXYVIuF3 2 | -------------------------------------------------------------------------------- /spec/containers/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | xenial: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.xenial 7 | ports: 8 | - "1122:22" 9 | # --security-opt seccomp=unconfined --tmpfs /run --tmpfs /run/lock -v /sys/fs/cgroup:/sys/fs/cgroup:ro 10 | security_opt: 11 | - seccomp:unconfined 12 | tmpfs: 13 | - /run 14 | - /run/lock 15 | volumes: 16 | - /sys/fs/cgroup:/sys/fs/cgroup:ro 17 | trusty: 18 | build: 19 | context: . 20 | dockerfile: Dockerfile.trusty 21 | ports: 22 | - "1123:22" 23 | amazon_linux: 24 | build: 25 | context: . 26 | dockerfile: Dockerfile.amazon_linux 27 | ports: 28 | - "1124:22" 29 | -------------------------------------------------------------------------------- /spec/containers/rc.conf: -------------------------------------------------------------------------------- 1 | # rc - System V runlevel compatibility 2 | # 3 | # This task runs the old sysv-rc runlevel scripts. It 4 | # is usually started by the telinit compatibility wrapper. 5 | # 6 | # Do not edit this file directly. If you want to change the behaviour, 7 | # please create a file rc.override and put your changes there. 8 | 9 | start on runlevel [0123456] 10 | 11 | stop on runlevel [!$RUNLEVEL] 12 | 13 | task 14 | 15 | export RUNLEVEL 16 | console output 17 | exec /etc/rc.d/rc $RUNLEVEL 18 | -------------------------------------------------------------------------------- /spec/containers/sshd_config: -------------------------------------------------------------------------------- 1 | HostKey /etc/ssh/ssh_host_rsa_key 2 | HostKey /etc/ssh/ssh_host_ecdsa_key 3 | HostKey /etc/ssh/ssh_host_ed25519_key 4 | SyslogFacility AUTHPRIV 5 | AuthorizedKeysFile .ssh/authorized_keys 6 | PasswordAuthentication no 7 | ChallengeResponseAuthentication no 8 | UsePAM yes 9 | X11Forwarding yes 10 | PrintLastLog yes 11 | UsePrivilegeSeparation sandbox 12 | AcceptEnv LANG LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES 13 | AcceptEnv LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT 14 | AcceptEnv LC_IDENTIFICATION LC_ALL LANGUAGE 15 | AcceptEnv XMODIFIERS 16 | Subsystem sftp /usr/libexec/openssh/sftp-server 17 | PermitRootLogin yes 18 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # This allows you to limit a spec run to individual examples or groups 48 | # you care about by tagging them with `:focus` metadata. When nothing 49 | # is tagged with `:focus`, all examples get run. RSpec also provides 50 | # aliases for `it`, `describe`, and `context` that include `:focus` 51 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 52 | config.filter_run_when_matching :focus 53 | 54 | # Allows RSpec to persist some state between runs in order to support 55 | # the `--only-failures` and `--next-failure` CLI options. We recommend 56 | # you configure your source control system to ignore this file. 57 | config.example_status_persistence_file_path = "spec/examples.txt" 58 | 59 | # Limits the available syntax to the non-monkey patched syntax that is 60 | # recommended. For more details, see: 61 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 62 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 63 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 64 | config.disable_monkey_patching! 65 | 66 | # This setting enables warnings. It's recommended, but in some cases may 67 | # be too noisy due to issues in dependencies. 68 | config.warnings = true 69 | 70 | # Many RSpec users commonly either run the entire suite or an individual 71 | # file, and it's useful to allow more verbose output when running an 72 | # individual spec file. 73 | if config.files_to_run.one? 74 | # Use the documentation formatter for detailed output, 75 | # unless a formatter has already been configured 76 | # (e.g. via a command-line flag). 77 | config.default_formatter = "doc" 78 | end 79 | 80 | # Print the 10 slowest examples and example groups at the 81 | # end of the spec run, to help surface which specs are running 82 | # particularly slow. 83 | #config.profile_examples = 10 84 | 85 | # Run specs in random order to surface order dependencies. If you find an 86 | # order dependency and want to debug it, you can fix the order by providing 87 | # the seed, which is printed after each run. 88 | # --seed 1234 89 | config.order = :random 90 | 91 | # Seed global randomization in this process using the `--seed` CLI option. 92 | # Setting this allows you to use `--seed` to deterministically reproduce 93 | # test failures related to randomization by passing the same `--seed` value 94 | # as the one that triggered the failure. 95 | Kernel.srand config.seed 96 | end 97 | -------------------------------------------------------------------------------- /spec/spec_support.rb: -------------------------------------------------------------------------------- 1 | Dir.glob(File.join(__dir__, 'support/**/*.rb')).sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /spec/support/serverspec.rb: -------------------------------------------------------------------------------- 1 | require 'serverspec' 2 | 3 | RSpec.configure do |config| 4 | config.before :suite do 5 | set :backend, :exec 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/wait_for_rc_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AmiSpec::WaitForRC, integration: true do 2 | let(:private_key_file) { File.expand_path(File.join('..', 'containers', 'ami-spec'), __FILE__) } 3 | context 'xenial server' do 4 | let(:ssh_port) { 1122 } 5 | it 'executes without printing any errors' do 6 | expect { described_class.wait("localhost", "root", private_key_file, ssh_port) }.to_not output.to_stdout 7 | end 8 | end 9 | 10 | context 'trusty server' do 11 | let(:ssh_port) { 1123 } 12 | it 'executes without printing any errors' do 13 | expect { described_class.wait("localhost", "root", private_key_file, ssh_port) }.to_not output.to_stdout 14 | end 15 | end 16 | 17 | context 'amazon linux server' do 18 | let(:ssh_port) { 1124 } 19 | it 'executes without printing any errors' do 20 | expect { described_class.wait("localhost", "root", private_key_file, ssh_port) }.to_not output.to_stdout 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/wait_for_ssh_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AmiSpec::WaitForSSH do 2 | describe '#wait' do 3 | let(:retries) { 30 } 4 | subject { described_class.wait('127.0.0.1', 'ubuntu', 'key.pem', 30) } 5 | 6 | before do 7 | allow_any_instance_of(Object).to receive(:sleep) 8 | end 9 | 10 | it 'returns after one attempt if ssh connection succeeds' do 11 | expect(Net::SSH).to receive(:start) 12 | 13 | subject 14 | end 15 | 16 | context 'ssh fails' do 17 | before do 18 | allow(Net::SSH).to receive(:start).and_raise(Errno::ECONNREFUSED, 'ssh failed') 19 | end 20 | 21 | it 'raises an exception' do 22 | expect{subject}.to raise_error(AmiSpec::InstanceConnectionTimeout) 23 | end 24 | 25 | it 'returns the last error' do 26 | expect(Net::SSH).to receive(:start).and_raise(Net::SSH::ConnectionTimeout, 'some other error') 27 | expect{subject}.to raise_error(AmiSpec::InstanceConnectionTimeout, /ssh failed/) 28 | end 29 | 30 | it 'tries the number of retries specified' do 31 | expect(Net::SSH).to receive(:start).exactly(retries).times 32 | 33 | expect{subject}.to raise_error(AmiSpec::InstanceConnectionTimeout) 34 | end 35 | end 36 | end 37 | end 38 | --------------------------------------------------------------------------------