1 | #!/usr/bin/env python
|
---|
2 | #
|
---|
3 | # Small library and commandline tool to do logical diffs of zonefiles
|
---|
4 | # ./zonediff -h gives you help output
|
---|
5 | #
|
---|
6 | # Requires dnspython to do all the heavy lifting
|
---|
7 | #
|
---|
8 | # (c)2009 Dennis Kaarsemaker <dennis@kaarsemaker.net>
|
---|
9 | #
|
---|
10 | # Permission to use, copy, modify, and distribute this software and its
|
---|
11 | # documentation for any purpose with or without fee is hereby granted,
|
---|
12 | # provided that the above copyright notice and this permission notice
|
---|
13 | # appear in all copies.
|
---|
14 | #
|
---|
15 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
---|
16 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
---|
17 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
---|
18 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
---|
19 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
---|
20 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
---|
21 | # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
---|
22 | """See diff_zones.__doc__ for more information"""
|
---|
23 |
|
---|
24 | __all__ = ['diff_zones', 'format_changes_plain', 'format_changes_html']
|
---|
25 |
|
---|
26 | try:
|
---|
27 | import dns.zone
|
---|
28 | except ImportError:
|
---|
29 | import sys
|
---|
30 | sys.stderr.write("Please install dnspython")
|
---|
31 | sys.exit(1)
|
---|
32 |
|
---|
33 | def diff_zones(zone1, zone2, ignore_ttl=False, ignore_soa=False):
|
---|
34 | """diff_zones(zone1, zone2, ignore_ttl=False, ignore_soa=False) -> changes
|
---|
35 | Compares two dns.zone.Zone objects and returns a list of all changes
|
---|
36 | in the format (name, oldnode, newnode).
|
---|
37 |
|
---|
38 | If ignore_ttl is true, a node will not be added to this list if the
|
---|
39 | only change is its TTL.
|
---|
40 |
|
---|
41 | If ignore_soa is true, a node will not be added to this list if the
|
---|
42 | only changes is a change in a SOA Rdata set.
|
---|
43 |
|
---|
44 | The returned nodes do include all Rdata sets, including unchanged ones.
|
---|
45 | """
|
---|
46 |
|
---|
47 | changes = []
|
---|
48 | for name in zone1:
|
---|
49 | name = str(name)
|
---|
50 | n1 = zone1.get_node(name)
|
---|
51 | n2 = zone2.get_node(name)
|
---|
52 | if not n2:
|
---|
53 | changes.append((str(name), n1, n2))
|
---|
54 | elif _nodes_differ(n1, n2, ignore_ttl, ignore_soa):
|
---|
55 | changes.append((str(name), n1, n2))
|
---|
56 |
|
---|
57 | for name in zone2:
|
---|
58 | n1 = zone1.get_node(name)
|
---|
59 | if not n1:
|
---|
60 | n2 = zone2.get_node(name)
|
---|
61 | changes.append((str(name), n1, n2))
|
---|
62 | return changes
|
---|
63 |
|
---|
64 | def _nodes_differ(n1, n2, ignore_ttl, ignore_soa):
|
---|
65 | if ignore_soa or not ignore_ttl:
|
---|
66 | # Compare datasets directly
|
---|
67 | for r in n1.rdatasets:
|
---|
68 | if ignore_soa and r.rdtype == dns.rdatatype.SOA:
|
---|
69 | continue
|
---|
70 | if r not in n2.rdatasets:
|
---|
71 | return True
|
---|
72 | if not ignore_ttl:
|
---|
73 | return r.ttl != n2.find_rdataset(r.rdclass, r.rdtype).ttl
|
---|
74 |
|
---|
75 | for r in n2.rdatasets:
|
---|
76 | if ignore_soa and r.rdtype == dns.rdatatype.SOA:
|
---|
77 | continue
|
---|
78 | if r not in n1.rdatasets:
|
---|
79 | return True
|
---|
80 | else:
|
---|
81 | return n1 != n2
|
---|
82 |
|
---|
83 | def format_changes_plain(oldf, newf, changes, ignore_ttl=False):
|
---|
84 | """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str
|
---|
85 | Given 2 filenames and a list of changes from diff_zones, produce diff-like
|
---|
86 | output. If ignore_ttl is True, TTL-only changes are not displayed"""
|
---|
87 |
|
---|
88 | ret = "--- %s\n+++ %s\n" % (oldf, newf)
|
---|
89 | for name, old, new in changes:
|
---|
90 | ret += "@ %s\n" % name
|
---|
91 | if not old:
|
---|
92 | for r in new.rdatasets:
|
---|
93 | ret += "+ %s\n" % str(r).replace('\n','\n+ ')
|
---|
94 | elif not new:
|
---|
95 | for r in old.rdatasets:
|
---|
96 | ret += "- %s\n" % str(r).replace('\n','\n+ ')
|
---|
97 | else:
|
---|
98 | for r in old.rdatasets:
|
---|
99 | if r not in new.rdatasets or (r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
---|
100 | ret += "- %s\n" % str(r).replace('\n','\n+ ')
|
---|
101 | for r in new.rdatasets:
|
---|
102 | if r not in old.rdatasets or (r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
---|
103 | ret += "+ %s\n" % str(r).replace('\n','\n+ ')
|
---|
104 | return ret
|
---|
105 |
|
---|
106 | def format_changes_html(oldf, newf, changes, ignore_ttl=False):
|
---|
107 | """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str
|
---|
108 | Given 2 filenames and a list of changes from diff_zones, produce nice html
|
---|
109 | output. If ignore_ttl is True, TTL-only changes are not displayed"""
|
---|
110 |
|
---|
111 | ret = '''<table class="zonediff">
|
---|
112 | <thead>
|
---|
113 | <tr>
|
---|
114 | <th> </th>
|
---|
115 | <th class="old">%s</th>
|
---|
116 | <th class="new">%s</th>
|
---|
117 | </tr>
|
---|
118 | </thead>
|
---|
119 | <tbody>\n''' % (oldf, newf)
|
---|
120 |
|
---|
121 | for name, old, new in changes:
|
---|
122 | ret += ' <tr class="rdata">\n <td class="rdname">%s</td>\n' % name
|
---|
123 | if not old:
|
---|
124 | for r in new.rdatasets:
|
---|
125 | ret += ' <td class="old"> </td>\n <td class="new">%s</td>\n' % str(r).replace('\n','<br />')
|
---|
126 | elif not new:
|
---|
127 | for r in old.rdatasets:
|
---|
128 | ret += ' <td class="old">%s</td>\n <td class="new"> </td>\n' % str(r).replace('\n','<br />')
|
---|
129 | else:
|
---|
130 | ret += ' <td class="old">'
|
---|
131 | for r in old.rdatasets:
|
---|
132 | if r not in new.rdatasets or (r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
---|
133 | ret += str(r).replace('\n','<br />')
|
---|
134 | ret += '</td>\n'
|
---|
135 | ret += ' <td class="new">'
|
---|
136 | for r in new.rdatasets:
|
---|
137 | if r not in old.rdatasets or (r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
---|
138 | ret += str(r).replace('\n','<br />')
|
---|
139 | ret += '</td>\n'
|
---|
140 | ret += ' </tr>\n'
|
---|
141 | return ret + ' </tbody>\n</table>'
|
---|
142 |
|
---|
143 | # Make this module usable as a script too.
|
---|
144 | if __name__ == '__main__':
|
---|
145 | import optparse
|
---|
146 | import subprocess
|
---|
147 | import sys
|
---|
148 | import traceback
|
---|
149 |
|
---|
150 | usage = """%prog zonefile1 zonefile2 - Show differences between zones in a diff-like format
|
---|
151 | %prog [--git|--bzr|--rcs] zonefile rev1 [rev2] - Show differences between two revisions of a zonefile
|
---|
152 |
|
---|
153 | The differences shown will be logical differences, not textual differences.
|
---|
154 | """
|
---|
155 | p = optparse.OptionParser(usage=usage)
|
---|
156 | p.add_option('-s', '--ignore-soa', action="store_true", default=False, dest="ignore_soa",
|
---|
157 | help="Ignore SOA-only changes to records")
|
---|
158 | p.add_option('-t', '--ignore-ttl', action="store_true", default=False, dest="ignore_ttl",
|
---|
159 | help="Ignore TTL-only changes to Rdata")
|
---|
160 | p.add_option('-T', '--traceback', action="store_true", default=False, dest="tracebacks",
|
---|
161 | help="Show python tracebacks when errors occur")
|
---|
162 | p.add_option('-H', '--html', action="store_true", default=False, dest="html",
|
---|
163 | help="Print HTML output")
|
---|
164 | p.add_option('-g', '--git', action="store_true", default=False, dest="use_git",
|
---|
165 | help="Use git revisions instead of real files")
|
---|
166 | p.add_option('-b', '--bzr', action="store_true", default=False, dest="use_bzr",
|
---|
167 | help="Use bzr revisions instead of real files")
|
---|
168 | p.add_option('-r', '--rcs', action="store_true", default=False, dest="use_rcs",
|
---|
169 | help="Use rcs revisions instead of real files")
|
---|
170 | opts, args = p.parse_args()
|
---|
171 | opts.use_vc = opts.use_git or opts.use_bzr or opts.use_rcs
|
---|
172 |
|
---|
173 | def _open(what, err):
|
---|
174 | if isinstance(what, basestring):
|
---|
175 | # Open as normal file
|
---|
176 | try:
|
---|
177 | return open(what, 'rb')
|
---|
178 | except:
|
---|
179 | sys.stderr.write(err + "\n")
|
---|
180 | if opts.tracebacks:
|
---|
181 | traceback.print_exc()
|
---|
182 | else:
|
---|
183 | # Must be a list, open subprocess
|
---|
184 | try:
|
---|
185 | proc = subprocess.Popen(what, stdout=subprocess.PIPE)
|
---|
186 | proc.wait()
|
---|
187 | if proc.returncode == 0:
|
---|
188 | return proc.stdout
|
---|
189 | sys.stderr.write(err + "\n")
|
---|
190 | except:
|
---|
191 | sys.stderr.write(err + "\n")
|
---|
192 | if opts.tracebacks:
|
---|
193 | traceback.print_exc()
|
---|
194 |
|
---|
195 | if not opts.use_vc and len(args) != 2:
|
---|
196 | p.print_help()
|
---|
197 | sys.exit(64)
|
---|
198 | if opts.use_vc and len(args) not in (2,3):
|
---|
199 | p.print_help()
|
---|
200 | sys.exit(64)
|
---|
201 |
|
---|
202 | # Open file desriptors
|
---|
203 | if not opts.use_vc:
|
---|
204 | oldn, newn = args
|
---|
205 | else:
|
---|
206 | if len(args) == 3:
|
---|
207 | filename, oldr, newr = args
|
---|
208 | oldn = "%s:%s" % (oldr, filename)
|
---|
209 | newn = "%s:%s" % (newr, filename)
|
---|
210 | else:
|
---|
211 | filename, oldr = args
|
---|
212 | newr = None
|
---|
213 | oldn = "%s:%s" % (oldr, filename)
|
---|
214 | newn = filename
|
---|
215 |
|
---|
216 |
|
---|
217 | old, new = None, None
|
---|
218 | oldz, newz = None, None
|
---|
219 | if opts.use_bzr:
|
---|
220 | old = _open(["bzr", "cat", "-r" + oldr, filename],
|
---|
221 | "Unable to retrieve revision %s of %s" % (oldr, filename))
|
---|
222 | if newr != None:
|
---|
223 | new = _open(["bzr", "cat", "-r" + newr, filename],
|
---|
224 | "Unable to retrieve revision %s of %s" % (newr, filename))
|
---|
225 | elif opts.use_git:
|
---|
226 | old = _open(["git", "show", oldn],
|
---|
227 | "Unable to retrieve revision %s of %s" % (oldr, filename))
|
---|
228 | if newr != None:
|
---|
229 | new = _open(["git", "show", newn],
|
---|
230 | "Unable to retrieve revision %s of %s" % (newr, filename))
|
---|
231 | elif opts.use_rcs:
|
---|
232 | old = _open(["co", "-q", "-p", "-r" + oldr, filename],
|
---|
233 | "Unable to retrieve revision %s of %s" % (oldr, filename))
|
---|
234 | if newr != None:
|
---|
235 | new = _open(["co", "-q", "-p", "-r" + newr, filename],
|
---|
236 | "Unable to retrieve revision %s of %s" % (newr, filename))
|
---|
237 | if not opts.use_vc:
|
---|
238 | old = _open(oldn, "Unable to open %s" % oldn)
|
---|
239 | if not opts.use_vc or newr == None:
|
---|
240 | new = _open(newn, "Unable to open %s" % newn)
|
---|
241 |
|
---|
242 | if not old or not new:
|
---|
243 | sys.exit(65)
|
---|
244 |
|
---|
245 | # Parse the zones
|
---|
246 | try:
|
---|
247 | oldz = dns.zone.from_file(old, origin = '.', check_origin=False)
|
---|
248 | except dns.exception.DNSException:
|
---|
249 | sys.stderr.write("Incorrect zonefile: %s\n", old)
|
---|
250 | if opts.tracebacks:
|
---|
251 | traceback.print_exc()
|
---|
252 | try:
|
---|
253 | newz = dns.zone.from_file(new, origin = '.', check_origin=False)
|
---|
254 | except dns.exception.DNSException:
|
---|
255 | sys.stderr.write("Incorrect zonefile: %s\n" % new)
|
---|
256 | if opts.tracebacks:
|
---|
257 | traceback.print_exc()
|
---|
258 | if not oldz or not newz:
|
---|
259 | sys.exit(65)
|
---|
260 |
|
---|
261 | changes = diff_zones(oldz, newz, opts.ignore_ttl, opts.ignore_soa)
|
---|
262 | changes.sort()
|
---|
263 |
|
---|
264 | if not changes:
|
---|
265 | sys.exit(0)
|
---|
266 | if opts.html:
|
---|
267 | print format_changes_html(oldn, newn, changes, opts.ignore_ttl)
|
---|
268 | else:
|
---|
269 | print format_changes_plain(oldn, newn, changes, opts.ignore_ttl)
|
---|
270 | sys.exit(1)
|
---|