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 +4 -4
- data/lib/dnsmadeeasy/cli/commands/zone.rb +16 -1
- data/lib/dnsmadeeasy/version.rb +1 -1
- data/lib/dnsmadeeasy/zone/apply_executor.rb +35 -5
- data/lib/dnsmadeeasy/zone/diff.rb +46 -24
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 759a25855a49669fe87a973eef041686c68237389bbf750eee5a3d98d49b3ebe
|
|
4
|
+
data.tar.gz: 66239b4a984c77bdb1b79390726c55283fec3557a008492acf8728360473049b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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)
|
data/lib/dnsmadeeasy/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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:
|
|
70
|
+
desired.new(ttl: remote.ttl)
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
def skipped_delete_actions
|
|
72
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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?
|
|
80
107
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|