dnsmadeeasy 0.4.0 → 1.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 (64) 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 +854 -0
  9. data/Rakefile +4 -4
  10. data/dnsmadeeasy.gemspec +56 -32
  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/plans/11-plan-zone-cli-arguments.md +114 -0
  25. data/docs/spec-zone-management.md +242 -0
  26. data/exe/dme +13 -2
  27. data/exe/dmez +6 -0
  28. data/lib/dme.rb +7 -2
  29. data/lib/dnsmadeeasy/api/client.rb +32 -26
  30. data/lib/dnsmadeeasy/cli/box_output.rb +9 -0
  31. data/lib/dnsmadeeasy/cli/commands/account.rb +222 -0
  32. data/lib/dnsmadeeasy/cli/commands/base.rb +119 -0
  33. data/lib/dnsmadeeasy/cli/commands/legacy_operation.rb +30 -0
  34. data/lib/dnsmadeeasy/cli/commands/version.rb +22 -0
  35. data/lib/dnsmadeeasy/cli/commands/zone.rb +407 -0
  36. data/lib/dnsmadeeasy/cli/commands.rb +19 -0
  37. data/lib/dnsmadeeasy/cli/input.rb +14 -0
  38. data/lib/dnsmadeeasy/cli/launcher.rb +53 -0
  39. data/lib/dnsmadeeasy/cli/message_helpers.rb +93 -0
  40. data/lib/dnsmadeeasy/cli/reported_error.rb +10 -0
  41. data/lib/dnsmadeeasy/credentials/api_keys.rb +11 -9
  42. data/lib/dnsmadeeasy/credentials.rb +0 -1
  43. data/lib/dnsmadeeasy/runner.rb +24 -24
  44. data/lib/dnsmadeeasy/types.rb +19 -0
  45. data/lib/dnsmadeeasy/version.rb +2 -1
  46. data/lib/dnsmadeeasy/zone/aname_flattener.rb +63 -0
  47. data/lib/dnsmadeeasy/zone/apply_executor.rb +219 -0
  48. data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
  49. data/lib/dnsmadeeasy/zone/diff.rb +174 -0
  50. data/lib/dnsmadeeasy/zone/file.rb +26 -0
  51. data/lib/dnsmadeeasy/zone/parser.rb +172 -0
  52. data/lib/dnsmadeeasy/zone/plan.rb +28 -0
  53. data/lib/dnsmadeeasy/zone/plan_action.rb +29 -0
  54. data/lib/dnsmadeeasy/zone/plan_renderer.rb +91 -0
  55. data/lib/dnsmadeeasy/zone/provider_record.rb +18 -0
  56. data/lib/dnsmadeeasy/zone/record.rb +44 -0
  57. data/lib/dnsmadeeasy/zone/record_set.rb +23 -0
  58. data/lib/dnsmadeeasy/zone/remote_adapter.rb +94 -0
  59. data/lib/dnsmadeeasy/zone/remote_records.rb +26 -0
  60. data/lib/dnsmadeeasy/zone/serializer.rb +119 -0
  61. data/lib/dnsmadeeasy.rb +61 -27
  62. metadata +212 -39
  63. data/.dme-help.png +0 -0
  64. data/README.adoc +0 -690
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/zone/plan'
4
+
5
+ module DnsMadeEasy
6
+ module Zone
7
+ # Builds a conservative execution plan from desired and remote record sets.
8
+ # TTL-only differences are ignored unless compare_ttl is true.
9
+ class Diff
10
+ def initialize(desired_records:, remote_records:, compare_ttl: false)
11
+ @provider_managed_records, @desired_records = desired_records.partition { |record| provider_managed?(record) }
12
+ @remote_records = remote_records
13
+ @compare_ttl = compare_ttl
14
+ end
15
+
16
+ def call
17
+ Plan.new(
18
+ creates: create_actions,
19
+ updates: update_actions,
20
+ skipped_creates: skipped_create_actions,
21
+ skipped_deletes: skipped_delete_actions,
22
+ ambiguous: []
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :desired_records,
29
+ :provider_managed_records,
30
+ :remote_records,
31
+ :compare_ttl
32
+
33
+ # Apex NS records are owned by DNS Made Easy: the API neither returns
34
+ # nor accepts them, so they are excluded from the diff and reported.
35
+ def provider_managed?(record)
36
+ record.owner == '@' && record.type == 'NS'
37
+ end
38
+
39
+ def skipped_create_actions
40
+ provider_managed_records.map do |record|
41
+ PlanAction.new(action: 'skipped_create', record: record,
42
+ message: 'Apex NS records are managed by the DNS provider')
43
+ end
44
+ end
45
+
46
+ def create_actions
47
+ group_creates = group_deltas.flat_map { |delta| delta.fetch(:creates) }
48
+ (missing_desired_records + group_creates).map do |record|
49
+ PlanAction.new(action: 'create', record: record)
50
+ end
51
+ end
52
+
53
+ def update_actions
54
+ group_deltas.flat_map do |delta|
55
+ delta.fetch(:updates).map do |desired, remote|
56
+ PlanAction.new(
57
+ action: 'update',
58
+ desired_record: update_payload(desired, remote),
59
+ remote_record: remote
60
+ )
61
+ end
62
+ end
63
+ end
64
+
65
+ # When TTLs are not compared, an update must not modify the remote TTL
66
+ # as a side effect, so the desired record inherits it.
67
+ def update_payload(desired, remote)
68
+ return desired if compare_ttl
69
+
70
+ desired.new(ttl: remote.ttl)
71
+ end
72
+
73
+ def skipped_delete_actions
74
+ group_deletes = group_deltas.flat_map { |delta| delta.fetch(:deletes) }
75
+ (remote_only_records + group_deletes).map do |record|
76
+ PlanAction.new(action: 'skipped_delete', record: record, message: 'Delete skipped by default')
77
+ end
78
+ end
79
+
80
+ # Individual records within an RRset carry no identity — only the set of
81
+ # values is meaningful in DNS. Within a changed (owner, type) group,
82
+ # records whose values match on both sides are unchanged; the rest are
83
+ # paired in sorted-value order as updates. Excess desired records become
84
+ # creates; excess remote records become (skipped) deletes. The end state
85
+ # is exact regardless of how records are paired.
86
+ def group_deltas
87
+ @group_deltas ||= changed_identity_pairs.values.map do |pair|
88
+ desired_changed = multiset_subtract(pair.fetch(:desired), pair.fetch(:remote)).sort_by(&:value)
89
+ remote_changed = multiset_subtract(pair.fetch(:remote), pair.fetch(:desired)).sort_by(&:value)
90
+ paired = [desired_changed.length, remote_changed.length].min
91
+
92
+ {
93
+ updates: desired_changed.first(paired).zip(remote_changed.first(paired)),
94
+ creates: desired_changed.drop(paired),
95
+ deletes: remote_changed.drop(paired)
96
+ }
97
+ end
98
+ end
99
+
100
+ # Records from +records+ that have no value-level counterpart in
101
+ # +others+, honoring duplicates (a multiset difference).
102
+ def multiset_subtract(records, others)
103
+ remaining = comparison_keys(others).tally
104
+ records.reject do |record|
105
+ key = comparison_key(record)
106
+ next false unless remaining[key].to_i.positive?
107
+
108
+ remaining[key] -= 1
109
+ true
110
+ end
111
+ end
112
+
113
+ def missing_desired_records
114
+ desired_records.reject { |record| remote_keys.include?(comparison_key(record)) } - changed_desired_records
115
+ end
116
+
117
+ def remote_only_records
118
+ remote_records.reject { |record| desired_keys.include?(comparison_key(record)) } - changed_remote_records
119
+ end
120
+
121
+ def changed_desired_records
122
+ changed_identity_pairs.values.flat_map { |pair| pair.fetch(:desired) }
123
+ end
124
+
125
+ def changed_remote_records
126
+ changed_identity_pairs.values.flat_map { |pair| pair.fetch(:remote) }
127
+ end
128
+
129
+ def changed_identity_pairs
130
+ @changed_identity_pairs ||= desired_by_identity.each_with_object({}) do |(identity, desired_group), pairs|
131
+ remote_group = remote_by_identity.fetch(identity, [])
132
+ next if remote_group.empty? || same_record_multiset?(desired_group, remote_group)
133
+
134
+ pairs[identity] = { desired: desired_group, remote: remote_group }
135
+ end
136
+ end
137
+
138
+ # Groups match regardless of order: the API returns records in
139
+ # arbitrary order while zone files are sorted.
140
+ def same_record_multiset?(desired_group, remote_group)
141
+ comparison_keys(desired_group).tally == comparison_keys(remote_group).tally
142
+ end
143
+
144
+ def desired_by_identity
145
+ @desired_by_identity ||= desired_records.group_by { |record| identity(record) }
146
+ end
147
+
148
+ def remote_by_identity
149
+ @remote_by_identity ||= remote_records.group_by { |record| identity(record) }
150
+ end
151
+
152
+ def identity(record)
153
+ [record.owner, record.type]
154
+ end
155
+
156
+ def desired_keys
157
+ @desired_keys ||= comparison_keys(desired_records)
158
+ end
159
+
160
+ def remote_keys
161
+ @remote_keys ||= comparison_keys(remote_records)
162
+ end
163
+
164
+ def comparison_keys(records)
165
+ records.map { |record| comparison_key(record) }
166
+ end
167
+
168
+ def comparison_key(record)
169
+ key = [record.owner, record.type, record.value, record.priority, record.weight, record.port]
170
+ compare_ttl ? key << record.ttl : key
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/types'
5
+ require 'dnsmadeeasy/zone/record_set'
6
+
7
+ module DnsMadeEasy
8
+ module Zone
9
+ # Parsed zone-file document with metadata needed for canonical output.
10
+ class File < Dry::Struct
11
+ transform_keys(&:to_sym)
12
+
13
+ attribute :origin, Types::NonEmptyString
14
+ attribute :ttl, Types::Ttl.default(300)
15
+ attribute :record_set, RecordSet
16
+
17
+ def records
18
+ record_set.records
19
+ end
20
+
21
+ def sorted
22
+ record_set.sorted
23
+ end
24
+ end
25
+ end
26
+ end
@@ -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