zonify 0.4.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. data/README +165 -0
  2. data/bin/zonify +254 -0
  3. data/lib/zonify.rb +582 -0
  4. metadata +85 -0
data/README ADDED
@@ -0,0 +1,165 @@
1
+ SYNOPSIS
2
+ zonify ... (-h|-[?]|--help) ...
3
+ zonify ec2 <rewrite rules>* > zone.ec2.yaml
4
+ zonify ec2/r53 <domain> <rewrite rules>* > changes.yaml
5
+ zonify r53 <domain> > zone.r53.yaml
6
+ zonify diff zone.r53.yaml zone.ec2.yaml > changes.yaml
7
+ zonify rewrite <rewrite rules>* < zone.ec2.yaml
8
+ zonify summarize < changes.yaml
9
+ zonify apply < changes.yaml
10
+ zonify sync <domain> <rewrite rules>*
11
+ zonify normalize <domain>
12
+ zonify eips
13
+
14
+ DESCRIPTION
15
+ The zonify tool allows one to create DNS entries for all instances,
16
+ tags and load balancers in EC2 and synchronize a Route 53 zone with
17
+ these entries.
18
+
19
+ The zonify tool and libraries intelligently insert a final and initial
20
+ '.' as needed to conform to DNS conventions. One may enter the domains
21
+ at the command line as example.com or example.com.; it will work either
22
+ way.
23
+
24
+ For access to AWS APIs, zonify uses the the conventional environment
25
+ variables to select regions and specify credentials:
26
+
27
+ AWS_ACCESS_KEY AWS_ACCESS_KEY_ID
28
+ AWS_SECRET_KEY AWS_SECRET_ACCESS_KEY
29
+ EC2_URL
30
+
31
+ These variables are used by many AWS libraries and tools. As a conve-
32
+ nience, the environment variable AWS_REGION may be used with region
33
+ nicknames:
34
+
35
+ AWS_REGION=eu-west-1
36
+
37
+ The Zonify subcommands allow staged generation, transformation and
38
+ auditing of entries as well as straightforward, one-step synchroniza-
39
+ tion.
40
+
41
+ ec2 (--srv-singleton|--no-srv-singleton)?
42
+ Organizes instances, load balancers, security groups and
43
+ instance metadata into DNS entries, with the generic suffix
44
+ '.' (intended to be transformed by later commands).
45
+
46
+ ec2/r53 (--types CNAME,SRV)? (--srv-singleton|--no-srv-singleton)?
47
+ Creates a changes file, describing how records under the
48
+ given suffix would be created and deleted to bring it in to
49
+ sync with EC2. By default, only records of type CNAME and SRV
50
+ are examined and changed.
51
+
52
+ r53 Capture all Route 53 records under the given suffix.
53
+
54
+ diff (--types CNAME,SRV,A,MX,...)?
55
+ Describe changes (which can be fed to the apply subcommand)
56
+ needed to bring a Route 53 domain in the first file into sync
57
+ with domain described in the second file. The suffix is taken
58
+ from the first file. The default with diff (unlike other
59
+ zonify subcommands) is to examine all record types.
60
+
61
+ rewrite (--srv-singleton|--no-srv-singleton)?
62
+ Apply rewrite rules to the domain file.
63
+
64
+ summarize
65
+ Summarize changes in a changes file, writing to STDOUT.
66
+
67
+ apply Apply a changes file.
68
+
69
+ sync (--types CNAME,SRV)? (--srv-singleton|--no-srv-singleton)?
70
+ Sync the given domain with EC2. By default, only records of
71
+ type CNAME and SRV are examined and changed.
72
+
73
+ normalize
74
+ Create CNAMEs for SRV records that have only one server in
75
+ them and rebase records on to the given domain.
76
+
77
+ eips List all Elastic IPs and DNS entries that map to them.
78
+
79
+ The --[no-]srv-singleton options control creation of CNAMEs for single-
80
+ ton SRV records. They are enabled by default; but it can be useful to
81
+ disable them for pre-processing the YAML and then adding them with nor-
82
+ malize. For example:
83
+
84
+ The --[no-]srv-singleton options also control creation of weighted
85
+ round-robin CNAMEs, an infelicity in nomenclature.
86
+
87
+ zonify r53 amz.example.com > r53.yaml
88
+ zonify ec2 --no-srv-singleton > ec2.yaml
89
+ my-yaml-rewriter < ec2.yaml > adjusted.yaml
90
+ zonify normalize amz.example.com < adjusted.yaml > normed.yaml
91
+ zonify diff r53.yaml normed.yaml | zonify apply
92
+
93
+ SYNC POLICY
94
+ Zonify assumes the domain given on the command line is entirely under
95
+ the control of Zonify; records not reflecting the present state of EC2
96
+ are scheduled for deletion in the generated changesets. This can be
97
+ controlled to some degree with the --types option.
98
+
99
+ The sync scopes over the domain and not necessarily the entire Route 53
100
+ zone. Say, for example, one has example.com in a Route 53 zone and one
101
+ plans to use amz.example.com for Amazon instance records. In this sce-
102
+ nario, Zonify will only specify changes that delete or create records
103
+ under amz.example.com; www.example.com, s0.mobile.example.com and simi-
104
+ lar records will not be affected.
105
+
106
+ YAML OUTPUT
107
+ All records and change sets are sorted by name on output. The data com-
108
+ ponents of records are also sorted. This ensures consistent output from
109
+ run to run; and allows the diff tool to return meaningful results when
110
+ outputs are compared.
111
+
112
+ One exception to this rule is the r53 subcommand, which preserves the
113
+ order of data as it was found in Route 53.
114
+
115
+ REWRITE RULES
116
+ Rewrite rules take the form <domain>(:<domain>)+. To shorten names
117
+ under the apache security group to web.amz.example.com, use:
118
+
119
+ apache.sg:web
120
+
121
+ To keep both forms, use the rule:
122
+
123
+ apache.sg:apache.sg:web
124
+
125
+ GENERATED RECORDS AND QUERYING
126
+ For records where there are potentially many servers -- security
127
+ groups, tags, load balancers -- Zonify creates SRV records. When a SRV
128
+ record has only one entry under it, a simple CNAME is created. When a
129
+ SRV record contains multiple records, multiple weighted round-robin
130
+ CNAMEs are created, one for each server in the SRV record.
131
+
132
+ Records created include:
133
+
134
+ i-ABCD1234.inst.
135
+ Individual instances.
136
+
137
+ _*._*.<value>.<key>.tag.
138
+ SRV records for tags.
139
+
140
+ _*._*.<name>.sg.
141
+ SRV records for security groups.
142
+
143
+ _*._*.<name>.elb
144
+ SRV records for instances behind Elastic Load Balancers.
145
+
146
+ domU-*.priv., ip-*.priv
147
+ Records pointing to the default hostname, derived from the
148
+ private DNS entry, set by many AMIs.
149
+
150
+ A list of all instances is placed under inst -- continuing with our
151
+ example above, this would be the SRV record _*._*.inst.amz.example.com.
152
+ To obtain the list of all instances with dig:
153
+
154
+ dig @8.8.8.8 +tcp +short _*._*.inst.amz.example.com SRV | cut -d' ' -f4
155
+
156
+ The cut call is necessary to remove some values, always nonces with
157
+ Zonify, that are part of standard format SRV records.
158
+
159
+ EXAMPLES
160
+ # Create records under amz.example.com, with instance names appearing
161
+ # directly under .amz.example.com.
162
+ zone sync amz.example.com name.tag:.
163
+ # Similar to above but stores changes to disk for later application.
164
+ zone ec2/r53 amz.example.com name.tag:. > changes.yaml
165
+
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env ruby
2
+ root = File.expand_path("#{File.dirname(__FILE__)}/..")
3
+ $LOAD_PATH.unshift("#{root}/lib") if File.directory?("#{root}/lib")
4
+ USAGE = File.read("#{root}/README")
5
+
6
+ require 'readline'
7
+ require 'yaml'
8
+
9
+ require 'rubygems'
10
+ require 'fog'
11
+
12
+ require 'zonify'
13
+
14
+
15
+ class CLIAWS
16
+ attr_reader :options
17
+ def initialize(access=nil, secret=nil)
18
+ @options = {}
19
+ @options.merge!(options) if options
20
+ env_adapter
21
+ { :region => ENV['AWS_REGION'],
22
+ :aws_access_key_id => (access or ENV['AWS_ACCESS_KEY']),
23
+ :aws_secret_access_key => (secret or ENV['AWS_SECRET_KEY'])
24
+ }.each{|k,v| @options.merge!(k=>v) if v }
25
+ end
26
+ def respond_to?(sym)
27
+ aws.respond_to?(sym)
28
+ end
29
+ def env_adapter
30
+ [ %w| AWS_ACCESS_KEY_ID AWS_ACCESS_KEY |,
31
+ %w| AWS_SECRET_ACCESS_KEY AWS_SECRET_KEY | ].each do |a, b|
32
+ ENV[a] = ENV[b] if ENV[b] # Ensure new variable takes precedence.
33
+ ENV[b] = ENV[a] unless ENV[b]
34
+ end
35
+ # Sometimes libraries can not not handle being passed a region if the
36
+ # EC2_URL is set.
37
+ ENV['EC2_URL'] = nil if ENV['AWS_REGION']
38
+ end
39
+ def method_missing(sym, *args, &block)
40
+ begin
41
+ aws.send(sym, *args, &block)
42
+ rescue Fog::Errors::Error => e
43
+ abort "AWS error: #{e}"
44
+ end
45
+ end
46
+ def aws
47
+ @aws ||= Zonify::AWS.create(@options)
48
+ end
49
+ end
50
+
51
+ def rejected(rejected_changes)
52
+ unless rejected_changes.empty?
53
+ STDERR.puts 'Rejected some changes, because they were too large.'
54
+ rejected_changes.each do |change|
55
+ STDOUT.puts <<YAML
56
+ - :name: #{change[:name]}
57
+ :type: #{change[:type]}
58
+ YAML
59
+ end
60
+ end
61
+ end
62
+
63
+ def check_name(suffix)
64
+ abort 'No domain given.' unless suffix
65
+ unless suffix.split('.').all?{|s| s.empty? or Zonify::LDH_RE.match(s) }
66
+ abort "Not a conventional, LDH domain name: #{suffix}"
67
+ end
68
+ end
69
+
70
+ def display(changes)
71
+ if changes.empty?
72
+ 'No changes; nothing to do.'
73
+ else
74
+ summary = changes.inject({}) do |acc, change|
75
+ name, type, set = [change[:name], change[:type], change[:set_identifier]]
76
+ key = "#{name} #{type}" + ( set ? " (#{set})" : "" )
77
+ acc[key] ||= []
78
+ acc[key] << change[:action]
79
+ acc
80
+ end.map do |k, v|
81
+ case v
82
+ when ['DELETE', 'CREATE'] then [k, 'replace']
83
+ when ['DELETE'] then [k, 'delete']
84
+ when ['CREATE'] then [k, 'create']
85
+ end
86
+ end.sort
87
+ len = summary.map{|name, op| [name.length, 64].min }.max
88
+ formatted = summary.map{|name, op| name.ljust(len) + " ---> " + op }
89
+ formatted.unshift("There are #{summary.length} changes.").join("\n")
90
+ end
91
+ end
92
+
93
+ def yaml_with_default(source, default)
94
+ s = source.read.rstrip
95
+ unless s.empty?
96
+ yaml = YAML.load(s)
97
+ abort 'Bad YAML parse.' unless yaml
98
+ yaml
99
+ else
100
+ default
101
+ end
102
+ end
103
+
104
+ def normed_with_mappings(mapping_args, suffix, records, norm=true)
105
+ mappings = mapping_args.map{|s| Zonify::Mappings.parse(s) }
106
+ abort 'Bad mapping parse.' if mappings.any?{|r| r.nil? }
107
+ mappings.each{|k, v| ([k] + v).each{|s| check_name(s) } }
108
+ clipped = Zonify::Mappings.rewrite(records, [[suffix,['.']]])
109
+ mapped = Zonify::Mappings.rewrite(clipped, mappings)
110
+ restored = Zonify::Mappings.rewrite(mapped, [['.',[suffix]]])
111
+ norm ? Zonify.normalize(restored) : restored
112
+ end
113
+
114
+ def confirm_dialogue
115
+ # Restore terminal state on SIGINT. Needed due to use of Readline.
116
+ stty_save = `stty -g 2>/dev/null`.chomp
117
+ trap('INT') do
118
+ system('stty', stty_save)
119
+ exit
120
+ end
121
+ perform = false
122
+ msg = 'Perform changes?'
123
+ while line = Readline.readline("#{msg} [Y/n] ")
124
+ case line
125
+ when /^(n|no)$/i
126
+ perform = false
127
+ STDERR.puts 'Abandoning changes...'
128
+ break
129
+ when /^(y|yes|)$/i
130
+ perform = true
131
+ STDERR.puts 'Initiating changes...'
132
+ break
133
+ when nil
134
+ perform = false
135
+ break
136
+ else
137
+ msg = "Perform changes? Please answer with 'y' or 'n'."
138
+ end
139
+ end
140
+ perform
141
+ end
142
+
143
+ def main
144
+ if ARGV.any?{|arg| %w| help -h --help -? |.member?(arg) }
145
+ puts USAGE
146
+ exit
147
+ end
148
+ #confirm = [ARGV.delete("-c"), ARGV.delete("--confirm")].any?
149
+ confirm = false
150
+ quiet = (ARGV.delete("-q") or ARGV.delete("--quiet"))
151
+ netlogging = (ARGV.delete("-n") or ARGV.delete("--net"))
152
+ norm = (ARGV.delete("--srv-singleton") or not
153
+ ARGV.delete("--no-srv-singleton"))
154
+ aws = CLIAWS.new
155
+ types = nil
156
+ while i = ARGV.index('--types')
157
+ ARGV.delete_at(i)
158
+ types = ARGV[i].split(/ +| *, */)
159
+ ARGV.delete_at(i)
160
+ bad = types.map{|s| s if not Zonify::RRTYPE_RE.match(s) }.compact
161
+ abort "Bad RR types: #{bad.join(' ')}" unless bad.empty?
162
+ end
163
+ case ARGV[0]
164
+ when 'ec2'
165
+ data = aws.ec2_zone
166
+ mappings = (ARGV[1..-1] or [])
167
+ mapped = normed_with_mappings(mappings, '.', data, norm)
168
+ STDOUT.write Zonify::YAML.format(mapped, '.')
169
+ when 'r53'
170
+ suffix = ARGV[1]
171
+ check_name(suffix)
172
+ zone, data = aws.route53_zone(suffix)
173
+ if zone and data
174
+ STDOUT.write Zonify::YAML.format(data, suffix)
175
+ else
176
+ STDERR.puts 'No zone found; outputting nonce zone.'
177
+ STDOUT.write Zonify::YAML.format({}, '.')
178
+ end
179
+ when 'eips'
180
+ result = aws.eip_scan
181
+ entries = result.keys.sort.map do |k|
182
+ dumped = ::YAML.dump(k=>result[k])
183
+ Zonify::YAML.trim_lines(dumped).join.sub(/ *\[\]$/,'')
184
+ end.join
185
+ STDOUT.write entries
186
+ when 'diff'
187
+ suffix, old_records = Zonify::YAML.read(File.read(ARGV[1]))
188
+ new_suffix, new_records = Zonify::YAML.read(File.read(ARGV[2]))
189
+ qualified = Zonify::Mappings.rewrite(new_records, [[new_suffix,[suffix]]])
190
+ changes = Zonify.diff(qualified, old_records, (types or %w| * |))
191
+ STDERR.puts(display(changes)) unless quiet
192
+ STDOUT.write(Zonify::YAML.trim_lines(YAML.dump(changes)))
193
+ when 'sync'
194
+ suffix = ARGV[1]
195
+ check_name(suffix)
196
+ new_records = aws.ec2_zone
197
+ mappings = (ARGV[2..-1] or [])
198
+ mapped = normed_with_mappings(mappings, suffix, new_records)
199
+ _, old_records = aws.route53_zone(suffix)
200
+ changes = Zonify.diff(mapped, old_records, (types or %w| CNAME SRV |))
201
+ STDERR.puts(display(changes)) if confirm or not quiet
202
+ perform = if confirm and not changes.empty?
203
+ confirm_dialogue
204
+ else
205
+ true
206
+ end
207
+ rejected(aws.apply(changes)) if perform
208
+ when 'ec2/r53'
209
+ suffix = ARGV[1]
210
+ check_name(suffix)
211
+ new_records = aws.ec2_zone
212
+ mappings = (ARGV[2..-1] or [])
213
+ mapped = normed_with_mappings(mappings, suffix, new_records)
214
+ _, old_records = aws.route53_zone(suffix)
215
+ changes = Zonify.diff(mapped, old_records, (types or %w| CNAME SRV |))
216
+ STDERR.puts(display(changes)) unless quiet
217
+ STDOUT.write(Zonify::YAML.trim_lines(YAML.dump(changes)))
218
+ when 'summarize'
219
+ handle = ARGV[1] ? File.open(ARGV[1]) : STDIN
220
+ changes = yaml_with_default(handle, [])
221
+ STDOUT.puts(display(changes))
222
+ when 'apply'
223
+ handle = ARGV[1] ? File.open(ARGV[1]) : STDIN
224
+ changes = yaml_with_default(handle, [])
225
+ STDERR.puts "" unless changes
226
+ STDERR.puts(display(changes)) if confirm or not quiet
227
+ perform = if confirm and not changes.empty?
228
+ confirm_dialogue
229
+ else
230
+ true
231
+ end
232
+ rejected(aws.apply(changes)) if perform
233
+ when 'rewrite'
234
+ suffix, records = Zonify::YAML.read(STDIN)
235
+ mappings = (ARGV[1..-1] or [])
236
+ abort 'No mappings given.' if mappings.empty?
237
+ mapped = normed_with_mappings(mappings, suffix, records, norm)
238
+ STDOUT.write(Zonify::YAML.format(mapped, suffix))
239
+ when 'normalize'
240
+ suffix, records = Zonify::YAML.read(STDIN)
241
+ desired_suffix = ARGV[1]
242
+ normed = if desired_suffix
243
+ normed_with_mappings([], desired_suffix, records)
244
+ else
245
+ Zonify.normalize(records)
246
+ end
247
+ STDOUT.write(Zonify::YAML.format(normed, (desired_suffix or suffix)))
248
+ else
249
+ abort 'Argument error.'
250
+ end
251
+ end
252
+
253
+ main
254
+
@@ -0,0 +1,582 @@
1
+ require 'yaml'
2
+
3
+ require 'fog'
4
+
5
+
6
+ module Zonify
7
+
8
+ # Set up for AWS interfaces and access to EC2 instance metadata.
9
+ class AWS
10
+ class << self
11
+ # Retrieve the EC2 instance ID of this instance.
12
+ def local_instance_id
13
+ s = `curl -s http://169.254.169.254/latest/meta-data/instance-id`
14
+ s.strip if $?.success?
15
+ end
16
+ # Initialize all AWS interfaces with the same access keys and logger
17
+ # (probably what you want to do). These are set up lazily; unused
18
+ # interfaces will not be initialized.
19
+ def create(options)
20
+ options_ec2 = options.merge( :provider=>'AWS',
21
+ :connection_options=>{:nonblock=>false} )
22
+ ec2 = Proc.new{|| Fog::Compute.new(options_ec2) }
23
+ options_elb = options_ec2.dup.delete_if{|k, _| k == :provider }
24
+ elb = Proc.new{|| Fog::AWS::ELB.new(options_elb) }
25
+ options_r53 = options_ec2.dup.delete_if{|k, _| k == :region }
26
+ r53 = Proc.new{|| Fog::DNS.new(options_r53) }
27
+ Zonify::AWS.new(:ec2_proc=>ec2, :elb_proc=>elb, :r53_proc=>r53)
28
+ end
29
+ end
30
+ attr_reader :ec2_proc, :elb_proc, :r53_proc
31
+ def initialize(opts={})
32
+ @ec2 = opts[:ec2]
33
+ @elb = opts[:elb]
34
+ @r53 = opts[:r53]
35
+ @ec2_proc = opts[:ec2_proc]
36
+ @elb_proc = opts[:elb_proc]
37
+ @r53_proc = opts[:r53_proc]
38
+ end
39
+ def ec2
40
+ @ec2 ||= @ec2_proc.call
41
+ end
42
+ def elb
43
+ @elb ||= @elb_proc.call
44
+ end
45
+ def r53
46
+ @r53 ||= @r53_proc.call
47
+ end
48
+ # Generate DNS entries based on EC2 instances, security groups and ELB load
49
+ # balancers under the user's AWS account.
50
+ def ec2_zone
51
+ Zonify.tree(Zonify.zone(instances, load_balancers))
52
+ end
53
+ # Retrieve Route53 zone data -- the zone ID as well as resource records --
54
+ # relevant to the given suffix. When there is any ambiguity, the zone with
55
+ # the longest name is chosen.
56
+ def route53_zone(suffix)
57
+ suffix_ = Zonify.dot_(suffix)
58
+ relevant_zone = r53.zones.select do |zone|
59
+ suffix_.end_with?(zone.domain)
60
+ end.sort_by{|zone| zone.domain.length }.last
61
+ if relevant_zone
62
+ relevant_records = relevant_zone.records.all!.map do |rr|
63
+ if rr.name.end_with?(suffix_)
64
+ rr.attributes.merge(:name=>Zonify.read_octal(rr.name))
65
+ end
66
+ end.compact
67
+ [relevant_zone, Zonify.tree_from_right_aws(relevant_records)]
68
+ end
69
+ end
70
+ # Apply a changeset to the records in Route53. The records must all be under
71
+ # the same zone and suffix.
72
+ def apply(changes, comment='Synced with Zonify tool.')
73
+ # Dumb way to do this because I can not figure out #reject!
74
+ keep = changes.select{|c| c[:value].length <= 100 }
75
+ filtered = changes.select{|c| c[:value].length > 100 }
76
+ unless keep.empty?
77
+ suffix = keep.first[:name] # Works because of longest submatch rule.
78
+ zone, _ = route53_zone(suffix)
79
+ Zonify.chunk_changesets(keep).each do |changeset|
80
+ rekeyed = changeset.map do |record|
81
+ record.inject({}) do |acc, pair|
82
+ k, v = pair
83
+ k_ = k == :value ? :resource_records : k
84
+ acc[k_] = v
85
+ acc
86
+ end
87
+ end
88
+ r53.change_resource_record_sets(zone.id, rekeyed, :comment=>comment)
89
+ end
90
+ end
91
+ filtered
92
+ end
93
+ def instances
94
+ ec2.servers.inject({}) do |acc, i|
95
+ dns = i.dns_name
96
+ # The default hostname for EC2 instances is derived from their internal
97
+ # DNS entry.
98
+ unless dns.nil? or dns.empty?
99
+ groups = (i.groups or [])
100
+ attrs = { :sg => groups,
101
+ :tags => (i.tags or []),
102
+ :dns => Zonify.dot_(dns).downcase }
103
+ if i.private_dns_name
104
+ attrs[:priv] = i.private_dns_name.split('.').first.downcase
105
+ end
106
+ acc[i.id] = attrs
107
+ end
108
+ acc
109
+ end
110
+ end
111
+ def load_balancers
112
+ elb.load_balancers.map do |elb|
113
+ { :instances => elb.instances,
114
+ :prefix => Zonify.cut_down_elb_name(elb.dns_name) }
115
+ end
116
+ end
117
+ def eips
118
+ ec2.addresses
119
+ end
120
+ def eip_scan
121
+ addresses = eips.map{|eip| eip.public_ip }
122
+ result = {}
123
+ addresses.each{|a| result[a] = [] }
124
+ r53.zones.sort_by{|zone| zone.domain.reverse }.each do |zone|
125
+ zone.records.all!.each do |rr|
126
+ check = case rr.type
127
+ when 'CNAME'
128
+ rr.value.map do |s|
129
+ Zonify.ec2_dns_to_ip(s)
130
+ end.compact
131
+ when 'A','AAAA'
132
+ rr.value
133
+ end
134
+ check ||= []
135
+ found = addresses.select{|a| check.member? a }.sort
136
+ unless found.empty?
137
+ name = Zonify.read_octal(rr.name)
138
+ found.each{|a| result[a] << name }
139
+ end
140
+ end
141
+ end
142
+ result
143
+ end
144
+ end
145
+
146
+ extend self
147
+
148
+
149
+ module Resolve
150
+ SRV_PREFIX = '_*._*'
151
+ end
152
+
153
+ # Records are all created with functions in this module, which ensures the
154
+ # necessary SRV prefixes, uniform TTLs and final dots in names.
155
+ module RR
156
+ extend self
157
+ def srv(service, name)
158
+ { :type=>'SRV', :value=>"0 0 0 #{Zonify.dot_(name)}",
159
+ :ttl=>'100', :name=>"#{Zonify::Resolve::SRV_PREFIX}.#{service}" }
160
+ end
161
+ def cname(name, dns, ttl='100')
162
+ { :type=>'CNAME', :value=>Zonify.dot_(dns),
163
+ :ttl=>ttl, :name=>Zonify.dot_(name) }
164
+ end
165
+ end
166
+
167
+ # Given EC2 host and ELB data, construct unqualified DNS entries to make a
168
+ # zone, of sorts.
169
+ def zone(hosts, elbs)
170
+ host_records = hosts.map do |id,info|
171
+ name = "#{id}.inst."
172
+ priv = "#{info[:priv]}.priv."
173
+ [ Zonify::RR.cname(name, info[:dns], '86400'),
174
+ Zonify::RR.cname(priv, info[:dns], '86400'),
175
+ Zonify::RR.srv('inst.', name) ] +
176
+ info[:tags].map do |tag|
177
+ k, v = tag
178
+ next if k.nil? or v.nil? or k.empty? or v.empty?
179
+ tag_dn = "#{Zonify.string_to_ldh(v)}.#{Zonify.string_to_ldh(k)}.tag."
180
+ Zonify::RR.srv(tag_dn, name)
181
+ end.compact
182
+ end.flatten
183
+ elb_records = elbs.map do |elb|
184
+ running = elb[:instances].select{|i| hosts[i] }
185
+ name = "#{elb[:prefix]}.elb."
186
+ running.map{|host| Zonify::RR.srv(name, "#{host}.inst.") }
187
+ end.flatten
188
+ sg_records = hosts.inject({}) do |acc, kv|
189
+ id, info = kv
190
+ info[:sg].each do |sg|
191
+ acc[sg] ||= []
192
+ acc[sg] << id
193
+ end
194
+ acc
195
+ end.map do |sg, ids|
196
+ sg_ldh = Zonify.string_to_ldh(sg)
197
+ name = "#{sg_ldh}.sg."
198
+ ids.map{|id| Zonify::RR.srv(name, "#{id}.inst.") }
199
+ end.flatten
200
+ [host_records, elb_records, sg_records].flatten
201
+ end
202
+
203
+ # Group DNS entries into a tree, with name at the top level, type at the
204
+ # next level and then resource records and TTL at the leaves. If the records
205
+ # are part of a weighted record set, then the record data is pushed down one
206
+ # more level, with the "set identifier" in between the type and data.
207
+ def tree(records)
208
+ records.inject({}) do |acc, record|
209
+ name, type, ttl, value,
210
+ weight, set = [ record[:name], record[:type],
211
+ record[:ttl], record[:value],
212
+ record[:weight], record[:set_identifier] ]
213
+ reference = acc[name] ||= {}
214
+ reference = reference[type] ||= {}
215
+ reference = reference[set] ||= {} if set
216
+ appended = (reference[:value] or []) << value
217
+ reference[:ttl] = ttl
218
+ reference[:value] = appended.sort.uniq
219
+ reference[:weight] = weight if weight
220
+ acc
221
+ end
222
+ end
223
+
224
+ # In the fully normalized tree of records, each multi-element SRV is
225
+ # associated with a set of equally weighted CNAMEs, one for each record.
226
+ # Singleton SRVs are associated with a single CNAME. All resource record lists
227
+ # are sorted and deduplicated.
228
+ def normalize(tree)
229
+ singles = Zonify.cname_singletons(tree)
230
+ merged = Zonify.merge(tree, singles)
231
+ remove, srvs = Zonify.srv_from_cnames(merged)
232
+ cleared = merged.inject({}) do |acc, pair|
233
+ name, info = pair
234
+ info.each do |type, data|
235
+ unless 'CNAME' == type and remove.member?(name)
236
+ acc[name] ||= {}
237
+ acc[name][type] = data
238
+ end
239
+ end
240
+ acc
241
+ end
242
+ stage2 = Zonify.merge(cleared, srvs)
243
+ multis = Zonify.cname_multitudinous(stage2)
244
+ stage3 = Zonify.merge(stage2, multis)
245
+ end
246
+
247
+ # For SRV records with a single entry, create a singleton CNAME as a
248
+ # convenience.
249
+ def cname_singletons(tree)
250
+ tree.inject({}) do |acc, pair|
251
+ name, info = pair
252
+ name_clipped = name.sub("#{Zonify::Resolve::SRV_PREFIX}.", '')
253
+ info.each do |type, data|
254
+ if 'SRV' == type and 1 == data[:value].length
255
+ rr_clipped = data[:value].map do |rr|
256
+ Zonify.dot_(rr.sub(/^([^ ]+ +){3}/, '').strip)
257
+ end
258
+ new_data = data.merge(:value=>rr_clipped)
259
+ acc[name_clipped] = { 'CNAME' => new_data }
260
+ end
261
+ end
262
+ acc
263
+ end
264
+ end
265
+
266
+ # Find CNAMEs with multiple records and create SRV records to replace them,
267
+ # as well as returning the list of CNAMEs to replace.
268
+ def srv_from_cnames(tree)
269
+ remove = []
270
+ srvs = tree.inject({}) do |acc, pair|
271
+ name, info = pair
272
+ name_srv = "#{Zonify::Resolve::SRV_PREFIX}.#{name}"
273
+ info.each do |type, data|
274
+ if 'CNAME' == type and 1 < data[:value].length
275
+ remove.push(name)
276
+ rr_srv = data[:value].map{|s| '0 0 0 ' + s }
277
+ acc[name_srv] ||= { }
278
+ acc[name_srv]['SRV'] = { :ttl=>100, :value=>rr_srv }
279
+ end
280
+ end
281
+ acc
282
+ end
283
+ [remove, srvs]
284
+ end
285
+
286
+ # For every SRV record that is not a singleton and that does not shadow an
287
+ # existing CNAME, we create WRRs for item in the SRV record.
288
+ def cname_multitudinous(tree)
289
+ tree.inject({}) do |acc, pair|
290
+ name, info = pair
291
+ name_clipped = name.sub("#{Zonify::Resolve::SRV_PREFIX}.", '')
292
+ info.each do |type, data|
293
+ if 'SRV' == type and 1 < data[:value].length
294
+ wrrs = data[:value].inject({}) do |accumulator, rr|
295
+ server = Zonify.dot_(rr.sub(/^([^ ]+ +){3}/, '').strip)
296
+ id = server.split('.').first # Always the isntance ID.
297
+ accumulator[id] = data.merge(:value=>[server], :weight=>"16")
298
+ accumulator
299
+ end
300
+ acc[name_clipped] = { 'CNAME' => wrrs }
301
+ end
302
+ end
303
+ acc
304
+ end
305
+ end
306
+
307
+ # Collate RightAWS style records in to the tree format used by the tree method.
308
+ def tree_from_right_aws(records)
309
+ records.inject({}) do |acc, record|
310
+ name, type, ttl, value,
311
+ weight, set = [ record[:name], record[:type],
312
+ record[:ttl], record[:value],
313
+ record[:weight], record[:set_identifier] ]
314
+ reference = acc[name] ||= {}
315
+ reference = reference[type] ||= {}
316
+ reference = reference[set] ||= {} if set
317
+ reference[:ttl] = ttl
318
+ reference[:value] = (value or [])
319
+ reference[:weight] = weight if weight
320
+ acc
321
+ end
322
+ end
323
+
324
+ # Merge all records from the trees, taking TTLs from the leftmost tree and
325
+ # sorting and deduplicating resource records. (When called on a single tree,
326
+ # this function serves to sort and deduplicate resource records.)
327
+ def merge(*trees)
328
+ acc = {}
329
+ trees.each do |tree|
330
+ tree.inject(acc) do |acc, pair|
331
+ name, info = pair
332
+ acc[name] ||= {}
333
+ info.inject(acc[name]) do |acc_, pair_|
334
+ type, data = pair_
335
+ case
336
+ when (not acc_[type])
337
+ acc_[type] = data.dup
338
+ when (not acc_[type][:value] and not data[:value]) # WRR records.
339
+ d = data.merge(acc_[type])
340
+ acc_[type] = d
341
+ else # Not WRR records.
342
+ acc_[type][:value] = (data[:value] + acc_[type][:value]).uniq.sort
343
+ end
344
+ acc_
345
+ end
346
+ acc
347
+ end
348
+ end
349
+ acc
350
+ end
351
+
352
+ # Old records that have the same elements as new records should be left as is.
353
+ # If they differ in any way, they should be marked for deletion and the new
354
+ # record marked for creation. Old records not in the new records should also
355
+ # be marked for deletion.
356
+ def diff(new_records, old_records, types=['CNAME','SRV'])
357
+ create_set = new_records.map do |name, v|
358
+ old = old_records[name]
359
+ v.map do |type, data|
360
+ if types.member? '*' or types.member? type
361
+ old_data = ((old and old[type]) or {})
362
+ unless Zonify.compare_records(old_data, data)
363
+ Zonify.hoist(data, name, type, 'CREATE')
364
+ end
365
+ end
366
+ end.compact
367
+ end
368
+ delete_set = old_records.map do |name, v|
369
+ new = new_records[name]
370
+ v.map do |type, data|
371
+ if types.member? '*' or types.member? type
372
+ new_data = ((new and new[type]) or {})
373
+ unless Zonify.compare_records(data, new_data)
374
+ Zonify.hoist(data, name, type, 'DELETE')
375
+ end
376
+ end
377
+ end.compact
378
+ end
379
+ (delete_set.flatten + create_set.flatten).sort_by do |record|
380
+ # Sort actions so that creation of a record comes immediately after a
381
+ # deletion.
382
+ delete_first = record[:action] == 'DELETE' ? 0 : 1
383
+ [record[:name], record[:type], delete_first]
384
+ end
385
+ end
386
+
387
+ def hoist(data, name, type, action)
388
+ meta = {:name=>name, :type=>type, :action=>action}
389
+ if data[:value] # Not a WRR.
390
+ [data.merge(meta)]
391
+ else # Is a WRR.
392
+ data.map{|k,v| v.merge(meta.merge(:set_identifier=>k)) }
393
+ end
394
+ end
395
+
396
+ # Determine whether two resource record sets are the same in all respects
397
+ # (keys missing in one should be missing in the other).
398
+ def compare_records(a, b)
399
+ keys = ((a.keys | b.keys) - [:value]).sort_by{|s| s.to_s }
400
+ as, bs = [a, b].map do |record|
401
+ keys.map{|k| record[k] } << Zonify.normRRs(record[:value])
402
+ end
403
+ as == bs
404
+ end
405
+
406
+ # Sometimes, resource_records are a single string; sometimes, an array. The
407
+ # array should be sorted for comparison's sake. Strings should be put in an
408
+ # array.
409
+ def normRRs(val)
410
+ case val
411
+ when Array then val.sort
412
+ else [val]
413
+ end
414
+ end
415
+
416
+ def read_octal(s)
417
+ after = s
418
+ acc = ''
419
+ loop do
420
+ before, match, after = after.partition(/\\([0-9][0-9][0-9])/)
421
+ acc += before
422
+ break if match.empty?
423
+ acc << $1.oct
424
+ end
425
+ acc
426
+ end
427
+
428
+ ELB_DNS_RE = /^([a-z0-9-]+)-[^-.]+[.].+$/
429
+ def cut_down_elb_name(s)
430
+ $1 if ELB_DNS_RE.match(s)
431
+ end
432
+
433
+ LDH_RE = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/
434
+ def string_to_ldh_component(s)
435
+ LDH_RE.match(s) ? s.downcase : s.downcase.gsub(/[^a-z0-9-]/, '-').
436
+ sub(/(^[-]+|[-]+$)/, '')[0,63]
437
+ end
438
+
439
+ def string_to_ldh(s)
440
+ s.split('.').map{|s| string_to_ldh_component(s) }.join('.')
441
+ end
442
+
443
+ def _dot(s)
444
+ /^[.]/.match(s) ? s : ".#{s}"
445
+ end
446
+
447
+ def dot_(s)
448
+ /[.]$/.match(s) ? s : "#{s}."
449
+ end
450
+
451
+ EC2_DNS_RE = /^ec2-([0-9]+)-([0-9]+)-([0-9]+)-([0-9]+)
452
+ [.]compute-[0-9]+[.]amazonaws[.]com[.]?$/x
453
+ def ec2_dns_to_ip(dns)
454
+ "#{$1}.#{$2}.#{$3}.#{$4}" if EC2_DNS_RE.match(dns)
455
+ end
456
+
457
+ module YAML
458
+ extend self
459
+ def format(records, suffix='')
460
+ _suffix_ = Zonify._dot(Zonify.dot_(suffix))
461
+ entries = records.keys.sort.map do |k|
462
+ dumped = ::YAML.dump(k=>records[k])
463
+ Zonify::YAML.trim_lines(dumped).map{|ln| ' ' + ln }.join
464
+ end.join
465
+ "suffix: #{_suffix_}\nrecords:\n" + entries
466
+ end
467
+ def read(text)
468
+ yaml = ::YAML.load(text)
469
+ if yaml['suffix'] and yaml['records']
470
+ [yaml['suffix'], yaml['records']]
471
+ end
472
+ end
473
+ def trim_lines(yaml)
474
+ lines = yaml.lines.to_a
475
+ lines.shift if /^---/.match(lines[0])
476
+ lines.pop if /^$/.match(lines[-1])
477
+ lines
478
+ end
479
+ end
480
+
481
+ # The Route 53 API has limitations on query size:
482
+ #
483
+ # - A request cannot contain more than 100 Change elements.
484
+ #
485
+ # - A request cannot contain more than 1000 ResourceRecord elements.
486
+ #
487
+ # - The sum of the number of characters (including spaces) in all Value
488
+ # elements in a request cannot exceed 32,000 characters.
489
+ #
490
+ def chunk_changesets(changes)
491
+ chunks = [[]]
492
+ changes.each do |change|
493
+ if fits(change, chunks.last)
494
+ chunks.last.push(change)
495
+ else
496
+ chunks.push([change])
497
+ end
498
+ end
499
+ chunks
500
+ end
501
+
502
+ def measureRRs(change)
503
+ [ change[:value].length,
504
+ change[:value].inject(0){|sum, s| s.length + sum } ]
505
+ end
506
+
507
+ # Determine whether we can add this record to the existing records, subject to
508
+ # Amazon size constraints.
509
+ def fits(change, changes)
510
+ new = changes + [change]
511
+ measured = new.map{|change| measureRRs(change) }
512
+ len, chars = measured.inject([0, 0]) do |acc, pair|
513
+ [ acc[0] + pair[0], acc[1] + pair[1] ]
514
+ end
515
+ new.length <= 100 and len <= 1000 and chars <= 30000 # margin of safety
516
+ end
517
+
518
+ module Mappings
519
+ extend self
520
+ def parse(s)
521
+ k, *v = s.split(':')
522
+ [k, v] if k and v and not v.empty?
523
+ end
524
+ # Apply mappings to the name in order. (A hash can be used for mappings but
525
+ # then one will not be able to predict the order.) If no mappings apply, the
526
+ # empty list is returned.
527
+ def apply(name, mappings)
528
+ name_ = Zonify.dot_(name)
529
+ mappings.map do |k, v|
530
+ _k_ = Zonify.dot_(Zonify._dot(k))
531
+ before = Zonify::Mappings.unsuffix(name_, _k_)
532
+ v.map{|s| Zonify.dot_(before + Zonify._dot(s)) } if before
533
+ end.compact.flatten
534
+ end
535
+ # Get the names that result from the mappings, or the original name if none
536
+ # apply. The first name in the list is taken to be the canonical name, the
537
+ # one used for groups of servers in SRV records.
538
+ def names(name, mappings)
539
+ mapped = Zonify::Mappings.apply(name, mappings)
540
+ mapped.empty? ? [name] : mapped
541
+ end
542
+ def unsuffix(s, suffix)
543
+ before, _, after = s.rpartition(suffix)
544
+ before if after.empty?
545
+ end
546
+ def rewrite(tree, mappings)
547
+ tree.inject({}) do |acc, pair|
548
+ name, info = pair
549
+ names = Zonify::Mappings.names(name, mappings)
550
+ names.each do |name|
551
+ acc[name] ||= {}
552
+ info.inject(acc[name]) do |acc_, pair_|
553
+ type, data = pair_
554
+ acc_[type] ||= {}
555
+ prefix_ = Zonify.dot_(Zonify::Resolve::SRV_PREFIX)
556
+ rrs = if type == 'SRV' and name.start_with? prefix_ and data[:value]
557
+ data[:value].map do |rr|
558
+ if /^(.+) ([^ ]+)$/.match(rr)
559
+ "#{$1} #{Zonify::Mappings.names($2, mappings).first}"
560
+ else
561
+ rr
562
+ end
563
+ end
564
+ end
565
+ addenda = rrs ? { :value => rrs + (acc_[type][:value] or []) } : {}
566
+ acc_[type] = data.merge(addenda)
567
+ acc_
568
+ end
569
+ end
570
+ acc
571
+ end
572
+ end
573
+ end
574
+
575
+ # Based on reading the Wikipedia page:
576
+ # http://en.wikipedia.org/wiki/List_of_DNS_record_types
577
+ # and the IANA registry:
578
+ # http://www.iana.org/assignments/dns-parameters
579
+ RRTYPE_RE = /^([*]|[A-Z0-9-]+)$/
580
+
581
+ end
582
+
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zonify
3
+ version: !ruby/object:Gem::Version
4
+ hash: 101
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 4
9
+ - 0
10
+ - 5
11
+ version: 0.4.0.5
12
+ platform: ruby
13
+ authors:
14
+ - AirBNB
15
+ autorequire:
16
+ bindir: bin
17
+ cert_chain: []
18
+
19
+ date: 2012-09-27 00:00:00 Z
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: fog
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 3
30
+ segments:
31
+ - 0
32
+ version: "0"
33
+ type: :runtime
34
+ version_requirements: *id001
35
+ description: |
36
+ Zonify provides a command line tool for generating DNS records from EC2
37
+ instances, instance tags, load balancers and security groups. A mechanism for
38
+ syncing these records with a zone stored in Route 53 is also provided.
39
+
40
+ email: contact@airbnb.com
41
+ executables:
42
+ - zonify
43
+ extensions: []
44
+
45
+ extra_rdoc_files: []
46
+
47
+ files:
48
+ - lib/zonify.rb
49
+ - README
50
+ - bin/zonify
51
+ homepage: https://github.com/airbnb/zonify
52
+ licenses:
53
+ - BSD
54
+ post_install_message:
55
+ rdoc_options: []
56
+
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ none: false
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ hash: 3
65
+ segments:
66
+ - 0
67
+ version: "0"
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ none: false
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ hash: 3
74
+ segments:
75
+ - 0
76
+ version: "0"
77
+ requirements: []
78
+
79
+ rubyforge_project:
80
+ rubygems_version: 1.8.24
81
+ signing_key:
82
+ specification_version: 3
83
+ summary: Generate DNS information from EC2 metadata.
84
+ test_files: []
85
+