record_store 7.0.1 → 7.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5380713a7eb24d7be7ea68e2d6e2691bc929bccc22cf5098ce805665b1f2493b
4
- data.tar.gz: 7a4a1354479436ed184cad12e3eb8d434c0e8fa610bbd734d66ca494bb4c5365
3
+ metadata.gz: e9f48a40d3262e148fff5aecff8c61e5f4f676bdc7f7bcb64e2964dc221ba078
4
+ data.tar.gz: 48a44aa9fc8a431653d09c1c524bcfee73ea09e6148ae9ff0552110b7db51173
5
5
  SHA512:
6
- metadata.gz: 0e5bfe2bf6e97fc9a771c2472bfc4d49d94333bde7bd40c01b67203a674df743be0fc9db80cff14c126aa1d4a9b295d7028c954b372f2b033e0c56feeb1fe6b1
7
- data.tar.gz: b49194a0c1e24f3a3929832c561236c9828c75ee97859233a8ce127cd7f7321526359d43f01f3653962778354b330a095dd94675aeaebb0195afa5bfe60fe1ab
6
+ metadata.gz: cb623045bb1b2db82232eb037adaf05a13c234d9d82e760603075ec590b5528f2daeb6dfe1df28bd6cc1987ba379f8ee070547af3336bb11213651012d527f5b
7
+ data.tar.gz: 73618a9cbae3b16028bcaa7dba64dda74e4324427fda3e364c7df0087ed72828f36965dae239bc0a2c37c1c7aece20950e0a0556a325e4cd10082e74c2da3429
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 7.1.1
4
+ - Bugfix: Cloudflare provider now handles empty record sets properly
5
+
6
+ ## 7.1.0
7
+ - Add Cloudflare as a supported DNS provider
8
+
3
9
  ## 7.0.1
4
10
  - Allow an underscore in the middle of a CNAME record
5
11
 
data/README.md CHANGED
@@ -95,6 +95,12 @@ Regarding the `fingerprint` and `key_content`, you'll need to generate an API Si
95
95
 
96
96
  The `tenancy` and `region` are in the `Profile` menu. The `tenancy` starts with `ocid1.tenancy.oc1..`.
97
97
 
98
+ ### Cloudflare
99
+
100
+ Record_store and the Cloudflare provider do not have any concept of a zone id or account. Zone names must be unique within all accounts the API key has access to, otherwise Cloudflare provider will error.
101
+
102
+ `ALIAS` records in zone files are supported and created in Cloudflare as [per-record flattened](https://developers.cloudflare.com/dns/cname-flattening/set-up-cname-flattening/#per-record) CNAME records.
103
+
98
104
  ----
99
105
 
100
106
  # Architecture
@@ -184,14 +190,14 @@ class Provider
184
190
  #
185
191
  # Arguments:
186
192
  # record - a kind of `Record`
187
- def add(zone, record)
193
+ def add(record, zone)
188
194
  end
189
195
 
190
196
  # Deletes an existing record from the zone. It is expected this call modifies external state.
191
197
  #
192
198
  # Arguments:
193
199
  # record - a kind of `Record`
194
- def remove(zone, record)
200
+ def remove(record, zone)
195
201
  end
196
202
 
197
203
  # Updates an existing record in the zone. It is expected this call modifies external state.
@@ -199,7 +205,7 @@ class Provider
199
205
  # Arguments:
200
206
  # id - provider specific ID of record to update
201
207
  # record - a kind of `Record` which the record with `id` should be updated to
202
- def update(zone, id, record)
208
+ def update(id, record, zone)
203
209
  end
204
210
  end
205
211
  ```
@@ -0,0 +1,97 @@
1
+ require 'net/http'
2
+ require_relative 'response'
3
+
4
+ module Cloudflare
5
+ class Client
6
+ def initialize(api_fqdn, api_token)
7
+ @api_fqdn = api_fqdn
8
+ @api_token = api_token
9
+ end
10
+
11
+ def get(endpoint, params = {})
12
+ uri = build_uri(endpoint, params)
13
+
14
+ response = request(:get, uri)
15
+ Cloudflare::Response.new(response)
16
+ end
17
+
18
+ def post(endpoint, body = nil)
19
+ uri = build_uri(endpoint)
20
+
21
+ response = request(:post, uri, body: body)
22
+ Cloudflare::Response.new(response)
23
+ end
24
+
25
+ def put(endpoint, body = nil)
26
+ uri = build_uri(endpoint)
27
+
28
+ response = request(:put, uri, body: body)
29
+ Cloudflare::Response.new(response)
30
+ end
31
+
32
+ def patch(endpoint, body = nil)
33
+ uri = build_uri(endpoint)
34
+
35
+ response = request(:patch, uri, body: body)
36
+ Cloudflare::Response.new(response)
37
+ end
38
+
39
+ def delete(endpoint)
40
+ uri = build_uri(endpoint)
41
+
42
+ response = request(:delete, uri)
43
+ Cloudflare::Response.new(response)
44
+ end
45
+
46
+ def request(method, uri, body: nil)
47
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |conn|
48
+ request = case method
49
+ when :get
50
+ Net::HTTP::Get.new(uri)
51
+ when :post
52
+ request = Net::HTTP::Post.new(uri)
53
+ request.body = body.to_json if body
54
+ request
55
+ when :put
56
+ request = Net::HTTP::Put.new(uri)
57
+ request.body = body.to_json if body
58
+ request
59
+ when :patch
60
+ request = Net::HTTP::Patch.new(uri)
61
+ request.body = body.to_json if body
62
+ request
63
+ when :delete
64
+ Net::HTTP::Delete.new(uri)
65
+ end
66
+
67
+ cloudflare_headers.each { |k, v| request[k] = v }
68
+
69
+ begin
70
+ response = conn.request(request)
71
+ rescue StandardError => e
72
+ raise "HTTP error: #{e.message}"
73
+ end
74
+ response
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def cloudflare_headers
81
+ @cloudflare_headers ||= {
82
+ 'User-Agent' => "Ruby",
83
+ 'Accept' => 'application/json',
84
+ 'Content-Type' => 'application/json',
85
+ 'Authorization' => "Bearer #{@api_token}"
86
+ }
87
+ end
88
+
89
+ def build_uri(endpoint, params = {})
90
+ URI::HTTPS.build(
91
+ host: @api_fqdn,
92
+ path: endpoint.chomp('/'),
93
+ query: URI.encode_www_form(params),
94
+ )
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,60 @@
1
+ require 'json'
2
+
3
+ module Cloudflare
4
+ class Response
5
+ attr_reader :http_response
6
+
7
+ def initialize(http_response)
8
+ @http_response = http_response
9
+ end
10
+
11
+ def status_code
12
+ @http_response.code.to_i
13
+ end
14
+
15
+ def result_raw
16
+ result = json['result']
17
+
18
+ return if result.nil? || result.empty? || !success
19
+
20
+ result
21
+ end
22
+
23
+ def result
24
+ result = result_raw
25
+ return result.first if result.is_a?(Array)
26
+
27
+ result
28
+ end
29
+
30
+ def success
31
+ json['success']
32
+ end
33
+
34
+ def errors
35
+ json.fetch('errors', [])
36
+ end
37
+
38
+ def messages
39
+ json.fetch('messages', [])
40
+ end
41
+
42
+ def error_messages
43
+ errors.map { |error| error['message'] }
44
+ end
45
+
46
+ private
47
+
48
+ def json_content?
49
+ @http_response['Content-Type']&.match?(%r{application/json})
50
+ end
51
+
52
+ def json
53
+ return {} unless json_content?
54
+
55
+ JSON.parse(@http_response.body)
56
+ rescue JSON::ParserError => e
57
+ raise "JSON parsing error: #{e.message}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,192 @@
1
+ require_relative 'cloudflare/client'
2
+
3
+ module RecordStore
4
+ class Provider::Cloudflare < Provider
5
+ class << self
6
+ def record_types
7
+ super | Set.new(%w(PTR))
8
+ end
9
+
10
+ def supports_alias?
11
+ true
12
+ end
13
+
14
+ def supports_spf?
15
+ # New as Cloudflare doesn't support the now deprecated spf type
16
+ false
17
+ end
18
+
19
+ # Returns: an array of `Record` for each record in the provider's zone
20
+ def retrieve_current_records(zone:, stdout: $stdout)
21
+ zone_id = zone_name_to_id(zone)
22
+
23
+ retry_on_connection_errors do
24
+ records = client.get("/client/v4/zones/#{zone_id}/dns_records").result_raw || []
25
+ records.map { |api_body| build_from_api(api_body) }
26
+ end
27
+ end
28
+
29
+ # Returns an array of the zones managed by provider as strings
30
+ # Cloudflare returns zones across all accounts accessible by the API token
31
+ # Can implement filtering in request if needed
32
+ def zones
33
+ retry_on_connection_errors do
34
+ zones = client.get('/client/v4/zones').result_raw || []
35
+ zones.map { |zone| zone['name'] }
36
+ end
37
+ end
38
+
39
+ def apply_changeset(changeset, stdout = $stdout)
40
+ deletes = []
41
+ patches = []
42
+ posts = []
43
+ puts = []
44
+
45
+ changeset.changes.each do |change|
46
+ case change.type
47
+ when :removal
48
+ stdout.puts "Removing #{change.record}..."
49
+ deletes << { id: change.record.id }
50
+ when :addition
51
+ stdout.puts "Creating #{change.record}..."
52
+ posts << build_api_body(change.record)
53
+ when :update
54
+ stdout.puts "Updating record with ID #{change.id} to #{change.record}..."
55
+ patches << build_api_body(change.record).merge(id: change.id)
56
+ else
57
+ raise ArgumentError, "Unknown change type #{change.type.inspect}"
58
+ end
59
+ end
60
+
61
+ zone_id = zone_name_to_id(changeset.zone)
62
+ api_body = {
63
+ deletes: deletes,
64
+ patches: patches,
65
+ posts: posts,
66
+ puts: puts
67
+ }
68
+
69
+ retry_on_connection_errors do
70
+ response = client.post("/client/v4/zones/#{zone_id}/dns_records/batch", api_body)
71
+ unless response.success
72
+ error_message = response.errors.map { |error| error['message'] }.join(', ')
73
+ raise RecordStore::Provider::Error, "Cloudflare API error: #{error_message}"
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def secrets
81
+ super.fetch('cloudflare')
82
+ end
83
+
84
+ def build_api_body(record)
85
+ api_body = {
86
+ name: record.fqdn,
87
+ ttl: record.ttl,
88
+ type: record.type,
89
+ }
90
+ case record
91
+ when Record::A, Record::AAAA
92
+ api_body[:content] = record.rdata_txt
93
+ when Record::CNAME
94
+ api_body[:content] = record.rdata_txt
95
+ when Record::PTR
96
+ api_body[:content] = record.rdata_txt
97
+ when Record::MX
98
+ api_body[:content] = record.exchange
99
+ api_body[:priority] = record.preference
100
+ when Record::TXT, Record::SPF
101
+ api_body[:content] = record.txtdata.gsub('\;', ';')
102
+ when Record::CAA
103
+ api_body[:data] = record.rdata
104
+ when Record::SRV
105
+ api_body[:data] = rdata
106
+ when Record::ALIAS
107
+ api_body[:type] = 'CNAME'
108
+ api_body[:content] = record.rdata_txt
109
+ api_body[:settings] = { flatten_cname: true }
110
+ when Record::NS
111
+ api_body[:content] = record.nsdname
112
+ end
113
+
114
+ api_body
115
+ end
116
+
117
+ def build_from_api(api_response)
118
+ fqdn = Record.ensure_ends_with_dot(api_response['name'])
119
+
120
+ record_type = api_response['type']
121
+
122
+ record = {
123
+ record_id: api_response['id'],
124
+ ttl: api_response['ttl'],
125
+ fqdn: fqdn.downcase,
126
+ }
127
+
128
+ case record_type
129
+ when 'A', 'AAAA'
130
+ record.merge!(address: api_response['content'])
131
+ when 'CNAME'
132
+ if api_response.dig('settings', 'flatten_cname')
133
+ record_type = 'ALIAS'
134
+ record.merge!(alias: api_response['content'])
135
+ else
136
+ record.merge!(cname: api_response['content'])
137
+ end
138
+ when 'TXT'
139
+ record.merge!(txtdata: Record.unescape(api_response['content']).gsub(';', '\;'))
140
+ when 'MX'
141
+ record.merge!(preference: api_response['priority'], exchange: api_response['content'])
142
+ when 'PTR'
143
+ record.merge!(ptrdname: api_response['content'])
144
+ when 'CAA'
145
+ flags, tag, value = api_response['content'].split(' ')
146
+
147
+ record.merge!(
148
+ flags: flags.to_i,
149
+ tag: tag,
150
+ value: Record.unquote(value),
151
+ )
152
+ when 'SRV'
153
+ weight, port, host = api_response['content'].split(' ')
154
+
155
+ record.merge!(
156
+ priority: api_response['priority'].to_i,
157
+ weight: weight.to_i,
158
+ port: port.to_i,
159
+ target: Record.ensure_ends_with_dot(host),
160
+ )
161
+ when 'NS'
162
+ record.merge!(nsdname: api_response['content'])
163
+ end
164
+
165
+ Record.const_get(record_type).new(record)
166
+ end
167
+
168
+ def client
169
+ Cloudflare::Client.new(
170
+ secrets['api_fqdn'],
171
+ secrets['api_token'],
172
+ )
173
+ end
174
+
175
+ def zone_name_to_id(zone_name)
176
+ retry_on_connection_errors do
177
+ matching_zones = client.get('/client/v4/zones').result_raw.select { |zone| zone['name'] == zone_name }
178
+
179
+ case matching_zones.size
180
+ when 0
181
+ raise "Zone not found for #{zone_name}"
182
+ when 1
183
+ matching_zones.first['id']
184
+ else
185
+ raise "Multiple zones found for #{zone_name}. " \
186
+ "API key must only return the zone on which records are to be managed."
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -36,6 +36,8 @@ module RecordStore
36
36
  'NS1'
37
37
  when /\.oraclecloud\.net\z/
38
38
  'OracleCloudDNS'
39
+ when /\.cloudflare\.com\z/
40
+ 'Cloudflare'
39
41
  end
40
42
  end
41
43
 
@@ -121,15 +123,15 @@ module RecordStore
121
123
 
122
124
  private
123
125
 
124
- def add(record)
126
+ def add(record, zone)
125
127
  raise NotImplementedError
126
128
  end
127
129
 
128
- def remove(record)
130
+ def remove(record, zone)
129
131
  raise NotImplementedError
130
132
  end
131
133
 
132
- def update(id, record)
134
+ def update(id, record, zone)
133
135
  raise NotImplementedError
134
136
  end
135
137
 
@@ -1,3 +1,3 @@
1
1
  module RecordStore
2
- VERSION = '7.0.1'.freeze
2
+ VERSION = '7.1.1'.freeze
3
3
  end
data/lib/record_store.rb CHANGED
@@ -36,6 +36,7 @@ require 'record_store/provider/dnsimple'
36
36
  require 'record_store/provider/google_cloud_dns'
37
37
  require 'record_store/provider/ns1'
38
38
  require 'record_store/provider/oracle_cloud_dns'
39
+ require 'record_store/provider/cloudflare'
39
40
  require 'record_store/cli'
40
41
 
41
42
  module RecordStore
data/record_store.gemspec CHANGED
@@ -35,7 +35,7 @@ Gem::Specification.new do |spec|
35
35
  spec.add_runtime_dependency 'fog-dynect', '>= 0.4', '< 0.6'
36
36
  spec.add_runtime_dependency 'fog-json'
37
37
  spec.add_runtime_dependency 'fog-xml'
38
- spec.add_runtime_dependency 'google-cloud-dns', '~> 0.31'
38
+ spec.add_runtime_dependency 'google-cloud-dns', '>= 0.31', '< 2.0'
39
39
  spec.add_runtime_dependency 'ns1'
40
40
  spec.add_runtime_dependency 'oci', '>= 2.14', '< 2.22'
41
41
  spec.add_runtime_dependency 'ruby-limiter', '>= 1.0.1', '< 3'
@@ -46,7 +46,7 @@ Gem::Specification.new do |spec|
46
46
  spec.add_development_dependency 'mocha'
47
47
  spec.add_development_dependency 'pry'
48
48
  spec.add_development_dependency 'rake'
49
- spec.add_development_dependency 'rubocop', '~> 1.64.0'
49
+ spec.add_development_dependency 'rubocop', '~> 1.67.0'
50
50
  spec.add_development_dependency 'rubocop-shopify', '~> 2.15.1'
51
51
  spec.add_development_dependency 'vcr'
52
52
  spec.add_development_dependency 'webmock'
@@ -23,5 +23,9 @@
23
23
  "token_uri": "https://oauth2.googleapis.com/token",
24
24
  "auth_provider_x509_cert_url": "auth_provider_x509_cert_url",
25
25
  "client_x509_cert_url": "client_x509_cert_url"
26
+ },
27
+ "cloudflare": {
28
+ "api_fqdn": "api.cloudflare.com",
29
+ "api_token": "local-dev-key"
26
30
  }
27
31
  }
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: record_store
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.1
4
+ version: 7.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
8
8
  - Emil Stolarsky
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2024-06-19 00:00:00.000000000 Z
12
+ date: 2024-10-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel
@@ -145,16 +145,22 @@ dependencies:
145
145
  name: google-cloud-dns
146
146
  requirement: !ruby/object:Gem::Requirement
147
147
  requirements:
148
- - - "~>"
148
+ - - ">="
149
149
  - !ruby/object:Gem::Version
150
150
  version: '0.31'
151
+ - - "<"
152
+ - !ruby/object:Gem::Version
153
+ version: '2.0'
151
154
  type: :runtime
152
155
  prerelease: false
153
156
  version_requirements: !ruby/object:Gem::Requirement
154
157
  requirements:
155
- - - "~>"
158
+ - - ">="
156
159
  - !ruby/object:Gem::Version
157
160
  version: '0.31'
161
+ - - "<"
162
+ - !ruby/object:Gem::Version
163
+ version: '2.0'
158
164
  - !ruby/object:Gem::Dependency
159
165
  name: ns1
160
166
  requirement: !ruby/object:Gem::Requirement
@@ -299,14 +305,14 @@ dependencies:
299
305
  requirements:
300
306
  - - "~>"
301
307
  - !ruby/object:Gem::Version
302
- version: 1.64.0
308
+ version: 1.67.0
303
309
  type: :development
304
310
  prerelease: false
305
311
  version_requirements: !ruby/object:Gem::Requirement
306
312
  requirements:
307
313
  - - "~>"
308
314
  - !ruby/object:Gem::Version
309
- version: 1.64.0
315
+ version: 1.67.0
310
316
  - !ruby/object:Gem::Dependency
311
317
  name: rubocop-shopify
312
318
  requirement: !ruby/object:Gem::Requirement
@@ -383,6 +389,9 @@ files:
383
389
  - lib/record_store/changeset.rb
384
390
  - lib/record_store/cli.rb
385
391
  - lib/record_store/provider.rb
392
+ - lib/record_store/provider/cloudflare.rb
393
+ - lib/record_store/provider/cloudflare/client.rb
394
+ - lib/record_store/provider/cloudflare/response.rb
386
395
  - lib/record_store/provider/dnsimple.rb
387
396
  - lib/record_store/provider/dnsimple/patch_api_header.rb
388
397
  - lib/record_store/provider/dnsimple/patch_request_error_to_include_errors.rb
@@ -431,7 +440,7 @@ licenses:
431
440
  - MIT
432
441
  metadata:
433
442
  allowed_push_host: https://rubygems.org
434
- post_install_message:
443
+ post_install_message:
435
444
  rdoc_options: []
436
445
  require_paths:
437
446
  - lib
@@ -446,8 +455,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
446
455
  - !ruby/object:Gem::Version
447
456
  version: '0'
448
457
  requirements: []
449
- rubygems_version: 3.5.13
450
- signing_key:
458
+ rubygems_version: 3.5.22
459
+ signing_key:
451
460
  specification_version: 4
452
461
  summary: Manage DNS using git
453
462
  test_files: []