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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-box'
4
+
5
+ module DnsMadeEasy
6
+ module CLI
7
+ # Colorful boxed CLI status messages. All boxes print to stderr so that
8
+ # stdout carries only command payload (zone files, plan output) and
9
+ # stays safe to pipe or redirect.
10
+ #
11
+ # Include the module to call info/success/warning/error directly from a
12
+ # command; boxes then print to the includer's @err stream when present.
13
+ # The module-level methods (MessageHelpers.error etc.) remain available
14
+ # for callers outside the command classes.
15
+ module MessageHelpers
16
+ BOX_OPTIONS = {
17
+ border: { type: :thick },
18
+ width: 85
19
+ }.freeze
20
+
21
+ # Right-aligned key column for "key: value" lines inside boxes.
22
+ KEY_WIDTH = 30
23
+
24
+ class << self
25
+ attr_accessor :stdout, :stderr
26
+
27
+ def included(base)
28
+ base.include(InstanceMethods)
29
+ end
30
+
31
+ def info(message)
32
+ print_box(:info, message, stderr)
33
+ end
34
+
35
+ def warn(message)
36
+ print_box(:warn, message, stderr)
37
+ end
38
+
39
+ def error(message)
40
+ print_box(:error, message, stderr)
41
+ end
42
+
43
+ def success(message)
44
+ print_box(:success, message, stderr)
45
+ end
46
+
47
+ def print_box(box_type, message, output)
48
+ box = TTY::Box.public_send(box_type, message, **BOX_OPTIONS)
49
+ output.puts
50
+ output.puts(box)
51
+ box
52
+ end
53
+
54
+ def kv(key, value)
55
+ format("%#{KEY_WIDTH}s: %s", key, value)
56
+ end
57
+ end
58
+
59
+ # Boxed helpers for command classes. The warn box is exposed as
60
+ # #warning because commands already use Kernel-style plain #warn.
61
+ module InstanceMethods
62
+ def info(message)
63
+ MessageHelpers.print_box(:info, message, message_output)
64
+ end
65
+
66
+ def warning(message)
67
+ MessageHelpers.print_box(:warn, message, message_output)
68
+ end
69
+
70
+ def error(message)
71
+ MessageHelpers.print_box(:error, message, message_output)
72
+ end
73
+
74
+ def success(message)
75
+ MessageHelpers.print_box(:success, message, message_output)
76
+ end
77
+
78
+ def kv(key, value)
79
+ MessageHelpers.kv(key, value)
80
+ end
81
+
82
+ private
83
+
84
+ def message_output
85
+ (defined?(@err) && @err) || MessageHelpers.stderr
86
+ end
87
+ end
88
+
89
+ self.stdout = $stdout
90
+ self.stderr = $stderr
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DnsMadeEasy
4
+ module CLI
5
+ # Raised after an error has already been rendered to the user;
6
+ # the Launcher exits non-zero without printing it again.
7
+ class ReportedError < StandardError
8
+ end
9
+ end
10
+ end
@@ -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.5'
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,219 @@
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
+ # The DNS Made Easy API intermittently rejects rapid record mutations
17
+ # (rate limiting); short exponential backoff absorbs transient failures.
18
+ RETRY_ATTEMPTS = 3
19
+ RETRY_BASE_SLEEP = 1.0
20
+
21
+ # rubocop:disable Metrics/ParameterLists -- dependency-injected executor
22
+ def initialize(client:, domain:, plan:, remote_records:, mode: :merge, spinner_factory: nil, spinner_output: $stderr,
23
+ max_threads: DEFAULT_MAX_THREADS, sleeper: nil)
24
+ @client = client
25
+ @domain = domain
26
+ @plan = plan
27
+ @remote_records = remote_records
28
+ @mode = mode
29
+ @spinner_factory = spinner_factory || default_spinner_factory(spinner_output)
30
+ @max_threads = [max_threads.to_i, 1].max
31
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
32
+ end
33
+ # rubocop:enable Metrics/ParameterLists
34
+
35
+ def executable_action_count
36
+ executable_actions.length
37
+ end
38
+
39
+ def call
40
+ return Failure(["Unsupported apply mode: #{mode}"]) unless MODES.include?(mode)
41
+
42
+ applied_actions, failed_actions = execute_actions(executable_actions)
43
+
44
+ Success(ApplyResult.new(
45
+ applied_actions: applied_actions,
46
+ failed_actions: failed_actions,
47
+ skipped_actions: skipped_actions
48
+ ))
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :client,
54
+ :domain,
55
+ :plan,
56
+ :remote_records,
57
+ :mode,
58
+ :spinner_factory,
59
+ :max_threads,
60
+ :sleeper
61
+
62
+ def executable_actions
63
+ sort_actions(
64
+ case mode
65
+ when :add_only
66
+ plan.creates
67
+ when :delete_only
68
+ deletable_actions
69
+ else
70
+ plan.creates + plan.updates
71
+ end
72
+ )
73
+ end
74
+
75
+ def skipped_actions
76
+ plan.actions - executable_actions
77
+ end
78
+
79
+ def execute_actions(actions)
80
+ return [[], []] if actions.empty?
81
+
82
+ applied_actions = []
83
+ failed_actions = []
84
+ outcome_mutex = Mutex.new
85
+ spinner_group = spinner_factory.call("Applying #{actions.length} DNS action(s)")
86
+
87
+ action_chunks(actions).each_with_index do |chunk, index|
88
+ register_chunk(spinner_group, chunk, index, outcome_mutex, applied_actions, failed_actions)
89
+ end
90
+
91
+ spinner_group.auto_spin
92
+
93
+ [ordered_actions(applied_actions), ordered_actions(failed_actions)]
94
+ end
95
+
96
+ def register_chunk(spinner_group, chunk, index, outcome_mutex, applied_actions, failed_actions)
97
+ spinner_group.register("[:spinner] worker #{index + 1}: #{chunk.length} action(s)") do |spinner|
98
+ failed = execute_chunk(chunk, outcome_mutex, applied_actions, failed_actions)
99
+ failed ? spinner.error : spinner.success
100
+ end
101
+ end
102
+
103
+ def execute_chunk(chunk, outcome_mutex, applied_actions, failed_actions)
104
+ failed = false
105
+ chunk.each do |indexed_action|
106
+ failed = true if execute_indexed_action(indexed_action, outcome_mutex, applied_actions, failed_actions)
107
+ end
108
+ failed
109
+ end
110
+
111
+ def execute_indexed_action(indexed_action, outcome_mutex, applied_actions, failed_actions)
112
+ execute_action_with_retries(indexed_action.fetch(:action))
113
+ outcome_mutex.synchronize { applied_actions << indexed_action }
114
+ false
115
+ rescue StandardError => e
116
+ failed = indexed_action.merge(action: annotate_failure(indexed_action.fetch(:action), e))
117
+ outcome_mutex.synchronize { failed_actions << failed }
118
+ true
119
+ end
120
+
121
+ def execute_action_with_retries(action)
122
+ attempt = 0
123
+ begin
124
+ execute_action(action)
125
+ rescue StandardError
126
+ attempt += 1
127
+ raise if attempt >= RETRY_ATTEMPTS
128
+
129
+ sleeper.call(RETRY_BASE_SLEEP * (2**(attempt - 1)))
130
+ retry
131
+ end
132
+ end
133
+
134
+ # Attach the error to the failed action so the CLI can explain what
135
+ # went wrong instead of reporting a bare count.
136
+ def annotate_failure(action, error)
137
+ message = error.message.to_s.strip
138
+ action.new(message: message.empty? ? error.class.name : message)
139
+ end
140
+
141
+ def action_chunks(actions)
142
+ indexed_actions = actions.each_with_index.map { |action, index| { action: action, index: index } }
143
+ worker_count = [indexed_actions.length, max_threads].min
144
+ chunk_size = (indexed_actions.length.to_f / worker_count).ceil
145
+ indexed_actions.each_slice(chunk_size).to_a
146
+ end
147
+
148
+ def ordered_actions(indexed_actions)
149
+ indexed_actions.sort_by { |indexed_action| indexed_action.fetch(:index) }.map { |indexed_action| indexed_action.fetch(:action) }
150
+ end
151
+
152
+ def execute_action(action)
153
+ case action.action
154
+ when 'create'
155
+ create_record(action.record)
156
+ when 'update'
157
+ update_record(action)
158
+ when 'skipped_delete'
159
+ delete_record(action.record)
160
+ end
161
+ end
162
+
163
+ def create_record(record)
164
+ client.create_record(domain, api_owner(record), record.type, record.value, record_options(record))
165
+ end
166
+
167
+ def update_record(action)
168
+ provider_id = provider_id_for(action.remote_record)
169
+ client.update_record(domain, provider_id, api_owner(action.desired_record), action.desired_record.type,
170
+ action.desired_record.value, record_options(action.desired_record))
171
+ end
172
+
173
+ def delete_record(record)
174
+ client.delete_record(domain, provider_id_for(record))
175
+ end
176
+
177
+ def provider_id_for(record)
178
+ provider_record = remote_records.provider_records.find { |candidate| candidate.record == record }
179
+ raise KeyError, "No provider id for #{record.owner} #{record.type}" unless provider_record&.provider_id
180
+
181
+ provider_record.provider_id
182
+ end
183
+
184
+ def deletable_actions
185
+ plan.skipped_deletes.reject { |action| protected_delete?(action.record) }
186
+ end
187
+
188
+ def sort_actions(actions)
189
+ actions.sort_by(&:sort_key)
190
+ end
191
+
192
+ def protected_delete?(record)
193
+ record.type == 'SOA' || (record.owner == '@' && record.type == 'NS')
194
+ end
195
+
196
+ def api_owner(record)
197
+ record.owner == '@' ? '' : record.owner
198
+ end
199
+
200
+ def record_options(record)
201
+ options = { 'ttl' => record.ttl }
202
+ options['mxLevel'] = record.priority if record.type == 'MX'
203
+ options.merge!('priority' => record.priority, 'weight' => record.weight, 'port' => record.port) if record.type == 'SRV'
204
+ options.compact
205
+ end
206
+
207
+ def default_spinner_factory(spinner_output)
208
+ lambda do |message|
209
+ TTY::Spinner::Multi.new(
210
+ "[:spinner] #{message}",
211
+ output: spinner_output,
212
+ clear: true,
213
+ hide_cursor: true
214
+ )
215
+ end
216
+ end
217
+ end
218
+ end
219
+ 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