belt 0.3.33 → 0.3.35
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/lib/belt/cli/cognito_sharer.rb +158 -0
- data/lib/belt/cli/deploy_command.rb +15 -0
- data/lib/belt/cli/dns_command.rb +7 -0
- data/lib/belt/cli/dynamo_copier.rb +255 -0
- data/lib/belt/cli/frontend_env_map.rb +32 -2
- data/lib/belt/cli/nested_environment.rb +164 -0
- data/lib/belt/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b880627c5b6f7d73fe1957bfeff61f0b601d8520f3b57053193bef5891b2ef9c
|
|
4
|
+
data.tar.gz: 2069b8d810cc8bf3ef0ec8cd4c15b2baf95719bc7f546875b3dc0987e26ca8d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e2583c0ec4320dc90e00a72c48b8f04d208cfc0f4dc561306b6630495456ca9cf81c18d396804f4bbf9267ac6512238187ac864e5af6c1e2559c2a2bfccf89b
|
|
7
|
+
data.tar.gz: 9cf50f3894ef696c965183d9b45f572cc4cc245817e9b6f30ae3fdefe67613b0b3ba762f78bb34dc68587c22fc3f01eb1cbf44c5c8a817c3cb6d26aa5dd24d15
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require_relative 'nested_environment'
|
|
7
|
+
|
|
8
|
+
module Belt
|
|
9
|
+
module CLI
|
|
10
|
+
# Points a nested environment's Lambdas at the parent Cognito pool.
|
|
11
|
+
#
|
|
12
|
+
# Nested envs still *create* their own pool when the app's terraform isn't
|
|
13
|
+
# the Belt-generated auth template. After apply we rewrite COGNITO_* env
|
|
14
|
+
# vars so login and JWT checks use the parent. Destroy only tears down the
|
|
15
|
+
# nested pool — the parent pool is never in the child's terraform state.
|
|
16
|
+
class CognitoSharer
|
|
17
|
+
POOL_KEY = /cognito.*pool.?id/i
|
|
18
|
+
CLIENT_KEY = /cognito.*client/i
|
|
19
|
+
ISSUER_KEY = /cognito.*issuer/i
|
|
20
|
+
|
|
21
|
+
def initialize(nested_env)
|
|
22
|
+
@nested = nested_env
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# rubocop:disable Naming/PredicateMethod
|
|
26
|
+
def run
|
|
27
|
+
# rubocop:enable Naming/PredicateMethod
|
|
28
|
+
pool_id = @nested.parent_cognito_pool_id
|
|
29
|
+
unless pool_id
|
|
30
|
+
puts ' ⚠ Could not read parent Cognito pool ID — nested env will use its own pool'
|
|
31
|
+
reason = @nested.last_output_error
|
|
32
|
+
detail = if reason && !reason.empty?
|
|
33
|
+
"terraform output for '#{@nested.parent}' failed: #{reason.lines.first&.strip}"
|
|
34
|
+
else
|
|
35
|
+
"no cognito_user_pool_id output found in '#{@nested.parent}'"
|
|
36
|
+
end
|
|
37
|
+
puts " (#{detail})"
|
|
38
|
+
return false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
names = lambda_function_names
|
|
42
|
+
if names.empty?
|
|
43
|
+
puts ' ℹ No Lambda functions found to rewire for Cognito'
|
|
44
|
+
return true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
puts " 🔑 Sharing parent '#{@nested.parent}' Cognito pool (#{pool_id})"
|
|
48
|
+
|
|
49
|
+
names.each { |name| rewire_function(name, pool_id) }
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def lambda_function_names
|
|
56
|
+
funcs = @nested.child_outputs['lambda_functions']
|
|
57
|
+
names = case funcs
|
|
58
|
+
when Hash then funcs.values
|
|
59
|
+
when Array then funcs
|
|
60
|
+
else []
|
|
61
|
+
end
|
|
62
|
+
names.map { |value| value.to_s.split(':').last }.reject(&:empty?).uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def rewire_function(function_name, pool_id)
|
|
66
|
+
wait_until_ready(function_name)
|
|
67
|
+
|
|
68
|
+
config = aws_json('lambda', 'get-function-configuration',
|
|
69
|
+
'--function-name', function_name, '--output', 'json')
|
|
70
|
+
unless config
|
|
71
|
+
puts " ⚠ #{function_name}: could not read configuration"
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
variables = config.dig('Environment', 'Variables')
|
|
76
|
+
unless variables.is_a?(Hash) && variables.any?
|
|
77
|
+
puts " skip #{function_name} (no environment variables)"
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
updated = rewrite_variables(variables, pool_id)
|
|
82
|
+
if updated == variables
|
|
83
|
+
puts " skip #{function_name} (already on parent pool)"
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if update_environment(function_name, updated)
|
|
88
|
+
puts " rewire #{function_name}"
|
|
89
|
+
else
|
|
90
|
+
puts " ⚠ #{function_name}: failed to update Cognito env vars"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def rewrite_variables(variables, pool_id)
|
|
95
|
+
client_id = @nested.parent_cognito_client_id
|
|
96
|
+
issuer = @nested.parent_issuer
|
|
97
|
+
child_pool = @nested.child_cognito_pool_id
|
|
98
|
+
child_client = @nested.child_cognito_client_id
|
|
99
|
+
|
|
100
|
+
variables.each_with_object({}) do |(key, value), hash|
|
|
101
|
+
hash[key] = new_value_for(key, value, pool_id: pool_id, client_id: client_id,
|
|
102
|
+
issuer: issuer, child_pool: child_pool,
|
|
103
|
+
child_client: child_client)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def new_value_for(key, value, pool_id:, client_id:, issuer:, child_pool:, child_client:)
|
|
108
|
+
return pool_id if key.match?(POOL_KEY) || (child_pool && value == child_pool)
|
|
109
|
+
return client_id if client_id && (key.match?(CLIENT_KEY) || value == child_client)
|
|
110
|
+
return issuer if issuer && key.match?(ISSUER_KEY)
|
|
111
|
+
|
|
112
|
+
value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def update_environment(function_name, variables)
|
|
116
|
+
payload = { 'FunctionName' => function_name, 'Environment' => { 'Variables' => variables } }
|
|
117
|
+
Tempfile.create(['belt-cognito', '.json']) do |file|
|
|
118
|
+
file.write(JSON.generate(payload))
|
|
119
|
+
file.flush
|
|
120
|
+
_output, status = Open3.capture2(
|
|
121
|
+
'aws', 'lambda', 'update-function-configuration',
|
|
122
|
+
'--cli-input-json', "file://#{file.path}",
|
|
123
|
+
'--output', 'json'
|
|
124
|
+
)
|
|
125
|
+
status.success?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def wait_until_ready(function_name)
|
|
130
|
+
30.times do
|
|
131
|
+
output, status = Open3.capture2(
|
|
132
|
+
'aws', 'lambda', 'get-function-configuration',
|
|
133
|
+
'--function-name', function_name,
|
|
134
|
+
'--query', 'LastUpdateStatus',
|
|
135
|
+
'--output', 'text'
|
|
136
|
+
)
|
|
137
|
+
return unless status.success?
|
|
138
|
+
|
|
139
|
+
case output.strip
|
|
140
|
+
when 'Successful' then return
|
|
141
|
+
when 'Failed' then return
|
|
142
|
+
else
|
|
143
|
+
sleep 1
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def aws_json(*)
|
|
149
|
+
output, status = Open3.capture2('aws', *)
|
|
150
|
+
return nil unless status.success?
|
|
151
|
+
|
|
152
|
+
JSON.parse(output)
|
|
153
|
+
rescue JSON::ParserError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -11,6 +11,9 @@ require_relative 'backup_runner'
|
|
|
11
11
|
require_relative 'environment_config'
|
|
12
12
|
require_relative 'path_gem_materializer'
|
|
13
13
|
require_relative 'zip_artifact_builder'
|
|
14
|
+
require_relative 'nested_environment'
|
|
15
|
+
require_relative 'cognito_sharer'
|
|
16
|
+
require_relative 'dynamo_copier'
|
|
14
17
|
|
|
15
18
|
module Belt
|
|
16
19
|
module CLI
|
|
@@ -99,6 +102,7 @@ module Belt
|
|
|
99
102
|
6. terraform plan (preview changes)
|
|
100
103
|
7. Prompt for confirmation (unless --auto)
|
|
101
104
|
8. terraform apply (deploy changes)
|
|
105
|
+
9. Nested envs: share parent Cognito + copy DynamoDB if empty
|
|
102
106
|
|
|
103
107
|
Options:
|
|
104
108
|
--auto, --yes, -y Skip confirmation prompt (auto-approve)
|
|
@@ -172,6 +176,8 @@ module Belt
|
|
|
172
176
|
puts "\n✅ Deployed #{@env} successfully!"
|
|
173
177
|
print_outputs(env_dir)
|
|
174
178
|
|
|
179
|
+
run_nested_env_hooks
|
|
180
|
+
|
|
175
181
|
deploy_frontend_if_exists
|
|
176
182
|
|
|
177
183
|
puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
|
|
@@ -727,6 +733,15 @@ module Belt
|
|
|
727
733
|
FileUtils.rm_f('tfplan')
|
|
728
734
|
end
|
|
729
735
|
|
|
736
|
+
def run_nested_env_hooks
|
|
737
|
+
nested = NestedEnvironment.for(@env, infra_dir: @infra_dir)
|
|
738
|
+
return unless nested
|
|
739
|
+
|
|
740
|
+
puts "\n━━━ nested environment (parent: #{nested.parent}) ━━━"
|
|
741
|
+
CognitoSharer.new(nested).run
|
|
742
|
+
DynamoCopier.new(nested, app_name: detect_app_name_for_backup).run
|
|
743
|
+
end
|
|
744
|
+
|
|
730
745
|
def deploy_frontend_if_exists
|
|
731
746
|
require_relative 'frontend_registry'
|
|
732
747
|
require_relative 'frontend_deploy_command'
|
data/lib/belt/cli/dns_command.rb
CHANGED
|
@@ -167,12 +167,15 @@ module Belt
|
|
|
167
167
|
|
|
168
168
|
if auto
|
|
169
169
|
run_terraform('apply', env_config, 'tfplan')
|
|
170
|
+
cleanup_plan
|
|
170
171
|
else
|
|
171
172
|
print "\nApply this plan? [y/N] "
|
|
172
173
|
answer = $stdin.gets&.strip&.downcase
|
|
173
174
|
if %w[y yes].include?(answer)
|
|
174
175
|
run_terraform('apply', env_config, 'tfplan')
|
|
176
|
+
cleanup_plan
|
|
175
177
|
else
|
|
178
|
+
cleanup_plan
|
|
176
179
|
puts 'Aborted.'
|
|
177
180
|
exit 1
|
|
178
181
|
end
|
|
@@ -180,6 +183,10 @@ module Belt
|
|
|
180
183
|
end
|
|
181
184
|
end
|
|
182
185
|
|
|
186
|
+
def cleanup_plan
|
|
187
|
+
FileUtils.rm_f('tfplan')
|
|
188
|
+
end
|
|
189
|
+
|
|
183
190
|
# --- Add Environment ---
|
|
184
191
|
def add_environment(args)
|
|
185
192
|
env_name = args.shift
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require_relative 'nested_environment'
|
|
7
|
+
|
|
8
|
+
module Belt
|
|
9
|
+
module CLI
|
|
10
|
+
# Copies DynamoDB items from a parent environment into a nested child.
|
|
11
|
+
#
|
|
12
|
+
# Copy is skipped when the child table already has any items, so a PR
|
|
13
|
+
# sync / second deploy will not clobber test data. If a copy fails the
|
|
14
|
+
# child table is wiped (it was empty when we started) so the next deploy
|
|
15
|
+
# retries.
|
|
16
|
+
class DynamoCopier
|
|
17
|
+
BATCH_SIZE = 25
|
|
18
|
+
MAX_RETRIES = 8
|
|
19
|
+
|
|
20
|
+
def initialize(nested_env, app_name:)
|
|
21
|
+
@nested = nested_env
|
|
22
|
+
@app_name = app_name
|
|
23
|
+
@errors = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# rubocop:disable Naming/PredicateMethod
|
|
27
|
+
def run
|
|
28
|
+
# rubocop:enable Naming/PredicateMethod
|
|
29
|
+
pairs = table_pairs
|
|
30
|
+
if pairs.empty?
|
|
31
|
+
puts ' ℹ No parent DynamoDB tables found to copy'
|
|
32
|
+
return true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
puts " 💾 Copying DynamoDB data from '#{@nested.parent}' (empty tables only)"
|
|
36
|
+
|
|
37
|
+
copied = 0
|
|
38
|
+
skipped = 0
|
|
39
|
+
pairs.each do |parent_table, child_table|
|
|
40
|
+
result = copy_pair(parent_table, child_table)
|
|
41
|
+
case result
|
|
42
|
+
when :copied then copied += 1
|
|
43
|
+
when :skipped then skipped += 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
puts " copied #{copied}, skipped #{skipped}" \
|
|
48
|
+
"#{", #{@errors.size} error(s)" if @errors.any?}"
|
|
49
|
+
@errors.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def copy_pair(parent_table, child_table)
|
|
55
|
+
short = suffix_for(child_table, child_prefixes)
|
|
56
|
+
|
|
57
|
+
unless table_exists?(child_table)
|
|
58
|
+
puts " ⚠ #{short}: child table missing — skip"
|
|
59
|
+
return :skipped
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if table_has_items?(child_table)
|
|
63
|
+
puts " skip #{short} (already has data)"
|
|
64
|
+
return :skipped
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
items = scan_items(parent_table)
|
|
68
|
+
if items.nil?
|
|
69
|
+
fail_table(short, "failed to scan parent #{parent_table}")
|
|
70
|
+
return :failed
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if items.empty?
|
|
74
|
+
puts " skip #{short} (parent empty)"
|
|
75
|
+
return :skipped
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
write_items(child_table, items)
|
|
80
|
+
puts " copy #{short} (#{items.size} item#{'s' if items.size != 1})"
|
|
81
|
+
:copied
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
wipe_table(child_table)
|
|
84
|
+
fail_table(short, e.message)
|
|
85
|
+
:failed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def table_pairs
|
|
90
|
+
parent_tables = tables_with_prefixes(parent_prefixes)
|
|
91
|
+
child_tables = tables_with_prefixes(child_prefixes)
|
|
92
|
+
pairs = {}
|
|
93
|
+
|
|
94
|
+
parent_tables.each do |parent_table|
|
|
95
|
+
suffix = suffix_for(parent_table, parent_prefixes)
|
|
96
|
+
next if suffix.empty?
|
|
97
|
+
|
|
98
|
+
child_prefixes.each do |prefix|
|
|
99
|
+
candidate = "#{prefix}#{suffix}"
|
|
100
|
+
next unless child_tables.include?(candidate)
|
|
101
|
+
|
|
102
|
+
pairs[parent_table] = candidate
|
|
103
|
+
break
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
pairs
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parent_prefixes
|
|
111
|
+
@parent_prefixes ||= prefixes_for(@nested.parent)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def child_prefixes
|
|
115
|
+
@child_prefixes ||= prefixes_for(@nested.env)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def prefixes_for(env_name)
|
|
119
|
+
raw = "#{@app_name}-#{env_name}-"
|
|
120
|
+
sanitized = raw.tr('_', '-').downcase
|
|
121
|
+
[raw, sanitized].uniq
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def suffix_for(table_name, prefixes)
|
|
125
|
+
prefixes.each do |prefix|
|
|
126
|
+
return table_name.delete_prefix(prefix) if table_name.start_with?(prefix)
|
|
127
|
+
end
|
|
128
|
+
table_name
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def tables_with_prefixes(prefixes)
|
|
132
|
+
all_tables.select { |name| prefixes.any? { |prefix| name.start_with?(prefix) } }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def all_tables
|
|
136
|
+
@all_tables ||= list_all_tables
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def list_all_tables
|
|
140
|
+
names = []
|
|
141
|
+
start_name = nil
|
|
142
|
+
loop do
|
|
143
|
+
args = ['dynamodb', 'list-tables', '--output', 'json']
|
|
144
|
+
args += ['--exclusive-start-table-name', start_name] if start_name
|
|
145
|
+
data = aws_json(*args)
|
|
146
|
+
return names if data.nil?
|
|
147
|
+
|
|
148
|
+
names.concat(Array(data['TableNames']))
|
|
149
|
+
start_name = data['LastEvaluatedTableName']
|
|
150
|
+
break if start_name.nil? || start_name.empty?
|
|
151
|
+
end
|
|
152
|
+
names
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def table_exists?(name)
|
|
156
|
+
all_tables.include?(name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def table_has_items?(table_name)
|
|
160
|
+
data = aws_json('dynamodb', 'scan', '--table-name', table_name,
|
|
161
|
+
'--select', 'COUNT', '--limit', '1', '--output', 'json')
|
|
162
|
+
return false if data.nil?
|
|
163
|
+
|
|
164
|
+
data.fetch('Count', 0).to_i.positive?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def scan_items(table_name)
|
|
168
|
+
items = []
|
|
169
|
+
start_key = nil
|
|
170
|
+
loop do
|
|
171
|
+
args = ['dynamodb', 'scan', '--table-name', table_name, '--output', 'json']
|
|
172
|
+
args += ['--exclusive-start-key', JSON.generate(start_key)] if start_key
|
|
173
|
+
data = aws_json(*args)
|
|
174
|
+
return nil if data.nil?
|
|
175
|
+
|
|
176
|
+
items.concat(Array(data['Items']))
|
|
177
|
+
start_key = data['LastEvaluatedKey']
|
|
178
|
+
break if start_key.nil? || start_key.empty?
|
|
179
|
+
end
|
|
180
|
+
items
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def write_items(table_name, items)
|
|
184
|
+
items.each_slice(BATCH_SIZE) do |batch|
|
|
185
|
+
request = {
|
|
186
|
+
table_name => batch.map { |item| { 'PutRequest' => { 'Item' => item } } }
|
|
187
|
+
}
|
|
188
|
+
write_batch(request)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def write_batch(request_items, attempt = 0)
|
|
193
|
+
data = batch_write(request_items)
|
|
194
|
+
raise "batch-write-item failed for #{request_items.keys.join(', ')}" if data.nil?
|
|
195
|
+
|
|
196
|
+
unprocessed = data['UnprocessedItems']
|
|
197
|
+
return if unprocessed.nil? || unprocessed.empty?
|
|
198
|
+
raise "unprocessed items after #{MAX_RETRIES} retries" if attempt >= MAX_RETRIES
|
|
199
|
+
|
|
200
|
+
sleep(0.2 * (2**attempt))
|
|
201
|
+
write_batch(unprocessed, attempt + 1)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def batch_write(request_items)
|
|
205
|
+
Tempfile.create(['belt-dynamo', '.json']) do |file|
|
|
206
|
+
file.write(JSON.generate(request_items))
|
|
207
|
+
file.flush
|
|
208
|
+
aws_json('dynamodb', 'batch-write-item',
|
|
209
|
+
'--request-items', "file://#{file.path}",
|
|
210
|
+
'--output', 'json')
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def wipe_table(table_name)
|
|
215
|
+
items = scan_items(table_name)
|
|
216
|
+
return if items.nil? || items.empty?
|
|
217
|
+
|
|
218
|
+
keys = key_attribute_names(table_name)
|
|
219
|
+
return if keys.empty?
|
|
220
|
+
|
|
221
|
+
items.each_slice(BATCH_SIZE) do |batch|
|
|
222
|
+
request = {
|
|
223
|
+
table_name => batch.map do |item|
|
|
224
|
+
{ 'DeleteRequest' => { 'Key' => item.slice(*keys) } }
|
|
225
|
+
end
|
|
226
|
+
}
|
|
227
|
+
batch_write(request)
|
|
228
|
+
end
|
|
229
|
+
rescue StandardError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def key_attribute_names(table_name)
|
|
234
|
+
data = aws_json('dynamodb', 'describe-table', '--table-name', table_name, '--output', 'json')
|
|
235
|
+
return [] if data.nil?
|
|
236
|
+
|
|
237
|
+
Array(data.dig('Table', 'KeySchema')).map { |key| key['AttributeName'] }.compact
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def fail_table(short, message)
|
|
241
|
+
@errors << "#{short}: #{message}"
|
|
242
|
+
puts " ⚠ #{short}: #{message} (will retry on next deploy if table is empty)"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def aws_json(*)
|
|
246
|
+
output, status = Open3.capture2('aws', *)
|
|
247
|
+
return nil unless status.success?
|
|
248
|
+
|
|
249
|
+
JSON.parse(output)
|
|
250
|
+
rescue JSON::ParserError
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'open3'
|
|
5
5
|
require 'yaml'
|
|
6
|
+
require_relative 'nested_environment'
|
|
6
7
|
|
|
7
8
|
module Belt
|
|
8
9
|
module CLI
|
|
@@ -164,11 +165,40 @@ module Belt
|
|
|
164
165
|
end
|
|
165
166
|
|
|
166
167
|
def read_tf_output(name)
|
|
167
|
-
|
|
168
|
+
dirs_for(name).each do |dir|
|
|
169
|
+
value = read_tf_output_from(dir, name)
|
|
170
|
+
return value if value
|
|
171
|
+
end
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Nested envs share the parent Cognito pool. Cognito terraform outputs
|
|
176
|
+
# (pool id, client id, issuer, domain) are read from the parent so the
|
|
177
|
+
# SPA logs in with the same users. API/frontend URLs still come from the
|
|
178
|
+
# nested env itself.
|
|
179
|
+
def dirs_for(name)
|
|
180
|
+
dirs = []
|
|
181
|
+
dirs << parent_env_dir if cognito_output?(name) && parent_env_dir
|
|
182
|
+
dirs << @env_dir
|
|
183
|
+
dirs.compact.uniq.select { |dir| Dir.exist?(dir) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def cognito_output?(name)
|
|
187
|
+
name.to_s.downcase.include?('cognito')
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def parent_env_dir
|
|
191
|
+
return @parent_env_dir if defined?(@parent_env_dir)
|
|
192
|
+
|
|
193
|
+
infra_dir = File.dirname(@env_dir)
|
|
194
|
+
nested = NestedEnvironment.for(@env_name, infra_dir: infra_dir)
|
|
195
|
+
@parent_env_dir = nested&.parent_dir
|
|
196
|
+
end
|
|
168
197
|
|
|
198
|
+
def read_tf_output_from(dir, name)
|
|
169
199
|
output, status = Open3.capture2(
|
|
170
200
|
'terraform', 'output', '-raw', name,
|
|
171
|
-
chdir:
|
|
201
|
+
chdir: dir,
|
|
172
202
|
err: File::NULL
|
|
173
203
|
)
|
|
174
204
|
return nil unless status.success?
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
# A nested environment (e.g. fizzy123 under dev). Nested envs share the
|
|
10
|
+
# parent's Cognito pool and get a one-time DynamoDB copy on first deploy.
|
|
11
|
+
class NestedEnvironment
|
|
12
|
+
PARENT_TFVARS = /^\s*parent_environment\s*=\s*"([^"]+)"/
|
|
13
|
+
|
|
14
|
+
attr_reader :env, :parent, :infra_dir
|
|
15
|
+
|
|
16
|
+
# Populated by terraform_outputs when a read fails so callers (e.g.
|
|
17
|
+
# CognitoSharer) can explain *why* the parent pool couldn't be read
|
|
18
|
+
# instead of emitting a vague warning.
|
|
19
|
+
attr_reader :last_output_error
|
|
20
|
+
|
|
21
|
+
def self.parent_of(env, infra_dir:)
|
|
22
|
+
path = File.join(infra_dir, env, 'terraform.tfvars')
|
|
23
|
+
return nil unless File.file?(path)
|
|
24
|
+
|
|
25
|
+
match = File.read(path).match(PARENT_TFVARS)
|
|
26
|
+
parent = match && match[1]
|
|
27
|
+
parent.nil? || parent.empty? ? nil : parent
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.for(env, infra_dir:)
|
|
31
|
+
parent = parent_of(env, infra_dir: infra_dir)
|
|
32
|
+
return nil unless parent
|
|
33
|
+
return nil unless Dir.exist?(File.join(infra_dir, parent))
|
|
34
|
+
|
|
35
|
+
new(env: env, parent: parent, infra_dir: infra_dir)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(env:, parent:, infra_dir:)
|
|
39
|
+
@env = env
|
|
40
|
+
@parent = parent
|
|
41
|
+
@infra_dir = infra_dir
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def env_dir
|
|
45
|
+
File.join(@infra_dir, @env)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parent_dir
|
|
49
|
+
File.join(@infra_dir, @parent)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def child_outputs
|
|
53
|
+
@child_outputs ||= terraform_outputs(env_dir)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parent_outputs
|
|
57
|
+
# Read the parent under the parent's own AWS profile — a nested env may
|
|
58
|
+
# deploy with a different profile than its parent, and `terraform
|
|
59
|
+
# output` hits the S3 backend, so it needs the parent's credentials.
|
|
60
|
+
@parent_outputs ||= with_parent_profile { terraform_outputs(parent_dir) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parent_cognito_pool_id
|
|
64
|
+
pick_output(parent_outputs, %w[cognito_user_pool_id user_pool_id cognito_pool_id])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parent_cognito_client_id
|
|
68
|
+
pick_output(parent_outputs, %w[cognito_user_pool_client_id cognito_client_id user_pool_client_id])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parent_cognito_region
|
|
72
|
+
pick_output(parent_outputs, %w[cognito_region aws_region]) || ENV['AWS_REGION'] || 'us-east-1'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def child_cognito_pool_id
|
|
76
|
+
pick_output(child_outputs, %w[cognito_user_pool_id user_pool_id cognito_pool_id])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def child_cognito_client_id
|
|
80
|
+
pick_output(child_outputs, %w[cognito_user_pool_client_id cognito_client_id user_pool_client_id])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parent_issuer
|
|
84
|
+
pool_id = parent_cognito_pool_id
|
|
85
|
+
return nil unless pool_id
|
|
86
|
+
|
|
87
|
+
"https://cognito-idp.#{parent_cognito_region}.amazonaws.com/#{pool_id}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def pick_output(outputs, keys)
|
|
93
|
+
keys.each do |key|
|
|
94
|
+
val = outputs[key]
|
|
95
|
+
return val if val.is_a?(String) && !val.empty?
|
|
96
|
+
end
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def terraform_outputs(dir)
|
|
101
|
+
return {} unless Dir.exist?(dir)
|
|
102
|
+
|
|
103
|
+
output, err, status = run_output(dir)
|
|
104
|
+
|
|
105
|
+
# A fresh worktree (or a parent that was only ever deployed elsewhere)
|
|
106
|
+
# may not have an initialized backend yet. `terraform output` then
|
|
107
|
+
# fails with "Backend initialization required". Init once and retry
|
|
108
|
+
# before giving up so the parent's outputs are actually readable.
|
|
109
|
+
if !status.success? && backend_not_initialized?(err)
|
|
110
|
+
init_backend(dir)
|
|
111
|
+
output, err, status = run_output(dir)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
unless status.success?
|
|
115
|
+
@last_output_error = err.to_s.strip
|
|
116
|
+
return {}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
parse_outputs(output)
|
|
120
|
+
rescue JSON::ParserError, Errno::ENOENT => e
|
|
121
|
+
@last_output_error = e.message
|
|
122
|
+
{}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def run_output(dir)
|
|
126
|
+
Open3.capture3('terraform', 'output', '-json', chdir: dir)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def backend_not_initialized?(err)
|
|
130
|
+
err.to_s.match?(/backend initialization|terraform init|Backend reinitialization/i)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def init_backend(dir)
|
|
134
|
+
Open3.capture3('terraform', 'init', '-input=false', chdir: dir)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parse_outputs(output)
|
|
138
|
+
parsed = JSON.parse(output)
|
|
139
|
+
parsed.each_with_object({}) do |(key, spec), hash|
|
|
140
|
+
hash[key] = spec.is_a?(Hash) && spec.key?('value') ? spec['value'] : spec
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Apply the parent env's AWS profile (from its belt.rb) for the duration
|
|
145
|
+
# of the block, then restore whatever profile the child deploy set.
|
|
146
|
+
def with_parent_profile
|
|
147
|
+
parent_profile = EnvironmentConfig.load(@parent, infra_dir: @infra_dir).aws_profile
|
|
148
|
+
return yield if parent_profile.nil? || parent_profile.empty?
|
|
149
|
+
|
|
150
|
+
previous = ENV.fetch('AWS_PROFILE', nil)
|
|
151
|
+
ENV['AWS_PROFILE'] = parent_profile
|
|
152
|
+
begin
|
|
153
|
+
yield
|
|
154
|
+
ensure
|
|
155
|
+
if previous.nil?
|
|
156
|
+
ENV.delete('AWS_PROFILE')
|
|
157
|
+
else
|
|
158
|
+
ENV['AWS_PROFILE'] = previous
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
data/lib/belt/version.rb
CHANGED
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.35
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
@@ -108,12 +108,14 @@ files:
|
|
|
108
108
|
- lib/belt/cli/backup_config.rb
|
|
109
109
|
- lib/belt/cli/backup_runner.rb
|
|
110
110
|
- lib/belt/cli/bucket_security.rb
|
|
111
|
+
- lib/belt/cli/cognito_sharer.rb
|
|
111
112
|
- lib/belt/cli/console_command.rb
|
|
112
113
|
- lib/belt/cli/contracts_command.rb
|
|
113
114
|
- lib/belt/cli/deploy_command.rb
|
|
114
115
|
- lib/belt/cli/destroy_command.rb
|
|
115
116
|
- lib/belt/cli/dns_command.rb
|
|
116
117
|
- lib/belt/cli/doctor_command.rb
|
|
118
|
+
- lib/belt/cli/dynamo_copier.rb
|
|
117
119
|
- lib/belt/cli/env_resolver.rb
|
|
118
120
|
- lib/belt/cli/environment_command.rb
|
|
119
121
|
- lib/belt/cli/environment_config.rb
|
|
@@ -129,6 +131,7 @@ files:
|
|
|
129
131
|
- lib/belt/cli/index_command.rb
|
|
130
132
|
- lib/belt/cli/lambda_config_command.rb
|
|
131
133
|
- lib/belt/cli/logs_command.rb
|
|
134
|
+
- lib/belt/cli/nested_environment.rb
|
|
132
135
|
- lib/belt/cli/new_command.rb
|
|
133
136
|
- lib/belt/cli/path_gem_materializer.rb
|
|
134
137
|
- lib/belt/cli/plugin_command.rb
|