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.
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 +392 -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 +189 -0
  48. data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
  49. data/lib/dnsmadeeasy/zone/diff.rb +152 -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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require 'awesome_print'
5
+ require 'json'
6
+ require 'yaml'
7
+ require 'dnsmadeeasy/cli/message_helpers'
8
+
9
+ module DnsMadeEasy
10
+ module CLI
11
+ module Commands
12
+ # Base class for dry-cli commands.
13
+ class Base < Dry::CLI::Command
14
+ include MessageHelpers
15
+
16
+ SUPPORTED_FORMATS = %w[json json_pretty yaml pp].freeze
17
+ DEFAULT_CREDENTIAL_PATHS = [
18
+ Pathname.new('~/.dnsmadeeasy/credentials.ini').expand_path,
19
+ Pathname.new('./.dnsmadeeasy/credential.ini').expand_path
20
+ ].freeze
21
+
22
+ option :format, values: SUPPORTED_FORMATS, required: false, desc: 'Output format'
23
+ option :credentials, required: false, desc: 'Path to credentials INI file'
24
+ option :api_key, required: false, desc: 'DNS Made Easy API key'
25
+ option :api_secret, required: false, desc: 'DNS Made Easy API secret'
26
+
27
+ private
28
+
29
+ def puts(*)
30
+ @out.puts(*)
31
+ end
32
+
33
+ def warn(*)
34
+ @err.puts(*)
35
+ end
36
+
37
+ def print_formatted(result, format = nil)
38
+ case format&.to_sym
39
+ when :json
40
+ puts JSON.generate(result)
41
+ when :json_pretty
42
+ puts JSON.pretty_generate(result)
43
+ when :yaml
44
+ puts result.to_yaml
45
+ when :pp
46
+ require 'pp'
47
+ puts PP.pp(result, +'')
48
+ else
49
+ @out.puts(result.ai(indent: 10))
50
+ end
51
+ end
52
+
53
+ def configure_authentication(credentials: nil, api_key: nil, api_secret: nil)
54
+ resolved_api_key, resolved_api_secret = resolve_credentials(
55
+ credentials: credentials,
56
+ api_key: api_key,
57
+ api_secret: api_secret
58
+ )
59
+
60
+ DnsMadeEasy.api_key = resolved_api_key
61
+ DnsMadeEasy.api_secret = resolved_api_secret
62
+ end
63
+
64
+ def resolve_credentials(credentials: nil, api_key: nil, api_secret: nil)
65
+ return explicit_api_credentials(api_key, api_secret) if api_key || api_secret
66
+ return credentials_from_ini(Pathname.new(credentials).expand_path) if credentials
67
+ return [ENV.fetch('DNSMADEEASY_API_KEY'), ENV.fetch('DNSMADEEASY_API_SECRET')] if env_credentials?
68
+
69
+ default_credentials_path = DEFAULT_CREDENTIAL_PATHS.find(&:exist?)
70
+ return credentials_from_ini(default_credentials_path) if default_credentials_path
71
+
72
+ raise DnsMadeEasy::APIKeyAndSecretMissingError, 'DNS Made Easy credentials were not found'
73
+ end
74
+
75
+ def explicit_api_credentials(api_key, api_secret)
76
+ raise DnsMadeEasy::APIKeyAndSecretMissingError, '--api-key and --api-secret must be provided together' unless api_key && api_secret
77
+
78
+ [api_key, api_secret]
79
+ end
80
+
81
+ def env_credentials?
82
+ ENV.fetch('DNSMADEEASY_API_KEY', nil) && ENV.fetch('DNSMADEEASY_API_SECRET', nil)
83
+ end
84
+
85
+ def credentials_from_ini(path)
86
+ raise DnsMadeEasy::APIKeyAndSecretMissingError, "Credentials file #{path} does not exist" unless path.exist?
87
+
88
+ credentials = parse_ini_credentials(path)
89
+ api_key = credentials['api_key'] || credentials['dns_dnsmadeeasy_api_key']
90
+ api_secret = credentials['api_secret'] || credentials['dns_dnsmadeeasy_secret_key']
91
+ explicit_api_credentials(api_key, api_secret)
92
+ end
93
+
94
+ def parse_ini_credentials(path)
95
+ path.each_line.with_object({}) do |line, credentials|
96
+ key, value = parse_ini_line(line)
97
+ credentials[key] = value if key && value
98
+ end
99
+ end
100
+
101
+ def parse_ini_line(line)
102
+ stripped_line = line.strip
103
+ return nil if ignored_ini_line?(stripped_line)
104
+
105
+ key, value = stripped_line.split('=', 2).map(&:strip)
106
+ [key, unquote_ini_value(value)]
107
+ end
108
+
109
+ def ignored_ini_line?(stripped_line)
110
+ stripped_line.empty? || stripped_line.start_with?('#', ';', '[')
111
+ end
112
+
113
+ def unquote_ini_value(value)
114
+ value&.delete_prefix('"')&.delete_suffix('"')
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/api/client'
4
+ require 'dnsmadeeasy/cli/commands/base'
5
+
6
+ module DnsMadeEasy
7
+ module CLI
8
+ # Registered dry-cli command classes.
9
+ module Commands
10
+ # Provides a migration hint for pre-1.0 root-level API operations.
11
+ class LegacyOperation < Base
12
+ desc 'Show migration hint for legacy root-level API operation'
13
+
14
+ def initialize(operation_name)
15
+ super()
16
+ @operation_name = operation_name
17
+ end
18
+
19
+ def call(**)
20
+ warn "Use `dme account #{@operation_name}` for this API operation."
21
+ raise ArgumentError, "legacy root operation #{@operation_name.inspect} moved under account"
22
+ end
23
+ end
24
+
25
+ DnsMadeEasy::Api::Client.public_operations.each do |operation_name|
26
+ register operation_name, LegacyOperation.new(operation_name), hidden: true
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/version'
4
+ require 'dnsmadeeasy/cli/commands/base'
5
+
6
+ module DnsMadeEasy
7
+ module CLI
8
+ # Registered dry-cli command classes.
9
+ module Commands
10
+ # Prints the gem version.
11
+ class Version < Base
12
+ desc 'Print version'
13
+
14
+ def call(*)
15
+ puts DnsMadeEasy::VERSION
16
+ end
17
+ end
18
+
19
+ register 'version', Version, aliases: %w[v -v --version]
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/cli/commands/base'
4
+ require 'dnsmadeeasy/cli/input'
5
+ require 'dnsmadeeasy/cli/message_helpers'
6
+ require 'dnsmadeeasy/zone/aname_flattener'
7
+ require 'dnsmadeeasy/zone/apply_executor'
8
+ require 'dnsmadeeasy/zone/diff'
9
+ require 'json'
10
+ require 'dnsmadeeasy/zone/parser'
11
+ require 'dnsmadeeasy/zone/plan_renderer'
12
+ require 'dnsmadeeasy/zone/remote_adapter'
13
+ require 'dnsmadeeasy/zone/serializer'
14
+ require 'yaml'
15
+
16
+ module DnsMadeEasy
17
+ module CLI
18
+ # Registered dry-cli command classes.
19
+ module Commands
20
+ # Zone-file management commands.
21
+ module Zone
22
+ # Shared handling for the `DOMAIN FILE` positional-argument pair used
23
+ # by the provider-facing commands (plan, apply). Reads the zone file
24
+ # with a friendly failure instead of a raw Errno::ENOENT, and verifies
25
+ # the file's $ORIGIN agrees with the domain argument BEFORE any API
26
+ # call is made — diffing foo.com against bar.com.zone should be an
27
+ # error, not a surprise apply. Comparison strips the RFC trailing dot
28
+ # ($ORIGIN is an FQDN like "kig.re.") and ignores case.
29
+ module DomainArguments
30
+ private
31
+
32
+ def read_zone_source!(file, domain:)
33
+ ::File.read(file)
34
+ rescue Errno::ENOENT, Errno::EISDIR
35
+ lines = ["Zone file not found: #{file}"]
36
+ lines << swap_hint if ::File.file?(domain.to_s)
37
+ error(lines.join("\n"))
38
+ raise ReportedError, 'zone file not found'
39
+ end
40
+
41
+ def ensure_origin_matches!(domain:, origin:)
42
+ return if origin.nil? || normalize_domain(origin) == normalize_domain(domain)
43
+
44
+ lines = [
45
+ 'Domain and zone file disagree.',
46
+ kv('Domain argument', domain),
47
+ kv('Zone file $ORIGIN', origin)
48
+ ]
49
+ lines << swap_hint if ::File.file?(domain.to_s)
50
+ error(lines.join("\n"))
51
+ raise ReportedError, 'domain and zone file disagree'
52
+ end
53
+
54
+ def normalize_domain(name)
55
+ name.to_s.delete_suffix('.').downcase
56
+ end
57
+
58
+ def swap_hint
59
+ "It looks like the arguments are swapped. Usage: #{usage_hint}"
60
+ end
61
+ end
62
+
63
+ # Validates a standard DNS zone file.
64
+ class Validate < Base
65
+ desc 'Validate a DNS zone file'
66
+
67
+ argument :file, required: true, desc: 'Zone file path'
68
+
69
+ def call(file:, **)
70
+ result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
71
+
72
+ if result.success?
73
+ record_count = result.value!.records.length
74
+ success("Zone file is valid.\n#{kv('Records', record_count)}")
75
+ else
76
+ warning("Zone file is invalid.\n#{result.failure.join("\n")}")
77
+ raise ReportedError, 'zone file is invalid'
78
+ end
79
+ end
80
+ end
81
+
82
+ # Formats a standard DNS zone file into canonical output.
83
+ class Format < Base
84
+ desc 'Format a DNS zone file'
85
+
86
+ argument :file, required: true, desc: 'Zone file path'
87
+
88
+ def call(file:, **)
89
+ result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
90
+
91
+ if result.success?
92
+ puts DnsMadeEasy::Zone::Serializer.new(result.value!)
93
+ success("Zone file formatted.\n#{kv('Records', result.value!.records.length)}")
94
+ else
95
+ error("Zone file is invalid.\n#{result.failure.join("\n")}")
96
+ raise ReportedError, 'zone file is invalid'
97
+ end
98
+ end
99
+ end
100
+
101
+ # Exports DNS Made Easy records as canonical zone-file text.
102
+ class Export < Base
103
+ desc 'Export DNS Made Easy records as a canonical zone file'
104
+
105
+ argument :domain, required: true, desc: 'Domain name'
106
+
107
+ option :format, aliases: ['f'], values: %w[rfc json yaml], required: false,
108
+ desc: 'Export format: rfc, json, or yaml'
109
+ option :output, required: false, desc: 'Output file path'
110
+ option :ttl, required: false, desc: 'Default TTL for records missing provider TTL'
111
+ option :include_apex_ns, type: :boolean, default: false, desc: 'Include apex NS records'
112
+ option :strict_rfc, type: :boolean, default: false,
113
+ desc: 'Flatten ANAME records into resolved A records (RFC-portable output)'
114
+
115
+ def call(**options)
116
+ configure_authentication(credentials: options[:credentials], api_key: options[:api_key],
117
+ api_secret: options[:api_secret])
118
+
119
+ domain = options.fetch(:domain)
120
+ export_ttl = options[:ttl] || 300
121
+ result = DnsMadeEasy::Zone::RemoteAdapter.new(
122
+ DnsMadeEasy.client.records_for(domain),
123
+ domain: domain,
124
+ default_ttl: export_ttl
125
+ ).call
126
+ fail_export(result.failure) if result.failure?
127
+
128
+ records_result = export_ready_records(result.value!, strict_rfc: options[:strict_rfc])
129
+ fail_export(records_result.failure) if records_result.failure?
130
+
131
+ records, warnings = records_result.value!
132
+ export_records(
133
+ domain,
134
+ records,
135
+ warnings: warnings,
136
+ format: options[:format] || 'rfc',
137
+ output: options[:output],
138
+ ttl: export_ttl,
139
+ include_apex_ns: options[:include_apex_ns]
140
+ )
141
+ success(
142
+ [
143
+ 'Zone export complete.',
144
+ kv('Domain', domain),
145
+ kv('Records', records.length),
146
+ kv('Destination', options[:output] || 'STDOUT')
147
+ ].join("\n")
148
+ )
149
+ end
150
+
151
+ private
152
+
153
+ def fail_export(errors)
154
+ error("Zone export failed.\n#{errors.join("\n")}")
155
+ raise ReportedError, 'zone export failed'
156
+ end
157
+
158
+ def export_ready_records(remote_records, strict_rfc:)
159
+ return Dry::Monads::Success([remote_records.records, remote_records.warnings]) unless strict_rfc
160
+
161
+ DnsMadeEasy::Zone::AnameFlattener.new(remote_records.records).call.fmap do |(records, notices)|
162
+ [records, remote_records.warnings + notices]
163
+ end
164
+ end
165
+
166
+ def export_records(domain, records, warnings:, format:, output:, ttl:, include_apex_ns:)
167
+ warnings.each { |warning| warn warning }
168
+ zone_text = export_text(domain, records, format: format, ttl: ttl, include_apex_ns: include_apex_ns)
169
+
170
+ output ? ::File.write(output, zone_text) : puts(zone_text)
171
+ end
172
+
173
+ def export_text(domain, records, format:, ttl:, include_apex_ns:)
174
+ zone_file = zone_file(domain, records, ttl: ttl)
175
+
176
+ case format
177
+ when 'json'
178
+ JSON.pretty_generate(export_hash(zone_file))
179
+ when 'yaml'
180
+ export_hash(zone_file).to_yaml
181
+ else
182
+ DnsMadeEasy::Zone::Serializer.new(zone_file, omit_apex_ns: !include_apex_ns).to_s
183
+ end
184
+ end
185
+
186
+ def zone_file(domain, records, ttl:)
187
+ DnsMadeEasy::Zone::File.new(
188
+ origin: "#{domain.delete_suffix('.')}.",
189
+ ttl: dominant_ttl(records, fallback: ttl),
190
+ record_set: DnsMadeEasy::Zone::RecordSet.new(records: records)
191
+ )
192
+ end
193
+
194
+ # $TTL is the most common record TTL so the export stays faithful
195
+ # while keeping explicit per-record TTLs to a minimum.
196
+ def dominant_ttl(records, fallback:)
197
+ records.map(&:ttl).tally.max_by { |ttl, count| [count, -ttl] }&.first || fallback
198
+ end
199
+
200
+ def export_hash(zone_file)
201
+ {
202
+ 'origin' => zone_file.origin,
203
+ 'ttl' => zone_file.ttl,
204
+ 'records' => zone_file.sorted.map { |record| record_hash(record) }
205
+ }
206
+ end
207
+
208
+ def record_hash(record)
209
+ {
210
+ 'owner' => record.owner,
211
+ 'type' => record.type,
212
+ 'value' => record.value,
213
+ 'ttl' => record.ttl,
214
+ 'priority' => record.priority,
215
+ 'weight' => record.weight,
216
+ 'port' => record.port
217
+ }.compact
218
+ end
219
+ end
220
+
221
+ # Produces a non-destructive plan comparing a zone file to remote records.
222
+ class Plan < Base
223
+ include DomainArguments
224
+
225
+ desc 'Plan DNS changes for a zone file'
226
+
227
+ argument :domain, required: true, desc: 'Domain name'
228
+ argument :file, required: true, desc: 'Zone file path'
229
+
230
+ option :format, values: %w[text json], default: 'text', required: false, desc: 'Plan output format'
231
+ option :diff_ttl, type: :boolean, default: false, desc: 'Treat TTL-only differences as updates'
232
+
233
+ def call(domain:, file:, format: 'text', diff_ttl: false, credentials: nil, api_key: nil, api_secret: nil, **)
234
+ configure_authentication(credentials: credentials, api_key: api_key, api_secret: api_secret)
235
+
236
+ desired_result = DnsMadeEasy::Zone::Parser.new(read_zone_source!(file, domain: domain)).call
237
+ return fail_with('Zone file is invalid', desired_result.failure) if desired_result.failure?
238
+
239
+ ensure_origin_matches!(domain: domain, origin: desired_result.value!.origin)
240
+
241
+ plan_domain = normalize_domain(domain)
242
+ remote_result = remote_records(plan_domain)
243
+ return fail_with('Remote records are invalid', remote_result.failure) if remote_result.failure?
244
+
245
+ plan = DnsMadeEasy::Zone::Diff.new(
246
+ desired_records: desired_result.value!.records,
247
+ remote_records: remote_result.value!.records,
248
+ compare_ttl: diff_ttl
249
+ ).call
250
+ renderer = DnsMadeEasy::Zone::PlanRenderer.new(plan)
251
+
252
+ puts(format == 'json' ? renderer.to_json : renderer.to_text)
253
+ success(plan_summary(plan_domain, plan))
254
+ end
255
+
256
+ private
257
+
258
+ def usage_hint
259
+ 'dmez zone plan DOMAIN FILE'
260
+ end
261
+
262
+ def plan_summary(domain, plan)
263
+ [
264
+ "Zone plan complete for #{domain}.",
265
+ kv('Creates', plan.creates.length),
266
+ kv('Updates', plan.updates.length),
267
+ kv('Skipped creates', plan.skipped_creates.length),
268
+ kv('Skipped deletes', plan.skipped_deletes.length),
269
+ kv('Manual review', plan.ambiguous.length)
270
+ ].join("\n")
271
+ end
272
+
273
+ def remote_records(domain)
274
+ DnsMadeEasy::Zone::RemoteAdapter.new(DnsMadeEasy.client.records_for(domain), domain: domain).call
275
+ end
276
+
277
+ def fail_with(message, errors)
278
+ error("#{message}.\n#{errors.join("\n")}")
279
+ raise ReportedError, message.downcase
280
+ end
281
+ end
282
+
283
+ # Applies a reviewed zone plan safely.
284
+ class Apply < Base
285
+ include DomainArguments
286
+
287
+ desc 'Apply DNS changes for a zone file'
288
+
289
+ argument :domain, required: true, desc: 'Domain name'
290
+ argument :file, required: true, desc: 'Zone file path'
291
+
292
+ option :yes, aliases: ['y'], type: :boolean, default: false, desc: 'Apply without confirmation prompt'
293
+ option :add_only, aliases: ['a'], type: :boolean, default: false, desc: 'Only add missing records'
294
+ option :delete_only, aliases: ['d'], type: :boolean, default: false, desc: 'Only apply deletions'
295
+ option :merge, aliases: ['m'], type: :boolean, default: true, desc: 'Merge creates and updates'
296
+ option :diff_ttl, type: :boolean, default: false, desc: 'Treat TTL-only differences as updates'
297
+
298
+ def call(**options)
299
+ configure_authentication(credentials: options[:credentials], api_key: options[:api_key],
300
+ api_secret: options[:api_secret])
301
+
302
+ plan_context = build_plan_context(options.fetch(:domain), options.fetch(:file),
303
+ compare_ttl: options[:diff_ttl])
304
+ return fail_with('Zone apply failed', plan_context.failure) if plan_context.failure?
305
+
306
+ mode = apply_mode(options)
307
+ executor = DnsMadeEasy::Zone::ApplyExecutor.new(
308
+ client: DnsMadeEasy.client,
309
+ domain: plan_context.value!.fetch(:domain),
310
+ plan: plan_context.value!.fetch(:plan),
311
+ remote_records: plan_context.value!.fetch(:remote_records),
312
+ mode: mode,
313
+ spinner_output: @err
314
+ )
315
+ executable_count = executor.executable_action_count
316
+ confirm!(executable_count) unless options[:yes]
317
+
318
+ result = executor.call
319
+ return fail_with('Zone apply failed', result.failure) if result.failure?
320
+
321
+ print_apply_summary(plan_context.value!.fetch(:domain), result.value!)
322
+ end
323
+
324
+ private
325
+
326
+ def build_plan_context(domain, file, compare_ttl: false)
327
+ desired_result = DnsMadeEasy::Zone::Parser.new(read_zone_source!(file, domain: domain)).call
328
+ return desired_result if desired_result.failure?
329
+
330
+ ensure_origin_matches!(domain: domain, origin: desired_result.value!.origin)
331
+
332
+ plan_domain = normalize_domain(domain)
333
+ remote_result = DnsMadeEasy::Zone::RemoteAdapter.new(DnsMadeEasy.client.records_for(plan_domain),
334
+ domain: plan_domain).call
335
+ return remote_result if remote_result.failure?
336
+
337
+ plan = DnsMadeEasy::Zone::Diff.new(
338
+ desired_records: desired_result.value!.records,
339
+ remote_records: remote_result.value!.records,
340
+ compare_ttl: compare_ttl
341
+ ).call
342
+
343
+ Dry::Monads::Success(domain: plan_domain, plan: plan, remote_records: remote_result.value!)
344
+ end
345
+
346
+ def usage_hint
347
+ 'dmez zone apply DOMAIN FILE'
348
+ end
349
+
350
+ def apply_mode(options)
351
+ return :add_only if options[:add_only]
352
+ return :delete_only if options[:delete_only]
353
+
354
+ :merge
355
+ end
356
+
357
+ def confirm!(executable_count)
358
+ warn "Apply #{executable_count} action(s)? Type yes to continue:"
359
+ response = DnsMadeEasy::CLI::Input.stdin.gets.to_s.strip
360
+ return if response == 'yes'
361
+
362
+ raise ArgumentError, 'zone apply cancelled'
363
+ end
364
+
365
+ def print_apply_summary(domain, result)
366
+ summary = [
367
+ "Zone apply complete for #{domain}.",
368
+ kv('Applied', result.applied_actions.length),
369
+ kv('Failed', result.failed_actions.length),
370
+ kv('Skipped', result.skipped_actions.length)
371
+ ].join("\n")
372
+
373
+ result.failed_actions.empty? ? success(summary) : warning(summary)
374
+ end
375
+
376
+ def fail_with(message, errors)
377
+ error("#{message}.\n#{errors.join("\n")}")
378
+ raise ReportedError, message.downcase
379
+ end
380
+ end
381
+ end
382
+
383
+ register 'zone' do |prefix|
384
+ prefix.register 'validate', Zone::Validate
385
+ prefix.register 'fmt', Zone::Format, aliases: ['format']
386
+ prefix.register 'export', Zone::Export
387
+ prefix.register 'plan', Zone::Plan
388
+ prefix.register 'apply', Zone::Apply
389
+ end
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+
5
+ module DnsMadeEasy
6
+ module CLI
7
+ module Commands
8
+ extend Dry::CLI::Registry
9
+ end
10
+ end
11
+ end
12
+
13
+ require 'dnsmadeeasy/cli/message_helpers'
14
+ require 'dnsmadeeasy/cli/reported_error'
15
+ require 'dnsmadeeasy/cli/commands/base'
16
+ require 'dnsmadeeasy/cli/commands/version'
17
+ require 'dnsmadeeasy/cli/commands/account'
18
+ require 'dnsmadeeasy/cli/commands/zone'
19
+ require 'dnsmadeeasy/cli/commands/legacy_operation'
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DnsMadeEasy
4
+ module CLI
5
+ # Shared input stream for commands that need confirmation.
6
+ module Input
7
+ class << self
8
+ attr_accessor :stdin
9
+ end
10
+
11
+ self.stdin = $stdin
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require 'dnsmadeeasy/version'
5
+ require 'dnsmadeeasy/cli/commands'
6
+ require 'dnsmadeeasy/cli/input'
7
+
8
+ module DnsMadeEasy
9
+ module CLI
10
+ # Entrypoint used by the executable and in-process CLI specs.
11
+ class Launcher
12
+ attr_reader :argv, :stdin, :stdout, :stderr, :kernel
13
+
14
+ def initialize(argv, stdin = $stdin, stdout = $stdout, stderr = $stderr, kernel = Kernel)
15
+ @argv = argv
16
+ @stdin = stdin
17
+ @stdout = stdout
18
+ @stderr = stderr
19
+ @kernel = kernel
20
+ end
21
+
22
+ def execute!
23
+ return print_account_operation_help if account_operation_help?
24
+
25
+ Input.stdin = stdin
26
+ command.call(arguments: argv, out: stdout, err: stderr)
27
+ 0
28
+ rescue SystemExit => e
29
+ e.status
30
+ rescue ReportedError
31
+ 1
32
+ rescue StandardError => e
33
+ stderr.puts(e.message)
34
+ 1
35
+ end
36
+
37
+ private
38
+
39
+ def account_operation_help?
40
+ argv.first == 'account' && argv[1] && !argv[1].start_with?('-') && argv.intersect?(%w[--help -h])
41
+ end
42
+
43
+ def print_account_operation_help
44
+ stdout.puts Commands::Account.operation_help(argv[1])
45
+ 0
46
+ end
47
+
48
+ def command
49
+ @command ||= Dry::CLI.new(Commands)
50
+ end
51
+ end
52
+ end
53
+ end