dnsmadeeasy 0.4.0 → 1.0.3
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 +5 -10
- data/.gitignore +2 -0
- data/.rubocop.yml +5 -4
- data/.rubocop_todo.yml +251 -143
- data/CLAUDE.md +67 -0
- data/Gemfile +9 -1
- data/README.md +854 -0
- data/Rakefile +4 -4
- data/dnsmadeeasy.gemspec +56 -32
- 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/plans/11-plan-zone-cli-arguments.md +114 -0
- data/docs/spec-zone-management.md +242 -0
- data/exe/dme +13 -2
- data/exe/dmez +6 -0
- data/lib/dme.rb +7 -2
- data/lib/dnsmadeeasy/api/client.rb +32 -26
- 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 +392 -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 +93 -0
- data/lib/dnsmadeeasy/cli/reported_error.rb +10 -0
- data/lib/dnsmadeeasy/credentials/api_keys.rb +11 -9
- data/lib/dnsmadeeasy/credentials.rb +0 -1
- data/lib/dnsmadeeasy/runner.rb +24 -24
- 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 +119 -0
- data/lib/dnsmadeeasy.rb +61 -27
- metadata +212 -39
- data/.dme-help.png +0 -0
- 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
|
|
@@ -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})
|
|
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,
|
|
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
|
-
|
|
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,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
data/lib/dnsmadeeasy/runner.rb
CHANGED
|
@@ -11,7 +11,7 @@ require 'etc'
|
|
|
11
11
|
|
|
12
12
|
module DnsMadeEasy
|
|
13
13
|
class Runner
|
|
14
|
-
SUPPORTED_FORMATS = %w
|
|
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
|
-
|
|
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
|
|
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::
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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}"
|
|
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(
|
|
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(
|
|
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(*
|
|
246
|
-
super
|
|
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
|
data/lib/dnsmadeeasy/version.rb
CHANGED
|
@@ -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
|