module Aws::ENI

Constants

RESOURCE_LIMITS

aws interface and private ip address limits for each instance type transcribed from: docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-eni.html

VERSION

Public Instance Methods

allocate_elastic_ip() click to toggle source

allocate a new elastic ip address

# File lib/aws-eni.rb, line 568
def allocate_elastic_ip
  eip = Client.allocate_address(domain: 'vpc')
  wait_for 'new elastic ip to become available', rescue: Errors::UnknownAddress do
    # strangely this doesn't happen immediately in some cases
    Client.describe_address(eip[:allocation_id])
  end
  {
    public_ip:     eip[:public_ip],
    allocation_id: eip[:allocation_id]
  }
end
assert_client_access() click to toggle source

throw exception if we do not have permissions to perform all needed EC2 operations with our given AWS credentials

# File lib/aws-eni.rb, line 611
def assert_client_access
  raise Errors::ClientPermissionError, 'Insufficient access to EC2 operations for network interface modification' unless has_client_access?
end
assert_interface_access() click to toggle source

throw exception if we cannot modify our machine's interface configuration

# File lib/aws-eni.rb, line 599
def assert_interface_access
  raise Errors::InterfacePermissionError, 'Insufficient user priveleges to configure network interfaces' unless has_interface_access?
end
assign_secondary_ip(id, options = {}) click to toggle source

add new private ip using the AWS api and add it to our local ip config

# File lib/aws-eni.rb, line 255
def assign_secondary_ip(id, options = {})
  device = Interface[id].assert(
    exists: true,
    device_name:   options[:device_name],
    interface_id:  options[:interface_id],
    device_number: options[:device_number]
  )
  interface_id = device.interface_id
  current_ips = Client.interface_private_ips(interface_id)
  do_config = options[:configure] != false
  do_block = options[:block] != false
  new_ip = options[:private_ip]

  if current_ips.count >= interface_ip_limit
    raise Errors::LimitExceeded, "Unable to assign #{new_ip || 'new IP'} to #{interface_id} (assignment limit exceeded)"
  end

  if do_block && !device.enabled?
    raise Errors::InvalidInput, "Interface #{device.name} is not enabled (cannot test connection)"
  end

  if new_ip
    if current_ips.include?(new_ip)
      raise Errors::ClientOperationError, "IP #{new_ip} already assigned to #{device.name}"
    end
    Client.assign_private_ip_addresses(
      network_interface_id: interface_id,
      private_ip_addresses: [new_ip],
      allow_reassignment: false
    )
  else
    Client.assign_private_ip_addresses(
      network_interface_id: interface_id,
      secondary_private_ip_address_count: 1,
      allow_reassignment: false
    )
    wait_for 'new private IP address to be assigned' do
      client_ips = Client.interface_private_ips(interface_id)
      new_ip = (client_ips - current_ips).first || (device.meta_ips - current_ips).first
    end
  end

  if do_config
    device.add_alias(new_ip)
    if do_block && !Interface.test(new_ip, target: device.gateway, timeout: timeout)
      raise Errors::TimeoutError, 'Timed out waiting for IP address to become active'
    end
  end

  if do_block
    # ensure new state has propagated to avoid race conditions
    wait_for 'new private IP address to appear in metadata' do
      device.meta_ips.include?(new_ip)
    end
    wait_for 'new private IP address to appear in EC2 resource' do
      Client.interface_private_ips(interface_id).include?(new_ip)
    end
  end
  {
    private_ip:    new_ip,
    interface_id:  interface_id,
    device_name:   device.name,
    device_number: device.device_number,
    interface_ips: current_ips << new_ip
  }
end
associate_elastic_ip(private_ip, options = {}) click to toggle source

associate a private ip with an elastic ip through the AWS api

# File lib/aws-eni.rb, line 391
def associate_elastic_ip(private_ip, options = {})
  raise Errors::MissingInput, 'You must specify a private IP address' unless private_ip
  do_alloc = !!options[:new]
  do_block = options[:block] != false

  find = options[:device_name] || options[:device_number] || options[:interface_id] || private_ip
  device = Interface[find].assert(
    exists: true,
    private_ip:    private_ip,
    device_name:   options[:device_name],
    interface_id:  options[:interface_id],
    device_number: options[:device_number]
  )
  options[:public_ip] ||= options[:allocation_id]

  if do_block && !device.enabled?
    raise Errors::InvalidInput, "Interface #{device.name} is not enabled (cannot block)"
  end
  if public_ip = device.public_ips[private_ip]
    raise Errors::ClientOperationError, "IP #{private_ip} already has an associated EIP (#{public_ip})"
  end

  if options[:public_ip]
    eip = Client.describe_address(options[:public_ip])
    if options[:allocation_id] && eip[:allocation_id] != options[:allocation_id]
      raise Errors::InvalidAddress, "EIP #{eip[:public_ip]} (#{eip[:allocation_id]}) does not match #{options[:allocation_id]}"
    end
  elsif do_alloc || !eip = Client.available_addresses.first
    allocated = true
    eip = allocate_elastic_ip
  end

  resp = Client.associate_address(
    network_interface_id: device.interface_id,
    allocation_id:        eip[:allocation_id],
    private_ip_address:   private_ip,
    allow_reassociation:  false
  )

  if do_block
    # ensure new state has propagated to avoid race conditions
    wait_for 'public IP address to appear in metadata' do
      device.public_ips.has_value?(eip[:public_ip])
    end
    if !Interface.test(private_ip, timeout: timeout)
      raise Errors::TimeoutError, 'Timed out waiting for IP address to become active'
    end
  end
  {
    private_ip:     private_ip,
    device_name:    device.name,
    interface_id:   device.interface_id,
    allocated:      !!allocated,
    public_ip:      eip[:public_ip],
    allocation_id:  eip[:allocation_id],
    association_id: resp[:association_id]
  }
end
attach_interface(interface_id, options = {}) click to toggle source

attach network interface

# File lib/aws-eni.rb, line 107
def attach_interface(interface_id, options = {})
  do_block = options[:block] != false
  do_enable = options[:enable] != false
  do_config = options[:configure] != false
  assert_interface_access if do_config || do_enable

  device = Interface[options[:device_number] || options[:name]].assert(exists: false)

  if Interface.count >= interface_limit
    raise Errors::LimitExceeded, "Unable to attach #{interface_id} to #{device.name} (attachment limit exceeded)"
  end

  response = Client.attach_network_interface(
    network_interface_id: interface_id,
    instance_id: environment[:instance_id],
    device_index: device.device_number
  )
  if do_block || do_config || do_enable
    wait_for 'the interface to attach', rescue: Errors::InvalidInterface do
      device.exists? && Client.interface_attached(device.interface_id)
    end
  end
  device.configure if do_config
  device.enable if do_enable
  {
    interface_id:  interface_id,
    device_name:   device.name,
    device_number: device.device_number,
    enabled:       do_enable,
    configured:    do_config,
    api_response:  response
  }
end
clean_interfaces(filter = nil, options = {}) click to toggle source

delete unattached network interfaces

# File lib/aws-eni.rb, line 199
def clean_interfaces(filter = nil, options = {})
  public_ips = []
  do_release = !!options[:release]
  safe_mode = options[:safe_mode] != false

  filters = [
    { name: 'vpc-id', values: [environment[:vpc_id]] },
    { name: 'status', values: ['available'] }
  ]
  if filter
    case filter
    when /^eni-/
      filters << { name: 'network-interface-id', values: [filter] }
    when /^subnet-/
      filters << { name: 'subnet-id', values: [filter] }
    when /^#{environment[:region]}[a-z]$/
      filters << { name: 'availability-zone', values: [filter] }
    else
      raise Errors::InvalidInput, "Unknown interface attribute: #{filter}"
    end
  end
  if safe_mode
    filters << { name: 'tag:created by', values: [owner_tag] }
  end

  descriptions = Client.describe_network_interfaces(filters: filters)
  interfaces = descriptions[:network_interfaces].select do |interface|
    created_recently = interface.tag_set.any? do |tag|
      begin
        tag.key == 'created on' && Time.now - Time.parse(tag.value) < 60
      rescue ArgumentError
      end
    end
    unless safe_mode && created_recently
      interface[:private_ip_addresses].each do |addr|
        if assoc = addr[:association]
          public_ips << {
            public_ip:     assoc[:public_ip],
            allocation_id: assoc[:allocation_id]
          }
          dissociate_elastic_ip(assoc[:allocation_id], release: true) if do_release
        end
      end
      Client.delete_network_interface(network_interface_id: interface[:network_interface_id])
      true
    end
  end
  {
    interfaces:   interfaces.map { |eni| eni[:network_interface_id] },
    public_ips:   public_ips,
    released:     do_release,
    api_response: interfaces
  }
end
configure(filter = nil, options = {}) click to toggle source

sync local machine's network interface config with the EC2 meta-data pass dry_run option to check whether configuration is out of sync without modifying it

# File lib/aws-eni.rb, line 61
def configure(filter = nil, options = {})
  Interface.configure(filter, options) if environment
end
create_interface(options = {}) click to toggle source

create network interface

# File lib/aws-eni.rb, line 81
def create_interface(options = {})
  timestamp = Time.now.xmlschema
  params = {}
  params[:subnet_id] = options[:subnet_id] || Interface.first.subnet_id
  params[:private_ip_address] = options[:primary_ip] if options[:primary_ip]
  params[:groups] = [*options[:security_groups]] if options[:security_groups]
  params[:description] = "generated by #{owner_tag} from #{environment[:instance_id]} on #{timestamp}"

  interface = Client.create_network_interface(params)[:network_interface]
  wait_for 'the interface to be created', rescue: Errors::UnknownInterface do
    if Client.describe_interface(interface[:network_interface_id])
      Client.create_tags(resources: [interface[:network_interface_id]], tags: [
        { key: 'created by',   value: owner_tag },
        { key: 'created on',   value: timestamp },
        { key: 'created from', value: environment[:instance_id] }
      ])
    end
  end
  {
    interface_id: interface[:network_interface_id],
    subnet_id:    interface[:subnet_id],
    api_response: interface
  }
end
deconfigure(filter = nil) click to toggle source

clear local machine's network interface config

# File lib/aws-eni.rb, line 66
def deconfigure(filter = nil)
  Interface.deconfigure(filter) if environment
end
detach_interface(selector, options = {}) click to toggle source

detach network interface

# File lib/aws-eni.rb, line 142
def detach_interface(selector, options = {})
  device = Interface[selector].assert(
    exists: true,
    device_name:   options[:device_name],
    interface_id:  options[:interface_id],
    device_number: options[:device_number]
  )
  interface = Client.describe_interface(device.interface_id)

  do_release = !!options[:release]
  created_by_us = interface.tag_set.any? { |tag| tag.key == 'created by' && tag.value == owner_tag }
  do_delete = options[:delete] != false && created_by_us
  do_block = options[:block] != false

  if device.name == 'eth0'
    raise Errors::InvalidInterface, 'For safety, interface eth0 cannot be detached.'
  end
  unless interface[:attachment] && interface[:attachment][:instance_id] == environment[:instance_id]
    raise Errors::InvalidInterface, "Interface #{interface_id} is not attached to this machine"
  end

  public_ips = []
  interface[:private_ip_addresses].each do |addr|
    if assoc = addr[:association]
      public_ips << {
        public_ip:     assoc[:public_ip],
        allocation_id: assoc[:allocation_id]
      }
      dissociate_elastic_ip(assoc[:allocation_id], release: true) if do_release
    end
  end

  device.disable
  device.deconfigure
  Client.detach_network_interface(
    attachment_id: interface[:attachment][:attachment_id],
    force: true
  )
  if do_block || do_delete
    wait_for 'the interface to detach', interval: 0.3 do
      !device.exists? && !Client.interface_attached(interface[:network_interface_id])
    end
  end
  Client.delete_network_interface(network_interface_id: interface[:network_interface_id]) if do_delete
  {
    interface_id:  interface[:network_interface_id],
    device_name:   device.name,
    device_number: device.device_number,
    created_by_us: created_by_us,
    deleted:       do_delete,
    released:      do_release,
    public_ips:    public_ips,
    api_response:  interface
  }
end
disable(filter = nil) click to toggle source

disable one or more network interfaces

# File lib/aws-eni.rb, line 76
def disable(filter = nil)
  Interface.filter(filter).select{ |dev| dev.enabled? && dev.name != 'eth0' }.each(&:disable).count
end
dissociate_elastic_ip(address, options = {}) click to toggle source

dissociate a public ip from a private ip through the AWS api and optionally release the public ip

# File lib/aws-eni.rb, line 452
def dissociate_elastic_ip(address, options = {})
  do_release = !!options[:release]
  do_block = options[:block] != false

  # assert device attributes if we've specified a device
  if find = options[:device_name] || options[:device_number]
    device = Interface[find].assert(
      device_name:   options[:device_name],
      device_number: options[:device_number],
      interface_id:  options[:interface_id],
      private_ip:    options[:private_ip],
      public_ip:     options[:public_ip]
    )
  end

  # get our address info
  eip = Client.describe_address(address)
  device ||= Interface.find { |dev| dev.interface_id == eip[:network_interface_id] }

  # assert eip attributes if options provided
  if options[:private_ip] && eip[:private_ip_address] != options[:private_ip]
    raise Errors::InvalidAddress, "#{address} is not associated with IP #{options[:private_ip]}"
  end
  if options[:public_ip] && eip[:public_ip] != options[:public_ip]
    raise Errors::InvalidAddress, "#{address} is not associated with public IP #{options[:public_ip]}"
  end
  if options[:allocation_id] && eip[:allocation_id] != options[:allocation_id]
    raise Errors::InvalidAddress, "#{address} is not associated with allocation ID #{options[:allocation_id]}"
  end
  if options[:association_id] && eip[:association_id] != options[:association_id]
    raise Errors::InvalidAddress, "#{address} is not associated with association ID #{options[:association_id]}"
  end
  if options[:interface_id] && eip[:network_interface_id] != options[:interface_id]
    raise Errors::InvalidAddress, "#{address} is not associated with interface ID #{options[:interface_id]}"
  end

  if device
    if device.name == 'eth0' && device.local_ips.first == eip[:private_ip_address]
      raise Errors::ClientOperationError, 'For safety, a public address cannot be dissociated from the primary IP on eth0'
    end
  elsif Client.interface_attached(eip[:network_interface_id])
    raise Errors::ClientOperationError, "#{address} is associated with an interface attached to another machine"
  end

  Client.disassociate_address(association_id: eip[:association_id])
  Client.release_address(allocation_id: eip[:allocation_id]) if do_release

  if device && do_block
    # ensure new state has propagated to avoid race conditions
    wait_for 'public IP address to be removed from metadata' do
      !device.public_ips.has_value?(eip[:public_ip])
    end
  end
  {
    private_ip:     eip[:private_ip_address],
    device_name:    device && device.name,
    interface_id:   eip[:network_interface_id],
    public_ip:      eip[:public_ip],
    allocation_id:  eip[:allocation_id],
    association_id: eip[:association_id],
    released:       do_release
  }
end
enable(filter = nil) click to toggle source

enable one or more network interfaces

# File lib/aws-eni.rb, line 71
def enable(filter = nil)
  Interface.filter(filter).select{ |dev| !dev.enabled? }.each(&:enable).count
end
environment() click to toggle source
# File lib/aws-eni.rb, line 15
def environment
  @lock.synchronize do
    @environment ||= Meta.connection do
      hwaddr = Meta.instance('network/interfaces/macs/').lines.first.strip.chomp('/')
      {
        instance_id:       Meta.instance('instance-id'),
        instance_type:     Meta.instance('instance-type'),
        availability_zone: Meta.instance('placement/availability-zone'),
        region:            Meta.instance('placement/availability-zone').sub(/^(.*)[a-z]$/,'\1'),
        vpc_id:            Meta.interface(hwaddr, 'vpc-id'),
        vpc_cidr:          Meta.interface(hwaddr, 'vpc-ipv4-cidr-block')
      }.freeze
    end
  end
rescue Errors::MetaNotFound
  raise Errors::EnvironmentError, 'Unable to detect VPC, library incompatible with EC2-Classic'
rescue Errors::MetaConnectionFailed
  raise Errors::EnvironmentError, 'Unable to load EC2 meta-data'
end
has_client_access?() click to toggle source

test whether we have permission to perform all necessary EC2 operations within our given AWS access credentials

# File lib/aws-eni.rb, line 605
def has_client_access?
  Client.has_access?
end
has_interface_access?() click to toggle source

test whether we have permission to modify our machine's interface configuration

# File lib/aws-eni.rb, line 594
def has_interface_access?
  Interface.mutable?
end
list(filter = nil) click to toggle source

return our internal model of this instance's network configuration on AWS

# File lib/aws-eni.rb, line 54
def list(filter = nil)
  Interface.filter(filter).map(&:to_h) if environment
end
owner_tag(new_owner = nil) click to toggle source
# File lib/aws-eni.rb, line 35
def owner_tag(new_owner = nil)
  @owner_tag = new_owner.to_s if new_owner
  @owner_tag ||= 'aws-eni script'
end
release_elastic_ip(ip) click to toggle source

release the specified elastic ip address

# File lib/aws-eni.rb, line 581
def release_elastic_ip(ip)
  eip = Client.describe_address(ip)
  if eip[:association_id]
    raise Errors::ClientOperationError, "Elastic IP #{eip[:public_ip]} (#{eip[:allocation_id]}) is currently in use"
  end
  Client.release_address(allocation_id: eip[:allocation_id])
  {
    public_ip:     eip[:public_ip],
    allocation_id: eip[:allocation_id]
  }
end
test_association(address, options = {}) click to toggle source

validate an internet connection on a secondary ip address with an associated elastic ip

# File lib/aws-eni.rb, line 518
def test_association(address, options = {})
  timeout = options[:timeout] || self.timeout

  # assert device attributes if we've specified a device
  if find = options[:device_name] || options[:device_number]
    device = Interface[find].assert(
      device_name:   options[:device_name],
      device_number: options[:device_number],
      interface_id:  options[:interface_id],
      private_ip:    options[:private_ip],
      public_ip:     options[:public_ip]
    )
  end

  # get our address info
  eip = Client.describe_address(address)
  device ||= Interface.find { |dev| dev.interface_id == eip[:network_interface_id] }

  # assert eip attributes if options provided
  if options[:private_ip] && eip[:private_ip_address] != options[:private_ip]
    raise Errors::InvalidAddress, "#{address} is not associated with IP #{options[:private_ip]}"
  end
  if options[:public_ip] && eip[:public_ip] != options[:public_ip]
    raise Errors::InvalidAddress, "#{address} is not associated with public IP #{options[:public_ip]}"
  end
  if options[:allocation_id] && eip[:allocation_id] != options[:allocation_id]
    raise Errors::InvalidAddress, "#{address} is not associated with allocation ID #{options[:allocation_id]}"
  end
  if options[:association_id] && eip[:association_id] != options[:association_id]
    raise Errors::InvalidAddress, "#{address} is not associated with association ID #{options[:association_id]}"
  end
  if options[:interface_id] && eip[:network_interface_id] != options[:interface_id]
    raise Errors::InvalidAddress, "#{address} is not associated with interface ID #{options[:interface_id]}"
  end

  # assert that this eip attached to an enabled, configured interface on this machine
  unless device
    raise Errors::InvalidAddress, "#{address} is not associated with an interface on this machine"
  end
  unless device.enabled?
    raise Errors::InvalidAddress, "#{address} cannot be tested while #{device.name} is disabled"
  end
  unless device.local_ips.include?(eip[:private_ip_address])
    raise Errors::InvalidAddress, "#{address} cannot be tested until #{device.name} is configured"
  end

  Interface.test(eip[:private_ip_address], timeout: timeout)
end
test_secondary_ip(private_ip, options = {}) click to toggle source

validate a local area connection on a secondary ip address

# File lib/aws-eni.rb, line 375
def test_secondary_ip(private_ip, options = {})
  timeout = options[:timeout] || self.timeout

  find = options[:device_name] || options[:device_number] || options[:interface_id] || private_ip
  device = Interface[find].assert(
    exists: true,
    enabled: true,
    device_name:   options[:device_name],
    interface_id:  options[:interface_id],
    device_number: options[:device_number],
    private_ip:    private_ip
  )
  Interface.test(private_ip, target: device.gateway, timeout: timeout)
end
timeout(new_default = nil) click to toggle source
# File lib/aws-eni.rb, line 40
def timeout(new_default = nil)
  @timeout = new_default.to_i if new_default
  @timeout ||= 120
end
unassign_secondary_ip(private_ip, options = {}) click to toggle source

remove a private ip using the AWS api and remove it from local config

# File lib/aws-eni.rb, line 323
def unassign_secondary_ip(private_ip, options = {})
  do_release = !!options[:release]
  do_block = options[:block] != false

  find = options[:device_name] || options[:device_number] || options[:interface_id] || private_ip
  device = Interface[find].assert(
    exists: true,
    device_name:   options[:device_name],
    interface_id:  options[:interface_id],
    device_number: options[:device_number]
  )

  interface = Client.describe_interface(device.interface_id)

  unless addr_info = interface[:private_ip_addresses].find { |addr| addr[:private_ip_address] == private_ip }
    raise Errors::ClientOperationError, "IP #{private_ip} not found on #{device.name}"
  end
  if addr_info[:primary]
    raise Errors::ClientOperationError, 'The primary IP address of an interface cannot be unassigned'
  end

  if assoc = addr_info[:association]
    dissociate_elastic_ip(assoc[:allocation_id], release: do_release)
  end

  device.remove_alias(private_ip)
  Client.unassign_private_ip_addresses(
    network_interface_id: interface[:network_interface_id],
    private_ip_addresses: [private_ip]
  )

  if do_block
    # ensure new state has propagated to avoid race conditions
    wait_for 'private IP address to be removed from metadata' do
      !device.meta_ips.include?(private_ip)
    end
    wait_for 'private IP address to be removed from EC2 resource' do
      !Client.interface_private_ips(device.interface_id).include?(private_ip)
    end
  end
  {
    private_ip:     private_ip,
    device_name:    device.name,
    interface_id:   device.interface_id,
    public_ip:      assoc && assoc[:public_ip],
    allocation_id:  assoc && assoc[:allocation_id],
    association_id: assoc && assoc[:association_id],
    released:       assoc && do_release
  }
end
verbose(set_verbose = nil) click to toggle source
# File lib/aws-eni.rb, line 45
def verbose(set_verbose = nil)
  unless set_verbose.nil?
    @verbose = !!set_verbose
    Interface.verbose = @verbose
  end
  @verbose
end

Private Instance Methods

interface_ip_limit() click to toggle source
# File lib/aws-eni.rb, line 689
def interface_ip_limit
  Array(RESOURCE_LIMITS[environment[:instance_type]]).last || 30
end
interface_limit() click to toggle source
# File lib/aws-eni.rb, line 685
def interface_limit
  Array(RESOURCE_LIMITS[environment[:instance_type]]).first || 8
end
wait_for(task, options = {}, &block) click to toggle source
# File lib/aws-eni.rb, line 617
def wait_for(task, options = {}, &block)
  errors = [*options[:rescue]]
  timeout = options[:timeout] || self.timeout
  interval = options[:interval] || 0.3

  until timeout < 0
    begin
      break if block.call
    rescue *errors => e
    end
    sleep interval
    timeout -= interval
  end
  raise Errors::TimeoutError, "Timed out waiting for #{task}" unless timeout > 0
end