belt 0.2.7 → 0.2.9

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: f4b3470dab325ae0dcace82544700b3098a3a631da486bf470747e09113511f0
4
- data.tar.gz: 723dd93296867be2ca2f0894f83d94540ea4178c4018091a91920783b913d4cf
3
+ metadata.gz: 37cf283dc30b43f875f8600eb4b00e46b5782f374d3be306d0c5adaec7e9024a
4
+ data.tar.gz: 1153f552cef87426f91afcf28647065017b77a09bdd9347241366ed9cc455b76
5
5
  SHA512:
6
- metadata.gz: 25c94edb4be8e75db564b59e8cf608f0bdb139cae1fa2d33a53c68c4b845f70e34f4a9269953fca98bdfa85fdc0d9d9f09dfbb3e3e0514892b093a81668447b9
7
- data.tar.gz: 04c1b7c25414c0ae2e942ed27e8f73e5de358c76db8d830a7578dd54ee15312472a21a136f50860b11c7587386600ad8483abb2d4bb0149d6fbb2727db5ffdfa
6
+ metadata.gz: 4cafc0f29e0148e74950e9fe0898e74632f2563980f60ffece7bfbb58ec7187ec30f928a0958dff9d9832fe79b21e8a87e512f26c4cddfa58c1f243776cea5eb
7
+ data.tar.gz: 7850fc8e4c5921646c25f77f67fdfcae663e148ec597b514b095b637733b0e55f34182e8c64ea52f2294ed687b3a24f7b6a56462b30df07811a01ffae23da952
@@ -7,17 +7,11 @@ module Belt
7
7
 
8
8
  # Load backup config from infrastructure/<env>/belt.rb
9
9
  # Returns nil if no config file exists or backups aren't configured
10
+ # NOTE: Prefer EnvironmentConfig.load which loads the full config including backups.
11
+ # This method is kept for backward compatibility.
10
12
  def self.load(env, infra_dir: nil)
11
- infra_dir ||= 'infrastructure'
12
- config_file = File.join(infra_dir, env, 'belt.rb')
13
- return nil unless File.exist?(config_file)
14
-
15
- config = new
16
- evaluator = ConfigEvaluator.new(config)
17
- evaluator.evaluate(File.read(config_file), config_file)
18
-
19
- # Return nil if no backups were configured
20
- config.any? ? config : nil
13
+ env_config = EnvironmentConfig.load(env, infra_dir: infra_dir)
14
+ env_config.backups? ? env_config.backup_config : nil
21
15
  end
22
16
 
23
17
  def initialize
@@ -38,85 +32,6 @@ module Belt
38
32
  def any?
39
33
  dynamodb? || cognito? || @s3_buckets.any?
40
34
  end
41
-
42
- # Evaluates the belt.rb config file in an isolated context
43
- # that intercepts Belt.configure calls
44
- class ConfigEvaluator
45
- def initialize(config)
46
- @config = config
47
- end
48
-
49
- def evaluate(content, filename)
50
- # The config file calls Belt.configure { |config| ... }
51
- # We wrap the content to intercept the Belt constant lookup
52
- # by evaluating in a module where Belt is redefined
53
- config = @config
54
- sandbox = Module.new
55
- # Define a Belt module inside the sandbox that has .configure
56
- belt_proxy = Module.new do
57
- define_singleton_method(:configure) do |&block|
58
- block.call(ConfigDSL.new(config))
59
- end
60
- end
61
- sandbox.const_set(:Belt, belt_proxy)
62
-
63
- # Evaluate the file content within the sandbox module
64
- sandbox.module_eval(content, filename)
65
- end
66
- end
67
-
68
- # DSL yielded to the block inside Belt.configure { |config| ... }
69
- class ConfigDSL
70
- def initialize(config)
71
- @config = config
72
- end
73
-
74
- def backups(enabled = nil, &block)
75
- if enabled == true
76
- # Simple mode: config.backups = true → just DynamoDB for all tables
77
- @config.instance_variable_set(:@dynamodb_tables, :all)
78
- elsif block
79
- backup_dsl = BackupDSL.new(@config)
80
- backup_dsl.instance_eval(&block)
81
- end
82
- end
83
- end
84
-
85
- # DSL for the backups block
86
- class BackupDSL
87
- def initialize(config)
88
- @config = config
89
- end
90
-
91
- def dynamodb(scope = :all)
92
- if scope == :all
93
- @config.instance_variable_set(:@dynamodb_tables, :all)
94
- else
95
- tables = Array(scope)
96
- @config.instance_variable_set(:@dynamodb_tables, tables.map(&:to_s))
97
- end
98
- end
99
-
100
- def cognito(*exports)
101
- @config.instance_variable_set(:@cognito_exports, exports.flatten.map(&:to_sym))
102
- end
103
-
104
- def s3(*buckets)
105
- @config.instance_variable_set(:@s3_buckets, buckets.flatten.map(&:to_sym))
106
- end
107
-
108
- def retention(opts = {})
109
- current = @config.instance_variable_get(:@retention)
110
- merged = current.merge(opts.transform_keys(&:to_sym))
111
-
112
- # Convert duration objects (90.days) to integer days
113
- merged.transform_values! do |v|
114
- v.respond_to?(:to_i) ? v.to_i : v
115
- end
116
-
117
- @config.instance_variable_set(:@retention, merged)
118
- end
119
- end
120
35
  end
121
36
  end
122
37
  end
@@ -14,7 +14,7 @@ module Belt
14
14
  @infra_dir = infra_dir
15
15
  @app_name = app_name
16
16
  @timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
17
- @backup_bucket = "#{@app_name}-backups-#{@env}"
17
+ @backup_bucket = sanitize_bucket_name("#{@app_name}-backups-#{@env}")
18
18
  @errors = []
19
19
  @summary = []
20
20
  end
@@ -41,6 +41,8 @@ module Belt
41
41
 
42
42
  puts " 📦 Creating backup bucket: #{@backup_bucket}"
43
43
 
44
+ errors_before = @errors.size
45
+
44
46
  run_aws('s3', 'mb', "s3://#{@backup_bucket}", '--region', detect_region)
45
47
 
46
48
  # Enable versioning
@@ -54,7 +56,11 @@ module Belt
54
56
  '--public-access-block-configuration',
55
57
  'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true')
56
58
 
57
- puts ' ✅ Backup bucket created and secured'
59
+ if @errors.size > errors_before
60
+ puts ' ⚠️ Backup bucket creation failed (see warnings below)'
61
+ else
62
+ puts ' ✅ Backup bucket created and secured'
63
+ end
58
64
  end
59
65
 
60
66
  # ─── DynamoDB ────────────────────────────────────────────────────
@@ -153,11 +159,11 @@ module Belt
153
159
 
154
160
  def export_cognito_users(pool_id)
155
161
  all_users = []
156
- pagination_token = nil
162
+ next_token = nil
157
163
 
158
164
  loop do
159
- args = ['aws', 'cognito-idp', 'list-users', '--user-pool-id', pool_id, '--max-results', '60']
160
- args += ['--pagination-token', pagination_token] if pagination_token
165
+ args = ['aws', 'cognito-idp', 'list-users', '--user-pool-id', pool_id, '--max-items', '60']
166
+ args += ['--starting-token', next_token] if next_token
161
167
 
162
168
  output, status = Open3.capture2(*args)
163
169
  break unless status.success?
@@ -165,8 +171,8 @@ module Belt
165
171
  data = JSON.parse(output)
166
172
  all_users.concat(data['Users'] || [])
167
173
 
168
- pagination_token = data['PaginationToken']
169
- break if pagination_token.nil? || pagination_token.empty?
174
+ next_token = data['NextToken']
175
+ break if next_token.nil? || next_token.empty?
170
176
 
171
177
  puts ' Fetching next page of users...'
172
178
  end
@@ -327,69 +333,41 @@ module Belt
327
333
  end
328
334
  end
329
335
 
330
- # ─── Terraform Output Resolution ─────────────────────────────────
336
+ # ─── Table Discovery ──────────────────────────────────────────────
331
337
 
332
338
  def resolve_table_names
333
- @resolve_table_names ||= fetch_table_names_from_terraform
339
+ @resolve_table_names ||= discover_tables_from_aws
334
340
  end
335
341
 
336
- def fetch_table_names_from_terraform
337
- env_dir = File.join(@infra_dir, @env)
338
- return [] unless Dir.exist?(env_dir)
339
-
340
- Dir.chdir(env_dir) do
341
- names = try_terraform_table_output
342
- return names if names
342
+ def discover_tables_from_aws
343
+ # List all DynamoDB tables matching the app-env prefix directly from AWS.
344
+ # This is the source of truth — no Terraform output maintenance required.
345
+ prefix = "#{@app_name}-#{@env}-"
346
+ sanitized_prefix = sanitize_bucket_name(prefix)
343
347
 
344
- fetch_table_names_from_all_outputs
345
- end
346
- end
347
-
348
- def try_terraform_table_output
349
- output, status = Open3.capture2('terraform', 'output', '-json', 'dynamodb_table_names')
350
- return nil unless status.success?
351
-
352
- names = begin
353
- JSON.parse(output)
354
- rescue StandardError
355
- nil
348
+ output, status = Open3.capture2('aws', 'dynamodb', 'list-tables', '--output', 'json')
349
+ unless status.success?
350
+ @errors << 'Failed to list DynamoDB tables from AWS'
351
+ return []
356
352
  end
357
- names.is_a?(Array) && names.any? ? Array(names) : nil
358
- end
359
-
360
- def fetch_table_names_from_all_outputs
361
- output, status = Open3.capture2('terraform', 'output', '-json')
362
- return [] unless status.success?
363
353
 
364
- all_outputs = begin
365
- JSON.parse(output)
354
+ all_tables = begin
355
+ JSON.parse(output)['TableNames'] || []
366
356
  rescue StandardError
367
- {}
368
- end
369
-
370
- # Look for dynamodb_table_names in output
371
- if all_outputs['dynamodb_table_names']
372
- val = all_outputs['dynamodb_table_names']['value']
373
- return Array(val) if val
357
+ []
374
358
  end
375
359
 
376
- # Try to find any output that looks like table names
377
- all_outputs.each do |key, data|
378
- next unless key.include?('table')
379
-
380
- val = data['value']
381
- return Array(val) if val.is_a?(Array) && val.any?
382
- end
383
-
384
- []
360
+ all_tables.select { |t| t.start_with?(prefix) || t.start_with?(sanitized_prefix) }
385
361
  end
386
362
 
363
+ # ─── Terraform Output Resolution ─────────────────────────────────
364
+
387
365
  def resolve_cognito_pool_id
388
366
  env_dir = File.join(@infra_dir, @env)
389
367
  return nil unless Dir.exist?(env_dir)
390
368
 
391
369
  Dir.chdir(env_dir) do
392
- output, status = Open3.capture2('terraform', 'output', '-json', 'user_pool_id')
370
+ output, status = Open3.capture2('terraform', 'output', '-json', 'user_pool_id', err: File::NULL)
393
371
  if status.success?
394
372
  val = begin
395
373
  JSON.parse(output)
@@ -400,7 +378,7 @@ module Belt
400
378
  end
401
379
 
402
380
  # Fallback: search all outputs
403
- output, status = Open3.capture2('terraform', 'output', '-json')
381
+ output, status = Open3.capture2('terraform', 'output', '-json', err: File::NULL)
404
382
  if status.success?
405
383
  all_outputs = begin
406
384
  JSON.parse(output)
@@ -477,6 +455,11 @@ module Belt
477
455
 
478
456
  # ─── Helpers ─────────────────────────────────────────────────────
479
457
 
458
+ def sanitize_bucket_name(name)
459
+ # S3 bucket names must be DNS-compliant: lowercase, hyphens, no underscores
460
+ name.tr('_', '-').downcase.gsub(/[^a-z0-9\-.]/, '-').gsub(/-{2,}/, '-')
461
+ end
462
+
480
463
  def short_table_name(table_name)
481
464
  table_name.sub(/\A#{Regexp.escape(@app_name)}-#{Regexp.escape(@env)}-/, '')
482
465
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'optparse'
4
+ require_relative 'environment_config'
4
5
 
5
6
  module Belt
6
7
  module CLI
@@ -25,6 +26,8 @@ module Belt
25
26
  @environment = @args.first || ENV['BELT_ENV'] || 'dev'
26
27
  ENV['ENVIRONMENT'] = @environment
27
28
 
29
+ apply_env_config!
30
+
28
31
  if @options[:run]
29
32
  exec_runner(@options[:run])
30
33
  else
@@ -45,6 +48,12 @@ module Belt
45
48
  end.parse!(@args)
46
49
  end
47
50
 
51
+ def apply_env_config!
52
+ env_config = EnvironmentConfig.load(@environment)
53
+ env_config.apply!
54
+ puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
55
+ end
56
+
48
57
  def exec_console
49
58
  production_guard!
50
59
  boot_app
@@ -8,6 +8,7 @@ require_relative 'env_resolver'
8
8
  require_relative 'terraform_command'
9
9
  require_relative 'backup_config'
10
10
  require_relative 'backup_runner'
11
+ require_relative 'environment_config'
11
12
 
12
13
  module Belt
13
14
  module CLI
@@ -144,6 +145,8 @@ module Belt
144
145
  validate!
145
146
  env_dir = File.join(@infra_dir, @env)
146
147
 
148
+ load_and_apply_env_config!
149
+
147
150
  puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
148
151
 
149
152
  ensure_lockfile_consistent!
@@ -168,6 +171,7 @@ module Belt
168
171
 
169
172
  def run_backup_only
170
173
  validate!
174
+ load_and_apply_env_config!
171
175
 
172
176
  puts "belt → running backups for #{@env}\n\n"
173
177
 
@@ -190,6 +194,7 @@ module Belt
190
194
 
191
195
  def run_rebuild
192
196
  validate!
197
+ load_and_apply_env_config!
193
198
  validate_aws!
194
199
 
195
200
  puts "belt → rebuilding Lambda for #{@env}\n\n"
@@ -207,6 +212,21 @@ module Belt
207
212
 
208
213
  private
209
214
 
215
+ def load_and_apply_env_config!
216
+ @env_config = EnvironmentConfig.load(@env, infra_dir: @infra_dir)
217
+ @env_config.apply!
218
+ print_env_config_info
219
+ end
220
+
221
+ def print_env_config_info
222
+ puts " 🔑 Using AWS profile: #{@env_config.aws_profile}" if @env_config.aws_profile?
223
+ return unless @env_config.env_vars?
224
+
225
+ @env_config.env_vars.each_key do |key|
226
+ puts " 📌 Setting #{key}"
227
+ end
228
+ end
229
+
210
230
  def find_project_root
211
231
  # Walk up from infra dir to find project root (where Gemfile lives)
212
232
  dir = @infra_dir ? File.dirname(@infra_dir) : Dir.pwd
@@ -264,7 +284,7 @@ module Belt
264
284
  end
265
285
 
266
286
  def load_backup_config
267
- BackupConfig.load(@env, infra_dir: @infra_dir)
287
+ @env_config.backups? ? @env_config.backup_config : nil
268
288
  end
269
289
 
270
290
  def run_backup_phase(backup_config)
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class EnvironmentConfig
6
+ attr_reader :aws_profile, :env_vars, :backup_config
7
+
8
+ # Load environment config from infrastructure/<env>/belt.rb
9
+ # Returns an EnvironmentConfig instance (never nil — unconfigured is valid)
10
+ def self.load(env, infra_dir: nil)
11
+ infra_dir ||= 'infrastructure'
12
+ config_file = File.join(infra_dir, env, 'belt.rb')
13
+
14
+ config = new
15
+ return config unless File.exist?(config_file)
16
+
17
+ evaluator = ConfigEvaluator.new(config)
18
+ evaluator.evaluate(File.read(config_file), config_file)
19
+ config
20
+ end
21
+
22
+ def initialize
23
+ @aws_profile = nil
24
+ @env_vars = {}
25
+ @backup_config = BackupConfig.new
26
+ end
27
+
28
+ def aws_profile?
29
+ !@aws_profile.nil? && !@aws_profile.empty?
30
+ end
31
+
32
+ def env_vars?
33
+ @env_vars.any?
34
+ end
35
+
36
+ def backups?
37
+ @backup_config.any?
38
+ end
39
+
40
+ # Apply the configured aws_profile and env vars to the current process.
41
+ # Call this before running terraform, aws cli, etc.
42
+ def apply!
43
+ ENV['AWS_PROFILE'] = @aws_profile if aws_profile?
44
+ @env_vars.each { |key, value| ENV[key] = value }
45
+ end
46
+
47
+ class ConfigEvaluator
48
+ def initialize(config)
49
+ @config = config
50
+ end
51
+
52
+ def evaluate(content, filename)
53
+ config = @config
54
+ sandbox = Module.new
55
+ belt_proxy = Module.new do
56
+ define_singleton_method(:configure) do |&block|
57
+ block.call(ConfigDSL.new(config))
58
+ end
59
+ end
60
+ sandbox.const_set(:Belt, belt_proxy)
61
+ sandbox.module_eval(content, filename)
62
+ end
63
+ end
64
+
65
+ class ConfigDSL
66
+ def initialize(config)
67
+ @config = config
68
+ end
69
+
70
+ def aws_profile=(profile)
71
+ @config.instance_variable_set(:@aws_profile, profile.to_s)
72
+ end
73
+
74
+ def env(&)
75
+ env_dsl = EnvDSL.new(@config)
76
+ env_dsl.instance_eval(&)
77
+ end
78
+
79
+ def backups(enabled = nil, &block)
80
+ if enabled == true
81
+ @config.backup_config.instance_variable_set(:@dynamodb_tables, :all)
82
+ elsif block
83
+ backup_dsl = BackupDSL.new(@config.backup_config)
84
+ backup_dsl.instance_eval(&block)
85
+ end
86
+ end
87
+ end
88
+
89
+ class EnvDSL
90
+ def initialize(config)
91
+ @config = config
92
+ end
93
+
94
+ def set(key, value)
95
+ @config.instance_variable_get(:@env_vars)[key.to_s] = value.to_s
96
+ end
97
+ end
98
+
99
+ # DSL for the backups block
100
+ class BackupDSL
101
+ def initialize(backup_config)
102
+ @backup_config = backup_config
103
+ end
104
+
105
+ def dynamodb(scope = :all)
106
+ if scope == :all
107
+ @backup_config.instance_variable_set(:@dynamodb_tables, :all)
108
+ else
109
+ tables = Array(scope)
110
+ @backup_config.instance_variable_set(:@dynamodb_tables, tables.map(&:to_s))
111
+ end
112
+ end
113
+
114
+ def cognito(*exports)
115
+ @backup_config.instance_variable_set(:@cognito_exports, exports.flatten.map(&:to_sym))
116
+ end
117
+
118
+ def s3(*buckets)
119
+ @backup_config.instance_variable_set(:@s3_buckets, buckets.flatten.map(&:to_sym))
120
+ end
121
+
122
+ def retention(opts = {})
123
+ current = @backup_config.instance_variable_get(:@retention)
124
+ merged = current.merge(opts.transform_keys(&:to_sym))
125
+ merged.transform_values! { |v| v.respond_to?(:to_i) ? v.to_i : v }
126
+ @backup_config.instance_variable_set(:@retention, merged)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -61,9 +61,9 @@ module Belt
61
61
 
62
62
  def parse_models
63
63
  model_files = Dir.glob(File.join(MODELS_DIR, '*.rb'))
64
- .reject { |f| File.basename(f) == 'application_record.rb' }
65
- .reject { |f| File.basename(f).start_with?('concerns') }
66
- .sort
64
+ .reject { |f| File.basename(f) == 'application_record.rb' }
65
+ .reject { |f| File.basename(f).start_with?('concerns') }
66
+ .sort
67
67
 
68
68
  model_files.filter_map { |f| parse_model_file(f) }
69
69
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'env_resolver'
4
+ require_relative 'environment_config'
4
5
 
5
6
  module Belt
6
7
  module CLI
@@ -53,6 +54,7 @@ module Belt
53
54
 
54
55
  def run
55
56
  validate!
57
+ apply_env_config!
56
58
  env_dir = File.join(@infra_dir, @env)
57
59
  args = ['terraform', @action, *@extra_args]
58
60
  puts "belt → #{args.join(' ')} (in #{env_dir}/)"
@@ -61,6 +63,12 @@ module Belt
61
63
 
62
64
  private
63
65
 
66
+ def apply_env_config!
67
+ env_config = EnvironmentConfig.load(@env, infra_dir: @infra_dir)
68
+ env_config.apply!
69
+ puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
70
+ end
71
+
64
72
  def validate!
65
73
  unless @infra_dir
66
74
  abort "Error: No infrastructure/ directory found. Run `belt generate environment #{@env}` first."
@@ -18,7 +18,7 @@ module Belt
18
18
 
19
19
  allowed.any? do |pattern|
20
20
  if pattern.include?('*')
21
- regex = Regexp.new('\A' + Regexp.escape(pattern).gsub('\*', '[^.]+') + '\z')
21
+ regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '[^.]+')}\\z")
22
22
  regex.match?(origin)
23
23
  else
24
24
  pattern == origin
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.2.7'
4
+ VERSION = '0.2.9'
5
5
  end
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.2.7
4
+ version: 0.2.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -78,6 +78,7 @@ files:
78
78
  - lib/belt/cli/destroy_command.rb
79
79
  - lib/belt/cli/env_resolver.rb
80
80
  - lib/belt/cli/environment_command.rb
81
+ - lib/belt/cli/environment_config.rb
81
82
  - lib/belt/cli/frontend_command.rb
82
83
  - lib/belt/cli/frontend_deploy_command.rb
83
84
  - lib/belt/cli/frontend_env_command.rb