dnsmadeeasy 0.4.0 → 1.0.1

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +5 -10
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +5 -4
  5. data/.rubocop_todo.yml +251 -143
  6. data/CLAUDE.md +67 -0
  7. data/Gemfile +9 -1
  8. data/README.md +858 -0
  9. data/Rakefile +4 -4
  10. data/dnsmadeeasy.gemspec +42 -26
  11. data/docs/badges/coverage_badge.svg +21 -0
  12. data/docs/dry-cli-based-tools.webloc +8 -0
  13. data/docs/plan-zone-management.md +756 -0
  14. data/docs/plans/01-plan-ruby4-baseline.md +38 -0
  15. data/docs/plans/02-plan-dry-cli-launcher.md +44 -0
  16. data/docs/plans/03-plan-account-command.md +43 -0
  17. data/docs/plans/04-plan-zone-record-model.md +48 -0
  18. data/docs/plans/05-plan-zone-parser.md +41 -0
  19. data/docs/plans/06-plan-zone-formatter.md +43 -0
  20. data/docs/plans/07-plan-zone-export.md +58 -0
  21. data/docs/plans/08-plan-zone-diff.md +42 -0
  22. data/docs/plans/09-plan-zone-apply.md +69 -0
  23. data/docs/plans/10-plan-docs-cleanup.md +55 -0
  24. data/docs/spec-zone-management.md +242 -0
  25. data/exe/dme +13 -2
  26. data/exe/dmez +6 -0
  27. data/lib/dme.rb +7 -2
  28. data/lib/dnsmadeeasy/api/client.rb +32 -26
  29. data/lib/dnsmadeeasy/cli/box_output.rb +9 -0
  30. data/lib/dnsmadeeasy/cli/commands/account.rb +222 -0
  31. data/lib/dnsmadeeasy/cli/commands/base.rb +119 -0
  32. data/lib/dnsmadeeasy/cli/commands/legacy_operation.rb +30 -0
  33. data/lib/dnsmadeeasy/cli/commands/version.rb +22 -0
  34. data/lib/dnsmadeeasy/cli/commands/zone.rb +326 -0
  35. data/lib/dnsmadeeasy/cli/commands.rb +19 -0
  36. data/lib/dnsmadeeasy/cli/input.rb +14 -0
  37. data/lib/dnsmadeeasy/cli/launcher.rb +53 -0
  38. data/lib/dnsmadeeasy/cli/message_helpers.rb +81 -0
  39. data/lib/dnsmadeeasy/cli/reported_error.rb +10 -0
  40. data/lib/dnsmadeeasy/credentials/api_keys.rb +11 -9
  41. data/lib/dnsmadeeasy/credentials.rb +0 -1
  42. data/lib/dnsmadeeasy/runner.rb +24 -24
  43. data/lib/dnsmadeeasy/types.rb +19 -0
  44. data/lib/dnsmadeeasy/version.rb +2 -1
  45. data/lib/dnsmadeeasy/zone/aname_flattener.rb +63 -0
  46. data/lib/dnsmadeeasy/zone/apply_executor.rb +189 -0
  47. data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
  48. data/lib/dnsmadeeasy/zone/diff.rb +152 -0
  49. data/lib/dnsmadeeasy/zone/file.rb +26 -0
  50. data/lib/dnsmadeeasy/zone/parser.rb +172 -0
  51. data/lib/dnsmadeeasy/zone/plan.rb +28 -0
  52. data/lib/dnsmadeeasy/zone/plan_action.rb +29 -0
  53. data/lib/dnsmadeeasy/zone/plan_renderer.rb +91 -0
  54. data/lib/dnsmadeeasy/zone/provider_record.rb +18 -0
  55. data/lib/dnsmadeeasy/zone/record.rb +44 -0
  56. data/lib/dnsmadeeasy/zone/record_set.rb +23 -0
  57. data/lib/dnsmadeeasy/zone/remote_adapter.rb +94 -0
  58. data/lib/dnsmadeeasy/zone/remote_records.rb +26 -0
  59. data/lib/dnsmadeeasy/zone/serializer.rb +115 -0
  60. data/lib/dnsmadeeasy.rb +61 -27
  61. metadata +184 -25
  62. data/.dme-help.png +0 -0
  63. data/README.adoc +0 -690
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dns/zonefile'
4
+ require 'dry/monads'
5
+ require 'dnsmadeeasy/zone/file'
6
+ require 'dnsmadeeasy/zone/record'
7
+ require 'dnsmadeeasy/zone/record_set'
8
+
9
+ module DnsMadeEasy
10
+ module Zone
11
+ # Parses standard zone-file text into provider-neutral records.
12
+ class Parser
13
+ include Dry::Monads[:result]
14
+
15
+ # ANAME is absent from the dns-zonefile grammar; ANAME lines are
16
+ # rewritten to CNAME before parsing and converted back afterwards.
17
+ SUPPORTED_RECORD_CLASSES = {
18
+ 'DNS::Zonefile::A' => 'A',
19
+ 'DNS::Zonefile::AAAA' => 'AAAA',
20
+ 'DNS::Zonefile::CNAME' => 'CNAME',
21
+ 'DNS::Zonefile::MX' => 'MX',
22
+ 'DNS::Zonefile::NS' => 'NS',
23
+ 'DNS::Zonefile::PTR' => 'PTR',
24
+ 'DNS::Zonefile::SPF' => 'SPF',
25
+ 'DNS::Zonefile::SRV' => 'SRV',
26
+ 'DNS::Zonefile::TXT' => 'TXT'
27
+ }.freeze
28
+
29
+ ANAME_LINE = /^(?<owner>[^\s;]\S*)\s+(?:\d+\s+)?(?:IN\s+)?ANAME\s+(?<target>\S+)/i
30
+
31
+ def initialize(zone_text)
32
+ @zone_text = zone_text
33
+ @aname_keys = []
34
+ end
35
+
36
+ def call
37
+ zone = DNS::Zonefile.load(parseable_zone_text)
38
+ records, errors = build_records(zone)
39
+ return Failure(errors) if errors.any?
40
+
41
+ Success(File.new(origin: zone_origin(zone), ttl: zone_ttl(zone), record_set: RecordSet.new(records: records)))
42
+ rescue DNS::Zonefile::ParsingError, DNS::Zonefile::UnknownRecordType, Dry::Struct::Error => e
43
+ Failure([e.message])
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :zone_text,
49
+ :aname_keys
50
+
51
+ def parseable_zone_text
52
+ zone_lines = rewrite_aname_lines(zone_text.lines)
53
+ insert_synthetic_soa(zone_lines) unless zone_text.match?(/\bSOA\b/)
54
+ zone_lines.join
55
+ end
56
+
57
+ def rewrite_aname_lines(zone_lines)
58
+ zone_lines.map do |line|
59
+ match = ANAME_LINE.match(line)
60
+ next line unless match
61
+
62
+ aname_keys << [match[:owner], match[:target]]
63
+ line.sub(/\bANAME\b/i, 'CNAME')
64
+ end
65
+ end
66
+
67
+ def insert_synthetic_soa(zone_lines)
68
+ insertion_index = zone_lines.index { |line| !line.strip.start_with?('$') && !line.strip.empty? } || zone_lines.length
69
+ zone_lines.insert(insertion_index, synthetic_soa_line)
70
+ end
71
+
72
+ def synthetic_soa_line
73
+ "@ IN SOA ns.#{origin_from_text} hostmaster.#{origin_from_text} ( 1 1d 1d 4W #{ttl_from_text} )\n"
74
+ end
75
+
76
+ def origin_from_text
77
+ zone_text[/^\$ORIGIN\s+(\S+)/, 1] || 'example.invalid.'
78
+ end
79
+
80
+ def ttl_from_text
81
+ zone_text[/^\$TTL\s+(\d+)/, 1] || 300
82
+ end
83
+
84
+ def build_records(zone)
85
+ origin = zone_origin(zone)
86
+ zone.records.each_with_object([[], []]) do |provider_record, (records, errors)|
87
+ next if provider_record.is_a?(DNS::Zonefile::SOA)
88
+
89
+ record_type = SUPPORTED_RECORD_CLASSES[provider_record.class.name]
90
+ if record_type
91
+ records << build_record(provider_record, record_type, origin)
92
+ else
93
+ errors << "Unsupported DNS record type: #{provider_record.class.name.split('::').last}"
94
+ end
95
+ end
96
+ end
97
+
98
+ def zone_origin(zone)
99
+ zone.soa&.origin || '.'
100
+ end
101
+
102
+ def zone_ttl(zone)
103
+ zone.soa&.ttl || 300
104
+ end
105
+
106
+ def build_record(provider_record, record_type, origin)
107
+ record_type = 'ANAME' if record_type == 'CNAME' && aname?(provider_record, origin)
108
+ attributes = {
109
+ owner: normalize_owner(provider_record.host, origin),
110
+ type: record_type,
111
+ value: record_value(provider_record, record_type, origin),
112
+ ttl: provider_record.ttl
113
+ }.merge(record_metadata(provider_record, record_type))
114
+
115
+ Record.new(attributes)
116
+ end
117
+
118
+ def normalize_owner(host, origin)
119
+ return '@' if host == origin
120
+
121
+ host.delete_suffix(".#{origin}").delete_suffix('.')
122
+ end
123
+
124
+ def record_value(provider_record, record_type, origin)
125
+ case record_type
126
+ when 'A', 'AAAA'
127
+ provider_record.address
128
+ when 'ANAME', 'CNAME', 'MX', 'NS', 'PTR', 'SRV'
129
+ normalize_target(provider_record.domainname, origin)
130
+ when 'SPF', 'TXT'
131
+ normalize_text_value(provider_record.data)
132
+ end
133
+ end
134
+
135
+ def normalize_target(target, origin)
136
+ return '@' if target == origin
137
+
138
+ target
139
+ end
140
+
141
+ def aname?(provider_record, origin)
142
+ owner = normalize_owner(provider_record.host, origin)
143
+ target = absolute_name(provider_record.domainname, origin)
144
+ aname_keys.any? do |key_owner, key_target|
145
+ normalize_owner(absolute_name(key_owner, origin), origin) == owner &&
146
+ absolute_name(key_target, origin) == target
147
+ end
148
+ end
149
+
150
+ def absolute_name(name, origin)
151
+ return origin if name == '@'
152
+
153
+ name.end_with?('.') ? name : "#{name}.#{origin}"
154
+ end
155
+
156
+ def normalize_text_value(value)
157
+ value.delete_prefix('"').delete_suffix('"')
158
+ end
159
+
160
+ def record_metadata(provider_record, record_type)
161
+ case record_type
162
+ when 'MX'
163
+ { priority: provider_record.priority }
164
+ when 'SRV'
165
+ { priority: provider_record.priority, weight: provider_record.weight, port: provider_record.port }
166
+ else
167
+ {}
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/types'
5
+ require 'dnsmadeeasy/zone/plan_action'
6
+
7
+ module DnsMadeEasy
8
+ module Zone
9
+ # Diff result describing intended changes without applying them.
10
+ class Plan < Dry::Struct
11
+ transform_keys(&:to_sym)
12
+
13
+ attribute :creates, Types::Array.of(PlanAction).default([].freeze)
14
+ attribute :updates, Types::Array.of(PlanAction).default([].freeze)
15
+ attribute :skipped_creates, Types::Array.of(PlanAction).default([].freeze)
16
+ attribute :skipped_deletes, Types::Array.of(PlanAction).default([].freeze)
17
+ attribute :ambiguous, Types::Array.of(PlanAction).default([].freeze)
18
+
19
+ def actions
20
+ [creates, updates, skipped_creates, skipped_deletes, ambiguous].flatten.sort_by(&:sort_key)
21
+ end
22
+
23
+ def empty?
24
+ actions.empty?
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/types'
5
+ require 'dnsmadeeasy/zone/record'
6
+
7
+ module DnsMadeEasy
8
+ module Zone
9
+ # A single proposed zone-management action.
10
+ class PlanAction < Dry::Struct
11
+ transform_keys(&:to_sym)
12
+
13
+ ACTION_TYPES = %w[create update skipped_create skipped_delete ambiguous].freeze
14
+
15
+ attribute :action, Types::StrictString.enum(*ACTION_TYPES)
16
+ attribute :record, Record.optional.default(nil)
17
+ attribute :remote_record, Record.optional.default(nil)
18
+ attribute :desired_record, Record.optional.default(nil)
19
+ attribute :message, Types::OptionalString.default(nil)
20
+
21
+ def sort_key
22
+ [
23
+ ACTION_TYPES.index(action),
24
+ (record || desired_record || remote_record)&.sort_key || []
25
+ ]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module DnsMadeEasy
6
+ module Zone
7
+ # Renders zone plans for humans and automation.
8
+ class PlanRenderer
9
+ def initialize(plan)
10
+ @plan = plan
11
+ end
12
+
13
+ def to_text
14
+ return "No changes.\n" if plan.empty?
15
+
16
+ [
17
+ section('Create', plan.creates),
18
+ section('Update', plan.updates),
19
+ section('Skipped Creates', plan.skipped_creates),
20
+ section('Skipped Deletes', plan.skipped_deletes),
21
+ section('Manual Review', plan.ambiguous)
22
+ ].reject(&:empty?).join("\n")
23
+ end
24
+
25
+ def to_json(*)
26
+ JSON.pretty_generate(plan_hash)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :plan
32
+
33
+ def section(title, actions)
34
+ return '' if actions.empty?
35
+
36
+ ([title] + actions.sort_by(&:sort_key).map { |action| " - #{action_line(action)}" }).join("\n")
37
+ end
38
+
39
+ def action_line(action)
40
+ case action.action
41
+ when 'create'
42
+ record_line(action.record)
43
+ when 'update'
44
+ "#{record_line(action.remote_record)} -> #{record_line(action.desired_record)}"
45
+ when 'skipped_create', 'skipped_delete'
46
+ "#{record_line(action.record)} (#{action.message})"
47
+ when 'ambiguous'
48
+ "#{record_line(action.remote_record)} -> #{record_line(action.desired_record)} (#{action.message})"
49
+ end
50
+ end
51
+
52
+ def record_line(record)
53
+ [record.owner, record.type, record.priority, record.value, "(ttl=#{record.ttl})"].compact.join(' ')
54
+ end
55
+
56
+ def plan_hash
57
+ {
58
+ creates: plan.creates.map { |action| action_hash(action) },
59
+ updates: plan.updates.map { |action| action_hash(action) },
60
+ skipped_creates: plan.skipped_creates.map { |action| action_hash(action) },
61
+ skipped_deletes: plan.skipped_deletes.map { |action| action_hash(action) },
62
+ ambiguous: plan.ambiguous.map { |action| action_hash(action) }
63
+ }
64
+ end
65
+
66
+ def action_hash(action)
67
+ {
68
+ action: action.action,
69
+ record: record_hash(action.record),
70
+ remote_record: record_hash(action.remote_record),
71
+ desired_record: record_hash(action.desired_record),
72
+ message: action.message
73
+ }.compact
74
+ end
75
+
76
+ def record_hash(record)
77
+ return unless record
78
+
79
+ {
80
+ owner: record.owner,
81
+ type: record.type,
82
+ value: record.value,
83
+ ttl: record.ttl,
84
+ priority: record.priority,
85
+ weight: record.weight,
86
+ port: record.port
87
+ }.compact
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/types'
5
+ require 'dnsmadeeasy/zone/record'
6
+
7
+ module DnsMadeEasy
8
+ module Zone
9
+ # Associates provider metadata with a provider-neutral DNS record.
10
+ class ProviderRecord < Dry::Struct
11
+ transform_keys(&:to_sym)
12
+
13
+ attribute :record, Record
14
+ attribute :provider_id, Types::Coercible::Integer.optional.default(nil)
15
+ attribute :source_id, Types::OptionalString.default(nil)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/types'
5
+
6
+ module DnsMadeEasy
7
+ module Zone
8
+ # Provider-neutral DNS record value object.
9
+ class Record < Dry::Struct
10
+ transform_keys(&:to_sym)
11
+
12
+ attribute :owner, Types::NonEmptyString
13
+ attribute :type, Types::RecordType
14
+ attribute :value, Types::NonEmptyString
15
+ attribute :ttl, Types::Ttl.default(300)
16
+ attribute :priority, Types::OptionalInteger.default(nil)
17
+ attribute :weight, Types::OptionalInteger.default(nil)
18
+ attribute :port, Types::OptionalInteger.default(nil)
19
+
20
+ def sort_key
21
+ [
22
+ normalized_owner,
23
+ type_order,
24
+ type,
25
+ priority || -1,
26
+ weight || -1,
27
+ port || -1,
28
+ value,
29
+ ttl
30
+ ]
31
+ end
32
+
33
+ private
34
+
35
+ def normalized_owner
36
+ owner == '@' ? '' : owner
37
+ end
38
+
39
+ def type_order
40
+ Types::DNS_RECORD_TYPES.index(type) || Types::DNS_RECORD_TYPES.length
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/zone/record'
5
+
6
+ module DnsMadeEasy
7
+ module Zone
8
+ # Deterministically ordered collection of zone records.
9
+ class RecordSet < Dry::Struct
10
+ transform_keys(&:to_sym)
11
+
12
+ attribute :records, Types::Array.of(Record).default([].freeze)
13
+
14
+ def sorted
15
+ records.sort_by(&:sort_key)
16
+ end
17
+
18
+ def include?(record)
19
+ records.include?(record)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads'
4
+ require 'dnsmadeeasy/zone/provider_record'
5
+ require 'dnsmadeeasy/zone/record'
6
+ require 'dnsmadeeasy/zone/remote_records'
7
+
8
+ module DnsMadeEasy
9
+ module Zone
10
+ # Converts DNS Made Easy API record hashes into provider-neutral zone records.
11
+ class RemoteAdapter
12
+ include Dry::Monads[:result]
13
+
14
+ SUPPORTED_RECORD_TYPES = Types::DNS_RECORD_TYPES.freeze
15
+ SOURCE_ID = 'dns-made-easy'
16
+
17
+ def initialize(response, domain:, default_ttl: 300)
18
+ @response = response
19
+ @domain = domain
20
+ @default_ttl = default_ttl
21
+ end
22
+
23
+ def call
24
+ provider_records = []
25
+ warnings = []
26
+
27
+ remote_records.each do |remote_record|
28
+ if remote_record['type'] == 'HTTPRED'
29
+ warnings << omitted_httpred_warning(remote_record)
30
+ elsif SUPPORTED_RECORD_TYPES.include?(remote_record['type'])
31
+ provider_records << provider_record(remote_record)
32
+ else
33
+ return Failure(["Unsupported DNS Made Easy record type: #{remote_record['type']}"])
34
+ end
35
+ end
36
+
37
+ Success(RemoteRecords.new(provider_records: provider_records, warnings: warnings))
38
+ rescue Dry::Struct::Error => e
39
+ Failure([e.message])
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :response,
45
+ :domain,
46
+ :default_ttl
47
+
48
+ def remote_records
49
+ response.fetch('data', [])
50
+ end
51
+
52
+ def provider_record(remote_record)
53
+ ProviderRecord.new(
54
+ record: zone_record(remote_record),
55
+ provider_id: remote_record['id'],
56
+ source_id: SOURCE_ID
57
+ )
58
+ end
59
+
60
+ def zone_record(remote_record)
61
+ Record.new(
62
+ owner: owner(remote_record),
63
+ type: remote_record['type'],
64
+ value: record_value(remote_record),
65
+ ttl: remote_record['ttl'] || default_ttl,
66
+ priority: remote_record['mxLevel'] || remote_record['priority'],
67
+ weight: remote_record['weight'],
68
+ port: remote_record['port']
69
+ )
70
+ end
71
+
72
+ # DME returns TXT/SPF values with the zone-file quotes embedded, while
73
+ # Record holds unquoted text (Parser strips, Serializer re-quotes).
74
+ def record_value(remote_record)
75
+ value = remote_record['value']
76
+ return value unless %w[TXT SPF].include?(remote_record['type'])
77
+
78
+ value.delete_prefix('"').delete_suffix('"')
79
+ end
80
+
81
+ def owner(remote_record)
82
+ name = remote_record['name'].to_s
83
+ return '@' if name.empty? || name == domain || name == domain.delete_suffix('.')
84
+
85
+ name.delete_suffix(".#{domain.delete_suffix('.')}")
86
+ end
87
+
88
+ def omitted_httpred_warning(remote_record)
89
+ record_name = remote_record['name'].to_s.empty? ? '@' : remote_record['name']
90
+ "Omitted HTTPRED record #{record_name} -> #{remote_record['value']}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/types'
5
+ require 'dnsmadeeasy/zone/provider_record'
6
+ require 'dnsmadeeasy/zone/record_set'
7
+
8
+ module DnsMadeEasy
9
+ module Zone
10
+ # Provider records plus non-fatal warnings from remote conversion.
11
+ class RemoteRecords < Dry::Struct
12
+ transform_keys(&:to_sym)
13
+
14
+ attribute :provider_records, Types::Array.of(ProviderRecord).default([].freeze)
15
+ attribute :warnings, Types::Array.of(Types::StrictString).default([].freeze)
16
+
17
+ def records
18
+ provider_records.map(&:record)
19
+ end
20
+
21
+ def record_set
22
+ RecordSet.new(records: records)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/zone/file'
4
+
5
+ module DnsMadeEasy
6
+ module Zone
7
+ # Emits deterministic, normalized zone-file text.
8
+ class Serializer
9
+ def initialize(zone_file, omit_apex_ns: true)
10
+ @zone_file = zone_file
11
+ @omit_apex_ns = omit_apex_ns
12
+ end
13
+
14
+ def to_s
15
+ lines = [
16
+ "$ORIGIN #{zone_file.origin}",
17
+ "$TTL #{zone_file.ttl}",
18
+ ''
19
+ ]
20
+ lines.concat(serialized_records)
21
+ "#{lines.join("\n")}\n"
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :zone_file,
27
+ :omit_apex_ns
28
+
29
+ def serialized_records
30
+ grouped_records.flat_map.with_index do |records, index|
31
+ lines = records.map { |record| serialize_record(record) }
32
+ index.zero? ? lines : [''] + lines
33
+ end
34
+ end
35
+
36
+ def grouped_records
37
+ serializable_records.chunk { |record| record_group(record) }.map(&:last)
38
+ end
39
+
40
+ def serializable_records
41
+ zone_file.records
42
+ .reject { |record| omit_apex_ns && record.owner == '@' && record.type == 'NS' }
43
+ .sort_by { |record| serializer_sort_key(record) }
44
+ end
45
+
46
+ def record_group(record)
47
+ case record.type
48
+ when 'MX'
49
+ :mail
50
+ when 'TXT', 'SPF'
51
+ :text
52
+ else
53
+ :address
54
+ end
55
+ end
56
+
57
+ def serializer_sort_key(record)
58
+ [
59
+ serializer_type_group(record),
60
+ normalized_owner(record.owner),
61
+ record.type,
62
+ record.priority || -1,
63
+ record.value,
64
+ record.ttl
65
+ ]
66
+ end
67
+
68
+ def serializer_type_group(record)
69
+ case record.type
70
+ when 'A', 'AAAA', 'CNAME', 'ANAME', 'NS', 'PTR'
71
+ 0
72
+ when 'MX', 'SRV'
73
+ 1
74
+ when 'TXT', 'SPF'
75
+ 2
76
+ else
77
+ 3
78
+ end
79
+ end
80
+
81
+ def normalized_owner(owner)
82
+ owner == '@' ? '' : owner
83
+ end
84
+
85
+ def serialize_record(record)
86
+ [record.owner.ljust(8), ttl_token(record), 'IN', record.type.ljust(7), record_payload(record)].compact.join(' ')
87
+ end
88
+
89
+ # A single $TTL cannot represent a real zone, so records that deviate
90
+ # from the zone default carry an explicit TTL.
91
+ def ttl_token(record)
92
+ record.ttl == zone_file.ttl ? nil : record.ttl.to_s
93
+ end
94
+
95
+ def record_payload(record)
96
+ case record.type
97
+ when 'MX'
98
+ "#{record.priority} #{record.value}"
99
+ when 'SRV'
100
+ "#{record.priority} #{record.weight} #{record.port} #{record.value}"
101
+ when 'TXT', 'SPF'
102
+ quote_text(record.value)
103
+ else
104
+ record.value
105
+ end
106
+ end
107
+
108
+ def quote_text(value)
109
+ return value if value.start_with?('"') && value.end_with?('"')
110
+
111
+ %("#{value}")
112
+ end
113
+ end
114
+ end
115
+ end