dnsmadeeasy 0.4.0 → 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 +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 +858 -0
- data/Rakefile +4 -4
- data/dnsmadeeasy.gemspec +42 -26
- 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 +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 +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.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 +115 -0
- data/lib/dnsmadeeasy.rb +61 -27
- metadata +184 -25
- data/.dme-help.png +0 -0
- 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,326 @@
|
|
|
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
|
+
# Validates a standard DNS zone file.
|
|
23
|
+
class Validate < Base
|
|
24
|
+
desc 'Validate a DNS zone file'
|
|
25
|
+
|
|
26
|
+
argument :file, required: true, desc: 'Zone file path'
|
|
27
|
+
|
|
28
|
+
def call(file:, **)
|
|
29
|
+
result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
|
|
30
|
+
|
|
31
|
+
if result.success?
|
|
32
|
+
record_count = result.value!.records.length
|
|
33
|
+
success("Zone file is valid.\nRecords: #{record_count}")
|
|
34
|
+
else
|
|
35
|
+
warning("Zone file is invalid.\n#{result.failure.join("\n")}")
|
|
36
|
+
raise ReportedError, 'zone file is invalid'
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Formats a standard DNS zone file into canonical output.
|
|
42
|
+
class Format < Base
|
|
43
|
+
desc 'Format a DNS zone file'
|
|
44
|
+
|
|
45
|
+
argument :file, required: true, desc: 'Zone file path'
|
|
46
|
+
|
|
47
|
+
def call(file:, **)
|
|
48
|
+
result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
|
|
49
|
+
|
|
50
|
+
if result.success?
|
|
51
|
+
puts DnsMadeEasy::Zone::Serializer.new(result.value!)
|
|
52
|
+
success("Zone file formatted.\nRecords: #{result.value!.records.length}")
|
|
53
|
+
else
|
|
54
|
+
error("Zone file is invalid.\n#{result.failure.join("\n")}")
|
|
55
|
+
raise ReportedError, 'zone file is invalid'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Exports DNS Made Easy records as canonical zone-file text.
|
|
61
|
+
class Export < Base
|
|
62
|
+
desc 'Export DNS Made Easy records as a canonical zone file'
|
|
63
|
+
|
|
64
|
+
argument :domain, required: true, desc: 'Domain name'
|
|
65
|
+
|
|
66
|
+
option :format, aliases: ['f'], values: %w[rfc json yaml], required: false,
|
|
67
|
+
desc: 'Export format: rfc, json, or yaml'
|
|
68
|
+
option :output, required: false, desc: 'Output file path'
|
|
69
|
+
option :ttl, required: false, desc: 'Default TTL for records missing provider TTL'
|
|
70
|
+
option :include_apex_ns, type: :boolean, default: false, desc: 'Include apex NS records'
|
|
71
|
+
option :strict_rfc, type: :boolean, default: false,
|
|
72
|
+
desc: 'Flatten ANAME records into resolved A records (RFC-portable output)'
|
|
73
|
+
|
|
74
|
+
def call(**options)
|
|
75
|
+
configure_authentication(credentials: options[:credentials], api_key: options[:api_key],
|
|
76
|
+
api_secret: options[:api_secret])
|
|
77
|
+
|
|
78
|
+
domain = options.fetch(:domain)
|
|
79
|
+
export_ttl = options[:ttl] || 300
|
|
80
|
+
result = DnsMadeEasy::Zone::RemoteAdapter.new(
|
|
81
|
+
DnsMadeEasy.client.records_for(domain),
|
|
82
|
+
domain: domain,
|
|
83
|
+
default_ttl: export_ttl
|
|
84
|
+
).call
|
|
85
|
+
fail_export(result.failure) if result.failure?
|
|
86
|
+
|
|
87
|
+
records_result = export_ready_records(result.value!, strict_rfc: options[:strict_rfc])
|
|
88
|
+
fail_export(records_result.failure) if records_result.failure?
|
|
89
|
+
|
|
90
|
+
records, warnings = records_result.value!
|
|
91
|
+
export_records(
|
|
92
|
+
domain,
|
|
93
|
+
records,
|
|
94
|
+
warnings: warnings,
|
|
95
|
+
format: options[:format] || 'rfc',
|
|
96
|
+
output: options[:output],
|
|
97
|
+
ttl: export_ttl,
|
|
98
|
+
include_apex_ns: options[:include_apex_ns]
|
|
99
|
+
)
|
|
100
|
+
success(
|
|
101
|
+
"Zone export complete.\nDomain: #{domain}\nRecords: #{records.length}\n" \
|
|
102
|
+
"Destination: #{options[:output] || 'STDOUT'}"
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def fail_export(errors)
|
|
109
|
+
error("Zone export failed.\n#{errors.join("\n")}")
|
|
110
|
+
raise ReportedError, 'zone export failed'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def export_ready_records(remote_records, strict_rfc:)
|
|
114
|
+
return Dry::Monads::Success([remote_records.records, remote_records.warnings]) unless strict_rfc
|
|
115
|
+
|
|
116
|
+
DnsMadeEasy::Zone::AnameFlattener.new(remote_records.records).call.fmap do |(records, notices)|
|
|
117
|
+
[records, remote_records.warnings + notices]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def export_records(domain, records, warnings:, format:, output:, ttl:, include_apex_ns:)
|
|
122
|
+
warnings.each { |warning| warn warning }
|
|
123
|
+
zone_text = export_text(domain, records, format: format, ttl: ttl, include_apex_ns: include_apex_ns)
|
|
124
|
+
|
|
125
|
+
output ? ::File.write(output, zone_text) : puts(zone_text)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def export_text(domain, records, format:, ttl:, include_apex_ns:)
|
|
129
|
+
zone_file = zone_file(domain, records, ttl: ttl)
|
|
130
|
+
|
|
131
|
+
case format
|
|
132
|
+
when 'json'
|
|
133
|
+
JSON.pretty_generate(export_hash(zone_file))
|
|
134
|
+
when 'yaml'
|
|
135
|
+
export_hash(zone_file).to_yaml
|
|
136
|
+
else
|
|
137
|
+
DnsMadeEasy::Zone::Serializer.new(zone_file, omit_apex_ns: !include_apex_ns).to_s
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def zone_file(domain, records, ttl:)
|
|
142
|
+
DnsMadeEasy::Zone::File.new(
|
|
143
|
+
origin: "#{domain.delete_suffix('.')}.",
|
|
144
|
+
ttl: dominant_ttl(records, fallback: ttl),
|
|
145
|
+
record_set: DnsMadeEasy::Zone::RecordSet.new(records: records)
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# $TTL is the most common record TTL so the export stays faithful
|
|
150
|
+
# while keeping explicit per-record TTLs to a minimum.
|
|
151
|
+
def dominant_ttl(records, fallback:)
|
|
152
|
+
records.map(&:ttl).tally.max_by { |ttl, count| [count, -ttl] }&.first || fallback
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def export_hash(zone_file)
|
|
156
|
+
{
|
|
157
|
+
'origin' => zone_file.origin,
|
|
158
|
+
'ttl' => zone_file.ttl,
|
|
159
|
+
'records' => zone_file.sorted.map { |record| record_hash(record) }
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def record_hash(record)
|
|
164
|
+
{
|
|
165
|
+
'owner' => record.owner,
|
|
166
|
+
'type' => record.type,
|
|
167
|
+
'value' => record.value,
|
|
168
|
+
'ttl' => record.ttl,
|
|
169
|
+
'priority' => record.priority,
|
|
170
|
+
'weight' => record.weight,
|
|
171
|
+
'port' => record.port
|
|
172
|
+
}.compact
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Produces a non-destructive plan comparing a zone file to remote records.
|
|
177
|
+
class Plan < Base
|
|
178
|
+
desc 'Plan DNS changes for a zone file'
|
|
179
|
+
|
|
180
|
+
argument :file, required: true, desc: 'Zone file path'
|
|
181
|
+
|
|
182
|
+
option :domain, required: false, desc: 'Domain name'
|
|
183
|
+
option :format, values: %w[text json], default: 'text', required: false, desc: 'Plan output format'
|
|
184
|
+
option :diff_ttl, type: :boolean, default: false, desc: 'Treat TTL-only differences as updates'
|
|
185
|
+
|
|
186
|
+
def call(file:, domain: nil, format: 'text', diff_ttl: false, credentials: nil, api_key: nil, api_secret: nil, **)
|
|
187
|
+
configure_authentication(credentials: credentials, api_key: api_key, api_secret: api_secret)
|
|
188
|
+
|
|
189
|
+
desired_result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
|
|
190
|
+
return fail_with('Zone file is invalid', desired_result.failure) if desired_result.failure?
|
|
191
|
+
|
|
192
|
+
plan_domain = domain || desired_result.value!.origin
|
|
193
|
+
remote_result = remote_records(plan_domain)
|
|
194
|
+
return fail_with('Remote records are invalid', remote_result.failure) if remote_result.failure?
|
|
195
|
+
|
|
196
|
+
plan = DnsMadeEasy::Zone::Diff.new(
|
|
197
|
+
desired_records: desired_result.value!.records,
|
|
198
|
+
remote_records: remote_result.value!.records,
|
|
199
|
+
compare_ttl: diff_ttl
|
|
200
|
+
).call
|
|
201
|
+
renderer = DnsMadeEasy::Zone::PlanRenderer.new(plan)
|
|
202
|
+
|
|
203
|
+
puts(format == 'json' ? renderer.to_json : renderer.to_text)
|
|
204
|
+
success(plan_summary(plan_domain, plan))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def plan_summary(domain, plan)
|
|
210
|
+
[
|
|
211
|
+
"Zone plan complete for #{domain}.",
|
|
212
|
+
"Creates: #{plan.creates.length}, Updates: #{plan.updates.length}",
|
|
213
|
+
"Skipped creates: #{plan.skipped_creates.length}, Skipped deletes: #{plan.skipped_deletes.length}",
|
|
214
|
+
"Manual review: #{plan.ambiguous.length}"
|
|
215
|
+
].join("\n")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def remote_records(domain)
|
|
219
|
+
DnsMadeEasy::Zone::RemoteAdapter.new(DnsMadeEasy.client.records_for(domain), domain: domain).call
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def fail_with(message, errors)
|
|
223
|
+
error("#{message}.\n#{errors.join("\n")}")
|
|
224
|
+
raise ReportedError, message.downcase
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Applies a reviewed zone plan safely.
|
|
229
|
+
class Apply < Base
|
|
230
|
+
desc 'Apply DNS changes for a zone file'
|
|
231
|
+
|
|
232
|
+
argument :file, required: true, desc: 'Zone file path'
|
|
233
|
+
|
|
234
|
+
option :domain, required: false, desc: 'Domain name'
|
|
235
|
+
option :yes, aliases: ['y'], type: :boolean, default: false, desc: 'Apply without confirmation prompt'
|
|
236
|
+
option :add_only, aliases: ['a'], type: :boolean, default: false, desc: 'Only add missing records'
|
|
237
|
+
option :delete_only, aliases: ['d'], type: :boolean, default: false, desc: 'Only apply deletions'
|
|
238
|
+
option :merge, aliases: ['m'], type: :boolean, default: true, desc: 'Merge creates and updates'
|
|
239
|
+
option :diff_ttl, type: :boolean, default: false, desc: 'Treat TTL-only differences as updates'
|
|
240
|
+
|
|
241
|
+
def call(**options)
|
|
242
|
+
configure_authentication(credentials: options[:credentials], api_key: options[:api_key],
|
|
243
|
+
api_secret: options[:api_secret])
|
|
244
|
+
|
|
245
|
+
plan_context = build_plan_context(options.fetch(:file), options[:domain], compare_ttl: options[:diff_ttl])
|
|
246
|
+
return fail_with('Zone apply failed', plan_context.failure) if plan_context.failure?
|
|
247
|
+
|
|
248
|
+
mode = apply_mode(options)
|
|
249
|
+
executor = DnsMadeEasy::Zone::ApplyExecutor.new(
|
|
250
|
+
client: DnsMadeEasy.client,
|
|
251
|
+
domain: plan_context.value!.fetch(:domain),
|
|
252
|
+
plan: plan_context.value!.fetch(:plan),
|
|
253
|
+
remote_records: plan_context.value!.fetch(:remote_records),
|
|
254
|
+
mode: mode,
|
|
255
|
+
spinner_output: @err
|
|
256
|
+
)
|
|
257
|
+
executable_count = executor.executable_action_count
|
|
258
|
+
confirm!(executable_count) unless options[:yes]
|
|
259
|
+
|
|
260
|
+
result = executor.call
|
|
261
|
+
return fail_with('Zone apply failed', result.failure) if result.failure?
|
|
262
|
+
|
|
263
|
+
print_apply_summary(plan_context.value!.fetch(:domain), result.value!)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
def build_plan_context(file, domain, compare_ttl: false)
|
|
269
|
+
desired_result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
|
|
270
|
+
return desired_result if desired_result.failure?
|
|
271
|
+
|
|
272
|
+
plan_domain = domain || desired_result.value!.origin
|
|
273
|
+
remote_result = DnsMadeEasy::Zone::RemoteAdapter.new(DnsMadeEasy.client.records_for(plan_domain),
|
|
274
|
+
domain: plan_domain).call
|
|
275
|
+
return remote_result if remote_result.failure?
|
|
276
|
+
|
|
277
|
+
plan = DnsMadeEasy::Zone::Diff.new(
|
|
278
|
+
desired_records: desired_result.value!.records,
|
|
279
|
+
remote_records: remote_result.value!.records,
|
|
280
|
+
compare_ttl: compare_ttl
|
|
281
|
+
).call
|
|
282
|
+
|
|
283
|
+
Dry::Monads::Success(domain: plan_domain, plan: plan, remote_records: remote_result.value!)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def apply_mode(options)
|
|
287
|
+
return :add_only if options[:add_only]
|
|
288
|
+
return :delete_only if options[:delete_only]
|
|
289
|
+
|
|
290
|
+
:merge
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def confirm!(executable_count)
|
|
294
|
+
warn "Apply #{executable_count} action(s)? Type yes to continue:"
|
|
295
|
+
response = DnsMadeEasy::CLI::Input.stdin.gets.to_s.strip
|
|
296
|
+
return if response == 'yes'
|
|
297
|
+
|
|
298
|
+
raise ArgumentError, 'zone apply cancelled'
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def print_apply_summary(domain, result)
|
|
302
|
+
summary = "Zone apply complete for #{domain}.\n" \
|
|
303
|
+
"Applied: #{result.applied_actions.length}\n" \
|
|
304
|
+
"Failed: #{result.failed_actions.length}\n" \
|
|
305
|
+
"Skipped: #{result.skipped_actions.length}"
|
|
306
|
+
|
|
307
|
+
result.failed_actions.empty? ? success(summary) : warning(summary)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def fail_with(message, errors)
|
|
311
|
+
error("#{message}.\n#{errors.join("\n")}")
|
|
312
|
+
raise ReportedError, message.downcase
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
register 'zone' do |prefix|
|
|
318
|
+
prefix.register 'validate', Zone::Validate
|
|
319
|
+
prefix.register 'fmt', Zone::Format, aliases: ['format']
|
|
320
|
+
prefix.register 'export', Zone::Export
|
|
321
|
+
prefix.register 'plan', Zone::Plan
|
|
322
|
+
prefix.register 'apply', Zone::Apply
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
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,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
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
class << self
|
|
22
|
+
attr_accessor :stdout, :stderr
|
|
23
|
+
|
|
24
|
+
def included(base)
|
|
25
|
+
base.include(InstanceMethods)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def info(message)
|
|
29
|
+
print_box(:info, message, stderr)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def warn(message)
|
|
33
|
+
print_box(:warn, message, stderr)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def error(message)
|
|
37
|
+
print_box(:error, message, stderr)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def success(message)
|
|
41
|
+
print_box(:success, message, stderr)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def print_box(box_type, message, output)
|
|
45
|
+
box = TTY::Box.public_send(box_type, message, **BOX_OPTIONS)
|
|
46
|
+
output.puts(box)
|
|
47
|
+
box
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Boxed helpers for command classes. The warn box is exposed as
|
|
52
|
+
# #warning because commands already use Kernel-style plain #warn.
|
|
53
|
+
module InstanceMethods
|
|
54
|
+
def info(message)
|
|
55
|
+
MessageHelpers.print_box(:info, message, message_output)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def warning(message)
|
|
59
|
+
MessageHelpers.print_box(:warn, message, message_output)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def error(message)
|
|
63
|
+
MessageHelpers.print_box(:error, message, message_output)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def success(message)
|
|
67
|
+
MessageHelpers.print_box(:success, message, message_output)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def message_output
|
|
73
|
+
(defined?(@err) && @err) || MessageHelpers.stderr
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
self.stdout = $stdout
|
|
78
|
+
self.stderr = $stderr
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|