aws-eni 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ require 'time'
2
+ require 'aws-sdk'
3
+ require 'aws-eni/version'
4
+ require 'aws-eni/errors'
5
+ require 'aws-eni/meta'
6
+ require 'aws-eni/ifconfig'
7
+
8
+ module Aws
9
+ module ENI
10
+ extend self
11
+
12
+ def environment
13
+ @environment ||= {}.tap do |e|
14
+ hwaddr = IFconfig['eth0'].hwaddr
15
+ Meta.open_connection do |conn|
16
+ e[:instance_id] = Meta.http_get(conn, 'instance-id')
17
+ e[:availability_zone] = Meta.http_get(conn, 'placement/availability-zone')
18
+ e[:region] = e[:availability_zone].sub(/(.*)[a-z]/,'\1')
19
+ e[:vpc_id] = Meta.http_get(conn, "network/interfaces/macs/#{hwaddr}/vpc-id")
20
+ e[:vpc_cidr] = Meta.http_get(conn, "network/interfaces/macs/#{hwaddr}/vpc-ipv4-cidr-block")
21
+ end
22
+ unless e[:vpc_id]
23
+ raise EnvironmentError, "Unable to detect VPC settings, library incompatible with EC2-Classic"
24
+ end
25
+ end.freeze
26
+ rescue Meta::ConnectionFailed
27
+ raise EnvironmentError, "Unable to load EC2 meta-data"
28
+ end
29
+
30
+ def owner_tag(new_owner = nil)
31
+ @owner_tag = new_owner.to_s if new_owner
32
+ @owner_tag || 'aws-eni script'
33
+ end
34
+
35
+ def client
36
+ @client ||= Aws::EC2::Client.new(region: environment[:region])
37
+ end
38
+
39
+ # return our internal model of this instance's network configuration on AWS
40
+ def list(filter = nil)
41
+ IFconfig.filter(filter).map(&:to_h) if environment
42
+ end
43
+
44
+ # sync local machine's network interface config with the EC2 meta-data
45
+ # pass dry_run option to check whether configuration is out of sync without
46
+ # modifying it
47
+ def configure(filter = nil, options = {})
48
+ IFconfig.configure(filter, options) if environment
49
+ end
50
+
51
+ # clear local machine's network interface config
52
+ def deconfigure(filter = nil)
53
+ IFconfig.deconfigure(filter) if environment
54
+ end
55
+
56
+ # create network interface
57
+ def create_interface(options = {})
58
+ timestamp = Time.now.xmlschema
59
+ params = {}
60
+ params[:subnet_id] = options[:subnet_id] || IFconfig.first.subnet_id
61
+ params[:private_ip_address] = options[:primary_ip] if options[:primary_ip]
62
+ params[:groups] = [*options[:security_groups]] if options[:security_groups]
63
+ params[:description] = "generated by #{owner_tag} from #{environment[:instance_id]} on #{timestamp}"
64
+
65
+ response = client.create_network_interface(params)
66
+ client.create_tags(resources: [response[:network_interface][:network_interface_id]], tags: [
67
+ { key: 'created by', value: owner_tag },
68
+ { key: 'created on', value: timestamp },
69
+ { key: 'created from', value: environment[:instance_id] }
70
+ ])
71
+ {
72
+ id: response[:network_interface][:network_interface_id],
73
+ subnet_id: response[:network_interface][:subnet_id],
74
+ api_response: response[:network_interface]
75
+ }
76
+ end
77
+
78
+ # attach network interface
79
+ def attach_interface(id, options = {})
80
+ interface = IFconfig[options[:device_number] || options[:name]]
81
+ raise InvalidParameterError, "Interface #{interface.name} is already in use" if interface.exists?
82
+
83
+ params = {}
84
+ params[:network_interface_id] = id
85
+ params[:instance_id] = environment[:instance_id]
86
+ params[:device_index] = interface.device_number
87
+
88
+ response = client.attach_network_interface(params)
89
+ attached = wait_for(10) { interface.exists? }
90
+ raise TimeoutError, "Timed out waiting for the interface to attach" unless attached
91
+ interface.configure if options[:configure]
92
+ interface.enable if options[:enable]
93
+ {
94
+ id: interface.interface_id,
95
+ name: interface.name,
96
+ configured: options[:configure],
97
+ api_response: response
98
+ }
99
+ end
100
+
101
+ # detach network interface
102
+ def detach_interface(id, options = {})
103
+ interface = IFconfig.filter(id).first
104
+ raise InvalidParameterError, "Interface #{interface.name} does not exist" unless interface && interface.exists?
105
+ if options[:name] && interface.name != options[:name]
106
+ raise InvalidParameterError, "Interface #{interface.interface_id} not found on #{options[:name]}"
107
+ end
108
+ if options[:device_number] && interface.device_number != options[:device_number].to_i
109
+ raise InvalidParameterError, "Interface #{interface.interface_id} not found at index #{options[:device_number]}"
110
+ end
111
+
112
+ description = client.describe_network_interfaces(filters: [{
113
+ name: 'attachment.instance-id',
114
+ values: [environment[:instance_id]]
115
+ },{
116
+ name: 'network-interface-id',
117
+ values: [interface.interface_id]
118
+ }])
119
+ description = description[:network_interfaces].first
120
+ raise UnknownInterfaceError, "Interface attachment could not be located" unless description
121
+
122
+ interface.disable
123
+ interface.deconfigure
124
+ client.detach_network_interface(
125
+ attachment_id: description[:attachment][:attachment_id],
126
+ force: true
127
+ )
128
+ deleted = false
129
+ created_by_us = description.tag_set.any? { |tag| tag.key == 'created by' && tag.value == owner_tag }
130
+ unless options[:delete] == false || options[:delete].nil? && !created_by_us
131
+ detached = wait_for(10, 0.3) do
132
+ !interface.exists? && interface_status(description[:network_interface_id]) == 'available'
133
+ end
134
+ raise TimeoutError, "Timed out waiting for the interface to detach" unless detached
135
+ client.delete_network_interface(network_interface_id: description[:network_interface_id])
136
+ deleted = true
137
+ end
138
+ {
139
+ id: description[:network_interface_id],
140
+ name: "eth#{description[:attachment][:device_index]}",
141
+ device_number: description[:attachment][:device_index],
142
+ created_by_us: created_by_us,
143
+ deleted: deleted,
144
+ api_response: description
145
+ }
146
+ end
147
+
148
+ # delete unattached network interfaces
149
+ def clean_interfaces(filter = nil, options = {})
150
+ safe_mode = true unless options[:safe_mode] == false
151
+
152
+ filters = [
153
+ { name: 'vpc-id', values: [environment[:vpc_id]] },
154
+ { name: 'status', values: ['available'] }
155
+ ]
156
+ if filter
157
+ case filter
158
+ when /^eni-/
159
+ filters << { name: 'network-interface-id', values: [filter] }
160
+ when /^subnet-/
161
+ filters << { name: 'subnet-id', values: [filter] }
162
+ when /^#{environment[:region]}[a-z]$/
163
+ filters << { name: 'availability-zone', values: [filter] }
164
+ else
165
+ raise InvalidParameterError, "Unknown resource filter: #{filter}"
166
+ end
167
+ end
168
+ if safe_mode
169
+ filters << { name: 'tag:created by', values: [owner_tag] }
170
+ end
171
+
172
+ descriptions = client.describe_network_interfaces(filters: filters)
173
+ interfaces = descriptions[:network_interfaces].select do |interface|
174
+ skip = safe_mode && interface.tag_set.any? do |tag|
175
+ begin
176
+ tag.key == 'created on' && Time.now - Time.parse(tag.value) < 60
177
+ rescue ArgumentError
178
+ false
179
+ end
180
+ end
181
+ unless skip
182
+ client.delete_network_interface(network_interface_id: interface[:network_interface_id])
183
+ true
184
+ end
185
+ end
186
+ {
187
+ count: interfaces.count,
188
+ deleted: interfaces.map { |eni| eni[:network_interface_id] },
189
+ api_response: interfaces
190
+ }
191
+ end
192
+
193
+ # add new private ip using the AWS api and add it to our local ip config
194
+ def assign_secondary_ip(interface, options = {})
195
+ raise NoMethodError, "assign_secondary_ip not yet implemented"
196
+ {
197
+ private_ip: '0.0.0.0',
198
+ device_name: 'eth0',
199
+ interface_id: 'eni-1a2b3c4d'
200
+ }
201
+ end
202
+
203
+ # remove a private ip using the AWS api and remove it from local config
204
+ def unassign_secondary_ip(private_ip, options = {})
205
+ raise NoMethodError, "unassign_secondary_ip not yet implemented"
206
+ {
207
+ private_ip: '0.0.0.0',
208
+ device_name: 'eth0',
209
+ interface_id: 'eni-1a2b3c4d',
210
+ public_ip: '0.0.0.0',
211
+ allocation_id: 'eipalloc-1a2b3c4d',
212
+ association_id: 'eipassoc-1a2b3c4d',
213
+ released: true
214
+ }
215
+ end
216
+
217
+ # associate a private ip with an elastic ip through the AWS api
218
+ def associate_elastic_ip(private_ip, options = {})
219
+ raise NoMethodError, "associate_elastic_ip not yet implemented"
220
+ {
221
+ private_ip: '0.0.0.0',
222
+ device_name: 'eth0',
223
+ interface_id: 'eni-1a2b3c4d',
224
+ public_ip: '0.0.0.0',
225
+ allocation_id: 'eipalloc-1a2b3c4d',
226
+ association_id: 'eipassoc-1a2b3c4d'
227
+ }
228
+ end
229
+
230
+ # dissociate a public ip from a private ip through the AWS api and
231
+ # optionally release the public ip
232
+ def dissociate_elastic_ip(ip, options = {})
233
+ raise NoMethodError, "dissociate_elastic_ip not yet implemented"
234
+ {
235
+ private_ip: '0.0.0.0',
236
+ device_name: 'eth0',
237
+ interface_id: 'eni-1a2b3c4d',
238
+ public_ip: '0.0.0.0',
239
+ allocation_id: 'eipalloc-1a2b3c4d',
240
+ association_id: 'eipassoc-1a2b3c4d',
241
+ released: true
242
+ }
243
+ end
244
+
245
+ # allocate a new elastic ip address
246
+ def allocate_elastic_ip
247
+ raise NoMethodError, "allocate_elastic_ip not yet implemented"
248
+ {
249
+ public_ip: '0.0.0.0',
250
+ allocation_id: 'eipalloc-1a2b3c4d'
251
+ }
252
+ end
253
+
254
+ # release the specified elastic ip address
255
+ def release_elastic_ip(ip, options = {})
256
+ raise NoMethodError, "release_elastic_ip not yet implemented"
257
+ {
258
+ public_ip: '0.0.0.0',
259
+ allocation_id: 'eipalloc-1a2b3c4d'
260
+ }
261
+ end
262
+
263
+ private
264
+
265
+ def interface_status(id)
266
+ resp = client.describe_network_interfaces(network_interface_ids: [id])
267
+ resp[:network_interfaces].first[:status] unless resp[:network_interfaces].empty?
268
+ end
269
+
270
+ def wait_for(timer = 5, interval = 0.1, &block)
271
+ until timer < 0 or block.call
272
+ timer -= interval
273
+ sleep interval
274
+ end
275
+ timer > 0
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,13 @@
1
+
2
+ module Aws
3
+ module ENI
4
+ class Error < RuntimeError; end
5
+ class TimeoutError < Error; end
6
+ class MissingParameterError < Error; end
7
+ class InvalidParameterError < Error; end
8
+ class UnknownInterfaceError < Error; end
9
+ class EnvironmentError < Error; end
10
+ class CommandError < Error; end
11
+ class PermissionError < CommandError; end
12
+ end
13
+ end
@@ -0,0 +1,309 @@
1
+ require 'ipaddr'
2
+ require 'open3'
3
+ require 'aws-eni/errors'
4
+
5
+ module Aws
6
+ module ENI
7
+ class IFconfig
8
+
9
+ class << self
10
+ include Enumerable
11
+
12
+ attr_accessor :verbose
13
+
14
+ # Array-like accessor to automatically instantiate our class
15
+ def [](index)
16
+ index = $1.to_i if index.to_s =~ /^(?:eth)?([0-9]+)$/
17
+ index ||= next_available_index
18
+ @instance_cache ||= []
19
+ @instance_cache[index] ||= new("eth#{index}", false)
20
+ end
21
+
22
+ # Purge and deconfigure non-existent interfaces from the cache
23
+ def clean
24
+ # exists? will automatically call deconfigure if necessary
25
+ @instance_cache.map!{ |dev| dev if dev.exists? }
26
+ end
27
+
28
+ # Return array of available ethernet interfaces
29
+ def existing
30
+ Dir.entries("/sys/class/net/").grep(/^eth[0-9]+$/){ |name| self[name] }
31
+ end
32
+
33
+ # Return the next unused device index
34
+ def next_available_index
35
+ for index in 0..32 do
36
+ break index unless self[index].exists?
37
+ end
38
+ end
39
+
40
+ # Iterate over available ethernet interfaces (required for Enumerable)
41
+ def each(&block)
42
+ existing.each(&block)
43
+ end
44
+
45
+ # Return array of enabled interfaces
46
+ def enabled
47
+ select(&:enabled?)
48
+ end
49
+
50
+ # Configure all available interfaces identified by an optional selector
51
+ def configure(selector = nil, options = {})
52
+ filter(selector).reduce(0) do |count, dev|
53
+ count + dev.configure(options[:dry_run])
54
+ end
55
+ end
56
+
57
+ # Remove configuration on available interfaces identified by an optional
58
+ # selector
59
+ def deconfigure(selector = nil)
60
+ filter(selector).each(&:deconfigure)
61
+ true
62
+ end
63
+
64
+ # Return an array of available interfaces identified by name, id,
65
+ # hwaddr, or subnet id.
66
+ def filter(match = nil)
67
+ return existing unless match
68
+ select{ |dev| dev.is?(match) }.tap do |result|
69
+ raise UnknownInterfaceError, "No interface found matching \"#{match}\"" if result.empty?
70
+ end
71
+ end
72
+
73
+ # Execute a command
74
+ def exec(command, options = {})
75
+ output = nil
76
+ verbose = self.verbose || options[:verbose]
77
+ raise_errors = options[:raise_errors]
78
+ puts "ip #{command}" if verbose
79
+
80
+ Open3.popen3("/sbin/ip #{command}") do |i,o,e,t|
81
+ unless t.value.success?
82
+ error_msg = e.read
83
+ if error_msg =~ /operation not permitted/i
84
+ raise PermissionError, "Operation not permitted"
85
+ end
86
+ warn "Warning: #{error_msg}" if verbose
87
+ raise CommandError, error_msg if raise_errors
88
+ end
89
+ output = o.read
90
+ end
91
+ output
92
+ end
93
+ end
94
+
95
+ attr_reader :name, :device_number, :route_table
96
+
97
+ def initialize(name, auto_config = true)
98
+ unless name =~ /^eth([0-9]+)$/
99
+ raise UnknownInterfaceError, "Invalid interface: #{name}"
100
+ end
101
+ @name = name
102
+ @device_number = $1.to_i
103
+ @route_table = @device_number + 10000
104
+ configure if auto_config
105
+ end
106
+
107
+ # Get our interface's MAC address
108
+ def hwaddr
109
+ begin
110
+ exists? && IO.read("/sys/class/net/#{name}/address").strip
111
+ rescue Errno::ENOENT
112
+ end.tap do |address|
113
+ raise UnknownInterfaceError, "Unknown interface: #{name}" unless address
114
+ end
115
+ end
116
+
117
+ # Verify device exists on our system
118
+ def exists?
119
+ File.directory?("/sys/class/net/#{name}").tap do |exists|
120
+ deconfigure unless exists || @clean
121
+ end
122
+ end
123
+
124
+ # Validate and return basic interface metadata
125
+ def info
126
+ hwaddr = self.hwaddr
127
+ unless @meta_cache && hwaddr == @meta_cache[:hwaddr]
128
+ dev_path = "network/interfaces/macs/#{hwaddr}"
129
+ Meta.open_connection do |conn|
130
+ raise Meta::BadResponse unless Meta.http_get(conn, "#{dev_path}/")
131
+ @meta_cache = {
132
+ hwaddr: hwaddr,
133
+ interface_id: Meta.http_get(conn, "#{dev_path}/interface-id"),
134
+ subnet_id: Meta.http_get(conn, "#{dev_path}/subnet-id"),
135
+ subnet_cidr: Meta.http_get(conn, "#{dev_path}/subnet-ipv4-cidr-block")
136
+ }.freeze
137
+ end
138
+ end
139
+ @meta_cache
140
+ end
141
+
142
+ def interface_id
143
+ info[:interface_id]
144
+ end
145
+
146
+ def subnet_id
147
+ info[:subnet_id]
148
+ end
149
+
150
+ def subnet_cidr
151
+ info[:subnet_cidr]
152
+ end
153
+
154
+ # Return an array of configured ip addresses (primary + secondary)
155
+ def local_ips
156
+ list = exec("addr show dev #{name} primary") +
157
+ exec("addr show dev #{name} secondary")
158
+ list.lines.grep(/inet ([0-9\.]+)\/.* #{name}/i){ $1 }
159
+ end
160
+
161
+ def public_ips
162
+ ip_assoc = {}
163
+ dev_path = "network/interfaces/macs/#{hwaddr}"
164
+ Meta.open_connection do |conn|
165
+ # return an array of configured ip addresses (primary + secondary)
166
+ Meta.http_get(conn, "#{dev_path}/ipv4-associations/").to_s.each_line do |public_ip|
167
+ public_ip.strip!
168
+ local_ip = Meta.http_get(conn, "#{dev_path}/ipv4-associations/#{public_ip}")
169
+ ip_assoc[local_ip] = public_ip
170
+ end
171
+ end
172
+ ip_assoc
173
+ end
174
+
175
+ # Enable our interface
176
+ def enable
177
+ exec("link set dev #{name} up")
178
+ end
179
+
180
+ # Disable our interface
181
+ def disable
182
+ exec("link set dev #{name} down")
183
+ end
184
+
185
+ # Check whether our interface is enabled
186
+ def enabled?
187
+ exists? && exec("link show up").include?(name)
188
+ end
189
+
190
+ # Initialize a new interface config
191
+ def configure(dry_run = false)
192
+ changes = 0
193
+ info = self.info
194
+ prefix = info[:subnet_cidr].split('/').last.to_i
195
+ gateway = IPAddr.new(info[:subnet_cidr]).succ.to_s
196
+
197
+ meta_ips = Meta.get("network/interfaces/macs/#{info[:hwaddr]}/local-ipv4s").lines.map(&:strip)
198
+ local_primary, *local_aliases = local_ips
199
+ meta_primary, *meta_aliases = meta_ips
200
+
201
+ # ensure primary ip address is correct
202
+ if name != 'eth0' && local_primary != meta_primary
203
+ unless dry_run
204
+ deconfigure
205
+ exec("addr add #{meta_primary}/#{prefix} brd + dev #{name}")
206
+ exec("route add default via #{gateway} dev #{name} table #{route_table}")
207
+ exec("route flush cache")
208
+ end
209
+ changes += 1
210
+ end
211
+
212
+ # add missing secondary ips
213
+ (meta_aliases - local_aliases).each do |ip|
214
+ exec("addr add #{ip}/#{prefix} brd + dev #{name}") unless dry_run
215
+ changes += 1
216
+ end
217
+
218
+ # remove extra secondary ips
219
+ (local_aliases - meta_aliases).each do |ip|
220
+ exec("addr del #{ip}/#{prefix} dev #{name}") unless dry_run
221
+ changes += 1
222
+ end
223
+
224
+ unless name == 'eth0'
225
+ rules_to_add = meta_ips || []
226
+ exec("rule list").lines.grep(/^([0-9]+):.*\s([0-9\.]+)\s+lookup #{route_table}/) do
227
+ unless rules_to_add.delete($2)
228
+ exec("rule delete pref #{$1}") unless dry_run
229
+ changes += 1
230
+ end
231
+ end
232
+ rules_to_add.each do |ip|
233
+ exec("rule add from #{ip} lookup #{route_table}") unless dry_run
234
+ changes += 1
235
+ end
236
+ end
237
+
238
+ @clean = nil
239
+ changes
240
+ end
241
+
242
+ # Remove configuration for an interface
243
+ def deconfigure
244
+ # assume eth0 primary ip is managed by dhcp
245
+ if name == 'eth0'
246
+ exec("addr flush dev eth0 secondary")
247
+ else
248
+ exec("rule list").lines.grep(/^([0-9]+):.*lookup #{route_table}/) do
249
+ exec("rule delete pref #{$1}")
250
+ end
251
+ exec("addr flush dev #{name}")
252
+ exec("route flush table #{route_table}")
253
+ exec("route flush cache")
254
+ end
255
+ @clean = true
256
+ end
257
+
258
+ # Add a secondary ip to this interface
259
+ def add_alias(ip)
260
+ prefix = info[:subnet_cidr].split('/').last.to_i
261
+ exec("addr add #{ip}/#{prefix} brd + dev #{name}")
262
+
263
+ unless name == 'eth0' || exec("rule list") =~ /from #{ip} lookup #{route_table}/
264
+ exec("rule add from #{ip} lookup #{route_table}")
265
+ end
266
+ end
267
+
268
+ # Remove a secondary ip from this interface
269
+ def remove_alias
270
+ prefix = info[:subnet_cidr].split('/').last.to_i
271
+ exec("addr del #{ip}/#{prefix} dev #{name}")
272
+
273
+ if name != 'eth0' && exec("rule list") =~ /([0-9]+):\s+from #{ip} lookup #{route_table}/
274
+ exec("rule delete pref #{$1}")
275
+ end
276
+ end
277
+
278
+ # Identify this interface by one of its attributes
279
+ def is?(match)
280
+ if match == name
281
+ true
282
+ else
283
+ info = self.info
284
+ match == info[:interface_id] || match == info[:hwaddr] || match == info[:subnet_id]
285
+ end
286
+ end
287
+
288
+ # Return an array representation of our interface config, including public
289
+ # ip associations and enabled status
290
+ def to_h
291
+ info.merge({
292
+ name: name,
293
+ device_number: device_number,
294
+ route_table: route_table,
295
+ local_ips: local_ips,
296
+ public_ips: public_ips,
297
+ enabled: enabled?
298
+ })
299
+ end
300
+
301
+ private
302
+
303
+ # Alias for static method
304
+ def exec(command, options = {})
305
+ self.class.exec(command, options)
306
+ end
307
+ end
308
+ end
309
+ end