dnsmadeeasy 1.0.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fc4acfe2b0918ef18592054a35d07cbb708337b126ba512baaebdf4c77d83ad
4
- data.tar.gz: 8f1afb57a6f8b18b5e502998f81bbbaa69acbbeb4a1f3f1cabcc3b3eecebf07a
3
+ metadata.gz: 759a25855a49669fe87a973eef041686c68237389bbf750eee5a3d98d49b3ebe
4
+ data.tar.gz: 66239b4a984c77bdb1b79390726c55283fec3557a008492acf8728360473049b
5
5
  SHA512:
6
- metadata.gz: abfe6e3dd6a1a6e071b72d6938548d4ee84369c2897a891d343f557e2f0b31271967b51a58a9885beae29528db5d0b0fe0ddd44123fb3ed0f2036118913f220c
7
- data.tar.gz: 04a3941fa03915750cd0e10c57f61aa079f951dc04c6ff41eb208c2bd4c3e9b0cc9038ea2be7055a91a26d881157a918a769ac94f859f5a5af550520e9cce048
6
+ metadata.gz: e6754018965be4d4335ce0d7b5c27736a7696ab489e8e0cb055987573d283ceae48ee004b9d7a1d64a935de6a45f65baacacb122754e0b221b8f8b40c4288668
7
+ data.tar.gz: 35a19aa2914f68b26579544633ce48b0ce3071aa255ee08ce7d5addd04cf47d607737bf60fac074aed6f4acc5db99dbe717323888adbd6fa88cf5c33aea3957c
@@ -370,7 +370,22 @@ module DnsMadeEasy
370
370
  kv('Skipped', result.skipped_actions.length)
371
371
  ].join("\n")
372
372
 
373
- result.failed_actions.empty? ? success(summary) : warning(summary)
373
+ return success(summary) if result.failed_actions.empty?
374
+
375
+ warning(summary)
376
+ puts failed_section(result.failed_actions)
377
+ end
378
+
379
+ # Each failed action carries the API error message attached by the
380
+ # executor; a bare failure count is undebuggable.
381
+ def failed_section(actions)
382
+ (['Failed'] + actions.map { |action| " - #{failed_line(action)}" }).join("\n")
383
+ end
384
+
385
+ def failed_line(action)
386
+ record = action.record || action.desired_record
387
+ line = [record.owner, record.type, record.priority, record.value, "(ttl=#{record.ttl})"].compact.join(' ')
388
+ action.message ? "#{line} — #{action.message}" : line
374
389
  end
375
390
 
376
391
  def fail_with(message, errors)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DnsMadeEasy
4
4
  # Version 1.0+ is supporting zone file manipulation
5
- VERSION = '1.0.3'
5
+ VERSION = '1.0.5'
6
6
  end
@@ -13,8 +13,14 @@ module DnsMadeEasy
13
13
  MODES = %i[merge add_only delete_only].freeze
14
14
  DEFAULT_MAX_THREADS = 4
15
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
16
22
  def initialize(client:, domain:, plan:, remote_records:, mode: :merge, spinner_factory: nil, spinner_output: $stderr,
17
- max_threads: DEFAULT_MAX_THREADS)
23
+ max_threads: DEFAULT_MAX_THREADS, sleeper: nil)
18
24
  @client = client
19
25
  @domain = domain
20
26
  @plan = plan
@@ -22,7 +28,9 @@ module DnsMadeEasy
22
28
  @mode = mode
23
29
  @spinner_factory = spinner_factory || default_spinner_factory(spinner_output)
24
30
  @max_threads = [max_threads.to_i, 1].max
31
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
25
32
  end
33
+ # rubocop:enable Metrics/ParameterLists
26
34
 
27
35
  def executable_action_count
28
36
  executable_actions.length
@@ -48,7 +56,8 @@ module DnsMadeEasy
48
56
  :remote_records,
49
57
  :mode,
50
58
  :spinner_factory,
51
- :max_threads
59
+ :max_threads,
60
+ :sleeper
52
61
 
53
62
  def executable_actions
54
63
  sort_actions(
@@ -100,14 +109,35 @@ module DnsMadeEasy
100
109
  end
101
110
 
102
111
  def execute_indexed_action(indexed_action, outcome_mutex, applied_actions, failed_actions)
103
- execute_action(indexed_action.fetch(:action))
112
+ execute_action_with_retries(indexed_action.fetch(:action))
104
113
  outcome_mutex.synchronize { applied_actions << indexed_action }
105
114
  false
106
- rescue StandardError
107
- outcome_mutex.synchronize { failed_actions << indexed_action }
115
+ rescue StandardError => e
116
+ failed = indexed_action.merge(action: annotate_failure(indexed_action.fetch(:action), e))
117
+ outcome_mutex.synchronize { failed_actions << failed }
108
118
  true
109
119
  end
110
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
+
111
141
  def action_chunks(actions)
112
142
  indexed_actions = actions.each_with_index.map { |action, index| { action: action, index: index } }
113
143
  worker_count = [indexed_actions.length, max_threads].min
@@ -19,7 +19,7 @@ module DnsMadeEasy
19
19
  updates: update_actions,
20
20
  skipped_creates: skipped_create_actions,
21
21
  skipped_deletes: skipped_delete_actions,
22
- ambiguous: ambiguous_actions
22
+ ambiguous: []
23
23
  )
24
24
  end
25
25
 
@@ -44,47 +44,69 @@ module DnsMadeEasy
44
44
  end
45
45
 
46
46
  def create_actions
47
- missing_desired_records.map { |record| PlanAction.new(action: 'create', record: record) }
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
48
51
  end
49
52
 
50
53
  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
- )
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
59
62
  end
60
63
  end
61
64
 
62
65
  # When TTLs are not compared, an update must not modify the remote TTL
63
66
  # as a side effect, so the desired record inherits it.
64
- def update_payload(pair)
65
- desired = pair.fetch(:desired).first
67
+ def update_payload(desired, remote)
66
68
  return desired if compare_ttl
67
69
 
68
- desired.new(ttl: pair.fetch(:remote).first.ttl)
70
+ desired.new(ttl: remote.ttl)
69
71
  end
70
72
 
71
73
  def skipped_delete_actions
72
- remote_only_records.map do |record|
74
+ group_deletes = group_deltas.flat_map { |delta| delta.fetch(:deletes) }
75
+ (remote_only_records + group_deletes).map do |record|
73
76
  PlanAction.new(action: 'skipped_delete', record: record, message: 'Delete skipped by default')
74
77
  end
75
78
  end
76
79
 
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
+ # 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?
80
107
 
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
- )
108
+ remaining[key] -= 1
109
+ true
88
110
  end
89
111
  end
90
112
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dnsmadeeasy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul