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
@@ -13,7 +13,7 @@ module DnsMadeEasy
13
13
  # Immutable instance with key and secret.
14
14
  #
15
15
  class ApiKeys
16
- API_KEY_REGEX = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/.freeze
16
+ API_KEY_REGEX = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/
17
17
 
18
18
  attr_reader :api_key,
19
19
  :api_secret,
@@ -24,7 +24,7 @@ module DnsMadeEasy
24
24
  include Sym
25
25
 
26
26
  def initialize(key, secret, encryption_key = nil, default: false, account: nil)
27
- raise InvalidCredentialKeys, "Key and Secret can not be nil" if key.nil? || secret.nil?
27
+ raise InvalidCredentialKeys, 'Key and Secret can not be nil' if key.nil? || secret.nil?
28
28
 
29
29
  @default = default
30
30
  @account = account
@@ -38,17 +38,19 @@ module DnsMadeEasy
38
38
  @api_secret = secret
39
39
  end
40
40
 
41
- raise InvalidCredentialKeys, "Key [#{api_key}] or Secret [#{api_secret}] has failed validation for its format" unless valid?
41
+ return if valid?
42
+
43
+ raise InvalidCredentialKeys,
44
+ "Key [#{api_key}] or Secret [#{api_secret}] has failed validation for its format"
42
45
  end
43
46
 
44
47
  def sym_resolve(encryption_key)
45
48
  null_output = ::File.open('/dev/null', 'w')
46
- result = Sym::Application.new({ cache_passwords: true, key: encryption_key }, $stdin, null_output, null_output).execute
47
- if result.is_a?(Hash)
48
- raise InvalidCredentialKeys, "Unable to decrypt the data, error is: #{result[:exception]}"
49
- else
50
- result
51
- end
49
+ result = Sym::Application.new({ cache_passwords: true, key: encryption_key }, $stdin, null_output,
50
+ null_output).execute
51
+ raise InvalidCredentialKeys, "Unable to decrypt the data, error is: #{result[:exception]}" if result.is_a?(Hash)
52
+
53
+ result
52
54
  end
53
55
 
54
56
  def to_s
@@ -82,7 +82,6 @@ module DnsMadeEasy
82
82
  def keys_from_file(file: default_credentials_path,
83
83
  account: nil,
84
84
  encryption_key: nil)
85
-
86
85
  YamlFile.new(file: file).keys(account: account,
87
86
  encryption_key: encryption_key)
88
87
  end
@@ -11,7 +11,7 @@ require 'etc'
11
11
 
12
12
  module DnsMadeEasy
13
13
  class Runner
14
- SUPPORTED_FORMATS = %w(json json_pretty yaml pp).freeze
14
+ SUPPORTED_FORMATS = %w[json json_pretty yaml pp].freeze
15
15
  SUPPORTED_FORMAT_FLAGS = SUPPORTED_FORMATS.map { |f| "--#{f}" }.freeze
16
16
 
17
17
  attr_accessor :format, :argv, :operation
@@ -47,16 +47,16 @@ module DnsMadeEasy
47
47
  end
48
48
  0
49
49
  rescue ArgumentError => e
50
- STDERR.puts(e.backtrace.join("\n").yellow)
50
+ warn(e.backtrace.join("\n").yellow)
51
51
  sig = method_signature(e, method)
52
52
  print_signature(method, sig) if sig.shift == method.to_s
53
53
  print_error('Action', method.to_s.bold.yellow.to_s, 'has generated an error'.red, exception: e)
54
54
  1
55
- rescue NoMethodError => e
55
+ rescue NoMethodError
56
56
  print_error('Action', method.to_s.bold.yellow.to_s, 'is not valid.'.red)
57
57
  puts 'HINT: try running ' + 'dme operations'.bold.green + ' to see the list of valid operations.'
58
58
  2
59
- rescue Net::HTTPServerException => e
59
+ rescue Net::HTTPClientException => e
60
60
  print_error(exception: e)
61
61
  3
62
62
  end
@@ -68,11 +68,11 @@ module DnsMadeEasy
68
68
  EOF
69
69
  end
70
70
 
71
- if exception
72
- puts <<~EOF
73
- #{'Exception: '.bold.red}#{exception.inspect.red}
74
- EOF
75
- end
71
+ return unless exception
72
+
73
+ puts <<~EOF
74
+ #{'Exception: '.bold.red}#{exception.inspect.red}
75
+ EOF
76
76
  end
77
77
 
78
78
  def print_signature(method, sig)
@@ -108,7 +108,7 @@ module DnsMadeEasy
108
108
 
109
109
  def configure_authentication
110
110
  credentials_file = ENV['DNSMADEEASY_CREDENTIALS_FILE'] ||
111
- DnsMadeEasy::Credentials.default_credentials_path(user: Etc.getlogin)
111
+ DnsMadeEasy::Credentials.default_credentials_path(user: Etc.getlogin)
112
112
 
113
113
  if ENV['DNSMADEEASY_API_KEY'] && ENV['DNSMADEEASY_API_SECRET']
114
114
  DnsMadeEasy.api_key = ENV['DNSMADEEASY_API_KEY']
@@ -122,13 +122,13 @@ module DnsMadeEasy
122
122
  end
123
123
  end
124
124
 
125
- if DnsMadeEasy.api_key.nil?
126
- print_error('API Key/Secret was not detected or read from file')
127
- puts('You can also set two environment variables: ')
128
- puts(' DNSMADEEASY_API_KEY and ')
129
- puts(' • DNSMADEEASY_API_SECRET')
130
- exit 123
131
- end
125
+ return unless DnsMadeEasy.api_key.nil?
126
+
127
+ print_error('API Key/Secret was not detected or read from file')
128
+ puts('You can also set two environment variables: ')
129
+ puts(' • DNSMADEEASY_API_KEY and ')
130
+ puts(' • DNSMADEEASY_API_SECRET')
131
+ exit 123
132
132
  end
133
133
 
134
134
  def print_usage_message
@@ -147,7 +147,7 @@ module DnsMadeEasy
147
147
  puts <<~EOF
148
148
 
149
149
  #{header 'Credentials'}
150
- Store your credentials in a YAML file #{DnsMadeEasy::Credentials.default_credentials_path(user: ENV['USER']).blue}
150
+ Store your credentials in a YAML file #{DnsMadeEasy::Credentials.default_credentials_path(user: ENV.fetch('USER', nil)).blue}
151
151
  as follows:
152
152
 
153
153
  #{'# ~/.dnsmadeeasy/credentials.yml'.bold.black}
@@ -216,7 +216,7 @@ module DnsMadeEasy
216
216
  require 'pp'
217
217
  pp result
218
218
  else
219
- m = "to_#{format}".to_sym
219
+ m = :"to_#{format}"
220
220
  puts result.send(m) if result.respond_to?(m)
221
221
  end
222
222
  else
@@ -228,13 +228,13 @@ module DnsMadeEasy
228
228
  # ..../dnsmadeeasy/lib/dnsmadeeasy/api/client.rb:143:in `create_a_record'
229
229
  def method_signature(e, method)
230
230
  file, line, call_method = e.backtrace.first.split(':')
231
- call_method = call_method.gsub(/[']/, '').split('`').last
231
+ call_method = call_method.gsub('\'', '').split('`').last
232
232
  if call_method && call_method.to_sym == method.to_sym
233
233
  source_line = File.open(file).to_a[line.to_i - 1].chomp!
234
234
  if source_line =~ /def #{method}/
235
- signature = source_line.strip.gsub(/,/, '').split(/[ ()]/)
235
+ signature = source_line.strip.gsub(',', '').split(/[ ()]/)
236
236
  signature.shift # remove def
237
- return signature.reject { |a| a =~ /^([={}\)\(])*$/ }
237
+ return signature.reject { |a| a =~ /^([={})(])*$/ }
238
238
  end
239
239
  end
240
240
  []
@@ -242,8 +242,8 @@ module DnsMadeEasy
242
242
  []
243
243
  end
244
244
 
245
- def puts(*args)
246
- super(*args)
245
+ def puts(*)
246
+ super
247
247
  end
248
248
  end
249
249
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/types'
4
+
5
+ module DnsMadeEasy
6
+ # Shared dry-rb types used by value objects.
7
+ module Types
8
+ include Dry.Types()
9
+
10
+ DNS_RECORD_TYPES = %w[A AAAA CNAME ANAME MX NS PTR SPF SRV TXT].freeze
11
+
12
+ StrictString = Strict::String
13
+ NonEmptyString = StrictString.constrained(min_size: 1)
14
+ RecordType = StrictString.enum(*DNS_RECORD_TYPES)
15
+ Ttl = Coercible::Integer.constrained(gteq: 0)
16
+ OptionalInteger = Coercible::Integer.optional
17
+ OptionalString = StrictString.optional
18
+ end
19
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DnsMadeEasy
4
- VERSION = '0.4.0'
4
+ # Version 1.0+ is supporting zone file manipulation
5
+ VERSION = '1.0.1'
5
6
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads'
4
+ require 'resolv'
5
+ require 'dnsmadeeasy/zone/record'
6
+
7
+ module DnsMadeEasy
8
+ module Zone
9
+ # Flattens ANAME records into resolved A records for RFC-portable
10
+ # exports. Resolution is a point-in-time snapshot, so every conversion
11
+ # is reported as a notice.
12
+ class AnameFlattener
13
+ include Dry::Monads[:result]
14
+
15
+ def initialize(records, resolver: nil)
16
+ @records = records
17
+ @resolver = resolver || default_resolver
18
+ end
19
+
20
+ def call
21
+ flattened = []
22
+ notices = []
23
+
24
+ records.each do |record|
25
+ if record.type == 'ANAME'
26
+ addresses = resolve(record)
27
+ return Failure(["Unable to resolve ANAME target #{record.value} for --strict-rfc export"]) if addresses.empty?
28
+
29
+ addresses.each { |address| flattened << record.new(type: 'A', value: address) }
30
+ notices << flatten_notice(record, addresses)
31
+ else
32
+ flattened << record
33
+ end
34
+ end
35
+
36
+ Success([flattened, notices])
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :records,
42
+ :resolver
43
+
44
+ def resolve(record)
45
+ resolver.call(record.value)
46
+ rescue Resolv::ResolvError
47
+ []
48
+ end
49
+
50
+ def flatten_notice(record, addresses)
51
+ "Flattened ANAME #{record.owner} -> #{record.value} into A #{addresses.join(', ')} (point-in-time snapshot)"
52
+ end
53
+
54
+ def default_resolver
55
+ lambda do |target|
56
+ Resolv::DNS.open do |dns|
57
+ dns.getresources(target, Resolv::DNS::Resource::IN::A).map { |resource| resource.address.to_s }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -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