modules/mu/providers/aws.rb
# Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved
#
# Licensed under the BSD-3 license (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License in the root of the project or at
#
# http://egt-labs.com/mu/LICENSE.html
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require "net/http"
require 'open-uri'
require 'timeout'
require 'inifile'
autoload :Aws, "aws-sdk-core"
module MU
class Cloud
# Support for Amazon Web Services as a provisioning layer.
class AWS
@@myRegion_var = nil
@@creds_loaded = {}
# Module used by {MU::Cloud} to insert additional instance methods into
# instantiated resources in this cloud layer.
module AdditionalResourceMethods
end
# Is this a "real" cloud provider, or a stub like CloudFormation?
def self.virtual?
false
end
# List all AWS projects available to our credentials
def self.listHabitats(credentials = nil, use_cache: true)
cfg = credConfig(credentials)
return [] if !cfg or !cfg['account_number']
[cfg['account_number']]
end
# A hook that is always called just before any of the instance method of
# our resource implementations gets invoked, so that we can ensure that
# repetitive setup tasks (like resolving +:resource_group+ for Azure
# resources) have always been done.
# @param cloudobj [MU::Cloud]
# @param _deploy [MU::MommaCat]
def self.resourceInitHook(cloudobj, _deploy)
class << self
attr_reader :cloudformation_data
attr_reader :region
end
return if !cloudobj
cloudobj.instance_variable_set(:@cloudformation_data, {})
cloudobj.instance_variable_set(:@region, cloudobj.config['region'])
end
# Load some credentials for using the AWS API
# @param name [String]: The name of the mu.yaml AWS credential set to use. If not specified, will use the default credentials, and set the global Aws.config credentials to those.
# @return [Aws::Credentials]
def self.loadCredentials(name = nil)
gem 'aws-sdk-core'
@@creds_loaded ||= {}
if name.nil?
return @@creds_loaded["#default"] if @@creds_loaded["#default"]
else
return @@creds_loaded[name] if @@creds_loaded[name]
end
cred_cfg = credConfig(name)
if cred_cfg.nil?
return nil
end
cred_obj = nil
if cred_cfg['access_key'] and cred_cfg['access_secret'] and
# access key and secret just sitting in mu.yaml
!cred_cfg['access_key'].empty? and
!cred_cfg['access_secret'].empty?
cred_obj = Aws::Credentials.new(
cred_cfg['access_key'], cred_cfg['access_secret']
)
if name.nil?
# Aws.config = {
# access_key_id: cred_cfg['access_key'],
# secret_access_key: cred_cfg['access_secret'],
# region: cred_cfg['region']
# }
end
elsif cred_cfg['credentials_file'] and
!cred_cfg['credentials_file'].empty?
# pull access key and secret from an awscli-style credentials file
begin
File.read(cred_cfg["credentials_file"]) # make sure it's there
credfile = IniFile.load(cred_cfg["credentials_file"])
if !credfile.sections or credfile.sections.size == 0
raise ::IniFile::Error, "No AWS profiles found in #{cred_cfg["credentials_file"]}"
end
data = credfile.has_section?("default") ? credfile["default"] : credfile[credfile.sections.first]
if data["aws_access_key_id"] and data["aws_secret_access_key"]
cred_obj = Aws::Credentials.new(
data['aws_access_key_id'], data['aws_secret_access_key']
)
if name.nil?
# Aws.config = {
# access_key_id: data['aws_access_key_id'],
# secret_access_key: data['aws_secret_access_key'],
# region: cred_cfg['region']
# }
end
else
MU.log "AWS credentials in #{cred_cfg["credentials_file"]} specified, but is missing aws_access_key_id or aws_secret_access_key elements", MU::WARN
end
rescue IniFile::Error, Errno::ENOENT, Errno::EACCES => e
MU.log "AWS credentials file #{cred_cfg["credentials_file"]} is missing or invalid", MU::WARN, details: e.message
end
elsif cred_cfg['credentials'] and
!cred_cfg['credentials'].empty?
# pull access key and secret from a vault
begin
vault, item = cred_cfg["credentials"].split(/:/)
data = if !vault or !item
raise MuError.new "AWS #{name} credentials field value '#{cred_cfg["credentials"]}' malformed, should be vaultname:itemname", details: cred_cfg
else
MU::Groomer::Chef.getSecret(vault: vault, item: item).to_h
end
if data and data["access_key"] and data["access_secret"]
cred_obj = Aws::Credentials.new(
data['access_key'], data['access_secret']
)
if name.nil?
# Aws.config = {
# access_key_id: data['access_key'],
# secret_access_key: data['access_secret'],
# region: cred_cfg['region']
# }
end
else
raise MuError.new "AWS #{name} credentials vault:item #{cred_cfg["credentials"]} specified, but is missing access_key or access_secret elements", details: cred_cfg
end
rescue MU::Groomer::MuNoSuchSecret
raise MuError.new "AWS #{name} credentials vault:item #{cred_cfg["credentials"]} specified, but does not exist", details: cred_cfg
end
end
if !cred_obj and hosted?
# assume we've got an IAM profile and hope for the best
ENV.delete('AWS_ACCESS_KEY_ID')
ENV.delete('AWS_SECRET_ACCESS_KEY')
retries = 0
begin
cred_obj = Aws::InstanceProfileCredentials.new
if cred_obj.nil?
retries += 1
MU.log "Failed to fetch AWS instance profile credentials, attempt #{retries.to_s}/10", MU::WARN
sleep 3
end
end while cred_obj.nil? and retries < 10
# if name.nil?
# Aws.config = {region: ENV['EC2_REGION']}
# end
end
if cred_obj.nil?
MU.log "cred_obj is nil and hosted? says #{hosted?.to_s}", MU::WARN, details: name
end
if name.nil?
@@creds_loaded["#default"] = cred_obj
else
@@creds_loaded[name] = cred_obj
end
cred_obj
end
# Any cloud-specific instance methods we require our resource
# implementations to have, above and beyond the ones specified by
# {MU::Cloud}
# @return [Array<Symbol>]
def self.required_instance_methods
[:arn]
end
# Given an AWS region, check the API to make sure it's a valid one
# @param r [String]
# @return [String]
def self.validate_region(r, credentials: nil)
require "aws-sdk-ec2"
begin
MU::Cloud::AWS.ec2(region: r, credentials: credentials).describe_availability_zones.availability_zones.first.region_name
rescue ::Aws::EC2::Errors::UnauthorizedOperation => e
MU.log "Got '#{e.message}' trying to validate region #{r} (hosted: #{hosted?.to_s})", MU::ERR, details: loadCredentials(credentials)
raise MuError, "Got '#{e.message}' trying to validate region #{r} with credentials #{credentials ? credentials : "<default>"} (hosted: #{hosted?.to_s})"
end
end
# Tag an EC2 resource
#
# @param resource [String]: The cloud provider identifier of the resource to tag
# @param region [String]: The cloud provider region
# @param credentials [String]: Credentials to authorize API requests
# @param optional [Boolean]: Whether to apply our optional generic tags
# @param nametag [String]: A +Name+ tag to apply
# @param othertags [Array<Hash>]: Miscellaneous custom tags, in Basket of Kittens style
# @return [void]
def self.createStandardTags(resource = nil, region: MU.curRegion, credentials: nil, optional: true, nametag: nil, othertags: nil)
require "aws-sdk-ec2"
tags = []
MU::MommaCat.listStandardTags.each_pair { |name, value|
tags << {key: name, value: value} if !value.nil?
}
if optional
MU::MommaCat.listOptionalTags.each { |key, value|
tags << {key: name, value: value} if !value.nil?
}
end
if nametag
tags << { key: "Name", value: nametag }
end
if othertags
othertags.each { |tag|
tags << { key: tag['key'], value: tag['value'] }
}
end
if MU::Cloud::CloudFormation.emitCloudFormation
return tags
end
attempts = 0
begin
MU::Cloud::AWS.ec2(region: region, credentials: credentials).create_tags(
resources: [resource],
tags: tags
)
rescue Aws::EC2::Errors::ServiceError => e
MU.log "Got #{e.inspect} tagging #{resource} in #{region}, will retry", MU::WARN, details: caller.concat(tags) if attempts > 1
if attempts < 5
attempts = attempts + 1
sleep 15
retry
else
raise e
end
end
MU.log "Created standard tags for resource #{resource}", MU::DEBUG, details: caller
end
@@myVPCObj = nil
# If we reside in this cloud, return the VPC in which we, the Mu Master, reside.
# @return [MU::Cloud::VPC]
def self.myVPCObj
return @@myVPCObj if @@myVPCObj
return nil if !hosted?
instance = MU.myCloudDescriptor
return nil if !instance or !instance.vpc_id
vpc = MU::MommaCat.findStray("AWS", "vpc", cloud_id: instance.vpc_id, dummy_ok: true, no_deploy_search: true)
return nil if vpc.nil? or vpc.size == 0
@@myVPCObj = vpc.first
@@myVPCObj
end
# If we've configured AWS as a provider, or are simply hosted in AWS,
# decide what our default region is.
def self.myRegion(credentials = nil, debug: false)
loglevel = debug ? MU::NOTICE : MU::DEBUG
if @@myRegion_var
MU.log "AWS.myRegion: returning #{@@myRegion_var} from cache", loglevel
return @@myRegion_var
end
MU.log "AWS.myRegion: credConfig", loglevel, details: credConfig
MU.log "AWS.myRegion: hosted?", loglevel, details: hosted?.to_s
MU.log "AWS.myRegion: ENV['EC2_REGION']", loglevel, details: ENV['EC2_REGION']
MU.log "AWS.myRegion: $MU_CFG['aws']", loglevel, details: $MU_CFG['aws']
if credConfig.nil? and !hosted? and !ENV['EC2_REGION']
MU.log "AWS.myRegion: nothing of use set, returning", loglevel
return nil
end
if $MU_CFG and $MU_CFG['aws']
$MU_CFG['aws'].each_pair { |credset, cfg|
MU.log "AWS.myRegion: #{credset} != #{credentials} ?", loglevel, details: cfg
next if credentials and credset != credentials
MU.log "AWS.myRegion: validating credset #{credset}", loglevel, details: cfg
next if !cfg['region']
MU.log "AWS.myRegion: validation response", loglevel, details: validate_region(cfg['region'], credentials: credset)
if (cfg['default'] or !@@myRegion_var or $MU_CFG['aws'].size == 1) and validate_region(cfg['region'], credentials: credset)
MU.log "AWS.myRegion: liking this set", loglevel, details: cfg
@@myRegion_var = cfg['region']
break if cfg['default'] or credentials
end
}
elsif ENV.has_key?("EC2_REGION") and !ENV['EC2_REGION'].empty? and
validate_region(ENV['EC2_REGION']) and
(
(ENV.has_key?("AWS_SECRET_ACCESS_KEY") and ENV.has_key?("AWS_SECRET_ACCESS_KEY") ) or
(Aws.config['access_key'] and Aws.config['access_secret'])
)
# Make sure this string is valid by way of the API
MU.log "AWS.myRegion: using ENV", loglevel, details: ENV
@@myRegion_var = ENV['EC2_REGION']
end
if hosted? and !@@myRegion_var
# hacky, but useful in a pinch (and if we're hosted in AWS)
az_str = MU::Cloud::AWS.getAWSMetaData("placement/availability-zone")
MU.log "AWS.myRegion: using hosted", loglevel, details: az_str
@@myRegion_var = az_str.sub(/[a-z]$/i, "") if az_str
end
if credConfig and credConfig["region"]
@@myRegion_var ||= credConfig["region"]
end
@@myRegion_var
end
# Is the region we're dealing with a GovCloud region?
# @param region [String]: The region in question, defaults to the Mu Master's local region
def self.isGovCloud?(region = myRegion)
return false if !region
region.match(/^us-gov-/)
end
# @param resources [Array<String>]: The cloud provider identifier of the resource to untag
# @param key [String]: The name of the tag to remove
# @param value [String]: The value of the tag to remove
# @param region [String]: The cloud provider region
def self.removeTag(key, value, resources = [], region: myRegion)
MU::Cloud::AWS.ec2(region: region).delete_tags(
resources: resources,
tags: [
{
key: key,
value: value
}
]
)
end
@@azs = {}
# List the Availability Zones associated with a given Amazon Web Services
# region. If no region is given, search the one in which this MU master
# server resides.
# @param region [String]: The region to search.
# @return [Array<String>]: The Availability Zones in this region.
def self.listAZs(region: MU.curRegion, credentials: nil)
cfg = credConfig(credentials)
return [] if !cfg
if !region.nil? and @@azs[region]
return @@azs[region]
end
if region
azs = MU::Cloud::AWS.ec2(region: region, credentials: credentials).describe_availability_zones(
filters: [name: "region-name", values: [region]]
)
end
@@azs[region] ||= []
azs.data.availability_zones.each { |az|
@@azs[region] << az.zone_name if az.state == "available"
}
return @@azs[region]
end
# Do cloud-specific deploy instantiation tasks, such as copying SSH keys
# around, sticking secrets in buckets, creating resource groups, etc
# @param deploy [MU::MommaCat]
def self.initDeploy(deploy)
end
# Purge cloud-specific deploy meta-artifacts (SSH keys, resource groups,
# etc)
# @param deploy_id [MU::MommaCat]
def self.cleanDeploy(deploy_id, credentials: nil, noop: false)
if !noop
MU.log "Deleting s3://#{adminBucketName(credentials)}/#{deploy_id}-secret"
MU::Cloud::AWS.s3(credentials: credentials).delete_object(
bucket: adminBucketName(credentials),
key: "#{deploy_id}-secret"
)
listRegions(credentials: credentials).each { |r|
resp = MU::Cloud::AWS.ec2(region: r, credentials: credentials).describe_key_pairs(
filters: [{name: "key-name", values: ["deploy-#{MU.deploy_id}"]}]
)
resp.data.key_pairs.each { |keypair|
MU.log "Deleting key pair #{keypair.key_name} from #{r}"
MU::Cloud::AWS.ec2(region: r, credentials: credentials).delete_key_pair(key_name: keypair.key_name) if !noop
}
}
end
if hosted?
MU::Cloud::AWS.openFirewallForClients
end
end
# Plant a Mu deploy secret into a storage bucket somewhere for so our kittens can consume it
# @param deploy_id [String]: The deploy for which we're writing the secret
# @param value [String]: The contents of the secret
def self.writeDeploySecret(deploy, value, name = nil, credentials: nil)
require "aws-sdk-s3"
name ||= deploy.deploy_id+"-secret"
begin
MU.log "Writing #{name} to S3 bucket #{adminBucketName(credentials)}"
MU::Cloud::AWS.s3(region: myRegion, credentials: credentials).put_object(
acl: "private",
bucket: adminBucketName(credentials),
key: name,
body: value
)
rescue Aws::S3::Errors => e
raise MU::MommaCat::DeployInitializeError, "Got #{e.inspect} trying to write #{name} to #{adminBucketName(credentials)}"
end
end
# Log bucket policy for enabling CloudTrail logging to our log bucket in S3.
def self.cloudtrailBucketPolicy(credentials = nil)
cfg = credConfig(credentials)
policy_json = '{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AWSCloudTrailAclCheck20131101",
"Effect": "Allow",
"Principal": {
"AWS": "arn:'+(MU::Cloud::AWS.isGovCloud?(cfg['region']) ? "aws-us-gov" : "aws")+':iam::<%= MU.account_number %>:root",
"Service": "cloudtrail.amazonaws.com"
},
"Action": "s3:GetBucketAcl",
"Resource": "arn:'+(MU::Cloud::AWS.isGovCloud?(cfg['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU::Cloud::AWS.adminBucketName(credentials)+'"
},
{
"Sid": "AWSCloudTrailWrite20131101",
"Effect": "Allow",
"Principal": {
"AWS": "arn:'+(MU::Cloud::AWS.isGovCloud?(cfg['region']) ? "aws-us-gov" : "aws")+':iam::'+credToAcct(credentials)+':root",
"Service": "cloudtrail.amazonaws.com"
},
"Action": "s3:PutObject",
"Resource": "arn:'+(MU::Cloud::AWS.isGovCloud?(cfg['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU::Cloud::AWS.adminBucketName(credentials)+'/AWSLogs/'+credToAcct(credentials)+'/*",
"Condition": {
"StringEquals": {
"s3:x-amz-acl": "bucket-owner-full-control"
}
}
}
]
}'
ERB.new(policy_json).result
end
@@is_in_aws = nil
# Alias for #{MU::Cloud::AWS.hosted?}
def self.hosted
MU::Cloud::AWS.hosted?
end
# If we're in AWS and NVME-aware, return a mapping of AWS-side device
# names to actual NVME devices.
# @return [Hash]
def self.attachedNVMeDisks
if !hosted? or !File.executable?("/bin/lsblk") or !File.executable?("/sbin/nvme")
return {}
end
map = {}
devices = MU::Master.listBlockDevices
return {} if !devices
devices.each { |d|
if d =~ /^\/dev\/nvme/
%x{/sbin/nvme id-ctrl -v #{d}}.each_line { |desc|
if desc.match(/^0000: (?:[0-9a-f]{2} ){16}"(.+?)\./)
virt_dev = Regexp.last_match[1]
map[virt_dev] = d
break
end
}
end
}
map
end
# Map our own idea of what a block device is called back to whatever AWS
# and the operating system decided on amongst themselves. This currently
# exists to map generic "xvd[a-z]" style names back to real NVMe devices.
# @param dev [String]
def self.realDevicePath(dev)
return dev if !hosted?
value = nil
should_retry = Proc.new {
!value and MU::Master.nvme?
}
MU.retrier(loop_if: should_retry, wait: 5, max: 6) {
map = attachedNVMeDisks
value = if map[dev]
map[dev]
elsif map[dev.gsub(/.*?\//, '')]
map[dev.gsub(/.*?\//, '')]
else
dev # be nice to actually handle this too
end
}
value
end
# Determine whether we (the Mu master, presumably) are hosted in this
# cloud.
# @return [Boolean]
def self.hosted?
if $MU_CFG.has_key?("aws_is_hosted")
@@is_in_aws = $MU_CFG["aws_is_hosted"]
return $MU_CFG["aws_is_hosted"]
end
require 'open-uri'
if !@@is_in_aws.nil?
return @@is_in_aws
end
begin
Timeout.timeout(4) do
instance_id = URI.open("http://169.254.169.254/latest/meta-data/instance-id").read
if !instance_id.nil? and instance_id.size > 0
@@is_in_aws = true
region = getAWSMetaData("placement/availability-zone").sub(/[a-z]$/i, "")
begin
validate_region(region)
rescue MuError
@@creds_loaded.delete("#default")
@@is_in_aws = false
false
end
return true
end
end
rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::EHOSTUNREACH
end
@@is_in_aws = false
false
end
# If we're running this cloud, return the $MU_CFG blob we'd use to
# describe this environment as our target one.
def self.hosted_config
return nil if !hosted?
region = getAWSMetaData("placement/availability-zone").sub(/[a-z]$/i, "")
mac = getAWSMetaData("network/interfaces/macs/").split(/\n/)[0]
acct_num = getAWSMetaData("network/interfaces/macs/#{mac}owner-id")
acct_num.chomp!
{
"region" => region,
"account_number" => acct_num
}
end
# A non-working example configuration
def self.config_example
sample = hosted_config
sample ||= {
"region" => "us-east-1",
"account_number" => "123456789012",
}
# sample["access_key"] = "AKIAIXKNI3JY6JVVJIHA"
# sample["access_secret"] = "oWjHT+2N3veyswy7+UA5i+H14KpvrOIZlnRlxpkw"
sample["credentials_file"] = "#{Etc.getpwuid(Process.uid).dir}/.aws/credentials"
sample["log_bucket_name"] = "my-mu-s3-bucket"
sample
end
# Return what we think of as a cloud object's habitat. In AWS, this means
# the +account_number+ in which it's resident. If this is not applicable,
# such as for a {Habitat} or {Folder}, returns nil.
# @param cloudobj [MU::Cloud::AWS]: The resource from which to extract the habitat id
# @return [String,nil]
def self.habitat(cloudobj, nolookup: false, deploy: nil)
@@habmap ||= {}
# XXX whaddabout config['habitat'] HNNNGH
if cloudobj.respond_to?(:account_number) and cloudobj.account_number and
!cloudobj.account_number.empty?
return cloudobj.account_number
elsif cloudobj.config and cloudobj.config['account']
if nolookup
return cloudobj.config['account']
end
if @@habmap[cloudobj.config['account']]
return @@habmap[cloudobj.config['account']]
end
deploy ||= cloudobj.deploy if cloudobj.respond_to?(:deploy)
MU.log "Incomplete implementation: MU::Cloud::AWS.habitat", MU::DEBUG, details: deploy
# accountobj = accountLookup(cloudobj.config['account'], deploy, raise_on_fail: false)
# if accountobj
# @@habmap[cloudobj.config['account']] = accountobj.cloud_id
# return accountobj.cloud_id
# end
end
nil
end
@@my_acct_num = nil
@@my_hosted_cfg = nil
@@acct_to_profile_map = {}
# Map the name of a credential set back to an AWS account number
# @param name [String]
def self.credToAcct(name = nil)
creds = credConfig(name)
if creds['account_number'] and !creds['account_number'].empty?
return creds['account_number']
end
acct_num = MU::Cloud::AWS.iam(credentials: name).list_users.users.first.arn.split(/:/)[4]
acct_num.to_s
end
# Return the name strings of all known sets of credentials for this cloud
# @return [Array<String>]
def self.listCredentials
if !$MU_CFG['aws']
return hosted? ? ["#default"] : nil
end
$MU_CFG['aws'].keys
end
# Resolve the administrative S3 bucket for a given credential set, or
# return a default.
# @param credentials [String]
# @return [String]
def self.adminBucketName(credentials = nil)
require "aws-sdk-s3"
cfg = credConfig(credentials)
return nil if !cfg
if !cfg['log_bucket_name']
cfg['log_bucket_name'] = $MU_CFG['hostname']
MU.log "No AWS log bucket defined for credentials #{credentials}, attempting to use default of #{cfg['log_bucket_name']}", MU::WARN
end
resp = MU::Cloud::AWS.s3(credentials: credentials).list_buckets
found = false
resp.buckets.each { |b|
if b.name == cfg['log_bucket_name']
found = true
break
end
}
if !found
MU.log "Attempting to create log bucket #{cfg['log_bucket_name']} for credentials #{credentials}", MU::WARN
begin
MU::Cloud::AWS.s3(credentials: credentials).create_bucket(bucket: cfg['log_bucket_name'], acl: "private")
rescue Aws::S3::Errors::BucketAlreadyExists
raise MuError, "AWS credentials #{credentials} need a log bucket, and the name #{cfg['log_bucket_name']} is unavailable. Use mu-configure to edit credentials '#{credentials}' or 'hostname'"
end
end
cfg['log_bucket_name']
end
# Resolve the administrative S3 bucket for a given credential set, or
# return a default.
# @param credentials [String]
# @return [String]
def self.adminBucketUrl(credentials = nil)
return nil if !credConfig(credentials)
"s3://"+adminBucketName(credentials)+"/"
end
# Return the $MU_CFG data associated with a particular profile/name/set of
# credentials. If no account name is specified, will return one flagged as
# default. Returns nil if AWS is not configured. Throws an exception if
# an account name is specified which does not exist.
# @param name [String]: The name of the key under 'aws' in mu.yaml to return
# @return [Hash,nil]
def self.credConfig(name = nil, name_only: false)
# If there's nothing in mu.yaml (which is wrong), but we're running
# on a machine hosted in AWS, *and* that machine has an IAM profile,
# fake it with those credentials and hope for the best.
if !$MU_CFG['aws'] or !$MU_CFG['aws'].is_a?(Hash) or $MU_CFG['aws'].size == 0
if @@my_hosted_cfg
return name_only ? "#default" : @@my_hosted_cfg
end
if hosted?
begin
iam_blob = getAWSMetaData("iam/info")
if iam_blob
iam_data = JSON.parse(iam_blob)
if iam_data["InstanceProfileArn"] and !iam_data["InstanceProfileArn"].empty?
@@my_hosted_cfg = hosted_config
return name_only ? "#default" : @@my_hosted_cfg
end
end
rescue JSON::ParserError => e
end
elsif ENV['AWS_ACCESS_KEY_ID'] and ENV['AWS_SECRET_ACCESS_KEY']
env_config = {
"region" => ENV['EC2_REGION'] || "us-east-1",
"access_key" => ENV['AWS_ACCESS_KEY_ID'],
"access_secret" => ENV['AWS_SECRET_ACCESS_KEY'],
"log_bucket_name" => "mu-placeholder-bucket-name"
}
return name_only ? "#default" : env_config
end
return nil
end
if name.nil?
$MU_CFG['aws'].each_pair { |set, cfg|
if cfg['default']
return name_only ? set : cfg
end
}
else
if $MU_CFG['aws'][name]
return name_only ? name : $MU_CFG['aws'][name]
elsif @@acct_to_profile_map[name.to_s]
return name_only ? name : @@acct_to_profile_map[name.to_s]
elsif name.is_a?(Integer) or name.match(/^\d+$/)
# Try to map backwards from an account id, if that's what we go
$MU_CFG['aws'].each_pair { |acctname, cfg|
if cfg['account_number'] and name.to_s == cfg['account_number'].to_s
return name_only ? acctname : $MU_CFG['aws'][acctname]
end
}
# Check each credential sets' resident account, then
$MU_CFG['aws'].each_pair { |acctname, cfg|
begin
MU::Cloud::AWS.iam(credentials: acctname).list_users.users
# rescue ::Aws::IAM::Errors => e # XXX why does this NameError here?
rescue StandardError => e
MU.log e.inspect, MU::WARN, details: cfg
next
end
acct_num = MU::Cloud::AWS.iam(credentials: acctname).list_users.users.first.arn.split(/:/)[4]
cfg['account_number'] ||= acct_num.to_s
if acct_num.to_s == name.to_s
@@acct_to_profile_map[name.to_s] = cfg
return name_only ? name.to_s : cfg
end
}
end
raise MuError, "AWS credential set #{name} was requested, but I see no such working credentials in mu.yaml"
end
end
# Fetch the AWS account number where this Mu master resides. If it's not
# in AWS at all, or otherwise cannot be determined, return nil. here.
# XXX account for Google and non-cloud situations
# XXX this needs to be "myAccountNumber" or somesuch
# XXX and maybe do the IAM thing for arbitrary, non-resident accounts
def self.account_number
require "aws-sdk-ec2"
return nil if credConfig.nil?
return @@my_acct_num if @@my_acct_num
loadCredentials
# XXX take optional credential set argument
# begin
# user_list = MU::Cloud::AWS.iam(region: credConfig['region']).list_users.users
## rescue ::Aws::IAM::Errors => e # XXX why does this NameError here?
# rescue StandardError => e
# MU.log "Got #{e.inspect} while trying to figure out our account number", MU::WARN, details: caller
# end
# if user_list.nil? or user_list.size == 0
resp = MU::Cloud::AWS.getAWSMetaData("network/interfaces/macs/")
return nil if !resp
mac = resp.split(/\n/)[0]
acct_num = MU::Cloud::AWS.getAWSMetaData("network/interfaces/macs/#{mac}owner-id")
acct_num.chomp!
# else
# acct_num = MU::Cloud::AWS.iam(region: credConfig['region']).list_users.users.first.arn.split(/:/)[4]
# end
MU.setVar("acct_num", acct_num)
@@my_acct_num ||= acct_num
acct_num
end
@@regions = {}
@@regions_semaphore = Mutex.new
# List the Amazon Web Services region names available to this account. The
# region that is local to this Mu server will be listed first.
# @param us_only [Boolean]: Restrict results to United States only
# @return [Array<String>]
def self.listRegions(us_only = false, credentials: nil)
if @@regions.size == 0
return [] if credConfig.nil?
result = MU::Cloud::AWS.ec2(region: myRegion, credentials: credentials).describe_regions.regions
@@regions_semaphore.synchronize {
begin
result.each { |r|
@@regions[r.region_name] = Proc.new {
listAZs(region: r.region_name, credentials: credentials)
}
}
rescue ::Aws::EC2::Errors::AuthFailure => e
MU.log "Region #{r.region_name} throws #{e.message}, ignoring it", MU::ERR
end
}
end
regions = if us_only
@@regions.keys.delete_if { |r| !r.match(/^us\-/) }.uniq
else
@@regions.keys.uniq
end
# XXX GovCloud doesn't show up if you query a commercial endpoint... that's
# *probably* ok for most purposes? We can't call listAZs on it from out here
# apparently, so getting around it is nontrivial
# if !@@regions.has_key?("us-gov-west-1")
# @@regions["us-gov-west-1"] = Proc.new { listAZs("us-gov-west-1") }
# end
regions.sort! { |a, b|
val = a <=> b
if a == myRegion
val = -1
elsif b == myRegion
val = 1
end
val
}
regions
end
# Generate an EC2 keypair unique to this deployment, given a regular
# OpenSSH-style public key and a name.
# @param keyname [String]: The name of the key to create.
# @param public_key [String]: The public key
# @return [Array<String>]: keypairname, ssh_private_key, ssh_public_key
def self.createEc2SSHKey(keyname, public_key, credentials: nil)
require "aws-sdk-ec2"
# We replicate this key in all regions
if !MU::Cloud::CloudFormation.emitCloudFormation
MU::Cloud::AWS.listRegions.each { |region|
MU.log "Replicating #{keyname} to EC2 in #{region}", MU::DEBUG, details: @ssh_public_key
begin
MU::Cloud::AWS.ec2(region: region, credentials: credentials).import_key_pair(
key_name: keyname,
public_key_material: public_key
)
rescue ::Aws::EC2::Errors::AuthFailure => e
@@regions_semaphore.synchronize {
@@regions.delete(region)
}
MU.log "#{region} threw #{e.message}, skipping", MU::ERR
end
}
end
end
@@instance_types = nil
# Query the AWS API for the list of valid EC2 instance types and some of
# their attributes. We can use this in config validation and to help
# "translate" machine types across cloud providers.
# @param region [String]: Supported machine types can vary from region to region, so we look for the set we're interested in specifically
# @return [Hash]
def self.listInstanceTypes(region = myRegion)
return @@instance_types if @@instance_types and @@instance_types[region]
return {} if credConfig.nil?
if region.nil?
region = myRegion(debug: true)
end
return {} if region.nil?
human_region = @@regionLookup[region]
if human_region.nil?
MU.log "Failed to map a Pricing API region name from #{region}", MU::ERR
return {}
end
@@instance_types ||= {}
@@instance_types[region] ||= {}
# Pricing API isn't widely available, so ask a region we know supports
# it
resp = MU::Cloud::AWS.pricing(region: "us-east-1").get_products(
service_code: "AmazonEC2",
filters: [
{
field: "productFamily",
value: "Compute Instance",
type: "TERM_MATCH"
},
{
field: "tenancy",
value: "Shared",
type: "TERM_MATCH"
},
{
field: "location",
value: human_region,
type: "TERM_MATCH"
}
]
)
resp.price_list.each { |pricing|
data = JSON.parse(pricing)
type = data["product"]["attributes"]["instanceType"]
next if @@instance_types[region].has_key?(type)
@@instance_types[region][type] = {}
["ecu", "vcpu", "memory", "storage"].each { |a|
@@instance_types[region][type][a] = data["product"]["attributes"][a]
}
@@instance_types[region][type]["memory"].sub!(/ GiB/, "")
@@instance_types[region][type]["memory"] = @@instance_types[region][type]["memory"].to_f
@@instance_types[region][type]["vcpu"] = @@instance_types[region][type]["vcpu"].to_f
}
@@instance_types
end
@@certificates = {}
# AWS can stash API-available certificates in Amazon Certificate Manager
# or in IAM. Rather than make people crazy trying to get the syntax
# correct in our Baskets of Kittens, let's have a helper that tries to do
# the right thing, and only raise an exception if we need help to
# disambiguate.
# @param name [String]: The name of the cert. For IAM certs this can be any IAM name; for ACM, it's usually the domain name. If multiple matches are found, or no matches, an exception is raised.
# @param id [String]: The ARN of a known certificate. We just validate that it exists. This is ignored if a name parameter is supplied.
# @return [String]: The ARN of a matching certificate that is known to exist. If it is an ACM certificate, we also know that it is not expired.
def self.findSSLCertificate(name: nil, id: nil, region: myRegion, credentials: nil, raise_on_missing: true)
require "aws-sdk-iam"
if (name.nil? or name.empty?) and (id.nil? or id.empty?)
raise MuError, "Can't call findSSLCertificate without specifying either a name or an id"
end
if id and @@certificates[id]
return [id, @@certificates[id]]
end
if !name.nil? and !name.empty?
matches = []
acmcerts = MU::Cloud::AWS.acm(region: region, credentials: credentials).list_certificates(
certificate_statuses: ["ISSUED"]
)
acmcerts.certificate_summary_list.each { |cert|
matches << cert.certificate_arn if cert.domain_name == name
}
begin
iamcert = MU::Cloud::AWS.iam(credentials: credentials).get_server_certificate(
server_certificate_name: name
)
rescue Aws::IAM::Errors::ValidationError, Aws::IAM::Errors::NoSuchEntity
# valid names for ACM certs can break here, and that's ok to ignore
end
if !iamcert.nil?
matches << iamcert.server_certificate.server_certificate_metadata.arn
end
if matches.size == 1
id = matches.first
elsif matches.size == 0
if raise_on_missing
raise MuError, "No IAM or ACM certificate named #{name} was found in #{region}"
else
return nil
end
elsif matches.size > 1
raise MuError, "Multiple certificates named #{name} were found in #{region}. Remove extras or use ssl_certificate_id to supply the exact ARN of the one you want to use."
end
end
domains = []
if id.match(/^arn:aws(?:-us-gov)?:acm/)
resp = MU::Cloud::AWS.acm(region: region).describe_certificate(
certificate_arn: id
)
if resp.nil? or resp.certificate.nil?
raise MuError, "No such ACM certificate '#{id}'"
end
domains << resp.certificate.domain_name
if resp.certificate.subject_alternative_names
domains.concat(resp.certificate.subject_alternative_names)
end
elsif id.match(/^arn:aws(?:-us-gov)?:iam/)
resp = MU::Cloud::AWS.iam.list_server_certificates
if resp.nil?
raise MuError, "No such IAM certificate '#{id}'"
end
resp.server_certificate_metadata_list.each { |cert|
if cert.arn == id
if cert.expiration < Time.now
MU.log "IAM SSL certificate #{cert.server_certificate_name} (#{id}) is EXPIRED", MU::WARN
end
@@certificates[id] = [cert.server_certificate_name]
return [id, [cert.server_certificate_name]]
end
}
raise MuError, "No such IAM certificate '#{id}'"
else
raise MuError, "The format of '#{id}' doesn't look like an ARN for either Amazon Certificate Manager or IAM"
end
@@certificates[id] = domains.uniq
[id, domains.uniq]
end
# Given a domain name and an ACM or IAM certificate identifier, sort out
# whether the domain name is "covered" by the certificate
# @param name [String]
# @param cert_id [String]
# @return [Boolean]
def self.nameMatchesCertificate(name, cert_id)
_id, domains = findSSLCertificate(id: cert_id)
return false if !domains
domains.each { |dom|
if dom == name or
(dom =~ /^\*/ and name =~ /.*#{Regexp.quote(dom[1..-1])}/)
return true
end
}
false
end
# Given a {MU::Config::Ref} block for an IAM or ACM SSL certificate,
# look up and validate the specified certificate. This is intended to be
# invoked from resource implementations' +validateConfig+ methods.
# @param certblock [Hash,MU::Config::Ref]:
# @param region [String]: Default region to use when looking up the certificate, if its configuration block does not specify any
# @param credentials [String]: Default credentials to use when looking up the certificate, if its configuration block does not specify any
# @return [Boolean]
def self.resolveSSLCertificate(certblock, region: nil, credentials: nil)
return false if !certblock
ok = true
certblock['region'] ||= region if !certblock['id']
certblock['credentials'] ||= credentials
cert_arn, cert_domains = MU::Cloud::AWS.findSSLCertificate(
name: certblock["name"],
id: certblock["id"],
region: certblock['region'],
credentials: certblock['credentials']
)
if cert_arn
certblock['id'] ||= cert_arn
end
['region', 'credentials'].each { |field|
certblock.delete(field) if certblock[field].nil?
}
[cert_arn, cert_domains]
end
# Amazon Certificate Manager API
def self.acm(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@acm_api[credentials] ||= {}
@@acm_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "ACM", region: region, credentials: credentials)
@@acm_api[credentials][region]
end
# Amazon's IAM API
def self.iam(credentials: nil)
@@iam_api[credentials] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "IAM", credentials: credentials)
@@iam_api[credentials]
end
# Amazon's EC2 API
def self.ec2(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@ec2_api[credentials] ||= {}
@@ec2_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "EC2", region: region, credentials: credentials)
@@ec2_api[credentials][region]
end
# Amazon's Autoscaling API
def self.autoscale(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@autoscale_api[credentials] ||= {}
@@autoscale_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "AutoScaling", region: region, credentials: credentials)
@@autoscale_api[credentials][region]
end
# Amazon's ElasticLoadBalancing API
def self.elb(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@elb_api[credentials] ||= {}
@@elb_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "ElasticLoadBalancing", region: region, credentials: credentials)
@@elb_api[credentials][region]
end
# Amazon's ElasticLoadBalancingV2 (ALB) API
def self.elb2(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@elb2_api[credentials] ||= {}
@@elb2_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "ElasticLoadBalancingV2", region: region, credentials: credentials)
@@elb2_api[credentials][region]
end
# Amazon's Route53 API
def self.route53(credentials: nil)
@@route53_api[credentials] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "Route53", credentials: credentials)
@@route53_api[credentials]
end
# Amazon's RDS API
def self.rds(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@rds_api[credentials] ||= {}
@@rds_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "RDS", region: region, credentials: credentials)
@@rds_api[credentials][region]
end
# Amazon's CloudFormation API
def self.cloudformation(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cloudformation_api[credentials] ||= {}
@@cloudformation_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudFormation", region: region, credentials: credentials)
@@cloudformation_api[credentials][region]
end
# Amazon's S3 API
def self.s3(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@s3_api[credentials] ||= {}
@@s3_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "S3", region: region, credentials: credentials)
@@s3_api[credentials][region]
end
# Amazon's CloudTrail API
def self.cloudtrail(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cloudtrail_api[credentials] ||= {}
@@cloudtrail_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudTrail", region: region, credentials: credentials)
@@cloudtrail_api[credentials][region]
end
# Amazon's CloudWatch API
def self.cloudwatch(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cloudwatch_api[credentials] ||= {}
@@cloudwatch_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudWatch", region: region, credentials: credentials)
@@cloudwatch_api[credentials][region]
end
# Amazon's Web Application Firewall API (Global, for CloudFront et al)
def self.wafglobal(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@wafglobal_api[credentials] ||= {}
@@wafglobal[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "WAF", region: region, credentials: credentials)
@@wafglobal[credentials][region]
end
# Amazon's Web Application Firewall API (Regional, for ALBs et al)
def self.waf(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@waf[credentials] ||= {}
@@waf[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "WAFRegional", region: region, credentials: credentials)
@@waf[credentials][region]
end
# Amazon's CloudWatchLogs API
def self.cloudwatchlogs(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cloudwatchlogs_api[credentials] ||= {}
@@cloudwatchlogs_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudWatchLogs", region: region, credentials: credentials)
@@cloudwatchlogs_api[credentials][region]
end
# Amazon's CloudWatchEvents API
def self.cloudwatchevents(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cloudwatchevents_api[credentials] ||= {}
@@cloudwatchevents_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudWatchEvents", region: region, credentials: credentials)
@@cloudwatchevents_api[credentials][region]
end
# Amazon's CloudFront API
def self.cloudfront(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cloudfront_api[credentials] ||= {}
@@cloudfront_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudFront", region: region, credentials: credentials)
@@cloudfront_api[credentials][region]
end
# Amazon's ElastiCache API
def self.elasticache(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@elasticache_api[credentials] ||= {}
@@elasticache_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "ElastiCache", region: region, credentials: credentials)
@@elasticache_api[credentials][region]
end
# Amazon's SNS API
def self.sns(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@sns_api[credentials] ||= {}
@@sns_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "SNS", region: region, credentials: credentials)
@@sns_api[credentials][region]
end
# Amazon's SQS API
def self.sqs(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@sqs_api[credentials] ||= {}
@@sqs_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "SQS", region: region, credentials: credentials)
@@sqs_api[credentials][region]
end
# Amazon's EFS API
def self.efs(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@efs_api[credentials] ||= {}
@@efs_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "EFS", region: region, credentials: credentials)
@@efs_api[credentials][region]
end
# Amazon's Lambda API
def self.lambda(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@lambda_api[credentials] ||= {}
@@lambda_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "Lambda", region: region, credentials: credentials)
@@lambda_api[credentials][region]
end
# Amazon's API Gateway API
def self.apig(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@apig_api[credentials] ||= {}
@@apig_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "APIGateway", region: region, credentials: credentials)
@@apig_api[credentials][region]
end
# Amazon's Cloudwatch Events API
def self.cloudwatch_events(region = MU.cureRegion)
region ||= myRegion
@@cloudwatch_events_api[credentials] ||= {}
@@cloudwatch_events_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudWatchEvents", region: region, credentials: credentials)
@@cloudwatch_events_api
end
# Amazon's ECS API
def self.ecs(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@ecs_api[credentials] ||= {}
@@ecs_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "ECS", region: region, credentials: credentials)
@@ecs_api[credentials][region]
end
# Amazon's EKS API
def self.eks(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@eks_api[credentials] ||= {}
@@eks_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "EKS", region: region, credentials: credentials)
@@eks_api[credentials][region]
end
# Amazon's DynamoDB API
def self.dynamo(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@dynamo_api[credentials] ||= {}
@@dynamo_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "DynamoDB", region: region, credentials: credentials)
@@dynamo_api[credentials][region]
end
# Amazon's DynamoStream API
def self.dynamostream(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@dynamostream_api[credentials] ||= {}
@@dynamostream_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "DynamoDBStreams", region: region, credentials: credentials)
@@dynamostream_api[credentials][region]
end
# Amazon's Pricing API
def self.pricing(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@pricing_api[credentials] ||= {}
@@pricing_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "Pricing", region: region, credentials: credentials)
@@pricing_api[credentials][region]
end
# Amazon's Simple Systems Manager API
def self.ssm(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@ssm_api[credentials] ||= {}
@@ssm_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "SSM", region: region, credentials: credentials)
@@ssm_api[credentials][region]
end
# Amazon's Elasticsearch API
def self.elasticsearch(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@elasticsearch_api[credentials] ||= {}
@@elasticsearch_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "ElasticsearchService", region: region, credentials: credentials)
@@elasticsearch_api[credentials][region]
end
# Amazon's Cognito Identity API
def self.cognito_ident(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cognito_ident_api[credentials] ||= {}
@@cognito_ident_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CognitoIdentity", region: region, credentials: credentials)
@@cognito_ident_api[credentials][region]
end
# Amazon's Cognito Identity Provider API
def self.cognito_user(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cognito_user_api[credentials] ||= {}
@@cognito_user_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CognitoIdentityProvider", region: region, credentials: credentials)
@@cognito_user_api[credentials][region]
end
# Amazon's KMS API
def self.kms(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@kms_api[credentials] ||= {}
@@kms_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "KMS", region: region, credentials: credentials)
@@kms_api[credentials][region]
end
# Amazon's CloudFront API
def self.cloudfront(region: MU.curRegion, credentials: nil)
region ||= myRegion
@@cloudfront_api[credentials] ||= {}
@@cloudfront_api[credentials][region] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "CloudFront", region: region, credentials: credentials)
@@cloudfront_api[credentials][region]
end
# Amazon's Organizations API
def self.orgs(credentials: nil)
@@organizations_api ||= {}
# XXX org api doesn't seem to work in many regions
@@organizations_api[credentials] ||= MU::Cloud::AWS::AmazonEndpoint.new(api: "Organizations", credentials: credentials, region: "us-east-1")
@@organizations_api[credentials]
end
# Fetch an Amazon instance metadata parameter (example: public-ipv4).
# @param param [String]: The parameter name to fetch
# @return [String, nil]
def self.getAWSMetaData(param)
base_url = "http://169.254.169.254/latest/meta-data/"
begin
response = nil
Timeout.timeout(1) do
response = URI.open("#{base_url}/#{param}").read
end
response
rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH, Net::HTTPServerException, Errno::EHOSTUNREACH => e
# This is normal on machines checking to see if they're AWS-hosted
logger = MU::Logger.new
logger.log "Failed metadata request #{base_url}/#{param}: #{e.inspect}", MU::DEBUG
return nil
end
end
# Tag a resource. Defaults to applying our MU deployment identifier, if no
# arguments other than the resource identifier are given.
#
# @param resource [String]: The cloud provider identifier of the resource to tag
# @param tag_name [String]: The name of the tag to create
# @param tag_value [String]: The value of the tag
# @param region [String]: The cloud provider region
# @return [void]
def self.createTag(resource = nil,
tag_name="MU-ID",
tag_value=MU.deploy_id,
region: MU.curRegion,
credentials: nil)
require "aws-sdk-ec2"
attempts = 0
return nil if resource.nil?
resource = [resource] if resource.is_a?(String)
if !MU::Cloud::CloudFormation.emitCloudFormation
begin
MU::Cloud::AWS.ec2(credentials: credentials, region: region).create_tags(
resources: resource,
tags: [
{
key: tag_name,
value: tag_value
}
]
)
rescue Aws::EC2::Errors::ServiceError => e
MU.log "Got #{e.inspect} tagging #{resource} with #{tag_name}=#{tag_value}", MU::WARN if attempts > 1
if attempts < 5
attempts = attempts + 1
sleep 15
retry
else
raise e
end
end
MU.log "Created tag #{tag_name} with value #{tag_value} for resource #{resource}", MU::DEBUG
else
return {
"Key" => tag_name,
"Value" => tag_value
}
end
end
@syslog_port_semaphore = Mutex.new
# Punch AWS security group holes for client nodes to talk back to us, the
# Mu Master, if we're in AWS.
# @return [void]
def self.openFirewallForClients
require "aws-sdk-ec2"
MU::Cloud.resourceClass("AWS", :FirewallRule)
begin
if File.exist?(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb")
::Chef::Config.from_file(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb")
end
::Chef::Config[:environment] = MU.environment
rescue LoadError
# XXX why is Chef here
end
# This is the set of (TCP) ports we're opening to clients. We assume that
# we can and and remove these without impacting anything a human has
# created.
my_ports = [10514]
my_instance_id = MU::Cloud::AWS.getAWSMetaData("instance-id")
my_client_sg_name = "Mu Client Rules for #{MU.mu_public_ip}"
my_sgs = Array.new
MU.setVar("curRegion", myRegion) if !myRegion.nil?
MU.myCloudDescriptor.security_groups.each { |sg|
my_sgs << sg.group_id
}
resp = MU::Cloud::AWS.ec2.describe_security_groups(
filters: [
{name: "tag:MU-MASTER-IP", values: [MU.mu_public_ip]},
{name: "tag:Name", values: [my_client_sg_name]}
]
)
if resp.nil? or resp.security_groups.nil? or resp.security_groups.size == 0
if MU.myCloudDescriptor.vpc_id.nil?
sg_id = my_sgs.first
resp = MU::Cloud::AWS.ec2.describe_security_groups(group_ids: [sg_id])
group = resp.security_groups.first
MU.log "We don't have a security group named '#{my_client_sg_name}' available, and we are in EC2 Classic and so cannot create a new group. Defaulting to #{group.group_name}.", MU::NOTICE
else
group = MU::Cloud::AWS.ec2.create_security_group(
group_name: my_client_sg_name,
description: my_client_sg_name,
vpc_id: MU.myCloudDescriptor.vpc_id
)
sg_id = group.group_id
my_sgs << sg_id
MU::Cloud::AWS.createTag sg_id, "Name", my_client_sg_name
MU::Cloud::AWS.createTag sg_id, "MU-MASTER-IP", MU.mu_public_ip
MU::Cloud::AWS.ec2.modify_instance_attribute(
instance_id: my_instance_id,
groups: my_sgs
)
end
elsif resp.security_groups.size == 1
sg_id = resp.security_groups.first.group_id
resp = MU::Cloud::AWS.ec2.describe_security_groups(group_ids: [sg_id])
group = resp.security_groups.first
else
MU.log "Found more than one security group named #{my_client_sg_name}, aborting", MU::ERR
exit 1
end
if !my_sgs.include?(sg_id)
my_sgs << sg_id
MU.log "Associating #{my_client_sg_name} with #{MU.myInstanceId}", MU::NOTICE
MU::Cloud::AWS.ec2.modify_instance_attribute(
instance_id: MU.myInstanceId,
groups: my_sgs
)
end
begin
MU.log "Using AWS Security Group '#{group.group_name}' (#{sg_id})"
rescue NoMethodError
MU.log "Using AWS Security Group #{sg_id}"
end
allow_ips = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
MU::MommaCat.listAllNodes.values.each { |data|
next if data.nil? or !data.is_a?(Hash)
["public_ip_address"].each { |key|
if data.has_key?(key) and !data[key].nil? and !data[key].empty?
allow_ips << data[key] + "/32"
end
}
}
allow_ips.uniq!
@syslog_port_semaphore.synchronize {
my_ports.each { |port|
begin
group.ip_permissions.each { |rule|
if rule.ip_protocol == "tcp" and
rule.from_port == port and rule.to_port == port
MU.log "Revoking old rules for port #{port.to_s} from #{sg_id}", MU::NOTICE
begin
MU::Cloud::AWS.ec2(region: myRegion).revoke_security_group_ingress(
group_id: sg_id,
ip_permissions: [
{
ip_protocol: "tcp",
from_port: port,
to_port: port,
ip_ranges: MU.structToHash(rule.ip_ranges)
}
]
)
rescue Aws::EC2::Errors::InvalidPermissionNotFound
MU.log "Permission disappeared from #{sg_id} (port #{port.to_s}) before I could remove it", MU::WARN, details: MU.structToHash(rule.ip_ranges)
end
end
}
rescue NoMethodError
# XXX this is ok
end
MU.log "Adding current IP list to allow rule for port #{port.to_s} in #{sg_id}", details: allow_ips
allow_ips_cidr = []
allow_ips.each { |cidr|
allow_ips_cidr << {"cidr_ip" => cidr}
}
begin
MU::Cloud::AWS.ec2(region: myRegion).authorize_security_group_ingress(
group_id: sg_id,
ip_permissions: [
{
ip_protocol: "tcp",
from_port: 10514,
to_port: 10514,
ip_ranges: allow_ips_cidr
}
]
)
rescue Aws::EC2::Errors::InvalidPermissionDuplicate => e
MU.log "Got #{e.inspect} in MU::Cloud::AWS.openFirewallForClients", MU::WARN, details: allow_ips_cidr
end
}
}
end
# XXX we shouldn't have to do this, but AWS does not provide a way to look
# it up, and the pricing API only returns the human-readable strings.
@@regionLookup = {
"us-east-1" => "US East (N. Virginia)",
"us-east-2" => "US East (Ohio)",
"us-west-1" => "US West (N. California)",
"us-west-2" => "US West (Oregon)",
"us-gov-west-1" => "AWS GovCloud (US)",
"us-gov-east-1" => "AWS GovCloud (US)",
"ap-northeast-1" => "Asia Pacific (Tokyo)",
"ap-northeast-2" => "Asia Pacific (Seoul)",
"ap-south-1" => "Asia Pacific (Mumbai)",
"ap-southeast-1" => "Asia Pacific (Singapore)",
"ap-southeast-2" => "Asia Pacific (Sydney)",
"ca-central-1" => "Canada (Central)",
"eu-central-1" => "EU (Frankfurt)",
"eu-west-1" => "EU (Ireland)",
"eu-west-2" => "EU (London)",
"eu-west-3" => "EU (Paris)",
"sa-east-1" => "South America (Sao Paulo)"
}.freeze
@@regionNameLookup = @@regionLookup.invert.freeze
# Wrapper class for the EC2 API, so that we can catch some common transient
# endpoint errors without having to spray rescues all over the codebase.
class AmazonEndpoint
@api = nil
@region = nil
@cred_obj = nil
attr_reader :credentials
attr_reader :account
# Create an AWS API client
# @param region [String]: Amazon region so we know what endpoint to use
# @param api [String]: Which API are we wrapping?
def initialize(region: nil, api: "EC2", credentials: nil)
@cred_obj = MU::Cloud::AWS.loadCredentials(credentials)
@credentials = MU::Cloud::AWS.credConfig(credentials, name_only: true)
@api_name = api
if !@cred_obj
raise MuError, "Unable to locate valid AWS credentials for #{api} API. #{credentials ? "Credentials requested were '#{credentials}'": ""}"
end
params = {}
region ||= MU::Cloud::AWS.credConfig(credentials)['region']
region ||= MU.myRegion
if region
@region = region
params[:region] = @region
end
params[:credentials] = @cred_obj
MU.log "Initializing #{api} object with credentials #{credentials}", MU::DEBUG, details: params
require "aws-sdk-#{api.downcase}"
@api = Object.const_get("Aws::#{api}::Client").new(params)
end
@instance_cache = {}
# Catch-all for AWS client methods. Essentially a pass-through with some
# rescues for known silly endpoint behavior.
def method_missing(method_sym, *arguments)
# make sure error symbols are loaded for our exception handling later
require "aws-sdk-lambda"
require "aws-sdk-rds"
require "aws-sdk-ec2"
require "aws-sdk-route53"
require "aws-sdk-iam"
require "aws-sdk-efs"
require "aws-sdk-pricing"
require "aws-sdk-apigateway"
require "aws-sdk-ecs"
require "aws-sdk-eks"
require "aws-sdk-cloudwatchlogs"
require "aws-sdk-cloudwatchevents"
require "aws-sdk-elasticloadbalancing"
require "aws-sdk-elasticloadbalancingv2"
require "aws-sdk-autoscaling"
known_concats = {
"Pricing" => {
:get_products => :price_list
}
}
retries = 0
begin
MU.log "Calling #{@api_name}.#{method_sym} in #{@region}", MU::DEBUG, details: arguments
retval = if !arguments.nil? and arguments.size == 1
@api.method(method_sym).call(arguments[0])
elsif !arguments.nil? and arguments.size > 0
@api.method(method_sym).call(*arguments)
else
@api.method(method_sym).call
end
if !retval.nil?
begin
page_markers = {
:marker => :marker,
:next_token => :next_token,
:next_marker => :marker
}
paginator = nil
new_page = nil
page_markers.each_key { |m|
if !retval.nil? and retval.respond_to?(m)
paginator = m
new_page = retval.send(m)
break
end
}
if paginator and new_page and !new_page.empty?
resp = retval.respond_to?(:__getobj__) ? retval.__getobj__ : retval
concat_to = MU.structToHash(resp).keys.reject { |m|
m.to_s.match(/=$/) or m == paginator or resp.send(m).nil? or !resp.send(m).is_a?(Array)
}
if concat_to.empty? and known_concats[@api_name] and
known_concats[@api_name][method_sym]
concat_to << known_concats[@api_name][method_sym]
end
if concat_to.empty? and method_sym.to_s.match(/^(?:describe|list)_(.*)/)
my_attr = Regexp.last_match[1].to_sym
concat_to << my_attr if resp.respond_to?(my_attr)
end
if concat_to.size != 1
raise MuError.new "Tried to figure out where I might append paginated results for a #{@api_name}.#{method_sym}, but failed", details: MU.structToHash(resp).keys
else
concat_to = concat_to.first
new_args = arguments ? arguments.dup : [{}]
begin
if new_args.is_a?(Array)
new_args << {} if new_args.empty?
if new_args.size == 1 and new_args.first.is_a?(Hash)
new_args[0][page_markers[paginator]] = new_page
else
MU.log "I don't know how to insert a #{paginator} into these arguments for #{method_sym}", MU::WARN, details: new_args
end
elsif new_args.is_a?(Hash)
new_args[page_markers[paginator]] = new_page
end
MU.log "Attempting magic pagination for #{method_sym}", MU::DEBUG, details: new_args
# resp = if !arguments.nil? and arguments.size == 1
# @api.method(method_sym).call(new_args[0])
# elsif !arguments.nil? and arguments.size > 0
resp = @api.method(method_sym).call(*new_args)
# end
break if resp.nil?
resp = resp.__getobj__ if resp.respond_to?(:__getobj__)
retval.send(concat_to).concat(resp.send(concat_to))
new_page = resp.send(paginator) if !resp.nil?
end while !resp.nil? and !new_page.nil? and !new_page.empty?
end
end
rescue StandardError => e
MU.log "Made a good-faith effort to auto-paginate API call to #{method_sym} and failed with #{e.message}", MU::DEBUG, details: arguments
raise e
end
end
return retval
rescue Aws::Lambda::Errors::TooManyRequestsException, Aws::RDS::Errors::Throttling, Aws::EC2::Errors::InternalError, Aws::EC2::Errors::RequestLimitExceeded, Aws::EC2::Errors::Unavailable, Aws::Route53::Errors::Throttling, Aws::ElasticLoadBalancing::Errors::HttpFailureException, Aws::EC2::Errors::Http503Error, Aws::AutoScaling::Errors::Http503Error, Aws::AutoScaling::Errors::InternalFailure, Aws::AutoScaling::Errors::ServiceUnavailable, Aws::Route53::Errors::ServiceUnavailable, Aws::ElasticLoadBalancing::Errors::Throttling, Aws::RDS::Errors::ClientUnavailable, Aws::Waiters::Errors::UnexpectedError, Aws::ElasticLoadBalancing::Errors::ServiceUnavailable, Aws::ElasticLoadBalancingV2::Errors::Throttling, Seahorse::Client::NetworkingError, Aws::IAM::Errors::Throttling, Aws::EFS::Errors::ThrottlingException, Aws::Pricing::Errors::ThrottlingException, Aws::APIGateway::Errors::TooManyRequestsException, Aws::ECS::Errors::ThrottlingException, Net::ReadTimeout, Faraday::TimeoutError, Aws::CloudWatchLogs::Errors::ThrottlingException => e
if e.class.name == "Seahorse::Client::NetworkingError" and e.message.match(/Name or service not known/)
MU.log e.inspect, MU::ERR
raise e
end
retries = retries + 1
debuglevel = MU::DEBUG
interval = 5 + Random.rand(4) - 2
if retries < 10 and retries > 2
debuglevel = MU::NOTICE
interval = 20 + Random.rand(10) - 3
# elsif retries >= 10 and retries <= 100
elsif retries >= 10
debuglevel = MU::WARN
interval = 40 + Random.rand(15) - 5
# elsif retries > 100
# raise MuError, "Exhausted retries after #{retries} attempts while calling EC2's #{method_sym} in #{@region}. Args were: #{arguments}"
end
MU.log "Got #{e.inspect} calling EC2's #{method_sym} in #{@region} with credentials #{@credentials}, waiting #{interval.to_s}s and retrying. Args were: #{arguments}", debuglevel, details: caller
sleep interval
retry
rescue StandardError => e
MU.log "Got #{e.inspect} calling EC2's #{method_sym} in #{@region} with credentials #{@credentials}", MU::DEBUG, details: arguments
raise e
end
end
end
@@iam_api = {}
@@acm_api = {}
@@ec2_api = {}
@@autoscale_api = {}
@@elb_api = {}
@@elb2_api = {}
@@route53_api = {}
@@rds_api = {}
@@cloudformation_api = {}
@@s3_api = {}
@@cloudtrail_api = {}
@@cloudwatch_api = {}
@@wafglobal = {}
@@waf = {}
@@cloudwatchlogs_api = {}
@@cloudwatchevents_api = {}
@@cloudfront_api = {}
@@elasticache_api = {}
@@sns_api = {}
@@sqs_api = {}
@@efs_api ={}
@@lambda_api ={}
@@cloudwatch_events_api = {}
@@apig_api ={}
@@ecs_api ={}
@@eks_api ={}
@@pricing_api ={}
@@ssm_api ={}
@@elasticsearch_api ={}
@@cognito_ident_api ={}
@@cognito_user_api ={}
@@kms_api ={}
@@organization_api ={}
@@dynamo_api ={}
@@dynamostream_api ={}
@@cloudfront_api ={}
end
end
end