dnsmadeeasy 0.4.0 → 1.0.5

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 +407 -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 +219 -0
  48. data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
  49. data/lib/dnsmadeeasy/zone/diff.rb +174 -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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads'
4
+ require 'dnsmadeeasy/zone/provider_record'
5
+ require 'dnsmadeeasy/zone/record'
6
+ require 'dnsmadeeasy/zone/remote_records'
7
+
8
+ module DnsMadeEasy
9
+ module Zone
10
+ # Converts DNS Made Easy API record hashes into provider-neutral zone records.
11
+ class RemoteAdapter
12
+ include Dry::Monads[:result]
13
+
14
+ SUPPORTED_RECORD_TYPES = Types::DNS_RECORD_TYPES.freeze
15
+ SOURCE_ID = 'dns-made-easy'
16
+
17
+ def initialize(response, domain:, default_ttl: 300)
18
+ @response = response
19
+ @domain = domain
20
+ @default_ttl = default_ttl
21
+ end
22
+
23
+ def call
24
+ provider_records = []
25
+ warnings = []
26
+
27
+ remote_records.each do |remote_record|
28
+ if remote_record['type'] == 'HTTPRED'
29
+ warnings << omitted_httpred_warning(remote_record)
30
+ elsif SUPPORTED_RECORD_TYPES.include?(remote_record['type'])
31
+ provider_records << provider_record(remote_record)
32
+ else
33
+ return Failure(["Unsupported DNS Made Easy record type: #{remote_record['type']}"])
34
+ end
35
+ end
36
+
37
+ Success(RemoteRecords.new(provider_records: provider_records, warnings: warnings))
38
+ rescue Dry::Struct::Error => e
39
+ Failure([e.message])
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :response,
45
+ :domain,
46
+ :default_ttl
47
+
48
+ def remote_records
49
+ response.fetch('data', [])
50
+ end
51
+
52
+ def provider_record(remote_record)
53
+ ProviderRecord.new(
54
+ record: zone_record(remote_record),
55
+ provider_id: remote_record['id'],
56
+ source_id: SOURCE_ID
57
+ )
58
+ end
59
+
60
+ def zone_record(remote_record)
61
+ Record.new(
62
+ owner: owner(remote_record),
63
+ type: remote_record['type'],
64
+ value: record_value(remote_record),
65
+ ttl: remote_record['ttl'] || default_ttl,
66
+ priority: remote_record['mxLevel'] || remote_record['priority'],
67
+ weight: remote_record['weight'],
68
+ port: remote_record['port']
69
+ )
70
+ end
71
+
72
+ # DME returns TXT/SPF values with the zone-file quotes embedded, while
73
+ # Record holds unquoted text (Parser strips, Serializer re-quotes).
74
+ def record_value(remote_record)
75
+ value = remote_record['value']
76
+ return value unless %w[TXT SPF].include?(remote_record['type'])
77
+
78
+ value.delete_prefix('"').delete_suffix('"')
79
+ end
80
+
81
+ def owner(remote_record)
82
+ name = remote_record['name'].to_s
83
+ return '@' if name.empty? || name == domain || name == domain.delete_suffix('.')
84
+
85
+ name.delete_suffix(".#{domain.delete_suffix('.')}")
86
+ end
87
+
88
+ def omitted_httpred_warning(remote_record)
89
+ record_name = remote_record['name'].to_s.empty? ? '@' : remote_record['name']
90
+ "Omitted HTTPRED record #{record_name} -> #{remote_record['value']}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/struct'
4
+ require 'dnsmadeeasy/types'
5
+ require 'dnsmadeeasy/zone/provider_record'
6
+ require 'dnsmadeeasy/zone/record_set'
7
+
8
+ module DnsMadeEasy
9
+ module Zone
10
+ # Provider records plus non-fatal warnings from remote conversion.
11
+ class RemoteRecords < Dry::Struct
12
+ transform_keys(&:to_sym)
13
+
14
+ attribute :provider_records, Types::Array.of(ProviderRecord).default([].freeze)
15
+ attribute :warnings, Types::Array.of(Types::StrictString).default([].freeze)
16
+
17
+ def records
18
+ provider_records.map(&:record)
19
+ end
20
+
21
+ def record_set
22
+ RecordSet.new(records: records)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/zone/file'
4
+
5
+ module DnsMadeEasy
6
+ module Zone
7
+ # Emits deterministic, normalized zone-file text.
8
+ class Serializer
9
+ # Fixed columns: owner (30), TTL (5, blank when it matches $TTL),
10
+ # "IN " (3), record type (7), then the payload.
11
+ RECORD_FORMAT = '%-30s %5s %-3s%-7s %s'
12
+
13
+ def initialize(zone_file, omit_apex_ns: true)
14
+ @zone_file = zone_file
15
+ @omit_apex_ns = omit_apex_ns
16
+ end
17
+
18
+ def to_s
19
+ lines = [
20
+ "$ORIGIN #{zone_file.origin}",
21
+ "$TTL #{zone_file.ttl}",
22
+ ''
23
+ ]
24
+ lines.concat(serialized_records)
25
+ "#{lines.join("\n")}\n"
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :zone_file,
31
+ :omit_apex_ns
32
+
33
+ def serialized_records
34
+ grouped_records.flat_map.with_index do |records, index|
35
+ lines = records.map { |record| serialize_record(record) }
36
+ index.zero? ? lines : [''] + lines
37
+ end
38
+ end
39
+
40
+ def grouped_records
41
+ serializable_records.chunk { |record| record_group(record) }.map(&:last)
42
+ end
43
+
44
+ def serializable_records
45
+ zone_file.records
46
+ .reject { |record| omit_apex_ns && record.owner == '@' && record.type == 'NS' }
47
+ .sort_by { |record| serializer_sort_key(record) }
48
+ end
49
+
50
+ def record_group(record)
51
+ case record.type
52
+ when 'MX'
53
+ :mail
54
+ when 'TXT', 'SPF'
55
+ :text
56
+ else
57
+ :address
58
+ end
59
+ end
60
+
61
+ def serializer_sort_key(record)
62
+ [
63
+ serializer_type_group(record),
64
+ normalized_owner(record.owner),
65
+ record.type,
66
+ record.priority || -1,
67
+ record.value,
68
+ record.ttl
69
+ ]
70
+ end
71
+
72
+ def serializer_type_group(record)
73
+ case record.type
74
+ when 'A', 'AAAA', 'CNAME', 'ANAME', 'NS', 'PTR'
75
+ 0
76
+ when 'MX', 'SRV'
77
+ 1
78
+ when 'TXT', 'SPF'
79
+ 2
80
+ else
81
+ 3
82
+ end
83
+ end
84
+
85
+ def normalized_owner(owner)
86
+ owner == '@' ? '' : owner
87
+ end
88
+
89
+ def serialize_record(record)
90
+ format(RECORD_FORMAT, record.owner, ttl_token(record), 'IN', record.type, record_payload(record))
91
+ end
92
+
93
+ # A single $TTL cannot represent a real zone, so records that deviate
94
+ # from the zone default carry an explicit right-aligned TTL.
95
+ def ttl_token(record)
96
+ record.ttl == zone_file.ttl ? '' : record.ttl.to_s
97
+ end
98
+
99
+ def record_payload(record)
100
+ case record.type
101
+ when 'MX'
102
+ "#{record.priority} #{record.value}"
103
+ when 'SRV'
104
+ "#{record.priority} #{record.weight} #{record.port} #{record.value}"
105
+ when 'TXT', 'SPF'
106
+ quote_text(record.value)
107
+ else
108
+ record.value
109
+ end
110
+ end
111
+
112
+ def quote_text(value)
113
+ return value if value.start_with?('"') && value.end_with?('"')
114
+
115
+ %("#{value}")
116
+ end
117
+ end
118
+ end
119
+ end
data/lib/dnsmadeeasy.rb CHANGED
@@ -6,25 +6,49 @@ module DnsMadeEasy
6
6
  end
7
7
 
8
8
  require 'dnsmadeeasy/version'
9
+ require 'dnsmadeeasy/types'
9
10
  require 'dnsmadeeasy/credentials'
10
11
  require 'dnsmadeeasy/api/client'
12
+ require 'dnsmadeeasy/cli/launcher'
13
+ require 'dnsmadeeasy/zone/record'
14
+ require 'dnsmadeeasy/zone/provider_record'
15
+ require 'dnsmadeeasy/zone/record_set'
16
+ require 'dnsmadeeasy/zone/file'
17
+ require 'dnsmadeeasy/zone/parser'
18
+ require 'dnsmadeeasy/zone/aname_flattener'
19
+ require 'dnsmadeeasy/zone/apply_executor'
20
+ require 'dnsmadeeasy/zone/apply_result'
21
+ require 'dnsmadeeasy/zone/diff'
22
+ require 'dnsmadeeasy/zone/plan'
23
+ require 'dnsmadeeasy/zone/plan_action'
24
+ require 'dnsmadeeasy/zone/plan_renderer'
25
+ require 'dnsmadeeasy/zone/remote_adapter'
26
+ require 'dnsmadeeasy/zone/remote_records'
27
+ require 'dnsmadeeasy/zone/serializer'
11
28
 
12
29
  module DnsMadeEasy
13
- class Error < StandardError;
30
+ class Error < StandardError
14
31
  end
15
- class AuthenticationError < Error;
32
+
33
+ class AuthenticationError < Error
16
34
  end
17
- class APIKeyAndSecretMissingError < Error;
35
+
36
+ class APIKeyAndSecretMissingError < Error
18
37
  end
19
- class InvalidCredentialKeys < Error;
38
+
39
+ class InvalidCredentialKeys < Error
20
40
  end
21
- class AbstractMethodError < Error;
41
+
42
+ class AbstractMethodError < Error
22
43
  end
23
- class InvalidCredentialsFormatError < Error;
44
+
45
+ class InvalidCredentialsFormatError < Error
24
46
  end
25
- class NoSuchAccountError < Error;
47
+
48
+ class NoSuchAccountError < Error
26
49
  end
27
- class NoDomainError < Error;
50
+
51
+ class NoDomainError < Error
28
52
  end
29
53
 
30
54
  class << self
@@ -38,26 +62,22 @@ module DnsMadeEasy
38
62
  def configure_from_file(file = nil,
39
63
  account = nil,
40
64
  encryption_key = nil)
41
-
42
65
  credentials = ::DnsMadeEasy::Credentials.keys_from_file(
43
- file: file || ::DnsMadeEasy::Credentials.default_credentials_path(user: ENV['USER']),
66
+ file: file || ::DnsMadeEasy::Credentials.default_credentials_path(user: ENV.fetch('USER', nil)),
44
67
  account: account,
45
68
  encryption_key: encryption_key
46
69
  )
47
- if credentials
48
- configure do |config|
49
- config.api_key = credentials.api_key
50
- config.api_secret = credentials.api_secret
51
- end
52
- else
53
- raise APIKeyAndSecretMissingError, "Unable to load valid api keys from #{file}!"
70
+ raise APIKeyAndSecretMissingError, "Unable to load valid api keys from #{file}!" unless credentials
71
+
72
+ configure do |config|
73
+ config.api_key = credentials.api_key
74
+ config.api_secret = credentials.api_secret
54
75
  end
55
76
  end
56
77
 
57
78
  def credentials_from_file(file: DnsMadeEasy::Credentials.default_credentials_path,
58
79
  account: nil,
59
80
  encryption_key: nil)
60
-
61
81
  DnsMadeEasy::Credentials.keys_from_file file: file,
62
82
  account: account,
63
83
  encryption_key: encryption_key
@@ -67,37 +87,51 @@ module DnsMadeEasy
67
87
  self.default_api_key = value
68
88
  end
69
89
 
90
+ def api_key
91
+ default_api_key
92
+ end
93
+
70
94
  def api_secret=(value)
71
95
  self.default_api_secret = value
72
96
  end
73
97
 
74
- def client(**options)
75
- @client ||= create_client(false, **options)
98
+ def api_secret
99
+ default_api_secret
76
100
  end
77
101
 
78
- def sandbox_client(**options)
79
- @sandbox_client ||= create_client(true, **options)
102
+ def client(**)
103
+ @client ||= create_client(false, **)
104
+ end
105
+
106
+ def sandbox_client(**)
107
+ @sandbox_client ||= create_client(true, **)
80
108
  end
81
109
 
82
110
  def create_client(sandbox = false,
83
111
  api_key: default_api_key,
84
112
  api_secret: default_api_secret,
85
113
 
86
- **options)
114
+ **)
87
115
  raise APIKeyAndSecretMissingError, 'Please set #api_key and #api_secret' unless api_key && api_secret
88
116
 
89
- ::DnsMadeEasy::Api::Client.new(api_key, api_secret, sandbox, **options)
117
+ ::DnsMadeEasy::Api::Client.new(api_key, api_secret, sandbox, **)
90
118
  end
91
119
 
92
120
  # Basically delegate it all to the Client instance
93
121
  # if the method call is supported.
94
122
  #
95
- def method_missing(method, *args, &block)
123
+ def method_missing(method, ...)
96
124
  if client.respond_to?(method)
97
- client.send(method, *args, &block)
125
+ client.send(method, ...)
98
126
  else
99
- super(method, *args, &block)
127
+ super
100
128
  end
101
129
  end
130
+
131
+ def respond_to_missing?(method, include_private = false)
132
+ client.respond_to?(method, include_private) || super
133
+ rescue APIKeyAndSecretMissingError
134
+ super
135
+ end
102
136
  end
103
137
  end