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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +31 -0
- data/.gitignore +2 -0
- data/.rubocop.yml +6 -0
- data/.rubocop_todo.yml +272 -107
- data/.travis.yml +2 -1
- data/CLAUDE.md +67 -0
- data/Gemfile +9 -1
- data/README.md +506 -310
- data/Rakefile +4 -4
- data/dnsmadeeasy.gemspec +44 -27
- data/docs/badges/coverage_badge.svg +21 -0
- data/docs/dry-cli-based-tools.webloc +8 -0
- data/docs/plan-zone-management.md +756 -0
- data/docs/plans/01-plan-ruby4-baseline.md +38 -0
- data/docs/plans/02-plan-dry-cli-launcher.md +44 -0
- data/docs/plans/03-plan-account-command.md +43 -0
- data/docs/plans/04-plan-zone-record-model.md +48 -0
- data/docs/plans/05-plan-zone-parser.md +41 -0
- data/docs/plans/06-plan-zone-formatter.md +43 -0
- data/docs/plans/07-plan-zone-export.md +58 -0
- data/docs/plans/08-plan-zone-diff.md +42 -0
- data/docs/plans/09-plan-zone-apply.md +69 -0
- data/docs/plans/10-plan-docs-cleanup.md +55 -0
- data/docs/spec-zone-management.md +242 -0
- data/exe/dme +13 -2
- data/exe/dmez +6 -0
- data/lib/dme.rb +6 -6
- data/lib/dnsmadeeasy/api/client.rb +31 -32
- data/lib/dnsmadeeasy/cli/box_output.rb +9 -0
- data/lib/dnsmadeeasy/cli/commands/account.rb +222 -0
- data/lib/dnsmadeeasy/cli/commands/base.rb +119 -0
- data/lib/dnsmadeeasy/cli/commands/legacy_operation.rb +30 -0
- data/lib/dnsmadeeasy/cli/commands/version.rb +22 -0
- data/lib/dnsmadeeasy/cli/commands/zone.rb +326 -0
- data/lib/dnsmadeeasy/cli/commands.rb +19 -0
- data/lib/dnsmadeeasy/cli/input.rb +14 -0
- data/lib/dnsmadeeasy/cli/launcher.rb +53 -0
- data/lib/dnsmadeeasy/cli/message_helpers.rb +81 -0
- data/lib/dnsmadeeasy/cli/reported_error.rb +10 -0
- data/lib/dnsmadeeasy/credentials/api_keys.rb +11 -9
- data/lib/dnsmadeeasy/credentials/yaml_file.rb +28 -27
- data/lib/dnsmadeeasy/credentials.rb +3 -4
- data/lib/dnsmadeeasy/runner.rb +102 -98
- data/lib/dnsmadeeasy/types.rb +19 -0
- data/lib/dnsmadeeasy/version.rb +2 -1
- data/lib/dnsmadeeasy/zone/aname_flattener.rb +63 -0
- data/lib/dnsmadeeasy/zone/apply_executor.rb +189 -0
- data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
- data/lib/dnsmadeeasy/zone/diff.rb +152 -0
- data/lib/dnsmadeeasy/zone/file.rb +26 -0
- data/lib/dnsmadeeasy/zone/parser.rb +172 -0
- data/lib/dnsmadeeasy/zone/plan.rb +28 -0
- data/lib/dnsmadeeasy/zone/plan_action.rb +29 -0
- data/lib/dnsmadeeasy/zone/plan_renderer.rb +91 -0
- data/lib/dnsmadeeasy/zone/provider_record.rb +18 -0
- data/lib/dnsmadeeasy/zone/record.rb +44 -0
- data/lib/dnsmadeeasy/zone/record_set.rb +23 -0
- data/lib/dnsmadeeasy/zone/remote_adapter.rb +94 -0
- data/lib/dnsmadeeasy/zone/remote_records.rb +26 -0
- data/lib/dnsmadeeasy/zone/serializer.rb +115 -0
- data/lib/dnsmadeeasy.rb +61 -31
- 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
|