dnsmadeeasy 0.3.5 → 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 +31 -0
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +6 -0
  5. data/.rubocop_todo.yml +272 -107
  6. data/.travis.yml +2 -1
  7. data/CLAUDE.md +67 -0
  8. data/Gemfile +9 -1
  9. data/README.md +506 -310
  10. data/Rakefile +4 -4
  11. data/dnsmadeeasy.gemspec +44 -27
  12. data/docs/badges/coverage_badge.svg +21 -0
  13. data/docs/dry-cli-based-tools.webloc +8 -0
  14. data/docs/plan-zone-management.md +756 -0
  15. data/docs/plans/01-plan-ruby4-baseline.md +38 -0
  16. data/docs/plans/02-plan-dry-cli-launcher.md +44 -0
  17. data/docs/plans/03-plan-account-command.md +43 -0
  18. data/docs/plans/04-plan-zone-record-model.md +48 -0
  19. data/docs/plans/05-plan-zone-parser.md +41 -0
  20. data/docs/plans/06-plan-zone-formatter.md +43 -0
  21. data/docs/plans/07-plan-zone-export.md +58 -0
  22. data/docs/plans/08-plan-zone-diff.md +42 -0
  23. data/docs/plans/09-plan-zone-apply.md +69 -0
  24. data/docs/plans/10-plan-docs-cleanup.md +55 -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 +6 -6
  29. data/lib/dnsmadeeasy/api/client.rb +31 -32
  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 +326 -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 +81 -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/yaml_file.rb +28 -27
  43. data/lib/dnsmadeeasy/credentials.rb +3 -4
  44. data/lib/dnsmadeeasy/runner.rb +102 -98
  45. data/lib/dnsmadeeasy/types.rb +19 -0
  46. data/lib/dnsmadeeasy/version.rb +2 -1
  47. data/lib/dnsmadeeasy/zone/aname_flattener.rb +63 -0
  48. data/lib/dnsmadeeasy/zone/apply_executor.rb +189 -0
  49. data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
  50. data/lib/dnsmadeeasy/zone/diff.rb +152 -0
  51. data/lib/dnsmadeeasy/zone/file.rb +26 -0
  52. data/lib/dnsmadeeasy/zone/parser.rb +172 -0
  53. data/lib/dnsmadeeasy/zone/plan.rb +28 -0
  54. data/lib/dnsmadeeasy/zone/plan_action.rb +29 -0
  55. data/lib/dnsmadeeasy/zone/plan_renderer.rb +91 -0
  56. data/lib/dnsmadeeasy/zone/provider_record.rb +18 -0
  57. data/lib/dnsmadeeasy/zone/record.rb +44 -0
  58. data/lib/dnsmadeeasy/zone/record_set.rb +23 -0
  59. data/lib/dnsmadeeasy/zone/remote_adapter.rb +94 -0
  60. data/lib/dnsmadeeasy/zone/remote_records.rb +26 -0
  61. data/lib/dnsmadeeasy/zone/serializer.rb +115 -0
  62. data/lib/dnsmadeeasy.rb +61 -31
  63. metadata +184 -23
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads'
4
+ require 'dnsmadeeasy/zone/apply_result'
5
+ require 'tty-spinner'
6
+
7
+ module DnsMadeEasy
8
+ module Zone
9
+ # Executes safe plan actions against DNS Made Easy.
10
+ class ApplyExecutor
11
+ include Dry::Monads[:result]
12
+
13
+ MODES = %i[merge add_only delete_only].freeze
14
+ DEFAULT_MAX_THREADS = 4
15
+
16
+ def initialize(client:, domain:, plan:, remote_records:, mode: :merge, spinner_factory: nil, spinner_output: $stderr,
17
+ max_threads: DEFAULT_MAX_THREADS)
18
+ @client = client
19
+ @domain = domain
20
+ @plan = plan
21
+ @remote_records = remote_records
22
+ @mode = mode
23
+ @spinner_factory = spinner_factory || default_spinner_factory(spinner_output)
24
+ @max_threads = [max_threads.to_i, 1].max
25
+ end
26
+
27
+ def executable_action_count
28
+ executable_actions.length
29
+ end
30
+
31
+ def call
32
+ return Failure(["Unsupported apply mode: #{mode}"]) unless MODES.include?(mode)
33
+
34
+ applied_actions, failed_actions = execute_actions(executable_actions)
35
+
36
+ Success(ApplyResult.new(
37
+ applied_actions: applied_actions,
38
+ failed_actions: failed_actions,
39
+ skipped_actions: skipped_actions
40
+ ))
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :client,
46
+ :domain,
47
+ :plan,
48
+ :remote_records,
49
+ :mode,
50
+ :spinner_factory,
51
+ :max_threads
52
+
53
+ def executable_actions
54
+ sort_actions(
55
+ case mode
56
+ when :add_only
57
+ plan.creates
58
+ when :delete_only
59
+ deletable_actions
60
+ else
61
+ plan.creates + plan.updates
62
+ end
63
+ )
64
+ end
65
+
66
+ def skipped_actions
67
+ plan.actions - executable_actions
68
+ end
69
+
70
+ def execute_actions(actions)
71
+ return [[], []] if actions.empty?
72
+
73
+ applied_actions = []
74
+ failed_actions = []
75
+ outcome_mutex = Mutex.new
76
+ spinner_group = spinner_factory.call("Applying #{actions.length} DNS action(s)")
77
+
78
+ action_chunks(actions).each_with_index do |chunk, index|
79
+ register_chunk(spinner_group, chunk, index, outcome_mutex, applied_actions, failed_actions)
80
+ end
81
+
82
+ spinner_group.auto_spin
83
+
84
+ [ordered_actions(applied_actions), ordered_actions(failed_actions)]
85
+ end
86
+
87
+ def register_chunk(spinner_group, chunk, index, outcome_mutex, applied_actions, failed_actions)
88
+ spinner_group.register("[:spinner] worker #{index + 1}: #{chunk.length} action(s)") do |spinner|
89
+ failed = execute_chunk(chunk, outcome_mutex, applied_actions, failed_actions)
90
+ failed ? spinner.error : spinner.success
91
+ end
92
+ end
93
+
94
+ def execute_chunk(chunk, outcome_mutex, applied_actions, failed_actions)
95
+ failed = false
96
+ chunk.each do |indexed_action|
97
+ failed = true if execute_indexed_action(indexed_action, outcome_mutex, applied_actions, failed_actions)
98
+ end
99
+ failed
100
+ end
101
+
102
+ def execute_indexed_action(indexed_action, outcome_mutex, applied_actions, failed_actions)
103
+ execute_action(indexed_action.fetch(:action))
104
+ outcome_mutex.synchronize { applied_actions << indexed_action }
105
+ false
106
+ rescue StandardError
107
+ outcome_mutex.synchronize { failed_actions << indexed_action }
108
+ true
109
+ end
110
+
111
+ def action_chunks(actions)
112
+ indexed_actions = actions.each_with_index.map { |action, index| { action: action, index: index } }
113
+ worker_count = [indexed_actions.length, max_threads].min
114
+ chunk_size = (indexed_actions.length.to_f / worker_count).ceil
115
+ indexed_actions.each_slice(chunk_size).to_a
116
+ end
117
+
118
+ def ordered_actions(indexed_actions)
119
+ indexed_actions.sort_by { |indexed_action| indexed_action.fetch(:index) }.map { |indexed_action| indexed_action.fetch(:action) }
120
+ end
121
+
122
+ def execute_action(action)
123
+ case action.action
124
+ when 'create'
125
+ create_record(action.record)
126
+ when 'update'
127
+ update_record(action)
128
+ when 'skipped_delete'
129
+ delete_record(action.record)
130
+ end
131
+ end
132
+
133
+ def create_record(record)
134
+ client.create_record(domain, api_owner(record), record.type, record.value, record_options(record))
135
+ end
136
+
137
+ def update_record(action)
138
+ provider_id = provider_id_for(action.remote_record)
139
+ client.update_record(domain, provider_id, api_owner(action.desired_record), action.desired_record.type,
140
+ action.desired_record.value, record_options(action.desired_record))
141
+ end
142
+
143
+ def delete_record(record)
144
+ client.delete_record(domain, provider_id_for(record))
145
+ end
146
+
147
+ def provider_id_for(record)
148
+ provider_record = remote_records.provider_records.find { |candidate| candidate.record == record }
149
+ raise KeyError, "No provider id for #{record.owner} #{record.type}" unless provider_record&.provider_id
150
+
151
+ provider_record.provider_id
152
+ end
153
+
154
+ def deletable_actions
155
+ plan.skipped_deletes.reject { |action| protected_delete?(action.record) }
156
+ end
157
+
158
+ def sort_actions(actions)
159
+ actions.sort_by(&:sort_key)
160
+ end
161
+
162
+ def protected_delete?(record)
163
+ record.type == 'SOA' || (record.owner == '@' && record.type == 'NS')
164
+ end
165
+
166
+ def api_owner(record)
167
+ record.owner == '@' ? '' : record.owner
168
+ end
169
+
170
+ def record_options(record)
171
+ options = { 'ttl' => record.ttl }
172
+ options['mxLevel'] = record.priority if record.type == 'MX'
173
+ options.merge!('priority' => record.priority, 'weight' => record.weight, 'port' => record.port) if record.type == 'SRV'
174
+ options.compact
175
+ end
176
+
177
+ def default_spinner_factory(spinner_output)
178
+ lambda do |message|
179
+ TTY::Spinner::Multi.new(
180
+ "[:spinner] #{message}",
181
+ output: spinner_output,
182
+ clear: true,
183
+ hide_cursor: true
184
+ )
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,22 @@
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
+ # Result of executing a zone plan.
10
+ class ApplyResult < Dry::Struct
11
+ transform_keys(&:to_sym)
12
+
13
+ attribute :applied_actions, Types::Array.of(PlanAction).default([].freeze)
14
+ attribute :failed_actions, Types::Array.of(PlanAction).default([].freeze)
15
+ attribute :skipped_actions, Types::Array.of(PlanAction).default([].freeze)
16
+
17
+ def success?
18
+ failed_actions.empty?
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,152 @@
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: ambiguous_actions
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
+ missing_desired_records.map { |record| PlanAction.new(action: 'create', record: record) }
48
+ end
49
+
50
+ def update_actions
51
+ changed_identity_pairs.filter_map do |_identity, pair|
52
+ next unless pair.fetch(:desired).one? && pair.fetch(:remote).one?
53
+
54
+ PlanAction.new(
55
+ action: 'update',
56
+ desired_record: update_payload(pair),
57
+ remote_record: pair.fetch(:remote).first
58
+ )
59
+ end
60
+ end
61
+
62
+ # When TTLs are not compared, an update must not modify the remote TTL
63
+ # as a side effect, so the desired record inherits it.
64
+ def update_payload(pair)
65
+ desired = pair.fetch(:desired).first
66
+ return desired if compare_ttl
67
+
68
+ desired.new(ttl: pair.fetch(:remote).first.ttl)
69
+ end
70
+
71
+ def skipped_delete_actions
72
+ remote_only_records.map do |record|
73
+ PlanAction.new(action: 'skipped_delete', record: record, message: 'Delete skipped by default')
74
+ end
75
+ end
76
+
77
+ def ambiguous_actions
78
+ changed_identity_pairs.filter_map do |_identity, pair|
79
+ next if pair.fetch(:desired).one? && pair.fetch(:remote).one?
80
+
81
+ PlanAction.new(
82
+ action: 'ambiguous',
83
+ desired_record: pair.fetch(:desired).first,
84
+ remote_record: pair.fetch(:remote).first,
85
+ message: 'Multiple records share the same owner/type identity ' \
86
+ "(desired: #{pair.fetch(:desired).length}, remote: #{pair.fetch(:remote).length})"
87
+ )
88
+ end
89
+ end
90
+
91
+ def missing_desired_records
92
+ desired_records.reject { |record| remote_keys.include?(comparison_key(record)) } - changed_desired_records
93
+ end
94
+
95
+ def remote_only_records
96
+ remote_records.reject { |record| desired_keys.include?(comparison_key(record)) } - changed_remote_records
97
+ end
98
+
99
+ def changed_desired_records
100
+ changed_identity_pairs.values.flat_map { |pair| pair.fetch(:desired) }
101
+ end
102
+
103
+ def changed_remote_records
104
+ changed_identity_pairs.values.flat_map { |pair| pair.fetch(:remote) }
105
+ end
106
+
107
+ def changed_identity_pairs
108
+ @changed_identity_pairs ||= desired_by_identity.each_with_object({}) do |(identity, desired_group), pairs|
109
+ remote_group = remote_by_identity.fetch(identity, [])
110
+ next if remote_group.empty? || same_record_multiset?(desired_group, remote_group)
111
+
112
+ pairs[identity] = { desired: desired_group, remote: remote_group }
113
+ end
114
+ end
115
+
116
+ # Groups match regardless of order: the API returns records in
117
+ # arbitrary order while zone files are sorted.
118
+ def same_record_multiset?(desired_group, remote_group)
119
+ comparison_keys(desired_group).tally == comparison_keys(remote_group).tally
120
+ end
121
+
122
+ def desired_by_identity
123
+ @desired_by_identity ||= desired_records.group_by { |record| identity(record) }
124
+ end
125
+
126
+ def remote_by_identity
127
+ @remote_by_identity ||= remote_records.group_by { |record| identity(record) }
128
+ end
129
+
130
+ def identity(record)
131
+ [record.owner, record.type]
132
+ end
133
+
134
+ def desired_keys
135
+ @desired_keys ||= comparison_keys(desired_records)
136
+ end
137
+
138
+ def remote_keys
139
+ @remote_keys ||= comparison_keys(remote_records)
140
+ end
141
+
142
+ def comparison_keys(records)
143
+ records.map { |record| comparison_key(record) }
144
+ end
145
+
146
+ def comparison_key(record)
147
+ key = [record.owner, record.type, record.value, record.priority, record.weight, record.port]
148
+ compare_ttl ? key << record.ttl : key
149
+ end
150
+ end
151
+ end
152
+ 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