belt 0.3.40 → 0.3.42
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/CHANGELOG.md +29 -0
- data/lib/belt/cli/apex_dns_sync.rb +334 -0
- data/lib/belt/cli/deploy_command.rb +12 -0
- data/lib/belt/cli/dns_command.rb +330 -0
- data/lib/belt/route_dsl.rb +25 -13
- data/lib/belt/version.rb +1 -1
- data/lib/templates/module/outputs.tf.erb +28 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a46f69166308a770107e86d0ccee9499a62e60858ebf000c74990183670eacfc
|
|
4
|
+
data.tar.gz: 988f5e06c5b6f03fb42be923ea9864e9a9bdbf2f12bc4d8bd963c82066eaa9fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f614e8b43dfbe1a6431b0ae8f8a04f3bd92a33f305afd4c75d5a84252a45714b9462fbbbdc63d6978a873da674da053581f7a7afb521f011a06badcd6bc5913
|
|
7
|
+
data.tar.gz: 766f648f1bc4b795698f605f729682ab44baf7e27fa66dd8ff73e6c618cd1bba21cd8ae15c75cdff9d8c1b6e9a98b84f5be4b1bd828d7e16c0efcc0071ef9a77
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.41
|
|
4
|
+
|
|
5
|
+
### Bug Fix
|
|
6
|
+
|
|
7
|
+
- **Fix API Gateway sibling path parameter conflict**: When a resource has nested
|
|
8
|
+
resources (a block), member routes (show, update, destroy) now use `{param_name}`
|
|
9
|
+
(e.g., `{project_id}`) instead of `{id}` to match the nested routes. This prevents
|
|
10
|
+
API Gateway from rejecting routes due to sibling path segments having different
|
|
11
|
+
parameter names.
|
|
12
|
+
|
|
13
|
+
Without nested resources (no block):
|
|
14
|
+
```
|
|
15
|
+
GET /posts/{id}
|
|
16
|
+
PUT /posts/{id}
|
|
17
|
+
DELETE /posts/{id}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
With nested resources:
|
|
21
|
+
```
|
|
22
|
+
GET /projects/{project_id}
|
|
23
|
+
PUT /projects/{project_id}
|
|
24
|
+
DELETE /projects/{project_id}
|
|
25
|
+
GET /projects/{project_id}/epics
|
|
26
|
+
GET /projects/{project_id}/epics/{id}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This fixes the error: "Unable to create resource at path '...': A sibling ({id})
|
|
30
|
+
of this resource already has a variable path part".
|
|
31
|
+
|
|
3
32
|
## 0.3.40
|
|
4
33
|
|
|
5
34
|
### Breaking Change
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative 'environment_config'
|
|
6
|
+
|
|
7
|
+
module Belt
|
|
8
|
+
module CLI
|
|
9
|
+
# Syncs DNS records for apex (prod) environments to the root zone.
|
|
10
|
+
#
|
|
11
|
+
# Problem: When prod uses the apex domain (example.com), it creates a Route53
|
|
12
|
+
# zone and A records. But the registrar's NS records point to the ROOT zone
|
|
13
|
+
# (infrastructure/dns), not the prod zone. So the prod zone's records are
|
|
14
|
+
# invisible to the internet.
|
|
15
|
+
#
|
|
16
|
+
# Solution: After deploying prod, copy the A alias records (CloudFront, API
|
|
17
|
+
# Gateway) to the root zone, and ensure ACM validation CNAMEs are there too.
|
|
18
|
+
class ApexDnsSync
|
|
19
|
+
DNS_DIR = 'infrastructure/dns'
|
|
20
|
+
|
|
21
|
+
def initialize(env, infra_dir:, quiet: false)
|
|
22
|
+
@env = env
|
|
23
|
+
@infra_dir = infra_dir
|
|
24
|
+
@quiet = quiet
|
|
25
|
+
@project_root = find_project_root
|
|
26
|
+
@env_config = EnvironmentConfig.load(env, infra_dir: infra_dir)
|
|
27
|
+
@dns_config = dns_dir_exists? ? load_dns_config : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check if this environment needs apex DNS sync.
|
|
31
|
+
# Returns true if:
|
|
32
|
+
# 1. This is a prod environment (app_domain == domain, no env prefix)
|
|
33
|
+
# 2. DNS root zone exists (infrastructure/dns)
|
|
34
|
+
# 3. Domain is configured
|
|
35
|
+
def needs_sync?
|
|
36
|
+
return false unless dns_dir_exists?
|
|
37
|
+
return false unless apex_environment?
|
|
38
|
+
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Run the sync: copy prod's DNS records to the root zone.
|
|
43
|
+
def run
|
|
44
|
+
return unless needs_sync?
|
|
45
|
+
|
|
46
|
+
puts '━━━ apex DNS sync ━━━' unless @quiet
|
|
47
|
+
|
|
48
|
+
# Get prod's DNS targets (CloudFront, API Gateway)
|
|
49
|
+
targets = fetch_prod_targets
|
|
50
|
+
if targets.empty? || targets[:domain].nil?
|
|
51
|
+
puts ' ⚠ Could not read DNS targets from prod — skipping sync' unless @quiet
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get root zone ID
|
|
56
|
+
root_zone_id = fetch_root_zone_id
|
|
57
|
+
unless root_zone_id
|
|
58
|
+
puts ' ⚠ Could not read root zone ID — skipping sync' unless @quiet
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Sync ACM validation first (cert must validate before CloudFront works)
|
|
63
|
+
sync_acm_validation(root_zone_id, targets[:domain])
|
|
64
|
+
|
|
65
|
+
# Sync A alias records
|
|
66
|
+
sync_alias_records(root_zone_id, targets)
|
|
67
|
+
|
|
68
|
+
puts ' ✓ Apex DNS synced to root zone' unless @quiet
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def dns_dir_exists?
|
|
74
|
+
dns_path = File.join(@project_root, DNS_DIR)
|
|
75
|
+
Dir.exist?(dns_path) && File.exist?(File.join(dns_path, 'terraform.tfvars'))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def dns_dir_path
|
|
79
|
+
File.join(@project_root, DNS_DIR)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def find_project_root
|
|
83
|
+
dir = @infra_dir
|
|
84
|
+
while dir != '/'
|
|
85
|
+
return dir if File.exist?(File.join(dir, 'Gemfile')) ||
|
|
86
|
+
File.exist?(File.join(dir, 'belt.rb'))
|
|
87
|
+
|
|
88
|
+
dir = File.dirname(dir)
|
|
89
|
+
end
|
|
90
|
+
File.dirname(@infra_dir)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def load_dns_config
|
|
94
|
+
EnvironmentConfig.load('dns', infra_dir: File.join(@project_root, 'infrastructure'))
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def apex_environment?
|
|
100
|
+
# Check tfvars to see if this is a prod environment
|
|
101
|
+
env_dir = File.join(@infra_dir, @env)
|
|
102
|
+
return false unless Dir.exist?(env_dir)
|
|
103
|
+
|
|
104
|
+
tfvars_path = File.join(env_dir, 'terraform.tfvars')
|
|
105
|
+
return false unless File.exist?(tfvars_path)
|
|
106
|
+
|
|
107
|
+
content = File.read(tfvars_path)
|
|
108
|
+
domain_match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
|
|
109
|
+
env_match = content.match(/^\s*environment\s*=\s*"([^"]+)"/)
|
|
110
|
+
parent_match = content.match(/^\s*parent_environment\s*=\s*"([^"]+)"/)
|
|
111
|
+
|
|
112
|
+
return false unless domain_match
|
|
113
|
+
|
|
114
|
+
# Not apex if it's a nested environment
|
|
115
|
+
parent = parent_match ? parent_match[1] : ''
|
|
116
|
+
return false unless parent.empty?
|
|
117
|
+
|
|
118
|
+
# Apex if environment is "prod" (convention)
|
|
119
|
+
env_name = env_match ? env_match[1] : @env
|
|
120
|
+
env_name == 'prod'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fetch_prod_targets
|
|
124
|
+
env_dir = File.join(@infra_dir, @env)
|
|
125
|
+
targets = {}
|
|
126
|
+
|
|
127
|
+
Dir.chdir(env_dir) do
|
|
128
|
+
env = aws_env_for(@env_config)
|
|
129
|
+
|
|
130
|
+
# Get terraform outputs
|
|
131
|
+
output, status = Open3.capture2e(env, 'terraform', 'output', '-json')
|
|
132
|
+
return targets unless status.success?
|
|
133
|
+
|
|
134
|
+
data = parse_json(output) || {}
|
|
135
|
+
extract_cloudfront_target(data, targets)
|
|
136
|
+
extract_api_gateway_target(data, targets)
|
|
137
|
+
extract_domain(targets)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
targets
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def extract_cloudfront_target(data, targets)
|
|
144
|
+
cf_domain = data.dig('cloudfront_domain_name', 'value')
|
|
145
|
+
return unless cf_domain
|
|
146
|
+
|
|
147
|
+
cf_zone = data.dig('cloudfront_hosted_zone_id', 'value')
|
|
148
|
+
targets[:cloudfront] = {
|
|
149
|
+
domain_name: cf_domain,
|
|
150
|
+
hosted_zone_id: cf_zone || 'Z2FDTNDATAQYW2' # CloudFront's fixed zone ID
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_api_gateway_target(data, targets)
|
|
155
|
+
apigw_domain = data.dig('api_gateway_domain_name', 'value')
|
|
156
|
+
apigw_zone = data.dig('api_gateway_hosted_zone_id', 'value')
|
|
157
|
+
return unless apigw_domain && !apigw_domain.empty? && apigw_zone && !apigw_zone.empty?
|
|
158
|
+
|
|
159
|
+
targets[:api_gateway] = {
|
|
160
|
+
domain_name: apigw_domain,
|
|
161
|
+
hosted_zone_id: apigw_zone
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def extract_domain(targets)
|
|
166
|
+
tfvars_path = 'terraform.tfvars'
|
|
167
|
+
return unless File.exist?(tfvars_path)
|
|
168
|
+
|
|
169
|
+
match = File.read(tfvars_path).match(/^\s*domain\s*=\s*"([^"]+)"/)
|
|
170
|
+
targets[:domain] = match[1] if match
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def fetch_root_zone_id
|
|
174
|
+
return nil unless dns_dir_exists?
|
|
175
|
+
|
|
176
|
+
Dir.chdir(dns_dir_path) do
|
|
177
|
+
env = aws_env_for(@dns_config)
|
|
178
|
+
|
|
179
|
+
output, status = Open3.capture2e(env, 'terraform', 'output', '-json', 'root_zone_id')
|
|
180
|
+
return nil unless status.success?
|
|
181
|
+
|
|
182
|
+
begin
|
|
183
|
+
JSON.parse(output)
|
|
184
|
+
rescue JSON::ParserError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def sync_alias_records(root_zone_id, targets)
|
|
191
|
+
return unless targets[:domain] && targets[:cloudfront]
|
|
192
|
+
|
|
193
|
+
domain = targets[:domain]
|
|
194
|
+
cf = targets[:cloudfront]
|
|
195
|
+
apigw = targets[:api_gateway]
|
|
196
|
+
|
|
197
|
+
# Build change batch for alias records
|
|
198
|
+
changes = []
|
|
199
|
+
|
|
200
|
+
# Apex domain → CloudFront
|
|
201
|
+
changes << alias_change('UPSERT', domain, cf[:domain_name], cf[:hosted_zone_id])
|
|
202
|
+
|
|
203
|
+
# www → CloudFront
|
|
204
|
+
changes << alias_change('UPSERT', "www.#{domain}", cf[:domain_name], cf[:hosted_zone_id])
|
|
205
|
+
|
|
206
|
+
# api → API Gateway (if configured)
|
|
207
|
+
if apigw && apigw[:domain_name] && apigw[:hosted_zone_id]
|
|
208
|
+
changes << alias_change('UPSERT', "api.#{domain}", apigw[:domain_name], apigw[:hosted_zone_id])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
change_batch = {
|
|
212
|
+
Comment: 'Belt apex DNS sync',
|
|
213
|
+
Changes: changes
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Apply via Route53 API
|
|
217
|
+
env = aws_env_for(@dns_config)
|
|
218
|
+
_, status = Open3.capture2e(
|
|
219
|
+
env,
|
|
220
|
+
'aws', 'route53', 'change-resource-record-sets',
|
|
221
|
+
'--hosted-zone-id', root_zone_id,
|
|
222
|
+
'--change-batch', JSON.generate(change_batch)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if status.success?
|
|
226
|
+
records = [domain, "www.#{domain}"]
|
|
227
|
+
records << "api.#{domain}" if apigw
|
|
228
|
+
puts " ✓ A records: #{records.join(', ')}" unless @quiet
|
|
229
|
+
else
|
|
230
|
+
puts ' ⚠ Failed to sync A records to root zone' unless @quiet
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def sync_acm_validation(root_zone_id, _domain)
|
|
235
|
+
env_dir = File.join(@infra_dir, @env)
|
|
236
|
+
|
|
237
|
+
Dir.chdir(env_dir) do
|
|
238
|
+
env = aws_env_for(@env_config)
|
|
239
|
+
|
|
240
|
+
# Get ACM certificate from state
|
|
241
|
+
output, status = Open3.capture2e(
|
|
242
|
+
env,
|
|
243
|
+
'terraform', 'state', 'show', '-json', 'module.app.aws_acm_certificate.app[0]'
|
|
244
|
+
)
|
|
245
|
+
return unless status.success?
|
|
246
|
+
|
|
247
|
+
cert_data = parse_json(output)
|
|
248
|
+
return unless cert_data
|
|
249
|
+
|
|
250
|
+
cert_status = cert_data.dig('values', 'status')
|
|
251
|
+
|
|
252
|
+
# Skip if already issued
|
|
253
|
+
if cert_status == 'ISSUED'
|
|
254
|
+
puts ' ✓ ACM certificate already issued' unless @quiet
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get validation options
|
|
259
|
+
validation_options = cert_data.dig('values', 'domain_validation_options') || []
|
|
260
|
+
return if validation_options.empty?
|
|
261
|
+
|
|
262
|
+
changes = build_validation_changes(validation_options)
|
|
263
|
+
upsert_validation_cnames(root_zone_id, changes)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def build_validation_changes(validation_options)
|
|
268
|
+
changes = validation_options.map do |opt|
|
|
269
|
+
{
|
|
270
|
+
Action: 'UPSERT',
|
|
271
|
+
ResourceRecordSet: {
|
|
272
|
+
Name: opt['resource_record_name'],
|
|
273
|
+
Type: 'CNAME',
|
|
274
|
+
TTL: 300,
|
|
275
|
+
ResourceRecords: [{ Value: opt['resource_record_value'] }]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Dedupe by name (ACM uses same CNAME for base and wildcard)
|
|
281
|
+
changes.uniq! { |c| c[:ResourceRecordSet][:Name] }
|
|
282
|
+
changes
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def upsert_validation_cnames(root_zone_id, changes)
|
|
286
|
+
change_batch = {
|
|
287
|
+
Comment: 'Belt ACM validation sync',
|
|
288
|
+
Changes: changes
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
dns_env = aws_env_for(@dns_config)
|
|
292
|
+
_, status = Open3.capture2e(
|
|
293
|
+
dns_env,
|
|
294
|
+
'aws', 'route53', 'change-resource-record-sets',
|
|
295
|
+
'--hosted-zone-id', root_zone_id,
|
|
296
|
+
'--change-batch', JSON.generate(change_batch)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if status.success?
|
|
300
|
+
puts ' ✓ ACM validation CNAME synced (cert pending)' unless @quiet
|
|
301
|
+
else
|
|
302
|
+
puts ' ⚠ Failed to sync ACM validation CNAME' unless @quiet
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def alias_change(action, name, target_domain, target_zone_id)
|
|
307
|
+
{
|
|
308
|
+
Action: action,
|
|
309
|
+
ResourceRecordSet: {
|
|
310
|
+
Name: name,
|
|
311
|
+
Type: 'A',
|
|
312
|
+
AliasTarget: {
|
|
313
|
+
DNSName: target_domain,
|
|
314
|
+
HostedZoneId: target_zone_id,
|
|
315
|
+
EvaluateTargetHealth: false
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def aws_env_for(config)
|
|
322
|
+
env = {}
|
|
323
|
+
env['AWS_PROFILE'] = config.aws_profile if config&.aws_profile?
|
|
324
|
+
env
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def parse_json(output)
|
|
328
|
+
JSON.parse(output)
|
|
329
|
+
rescue JSON::ParserError
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
@@ -14,6 +14,7 @@ require_relative 'zip_artifact_builder'
|
|
|
14
14
|
require_relative 'nested_environment'
|
|
15
15
|
require_relative 'cognito_sharer'
|
|
16
16
|
require_relative 'dynamo_copier'
|
|
17
|
+
require_relative 'apex_dns_sync'
|
|
17
18
|
|
|
18
19
|
module Belt
|
|
19
20
|
module CLI
|
|
@@ -173,6 +174,9 @@ module Belt
|
|
|
173
174
|
run_apply
|
|
174
175
|
end
|
|
175
176
|
|
|
177
|
+
# Sync apex DNS records to root zone (if this is a prod/apex environment)
|
|
178
|
+
run_apex_dns_sync
|
|
179
|
+
|
|
176
180
|
puts "\n✅ Deployed #{@env} successfully!"
|
|
177
181
|
print_outputs(env_dir)
|
|
178
182
|
|
|
@@ -733,6 +737,14 @@ module Belt
|
|
|
733
737
|
FileUtils.rm_f('tfplan')
|
|
734
738
|
end
|
|
735
739
|
|
|
740
|
+
def run_apex_dns_sync
|
|
741
|
+
sync = ApexDnsSync.new(@env, infra_dir: @infra_dir)
|
|
742
|
+
return unless sync.needs_sync?
|
|
743
|
+
|
|
744
|
+
puts ''
|
|
745
|
+
sync.run
|
|
746
|
+
end
|
|
747
|
+
|
|
736
748
|
def run_nested_env_hooks
|
|
737
749
|
nested = NestedEnvironment.for(@env, infra_dir: @infra_dir)
|
|
738
750
|
return unless nested
|
data/lib/belt/cli/dns_command.rb
CHANGED
|
@@ -29,6 +29,8 @@ module Belt
|
|
|
29
29
|
new.remove_environment(args)
|
|
30
30
|
when 'show', 'list'
|
|
31
31
|
new.show(args)
|
|
32
|
+
when 'doctor'
|
|
33
|
+
new.doctor(args)
|
|
32
34
|
when '--help', '-h', 'help'
|
|
33
35
|
puts help
|
|
34
36
|
else
|
|
@@ -47,6 +49,7 @@ module Belt
|
|
|
47
49
|
add <env> Add an environment's NS records to dns/terraform.tfvars
|
|
48
50
|
remove <env> Remove an environment's NS records from dns/terraform.tfvars
|
|
49
51
|
show Show root zone name servers (for registrar configuration)
|
|
52
|
+
doctor Diagnose DNS configuration across all environments
|
|
50
53
|
help Show this help
|
|
51
54
|
|
|
52
55
|
Options for generate:
|
|
@@ -60,6 +63,7 @@ module Belt
|
|
|
60
63
|
belt dns add staging # Add staging's NS records to tfvars
|
|
61
64
|
belt dns remove staging # Remove staging's NS delegation
|
|
62
65
|
belt dns show # Show root name servers to configure at registrar
|
|
66
|
+
belt dns doctor # Check DNS health for all environments
|
|
63
67
|
|
|
64
68
|
The dns directory manages your root domain and delegates subdomains to
|
|
65
69
|
per-environment hosted zones. Each environment (dev, staging, prod) gets
|
|
@@ -343,8 +347,334 @@ module Belt
|
|
|
343
347
|
end
|
|
344
348
|
end
|
|
345
349
|
|
|
350
|
+
# --- Doctor ---
|
|
351
|
+
# Diagnose DNS configuration across all environments
|
|
352
|
+
def doctor(args)
|
|
353
|
+
require 'open3'
|
|
354
|
+
require_relative 'terraform_command'
|
|
355
|
+
|
|
356
|
+
# Parse --env flag for filtering to a specific environment
|
|
357
|
+
env_filter = nil
|
|
358
|
+
if args.include?('--env')
|
|
359
|
+
idx = args.index('--env')
|
|
360
|
+
env_filter = args[idx + 1]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Read domain from dns/terraform.tfvars
|
|
364
|
+
domain = read_domain_from_dns_tfvars
|
|
365
|
+
unless domain
|
|
366
|
+
puts 'Could not determine domain.'
|
|
367
|
+
puts "\nEnsure infrastructure/dns/terraform.tfvars has:"
|
|
368
|
+
puts ' domain = "example.com"'
|
|
369
|
+
exit 1
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
puts "DNS Health: #{domain}"
|
|
373
|
+
puts '═' * 64
|
|
374
|
+
puts ''
|
|
375
|
+
|
|
376
|
+
# Check root zone
|
|
377
|
+
if Dir.exist?(DNS_DIR)
|
|
378
|
+
check_root_zone(domain)
|
|
379
|
+
else
|
|
380
|
+
puts 'Root Zone (infrastructure/dns)'
|
|
381
|
+
puts '────────────────────────────────────────'
|
|
382
|
+
puts ' ⚠ Not configured'
|
|
383
|
+
puts ' Run: belt dns generate'
|
|
384
|
+
puts ''
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Check each environment
|
|
388
|
+
environments = if env_filter
|
|
389
|
+
[env_filter]
|
|
390
|
+
else
|
|
391
|
+
TerraformCommand.list_environments
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
environments.each do |env_name|
|
|
395
|
+
check_environment(env_name, domain)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
346
399
|
private
|
|
347
400
|
|
|
401
|
+
def parse_json(output)
|
|
402
|
+
JSON.parse(output)
|
|
403
|
+
rescue JSON::ParserError
|
|
404
|
+
nil
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def check_root_zone(domain)
|
|
408
|
+
puts 'Root Zone (shared account)'
|
|
409
|
+
puts '────────────────────────────────────────'
|
|
410
|
+
|
|
411
|
+
dns_config = load_dns_config
|
|
412
|
+
env = {}
|
|
413
|
+
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config.aws_profile?
|
|
414
|
+
|
|
415
|
+
outputs = Dir.chdir(DNS_DIR) do
|
|
416
|
+
output, status = Open3.capture2e(env, 'terraform', 'output', '-json')
|
|
417
|
+
unless status.success?
|
|
418
|
+
puts ' ⚠ Cannot read terraform state'
|
|
419
|
+
puts ' Run: belt dns deploy'
|
|
420
|
+
return
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
parse_json(output)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
unless outputs
|
|
427
|
+
puts ' ⚠ Failed to parse terraform outputs'
|
|
428
|
+
return
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
zone_id = outputs.dig('root_zone_id', 'value')
|
|
432
|
+
name_servers = outputs.dig('root_name_servers', 'value') || []
|
|
433
|
+
delegated = outputs.dig('delegated_environments', 'value') || []
|
|
434
|
+
|
|
435
|
+
if zone_id
|
|
436
|
+
if zone_exists_in_aws?(zone_id, env)
|
|
437
|
+
puts " ✓ Zone ID: #{zone_id}"
|
|
438
|
+
else
|
|
439
|
+
puts " ✗ Zone #{zone_id} not found in AWS (state stale?)"
|
|
440
|
+
end
|
|
441
|
+
else
|
|
442
|
+
puts ' ⚠ Zone not created yet'
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
if name_servers.any?
|
|
446
|
+
puts " ✓ NS records configured (#{name_servers.length} servers)"
|
|
447
|
+
|
|
448
|
+
# Check if registrar NS matches (via DNS lookup)
|
|
449
|
+
actual_ns = lookup_ns_records(domain)
|
|
450
|
+
if actual_ns.sort == name_servers.sort
|
|
451
|
+
puts ' ✓ Registrar NS records match'
|
|
452
|
+
elsif actual_ns.any?
|
|
453
|
+
puts ' ⚠ Registrar NS records differ from root zone'
|
|
454
|
+
puts " Expected: #{name_servers.first}..."
|
|
455
|
+
puts " Got: #{actual_ns.first}..."
|
|
456
|
+
else
|
|
457
|
+
puts ' ⚠ Could not verify registrar NS records (DNS lookup failed)'
|
|
458
|
+
end
|
|
459
|
+
else
|
|
460
|
+
puts ' ⚠ No NS records found'
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
if delegated.any?
|
|
464
|
+
puts " ✓ Delegated: #{delegated.join(', ')}"
|
|
465
|
+
else
|
|
466
|
+
puts ' ⚠ No environments delegated'
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Check for apex records (should exist if prod is deployed)
|
|
470
|
+
check_root_zone_apex_records(zone_id, domain, env) if zone_id
|
|
471
|
+
|
|
472
|
+
puts ''
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def check_root_zone_apex_records(zone_id, domain, env)
|
|
476
|
+
# List records in root zone to see if apex A records exist
|
|
477
|
+
output, status = Open3.capture2e(
|
|
478
|
+
env,
|
|
479
|
+
'aws', 'route53', 'list-resource-record-sets',
|
|
480
|
+
'--hosted-zone-id', zone_id,
|
|
481
|
+
'--query', "ResourceRecordSets[?Type=='A'].Name",
|
|
482
|
+
'--output', 'json'
|
|
483
|
+
)
|
|
484
|
+
return unless status.success?
|
|
485
|
+
|
|
486
|
+
records = begin
|
|
487
|
+
JSON.parse(output)
|
|
488
|
+
rescue JSON::ParserError
|
|
489
|
+
[]
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
apex_exists = records.any? { |r| r.chomp('.') == domain }
|
|
493
|
+
www_exists = records.any? { |r| r.chomp('.') == "www.#{domain}" }
|
|
494
|
+
api_exists = records.any? { |r| r.chomp('.') == "api.#{domain}" }
|
|
495
|
+
|
|
496
|
+
if apex_exists && www_exists && api_exists
|
|
497
|
+
puts " ✓ Apex A records present (#{domain}, www, api)"
|
|
498
|
+
elsif apex_exists || www_exists || api_exists
|
|
499
|
+
missing = []
|
|
500
|
+
missing << domain unless apex_exists
|
|
501
|
+
missing << "www.#{domain}" unless www_exists
|
|
502
|
+
missing << "api.#{domain}" unless api_exists
|
|
503
|
+
puts " ⚠ Some apex A records missing: #{missing.join(', ')}"
|
|
504
|
+
else
|
|
505
|
+
puts ' ⚠ No apex A records (prod not synced? run: belt deploy prod)'
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def check_environment(env_name, domain)
|
|
510
|
+
env_dir = "infrastructure/#{env_name}"
|
|
511
|
+
return unless Dir.exist?(env_dir)
|
|
512
|
+
|
|
513
|
+
# Determine expected domain for this env
|
|
514
|
+
tfvars_path = File.join(env_dir, 'terraform.tfvars')
|
|
515
|
+
env_domain = nil
|
|
516
|
+
is_prod = env_name == 'prod'
|
|
517
|
+
|
|
518
|
+
if File.exist?(tfvars_path)
|
|
519
|
+
content = File.read(tfvars_path)
|
|
520
|
+
domain_match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
|
|
521
|
+
env_match = content.match(/^\s*environment\s*=\s*"([^"]+)"/)
|
|
522
|
+
parent_match = content.match(/^\s*parent_environment\s*=\s*"([^"]+)"/)
|
|
523
|
+
|
|
524
|
+
configured_domain = domain_match ? domain_match[1] : nil
|
|
525
|
+
actual_env = env_match ? env_match[1] : env_name
|
|
526
|
+
parent = parent_match ? parent_match[1] : nil
|
|
527
|
+
is_prod = actual_env == 'prod'
|
|
528
|
+
is_nested = parent && !parent.empty?
|
|
529
|
+
|
|
530
|
+
if configured_domain
|
|
531
|
+
env_domain = if is_nested
|
|
532
|
+
"#{actual_env}.#{parent}.#{configured_domain}"
|
|
533
|
+
elsif is_prod
|
|
534
|
+
configured_domain
|
|
535
|
+
else
|
|
536
|
+
"#{actual_env}.#{configured_domain}"
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
label = if is_prod
|
|
542
|
+
"#{env_name} (#{env_domain || domain}) [apex]"
|
|
543
|
+
else
|
|
544
|
+
"#{env_name} (#{env_domain || "#{env_name}.#{domain}"})"
|
|
545
|
+
end
|
|
546
|
+
puts label
|
|
547
|
+
puts '────────────────────────────────────────'
|
|
548
|
+
|
|
549
|
+
env_config = begin
|
|
550
|
+
EnvironmentConfig.load(env_name)
|
|
551
|
+
rescue StandardError
|
|
552
|
+
nil
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
unless env_config
|
|
556
|
+
puts ' ⚠ No belt.rb config found'
|
|
557
|
+
puts ''
|
|
558
|
+
return
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
env = {}
|
|
562
|
+
env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
|
|
563
|
+
|
|
564
|
+
# Check terraform state
|
|
565
|
+
Dir.chdir(env_dir) do
|
|
566
|
+
output, status = Open3.capture2e(env, 'terraform', 'output', '-json')
|
|
567
|
+
unless status.success?
|
|
568
|
+
puts ' ⚠ Cannot read terraform state (not deployed?)'
|
|
569
|
+
puts ''
|
|
570
|
+
return
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
outputs = parse_json(output)
|
|
574
|
+
unless outputs
|
|
575
|
+
puts ' ⚠ Failed to parse terraform outputs'
|
|
576
|
+
puts ''
|
|
577
|
+
return
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
report_environment_status(outputs, env_name, env_dir, env, domain, env_domain, is_prod)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
puts ''
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def report_environment_status(outputs, env_name, env_dir, env, domain, env_domain, is_prod)
|
|
587
|
+
# Zone info
|
|
588
|
+
name_servers = outputs.dig('name_servers', 'value') || []
|
|
589
|
+
if name_servers.any?
|
|
590
|
+
puts " ✓ Zone deployed (#{name_servers.length} NS records)"
|
|
591
|
+
else
|
|
592
|
+
puts ' ⚠ No hosted zone found'
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Check ACM certificate
|
|
596
|
+
check_acm_cert(env_dir, env)
|
|
597
|
+
|
|
598
|
+
# Check delegation in root zone (for non-apex envs)
|
|
599
|
+
check_delegation(env_name, domain, name_servers) unless is_prod
|
|
600
|
+
|
|
601
|
+
# For apex (prod), check if DNS resolves
|
|
602
|
+
check_dns_resolution(env_domain) if is_prod && env_domain
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def check_acm_cert(_env_dir, aws_env)
|
|
606
|
+
# Try to get cert status from state
|
|
607
|
+
output, status = Open3.capture2e(
|
|
608
|
+
aws_env,
|
|
609
|
+
'terraform', 'state', 'show', '-json', 'module.app.aws_acm_certificate.app[0]'
|
|
610
|
+
)
|
|
611
|
+
return unless status.success?
|
|
612
|
+
|
|
613
|
+
cert_data = parse_json(output)
|
|
614
|
+
return unless cert_data
|
|
615
|
+
|
|
616
|
+
cert_domain = cert_data.dig('values', 'domain_name')
|
|
617
|
+
cert_status = cert_data.dig('values', 'status')
|
|
618
|
+
|
|
619
|
+
case cert_status
|
|
620
|
+
when 'ISSUED'
|
|
621
|
+
puts " ✓ ACM certificate: ISSUED (#{cert_domain})"
|
|
622
|
+
when 'PENDING_VALIDATION'
|
|
623
|
+
puts " ⚠ ACM certificate: PENDING_VALIDATION (#{cert_domain})"
|
|
624
|
+
puts ' Validation CNAME may need to be in root zone for apex domains'
|
|
625
|
+
else
|
|
626
|
+
puts " ⚠ ACM certificate: #{cert_status || 'unknown'}"
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def check_delegation(env_name, _domain, _expected_ns)
|
|
631
|
+
return unless Dir.exist?(DNS_DIR)
|
|
632
|
+
|
|
633
|
+
dns_config = load_dns_config
|
|
634
|
+
env = {}
|
|
635
|
+
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config.aws_profile?
|
|
636
|
+
|
|
637
|
+
# Read tfvars to check if this env is delegated
|
|
638
|
+
tfvars_path = "#{DNS_DIR}/terraform.tfvars"
|
|
639
|
+
return unless File.exist?(tfvars_path)
|
|
640
|
+
|
|
641
|
+
content = File.read(tfvars_path)
|
|
642
|
+
if content.include?("#{env_name} =")
|
|
643
|
+
puts ' ✓ NS delegation configured in root zone'
|
|
644
|
+
else
|
|
645
|
+
puts ' ⚠ NS delegation not configured in root zone'
|
|
646
|
+
puts " Run: belt dns add #{env_name}"
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def check_dns_resolution(domain)
|
|
651
|
+
# Try to resolve the domain
|
|
652
|
+
output, status = Open3.capture2e('dig', '+short', domain)
|
|
653
|
+
if status.success? && output.strip.length.positive?
|
|
654
|
+
ips = output.strip.split("\n")
|
|
655
|
+
puts " ✓ DNS resolves: #{ips.first}"
|
|
656
|
+
else
|
|
657
|
+
puts " ⚠ DNS does not resolve for #{domain}"
|
|
658
|
+
puts ' Apex A records may be missing from root zone'
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def read_domain_from_dns_tfvars
|
|
663
|
+
tfvars_path = "#{DNS_DIR}/terraform.tfvars"
|
|
664
|
+
return nil unless File.exist?(tfvars_path)
|
|
665
|
+
|
|
666
|
+
content = File.read(tfvars_path)
|
|
667
|
+
match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
|
|
668
|
+
match ? match[1] : nil
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def lookup_ns_records(domain)
|
|
672
|
+
output, status = Open3.capture2e('dig', '+short', 'NS', domain)
|
|
673
|
+
return [] unless status.success?
|
|
674
|
+
|
|
675
|
+
output.strip.split("\n").map { |ns| ns.chomp('.') }.sort
|
|
676
|
+
end
|
|
677
|
+
|
|
348
678
|
def templates
|
|
349
679
|
{
|
|
350
680
|
'main.tf.erb' => 'main.tf',
|
data/lib/belt/route_dsl.rb
CHANGED
|
@@ -416,9 +416,12 @@ module Belt
|
|
|
416
416
|
resource_options = options.merge(route_type: :resources)
|
|
417
417
|
actions = determine_actions(options)
|
|
418
418
|
|
|
419
|
-
|
|
419
|
+
# When there are nested resources, use {param_name} for member routes to avoid
|
|
420
|
+
# API Gateway sibling path parameter conflicts. Without nested resources, use {id}.
|
|
421
|
+
has_nested = block_given?
|
|
422
|
+
add_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: has_nested)
|
|
420
423
|
|
|
421
|
-
return unless
|
|
424
|
+
return unless has_nested
|
|
422
425
|
|
|
423
426
|
collection_prefix = "/#{resource_name}"
|
|
424
427
|
member_prefix = "/#{resource_name}/{#{param_name}}"
|
|
@@ -497,8 +500,11 @@ module Belt
|
|
|
497
500
|
options.merge(tables: [resource_name.to_sym])
|
|
498
501
|
end
|
|
499
502
|
|
|
500
|
-
def add_resource_routes(resource_name,
|
|
501
|
-
# Member routes (show/update/destroy) use {id}
|
|
503
|
+
def add_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: false)
|
|
504
|
+
# Member routes (show/update/destroy) use {id} by default (Rails convention).
|
|
505
|
+
# When nested resources exist (use_param_name: true), use {param_name} instead
|
|
506
|
+
# to avoid API Gateway sibling path parameter conflicts.
|
|
507
|
+
member_param = use_param_name ? param_name : 'id'
|
|
502
508
|
if actions.include?(:index)
|
|
503
509
|
add_route(:get, "/#{resource_name}",
|
|
504
510
|
resolve_request_model_for(resource_options, :index))
|
|
@@ -508,16 +514,16 @@ module Belt
|
|
|
508
514
|
resolve_request_model_for(resource_options, :create))
|
|
509
515
|
end
|
|
510
516
|
if actions.include?(:show)
|
|
511
|
-
add_route(:get, "/#{resource_name}/{
|
|
517
|
+
add_route(:get, "/#{resource_name}/{#{member_param}}",
|
|
512
518
|
resolve_request_model_for(resource_options, :show))
|
|
513
519
|
end
|
|
514
520
|
if actions.include?(:update)
|
|
515
|
-
add_route(:put, "/#{resource_name}/{
|
|
521
|
+
add_route(:put, "/#{resource_name}/{#{member_param}}",
|
|
516
522
|
resolve_request_model_for(resource_options, :update))
|
|
517
523
|
end
|
|
518
524
|
return unless actions.include?(:destroy)
|
|
519
525
|
|
|
520
|
-
add_route(:delete, "/#{resource_name}/{
|
|
526
|
+
add_route(:delete, "/#{resource_name}/{#{member_param}}", resolve_request_model_for(resource_options, :destroy))
|
|
521
527
|
end
|
|
522
528
|
|
|
523
529
|
def resolve_request_model_for(options, action)
|
|
@@ -799,8 +805,11 @@ module Belt
|
|
|
799
805
|
end
|
|
800
806
|
end
|
|
801
807
|
|
|
802
|
-
def add_scoped_resource_routes(resource_name,
|
|
803
|
-
# Member routes (show/update/destroy) use {id}
|
|
808
|
+
def add_scoped_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: false)
|
|
809
|
+
# Member routes (show/update/destroy) use {id} by default (Rails convention).
|
|
810
|
+
# When nested resources exist (use_param_name: true), use {param_name} instead
|
|
811
|
+
# to avoid API Gateway sibling path parameter conflicts.
|
|
812
|
+
member_param = use_param_name ? param_name : 'id'
|
|
804
813
|
if actions.include?(:index)
|
|
805
814
|
@gateway.send(:add_route, :get, build_path("/#{resource_name}"),
|
|
806
815
|
resolve_request_model_for(resource_options, :index))
|
|
@@ -810,16 +819,16 @@ module Belt
|
|
|
810
819
|
resolve_request_model_for(resource_options, :create))
|
|
811
820
|
end
|
|
812
821
|
if actions.include?(:show)
|
|
813
|
-
@gateway.send(:add_route, :get, build_path("/#{resource_name}/{
|
|
822
|
+
@gateway.send(:add_route, :get, build_path("/#{resource_name}/{#{member_param}}"),
|
|
814
823
|
resolve_request_model_for(resource_options, :show))
|
|
815
824
|
end
|
|
816
825
|
if actions.include?(:update)
|
|
817
|
-
@gateway.send(:add_route, :put, build_path("/#{resource_name}/{
|
|
826
|
+
@gateway.send(:add_route, :put, build_path("/#{resource_name}/{#{member_param}}"),
|
|
818
827
|
resolve_request_model_for(resource_options, :update))
|
|
819
828
|
end
|
|
820
829
|
return unless actions.include?(:destroy)
|
|
821
830
|
|
|
822
|
-
@gateway.send(:add_route, :delete, build_path("/#{resource_name}/{
|
|
831
|
+
@gateway.send(:add_route, :delete, build_path("/#{resource_name}/{#{member_param}}"),
|
|
823
832
|
resolve_request_model_for(resource_options, :destroy))
|
|
824
833
|
end
|
|
825
834
|
|
|
@@ -842,7 +851,10 @@ module Belt
|
|
|
842
851
|
resource_options = options.merge(route_type: :resources, controller: controller)
|
|
843
852
|
actions = determine_scoped_actions(options)
|
|
844
853
|
|
|
845
|
-
|
|
854
|
+
# When there are nested resources, use {param_name} for member routes to avoid
|
|
855
|
+
# API Gateway sibling path parameter conflicts. Without nested resources, use {id}.
|
|
856
|
+
has_nested = !block.nil?
|
|
857
|
+
add_scoped_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: has_nested)
|
|
846
858
|
build_nested_resource_block(resource_name, param_name, options, controller, &block) if block
|
|
847
859
|
end
|
|
848
860
|
|
data/lib/belt/version.rb
CHANGED
|
@@ -22,3 +22,31 @@ output "name_servers" {
|
|
|
22
22
|
description = "NS records to configure at your registrar (standalone envs only)"
|
|
23
23
|
value = local.dns_enabled && !local.is_nested ? aws_route53_zone.app[0].name_servers : []
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
# --- Apex DNS sync outputs ---
|
|
27
|
+
# These enable `belt deploy prod` to sync records to the root zone.
|
|
28
|
+
|
|
29
|
+
output "cloudfront_domain_name" {
|
|
30
|
+
description = "CloudFront distribution domain name (for DNS alias)"
|
|
31
|
+
value = aws_cloudfront_distribution.frontend.domain_name
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
output "cloudfront_hosted_zone_id" {
|
|
35
|
+
description = "CloudFront hosted zone ID (always Z2FDTNDATAQYW2)"
|
|
36
|
+
value = aws_cloudfront_distribution.frontend.hosted_zone_id
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
output "api_gateway_domain_name" {
|
|
40
|
+
description = "API Gateway custom domain regional domain name"
|
|
41
|
+
value = local.dns_enabled ? aws_api_gateway_domain_name.api[0].regional_domain_name : ""
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
output "api_gateway_hosted_zone_id" {
|
|
45
|
+
description = "API Gateway custom domain hosted zone ID"
|
|
46
|
+
value = local.dns_enabled ? aws_api_gateway_domain_name.api[0].regional_zone_id : ""
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
output "is_apex_environment" {
|
|
50
|
+
description = "True if this is an apex (prod) environment using the bare domain"
|
|
51
|
+
value = local.dns_enabled && !local.is_nested && var.environment == "prod"
|
|
52
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: belt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.42
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
@@ -103,6 +103,7 @@ files:
|
|
|
103
103
|
- lib/belt/assets/belt-default.jpg
|
|
104
104
|
- lib/belt/assets/welcome.css
|
|
105
105
|
- lib/belt/cli.rb
|
|
106
|
+
- lib/belt/cli/apex_dns_sync.rb
|
|
106
107
|
- lib/belt/cli/app_detection.rb
|
|
107
108
|
- lib/belt/cli/auth_command.rb
|
|
108
109
|
- lib/belt/cli/backup_config.rb
|