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,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,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
|