diff --git a/.travis.yml b/.travis.yml index 8e9f76cc..115e777b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,19 @@ language: ruby + sudo: false + +cache: bundler + rvm: -- ruby-head -- 2.3 -- 2.2 + - 2.2.7 + - 2.3.4 + +deploy: + provider: rubygems + api_key: + secure: A1AYezvoon6JdYHphtrUEeG98r7AtFqsPcETp4BX3sPlSvOghHqco57/JasxFBFbis1irfVxhjqIFaS0lfOO0yxfIJQFnbZ6tTzKJB7IkSWUMuVfGo6qRxnO4vnuodx9Vhh27A7lovuptMCQBDdHXoSPUjgqHacrEzYKLDOarHercnRNRT9mWYR//qFO4RHtQz7hG+6p9tUvGmSeUkXB6AZwDPycPAJgrCd6bRYwlTmID6Jlq07bjcqfTTN+jZzdC22nhgjw2Cao0skcszO3H71DZYeFhoRIA8sMKXnNt7LGtvA2FJFM65bJj3TLVVpDe5Itn/KpSLSwuE9phPpVhv828S98h8pbLVYrhat+2jvNOqwtVw9C0LC9GpPlBcd/8AYuDwEO5a+hdnZ08JnihzqSvQd2XX9XvBiKy/A5mBc5D9bYJ3YXsy6zXToMM0nP8Xr0z3NCEFWtJ3ueGzHVmsZqwdoiH/2g6syx4k1YBHb9zdeGciMdk1vmckPYZJLpGh4cj4bDeIHaGPaZ2tYiBSaQNN+6YEMShQgl1d5k5Du6lHdUKnYR4Z7lvQkjfU73lkDoA9PvZuR7b7yi44stEvsxTpNJCZUZN0JmlXAsGlS3hDpD8/LcJWI6M0y7tBPQ3gEB/3YSAYsAcqJZNZ3wyTuqLPZBPJFr6Prwq3VoMhU= + gem: vagrant-aws-iam-decoder + on: + repo: iam-decoder/vagrant-aws + branch: iam-decoder + rvm: 2.3.4 diff --git a/Gemfile b/Gemfile index 4d52f3a6..79bf4afc 100644 --- a/Gemfile +++ b/Gemfile @@ -1,14 +1,12 @@ source "https://rubygems.org" -gemspec - group :development do # We depend on Vagrant for development, but we don't add it as a - # gem dependency because we expect to be installed within the + # gem dependency because we expect it to be installed within the # Vagrant environment itself using `vagrant plugin`. gem "vagrant", :git => "https://github.com/mitchellh/vagrant.git" end group :plugins do - gem "vagrant-aws" , path: "." + gemspec end diff --git a/README.md b/README.md index 31bbf8cd..2a47c059 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,7 @@ # Vagrant AWS Provider -[![Gitter](https://badges.gitter.im/Join Chat.svg)](https://gitter.im/mitchellh/vagrant-aws?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) - -[![Gem Version](https://badge.fury.io/rb/vagrant-aws.png)][gem] -[![Dependency Status](https://gemnasium.com/mitchellh/vagrant-aws.png)][gemnasium] - - -[gem]: https://rubygems.org/gems/vagrant-aws -[gemnasium]: https://gemnasium.com/mitchellh/vagrant-aws +[![Build Status](https://travis-ci.org/iam-decoder/vagrant-aws.svg?branch=iam-decoder)](https://travis-ci.org/iam-decoder/vagrant-aws) +[![Gem Version](https://badge.fury.io/rb/vagrant-aws-iam-decoder.svg)](https://badge.fury.io/rb/vagrant-aws-iam-decoder) This is a [Vagrant](http://www.vagrantup.com) 1.2+ plugin that adds an [AWS](http://aws.amazon.com) provider to Vagrant, allowing Vagrant to control and provision machines in @@ -24,6 +18,7 @@ EC2 and VPC. * Define region-specific configurations so Vagrant can manage machines in multiple regions. * Package running instances into new vagrant-aws friendly boxes +* Spot Instance Support ## Usage @@ -32,7 +27,7 @@ installing, `vagrant up` and specify the `aws` provider. An example is shown below. ``` -$ vagrant plugin install vagrant-aws +$ vagrant plugin install vagrant-aws-iam-decoder ... $ vagrant up --provider=aws ... @@ -137,7 +132,7 @@ This provider exposes quite a few provider-specific configuration options: * `session_token` - The session token provided by STS * `private_ip_address` - The private IP address to assign to an instance within a [VPC](http://aws.amazon.com/vpc/) -* `elastic_ip` - Can be set to 'true', or to an existing Elastic IP address. +* `elastic_ip` - Can be set to 'true', or to an existing Elastic IP address. If true, allocate a new Elastic IP address to the instance. If set to an existing Elastic IP address, assign the address to the instance. * `region` - The region to start the instance in, such as "us-east-1" @@ -170,6 +165,11 @@ This provider exposes quite a few provider-specific configuration options: when you initiate shutdown from the instance. * `endpoint` - The endpoint URL for connecting to AWS (or an AWS-like service). Only required for non AWS clouds, such as [eucalyptus](https://github.com/eucalyptus/eucalyptus/wiki). +* `spot_instance` - Boolean value; indicates whether the config is for a spot instance, or on-demand. For more information about spot instances, see the [AWS Documentation](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/how-spot-instances-work.html) +* `spot_max_price` - Decimal value; state the maximum bid for your spot instance. If nil, it will compute average price in `region` for selected `instance_type`. +* `spot_price_product_description` - The product description for the spot price history used to compute average price. Defaults to 'Linux/UNIX'. +* `spot_valid_until` - Timestamp; when this spot instance request should expire, destroying any related instances. Ignored if `spot_instance` is not true. + These can be set like typical provider-specific configuration: ```ruby @@ -316,7 +316,7 @@ $ bundle exec rake If those pass, you're ready to start developing the plugin. You can test the plugin without installing it into your Vagrant environment by just creating a `Vagrantfile` in the top level of this directory (it is gitignored) -and add the following line to your `Vagrantfile` +and add the following line to your `Vagrantfile` ```ruby Vagrant.require_plugin "vagrant-aws" ``` diff --git a/lib/vagrant-aws.rb b/lib/vagrant-aws-iam-decoder.rb similarity index 100% rename from lib/vagrant-aws.rb rename to lib/vagrant-aws-iam-decoder.rb diff --git a/lib/vagrant-aws/action/run_instance.rb b/lib/vagrant-aws/action/run_instance.rb index b37e4c64..169666f4 100644 --- a/lib/vagrant-aws/action/run_instance.rb +++ b/lib/vagrant-aws/action/run_instance.rb @@ -107,7 +107,11 @@ def call(env) end begin - server = env[:aws_compute].servers.create(options) + server = if region_config.spot_instance + server_from_spot_request(env, region_config, options) + else + env[:aws_compute].servers.create(options) + end rescue Fog::Compute::AWS::NotFound => e # Invalid subnet doesn't have its own error so we catch and # check the error message here. @@ -212,6 +216,87 @@ def call(env) @app.call(env) end + # returns a fog server or nil + def server_from_spot_request(env, config, options) + if config.spot_max_price.nil? + spot_price_current = env[:aws_compute].describe_spot_price_history({ + 'StartTime' => Time.now.iso8601, + 'EndTime' => Time.now.iso8601, + 'InstanceType' => [config.instance_type], + 'ProductDescription' => [config.spot_price_product_description.nil? ? 'Linux/UNIX' : config.spot_price_product_description] + }) + + spot_price_current.body['spotPriceHistorySet'].each do |set| + (@price_set ||= []) << set['spotPrice'].to_f + end + + if @price_set.nil? + raise Errors::FogError, + :message => "Could not find any history spot prices." + end + + avg_price = @price_set.inject(0.0) { |sum, el| sum + el } / @price_set.size + + # make the bid 10% higher than the average + price = (avg_price * 1.1).round(4) + else + price = config.spot_max_price + end + + options.merge!({ + :price => price, + :valid_until => config.spot_valid_until + }) + + env[:ui].info(I18n.t("vagrant_aws.launching_spot_instance")) + env[:ui].info(" -- Price: #{price}") + env[:ui].info(" -- Valid until: #{config.spot_valid_until}") if config.spot_valid_until + + # create the spot instance + spot_req = env[:aws_compute].spot_requests.create(options) + + @logger.info("Spot request ID: #{spot_req.id}") + + # initialize state + status_code = "" + while true + sleep 5 + + spot_req.reload() + + # display something whenever the status code changes + if status_code != spot_req.state + env[:ui].info(spot_req.fault) + status_code = spot_req.state + end + spot_state = spot_req.state.to_sym + case spot_state + when :not_created, :open + @logger.debug("Spot request #{spot_state} #{status_code}, waiting") + when :active + break; # :) + when :closed, :cancelled, :failed + msg = "Spot request #{spot_state} #{status_code}, aborting" + @logger.error(msg) + raise Errors::FogError, :message => msg + else + @logger.debug("Unknown spot state #{spot_state} #{status_code}, waiting") + end + end + # cancel the spot request but let the server go thru + spot_req.destroy() + + server = env[:aws_compute].servers.get(spot_req.instance_id) + + # Spot Instances don't support tagging arguments on creation + # Retrospectively tag the server to handle this + if !config.tags.empty? + env[:aws_compute].create_tags(server.identity, config.tags) + end + + server + end + def recover(env) return if env["vagrant.error"].is_a?(Vagrant::Errors::VagrantError) diff --git a/lib/vagrant-aws/config.rb b/lib/vagrant-aws/config.rb index 30a19cb4..1822bde6 100644 --- a/lib/vagrant-aws/config.rb +++ b/lib/vagrant-aws/config.rb @@ -196,6 +196,26 @@ class Config < Vagrant.plugin("2", :config) # @return [String] attr_accessor :aws_profile + # Launch as spot instance + # + # @return [Boolean] + attr_accessor :spot_instance + + # Spot request max price + # + # @return [String] + attr_accessor :spot_max_price + + # Spot request validity + # + # @return [Time] + attr_accessor :spot_valid_until + + # The product description for the spot price history + # + # @return [String] + attr_accessor :spot_price_product_description + def initialize(region_specific=false) @access_key_id = UNSET_VALUE @ami = UNSET_VALUE @@ -233,6 +253,9 @@ def initialize(region_specific=false) @tenancy = UNSET_VALUE @aws_dir = UNSET_VALUE @aws_profile = UNSET_VALUE + @spot_instance = UNSET_VALUE + @spot_max_price = UNSET_VALUE + @spot_valid_until = UNSET_VALUE # Internal state (prefix with __ so they aren't automatically # merged) @@ -323,8 +346,8 @@ def finalize! if @access_key_id == UNSET_VALUE or @secret_access_key == UNSET_VALUE @aws_profile = 'default' if @aws_profile == UNSET_VALUE @aws_dir = ENV['HOME'].to_s + '/.aws/' if @aws_dir == UNSET_VALUE - @region, @access_key_id, @secret_access_key, @session_token = Credentials.new.get_aws_info(@aws_profile, @aws_dir) - @region = UNSET_VALUE if @region.nil? + @aws_region, @access_key_id, @secret_access_key, @session_token = Credentials.new.get_aws_info(@aws_profile, @aws_dir) + @region = @aws_region if @region == UNSET_VALUE and !@aws_region.nil? else @aws_profile = nil @aws_dir = nil @@ -409,6 +432,18 @@ def finalize! # default to nil @kernel_id = nil if @kernel_id == UNSET_VALUE + # By default don't use spot requests + @spot_instance = false if @spot_instance == UNSET_VALUE + + # default to nil + @spot_max_price = nil if @spot_max_price == UNSET_VALUE + + # Default: Request is effective indefinitely. + @spot_valid_until = nil if @spot_valid_until == UNSET_VALUE + + # default to nil + @spot_price_product_description = nil if @spot_price_product_description == UNSET_VALUE + # Compile our region specific configurations only within # NON-REGION-SPECIFIC configurations. if !@__region_specific @@ -479,14 +514,14 @@ def get_region_config(name) class Credentials < Vagrant.plugin("2", :config) - # This module reads AWS config and credentials. + # This module reads AWS config and credentials. # Behaviour aims to mimic what is described in AWS documentation: # http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html # http://docs.aws.amazon.com/cli/latest/topic/config-vars.html # Which is the following (stopping at the first successful case): # 1) read config and credentials from environment variables # 2) read config and credentials from files at location defined by environment variables - # 3) read config and credentials from files at default location + # 3) read config and credentials from files at default location # # The mandatory fields for a successful "get credentials" are the id and the secret keys. # Region is not required since Config#finalize falls back to sensible defaults. @@ -525,15 +560,17 @@ def get_aws_info(profile, location) private def read_aws_files(profile, aws_config, aws_creds) + # get info from config ini file for selected profile + data = File.read(aws_config) + doc_cfg = IniParse.parse(data) + # determine section in config ini file - if profile == 'default' + if profile == 'default' || !doc_cfg[profile].nil? ini_profile = profile else ini_profile = 'profile ' + profile end - # get info from config ini file for selected profile - data = File.read(aws_config) - doc_cfg = IniParse.parse(data) + aws_region = doc_cfg[ini_profile]['region'] # determine section in credentials ini file diff --git a/locales/en.yml b/locales/en.yml index ddc3d4d6..2a11409b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -18,6 +18,8 @@ en: launching_instance: |- Launching an instance with the following settings... + launching_spot_instance: |- + Launching a spot request instance with the following settings... launch_no_keypair: |- Warning! You didn't specify a keypair to launch your instance with. This can sometimes result in not being able to access your instance. @@ -104,7 +106,7 @@ en: Error: %{err} instance_package_timeout: |- The AMI failed to become "ready" in AWS. The timeout currently - set waiting for the instance to become ready is %{timeout} seconds. For + set waiting for the instance to become ready is %{timeout} seconds. For larger instances AMI burning may take long periods of time. Please ensure the timeout is set high enough, it can be changed by adjusting the `instance_package_timeout` configuration on the AWS provider. diff --git a/spec/vagrant-aws/config_spec.rb b/spec/vagrant-aws/config_spec.rb index eb309743..3f93c928 100644 --- a/spec/vagrant-aws/config_spec.rb +++ b/spec/vagrant-aws/config_spec.rb @@ -58,6 +58,10 @@ its("associate_public_ip") { should == false } its("unregister_elb_from_az") { should == true } its("tenancy") { should == "default" } + its("spot_instance") { should == false } + its("spot_max_price") { should be_nil } + its("spot_price_product_description") { should be_nil } + its("spot_valid_until") { should be_nil } end describe "overriding defaults" do @@ -219,6 +223,23 @@ its("access_key_id") { should == "AKIdefault" } its("secret_access_key") { should == "PASSdefault" } its("session_token") { should be_nil } + its("region") { should == "eu-west-1" } + end + + context "with default profile and overriding region" do + subject do + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:read).with(filename_cfg).and_return(data_cfg) + allow(File).to receive(:read).with(filename_keys).and_return(data_keys) + instance.region = "eu-central-1" + instance.tap do |o| + o.finalize! + end + end + its("access_key_id") { should == "AKIdefault" } + its("secret_access_key") { should == "PASSdefault" } + its("session_token") { should be_nil } + its("region") { should == "eu-central-1" } end context "without any credential environment variables and chosing a profile" do diff --git a/vagrant-aws.gemspec b/vagrant-aws-iam-decoder.gemspec similarity index 90% rename from vagrant-aws.gemspec rename to vagrant-aws-iam-decoder.gemspec index be8aae7a..f731bff4 100644 --- a/vagrant-aws.gemspec +++ b/vagrant-aws-iam-decoder.gemspec @@ -2,13 +2,15 @@ $:.unshift File.expand_path("../lib", __FILE__) require "vagrant-aws/version" Gem::Specification.new do |s| - s.name = "vagrant-aws" + s.name = "vagrant-aws-iam-decoder" s.version = VagrantPlugins::AWS::VERSION + # http://guides.rubygems.org/patterns/#prerelease-gems + s.version = "#{s.version}.pre.#{ENV['TRAVIS_BUILD_NUMBER']}" if ENV['TRAVIS'] s.platform = Gem::Platform::RUBY s.license = "MIT" s.authors = "Mitchell Hashimoto" s.email = "mitchell@hashicorp.com" - s.homepage = "http://www.vagrantup.com" + s.homepage = "https://github.com/iam-decoder/vagrant-aws" s.summary = "Enables Vagrant to manage machines in EC2 and VPC." s.description = "Enables Vagrant to manage machines in EC2 and VPC."