zonify 0.4.0.5

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.
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
+