belt 0.1.13 → 0.2.0

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: b3834960aad8c883c22d15acec6a320d860bd8b90acb3c7c74751c59ce49b959
4
- data.tar.gz: 29f96098a7ac79c0f3eb62edd9950a9d1e95e2f56b219b3fcd024b19f3b4daac
3
+ metadata.gz: dd7335772d9025878073df619babf32aff170b29d620420382c5f73a8eebff30
4
+ data.tar.gz: 8c0379aee3988b267d690d639c986bb827b4e0d403bf358b04ef86c5a64a4d60
5
5
  SHA512:
6
- metadata.gz: 597c85007b7e8838f77605ec31de2b20bac85bf29ec20de371b85f03dd4a9eb20abba6be12f5b8f701e2c36ee02377b3488efa00292060f36b8bc956ef04d8ed
7
- data.tar.gz: 4c24a2f2c554244010ec246bc337194f6616ed04394ff8aeea4d5aabe91c97adeac7aaad76fbfa9a812fa35397fc46770c347ee590a5d307e2073ee2e811ff13
6
+ metadata.gz: 17a2e1f23c328abc880c19228234cac3fd6d4af78abbf704b2abbdc703990312115cd39ff12e492fa671d28a79cc8276a681778071fa094ab6dfdc7931360889
7
+ data.tar.gz: bc78f0e686cae19a4806ad21b3dbf9fc52adb768854e915dcbef155c593bf0317f771eb260478125ce10717e83e7dfa1e4230ebb9c123ad59bfcafb4578c3ed9
@@ -6,8 +6,8 @@ module Belt
6
6
  # Detects the primary namespace from the route definitions.
7
7
  # Used by generators to determine controller directory and route file naming.
8
8
  def detect_namespace
9
- routes_file = 'infrastructure/routes.tf.rb'
10
- if File.exist?(routes_file)
9
+ routes_file = find_routes_file_path
10
+ if routes_file && File.exist?(routes_file)
11
11
  match = File.read(routes_file).match(/namespace :(\w+)/)
12
12
  return match[1] if match
13
13
  end
@@ -39,6 +39,18 @@ module Belt
39
39
  def s3_safe_name(name)
40
40
  name.to_s.downcase.tr('_', '-')
41
41
  end
42
+
43
+ # Finds routes.tf.rb checking config/ first, then infrastructure/ (legacy).
44
+ def find_routes_file_path
45
+ candidates = ['config/routes.tf.rb', 'infrastructure/routes.tf.rb']
46
+ candidates.find { |f| File.exist?(f) }
47
+ end
48
+
49
+ # Finds schema.tf.rb checking config/ first, then infrastructure/ (legacy).
50
+ def find_schema_file_path
51
+ candidates = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb']
52
+ candidates.find { |f| File.exist?(f) }
53
+ end
42
54
  end
43
55
  end
44
56
  end
@@ -215,8 +215,8 @@ module Belt
215
215
  end
216
216
 
217
217
  def remove_routes
218
- routes_file = 'infrastructure/routes.tf.rb'
219
- return unless File.exist?(routes_file)
218
+ routes_file = find_routes_file_path
219
+ return unless routes_file && File.exist?(routes_file)
220
220
 
221
221
  content = File.read(routes_file)
222
222
  original = content.dup
@@ -255,8 +255,8 @@ module Belt
255
255
  end
256
256
 
257
257
  def remove_schema
258
- schema_file = 'infrastructure/schema.tf.rb'
259
- return unless File.exist?(schema_file)
258
+ schema_file = find_schema_file_path
259
+ return unless schema_file && File.exist?(schema_file)
260
260
 
261
261
  content = File.read(schema_file)
262
262
  original = content.dup
@@ -38,8 +38,8 @@ module Belt
38
38
  Creates:
39
39
  lambda/models/<name>.rb Model with validations and fields
40
40
  lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
41
- infrastructure/routes.tf.rb Route entry added
42
- infrastructure/schema.tf.rb DynamoDB table schema added
41
+ config/routes.tf.rb Route entry added
42
+ config/schema.tf.rb DynamoDB table schema added
43
43
  lambda/lib/routes/<app>_routes.rb Route manifest updated
44
44
  frontend/src/pages/<names>/ React pages (if frontend exists)
45
45
 
@@ -279,13 +279,15 @@ module Belt
279
279
  conflicts << "lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
280
280
  end
281
281
 
282
- schema_file = 'infrastructure/schema.tf.rb'
283
- if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
282
+ schema_file = find_schema_file_path
283
+ if schema_file && File.exist?(schema_file) &&
284
+ File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
284
285
  conflicts << "#{schema_file} (model :#{@singular_name})"
285
286
  end
286
287
 
287
- routes_file = 'infrastructure/routes.tf.rb'
288
- if File.exist?(routes_file) && File.read(routes_file).match?(/resources :#{Regexp.escape(@resource_name)}\b/)
288
+ routes_file = find_routes_file_path
289
+ if routes_file && File.exist?(routes_file) &&
290
+ File.read(routes_file).match?(/resources :#{Regexp.escape(@resource_name)}\b/)
289
291
  conflicts << "#{routes_file} (resources :#{@resource_name})"
290
292
  end
291
293
 
@@ -302,8 +304,9 @@ module Belt
302
304
  conflicts = []
303
305
  conflicts << "lambda/models/#{@singular_name}.rb" if File.exist?("lambda/models/#{@singular_name}.rb")
304
306
 
305
- schema_file = 'infrastructure/schema.tf.rb'
306
- if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
307
+ schema_file = find_schema_file_path
308
+ if schema_file && File.exist?(schema_file) &&
309
+ File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
307
310
  conflicts << "#{schema_file} (model :#{@singular_name})"
308
311
  end
309
312
 
@@ -327,8 +330,8 @@ module Belt
327
330
  puts "\nFiles created/updated:"
328
331
  puts " lambda/models/#{@singular_name}.rb"
329
332
  puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
330
- puts ' infrastructure/routes.tf.rb (updated)'
331
- puts ' infrastructure/schema.tf.rb (updated)'
333
+ puts " #{find_routes_file_path || 'config/routes.tf.rb'} (updated)"
334
+ puts " #{find_schema_file_path || 'config/schema.tf.rb'} (updated)"
332
335
  puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
333
336
  puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
334
337
  end
@@ -352,8 +355,8 @@ module Belt
352
355
  end
353
356
 
354
357
  def inject_routes
355
- routes_file = 'infrastructure/routes.tf.rb'
356
- return unless File.exist?(routes_file)
358
+ routes_file = find_routes_file_path
359
+ return unless routes_file && File.exist?(routes_file)
357
360
 
358
361
  content = File.read(routes_file)
359
362
  tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
@@ -444,8 +447,8 @@ module Belt
444
447
  end
445
448
 
446
449
  def inject_schema
447
- schema_file = 'infrastructure/schema.tf.rb'
448
- return unless File.exist?(schema_file)
450
+ schema_file = find_schema_file_path
451
+ return unless schema_file && File.exist?(schema_file)
449
452
 
450
453
  content = File.read(schema_file)
451
454
 
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require_relative 'app_detection'
6
+
7
+ module Belt
8
+ module CLI
9
+ # Reads config/lambda/*.yml files and produces a merged lambda_config hash
10
+ # suitable for Conveyor Belt's `lambda_config` variable.
11
+ #
12
+ # Each YAML file follows a database.yml-like pattern:
13
+ #
14
+ # # config/lambda/customer.yml
15
+ # default: &default
16
+ # timeout: 60
17
+ # memory_size: 512
18
+ # env_keys:
19
+ # - IMAGES_BUCKET_NAME
20
+ # - IMAGES_CLOUDFRONT_DOMAIN
21
+ #
22
+ # dev:
23
+ # <<: *default
24
+ # memory_size: 256
25
+ #
26
+ # prod:
27
+ # <<: *default
28
+ # timeout: 30
29
+ # memory_size: 1024
30
+ #
31
+ class LambdaConfigCommand
32
+ include AppDetection
33
+
34
+ SUPPORTED_KEYS = %w[
35
+ timeout memory_size env_vars env_keys
36
+ s3_buckets dynamodb_tables sns_triggers sqs_triggers
37
+ reserved_concurrency ephemeral_storage
38
+ ].freeze
39
+
40
+ def self.run(args)
41
+ options = {}
42
+
43
+ i = 0
44
+ while i < args.length
45
+ case args[i]
46
+ when '-e', '--environment'
47
+ i += 1
48
+ options[:environment] = args[i]
49
+ when '-f', '--format'
50
+ i += 1
51
+ options[:format] = args[i]
52
+ when '--lambda'
53
+ i += 1
54
+ options[:lambda] = args[i]
55
+ when '-h', '--help'
56
+ puts help_text
57
+ exit 0
58
+ end
59
+ i += 1
60
+ end
61
+
62
+ new(options).run
63
+ end
64
+
65
+ def self.help_text
66
+ <<~HELP
67
+ Read lambda config from config/lambda/*.yml files.
68
+
69
+ Usage: belt lambda-config [options]
70
+
71
+ Reads YAML config files and produces a merged configuration hash for the
72
+ specified environment (like database.yml in Rails).
73
+
74
+ Options:
75
+ -e, --environment ENV Target environment (default: dev)
76
+ -f, --format FORMAT Output format: json (default), terraform
77
+ --lambda NAME Only output config for a specific lambda
78
+ -h, --help Show this help
79
+
80
+ Examples:
81
+ belt lambda-config -e prod
82
+ belt lambda-config -e dev --format terraform
83
+ belt lambda-config --lambda customer -e prod
84
+
85
+ Config files live at config/lambda/<name>.yml. Each file can define
86
+ environment-specific overrides using YAML anchors:
87
+
88
+ # config/lambda/customer.yml
89
+ default: &default
90
+ timeout: 60
91
+ memory_size: 512
92
+ env_keys:
93
+ - IMAGES_BUCKET_NAME
94
+ - STRIPE_KEY
95
+
96
+ dev:
97
+ <<: *default
98
+ memory_size: 256
99
+
100
+ prod:
101
+ <<: *default
102
+ memory_size: 1024
103
+
104
+ Keys:
105
+ timeout Lambda timeout in seconds (default: 30)
106
+ memory_size Lambda memory in MB (default: 256)
107
+ env_keys List of env var names this lambda needs
108
+ env_vars Static env vars (key: value pairs)
109
+ s3_buckets S3 bucket access declarations
110
+ dynamodb_tables DynamoDB table access declarations
111
+ sns_triggers SNS topic triggers
112
+ sqs_triggers SQS queue triggers
113
+ reserved_concurrency Reserved concurrency limit
114
+ ephemeral_storage Ephemeral storage in MB (512-10240)
115
+ HELP
116
+ end
117
+
118
+ def initialize(options = {})
119
+ @environment = options[:environment] || ENV.fetch('BELT_ENV', 'dev')
120
+ @format = options[:format] || 'json'
121
+ @lambda_filter = options[:lambda]
122
+ @config_dir = 'config/lambda'
123
+ end
124
+
125
+ def run
126
+ abort "Error: #{@config_dir}/ not found. Create lambda config files there." unless Dir.exist?(@config_dir)
127
+
128
+ configs = load_all_configs
129
+ configs = configs.select { |name, _| name == @lambda_filter } if @lambda_filter
130
+
131
+ if configs.empty?
132
+ if @lambda_filter
133
+ abort "Error: No config found for lambda '#{@lambda_filter}' in #{@config_dir}/"
134
+ else
135
+ abort "Error: No .yml files found in #{@config_dir}/"
136
+ end
137
+ end
138
+
139
+ output(configs)
140
+ end
141
+
142
+ # Public API: load and merge all configs for a given environment.
143
+ # Used by other belt commands and potentially by the Conveyor Belt provider.
144
+ def self.load_configs(environment: 'dev', config_dir: 'config/lambda')
145
+ return {} unless Dir.exist?(config_dir)
146
+
147
+ configs = {}
148
+ Dir.glob(File.join(config_dir, '*.yml')).each do |file|
149
+ name = File.basename(file, '.yml')
150
+ raw = YAML.safe_load_file(file, aliases: true) || {}
151
+ configs[name] = resolve_environment(raw, environment)
152
+ end
153
+ configs
154
+ end
155
+
156
+ private
157
+
158
+ def load_all_configs
159
+ self.class.load_configs(environment: @environment, config_dir: @config_dir)
160
+ end
161
+
162
+ def self.resolve_environment(raw, environment)
163
+ # Merge: default < environment-specific
164
+ base = raw['default'] || {}
165
+ env_config = raw[environment] || {}
166
+ merged = deep_merge(base, env_config)
167
+
168
+ # Remove the YAML anchor/environment keys from output
169
+ merged.reject { |k, _| k == 'default' || !SUPPORTED_KEYS.include?(k) }
170
+ end
171
+ private_class_method :resolve_environment
172
+
173
+ def self.deep_merge(base, override)
174
+ base.merge(override) do |_key, old_val, new_val|
175
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
176
+ deep_merge(old_val, new_val)
177
+ else
178
+ new_val
179
+ end
180
+ end
181
+ end
182
+ private_class_method :deep_merge
183
+
184
+ def output(configs)
185
+ case @format
186
+ when 'json'
187
+ puts JSON.pretty_generate(configs)
188
+ when 'terraform'
189
+ output_terraform(configs)
190
+ else
191
+ abort "Unknown format: #{@format}. Use 'json' or 'terraform'."
192
+ end
193
+ end
194
+
195
+ # Output a Terraform-compatible lambda_config block.
196
+ # This can be piped to a .tfvars file or used by the provider directly.
197
+ def output_terraform(configs)
198
+ puts 'lambda_config = {'
199
+ configs.each_with_index do |(name, config), idx|
200
+ puts " #{name} = {"
201
+ puts " timeout = #{config['timeout']}" if config['timeout']
202
+ puts " memory_size = #{config['memory_size']}" if config['memory_size']
203
+ puts " reserved_concurrency = #{config['reserved_concurrency']}" if config['reserved_concurrency']
204
+ puts " ephemeral_storage = #{config['ephemeral_storage']}" if config['ephemeral_storage']
205
+
206
+ output_env_vars_tf(config) if config['env_vars'] || config['env_keys']
207
+ output_s3_buckets_tf(config['s3_buckets']) if config['s3_buckets']
208
+ output_dynamodb_tables_tf(config['dynamodb_tables']) if config['dynamodb_tables']
209
+ output_sns_triggers_tf(config['sns_triggers']) if config['sns_triggers']
210
+ output_sqs_triggers_tf(config['sqs_triggers']) if config['sqs_triggers']
211
+
212
+ puts " }#{'' unless idx < configs.size - 1}"
213
+ puts '' if idx < configs.size - 1
214
+ end
215
+ puts '}'
216
+ end
217
+
218
+ def output_env_vars_tf(config)
219
+ env_vars = config['env_vars'] || {}
220
+ env_keys = config['env_keys'] || []
221
+
222
+ # env_keys become placeholder entries that Terraform must fill
223
+ all_vars = env_vars.dup
224
+ env_keys.each { |k| all_vars[k] ||= '' }
225
+
226
+ return if all_vars.empty?
227
+
228
+ puts ''
229
+ puts ' env_vars = {'
230
+ all_vars.each do |key, value|
231
+ if value.empty?
232
+ puts " #{key} = var.#{key.downcase}"
233
+ else
234
+ puts " #{key} = #{value.inspect}"
235
+ end
236
+ end
237
+ puts ' }'
238
+ end
239
+
240
+ def output_s3_buckets_tf(buckets)
241
+ return if buckets.nil? || buckets.empty?
242
+
243
+ puts ''
244
+ puts ' s3_buckets = ['
245
+ buckets.each do |bucket|
246
+ puts ' {'
247
+ puts " bucket_arn = #{bucket['bucket_arn'] || 'TODO'}"
248
+ puts " permissions = #{bucket['permissions'].inspect}" if bucket['permissions']
249
+ puts ' }'
250
+ end
251
+ puts ' ]'
252
+ end
253
+
254
+ def output_dynamodb_tables_tf(tables)
255
+ return if tables.nil? || tables.empty?
256
+
257
+ puts ''
258
+ puts ' dynamodb_tables = ['
259
+ tables.each do |table|
260
+ puts ' {'
261
+ puts " table_arn = #{table['table_arn'] || 'TODO'}"
262
+ puts " permissions = #{table['permissions'].inspect}" if table['permissions']
263
+ puts " index_names = #{table['index_names'].inspect}" if table['index_names']
264
+ puts ' }'
265
+ end
266
+ puts ' ]'
267
+ end
268
+
269
+ def output_sns_triggers_tf(triggers)
270
+ return if triggers.nil? || triggers.empty?
271
+
272
+ puts ''
273
+ puts ' sns_triggers = ['
274
+ triggers.each do |trigger|
275
+ puts ' {'
276
+ puts " topic_arn = #{trigger['topic_arn'] || 'TODO'}"
277
+ puts " statement_id = #{trigger['statement_id'].inspect}" if trigger['statement_id']
278
+ puts ' }'
279
+ end
280
+ puts ' ]'
281
+ end
282
+
283
+ def output_sqs_triggers_tf(triggers)
284
+ return if triggers.nil? || triggers.empty?
285
+
286
+ puts ''
287
+ puts ' sqs_triggers = ['
288
+ triggers.each do |trigger|
289
+ puts ' {'
290
+ puts " queue_arn = #{trigger['queue_arn'] || 'TODO'}"
291
+ puts " batch_size = #{trigger['batch_size']}" if trigger['batch_size']
292
+ puts ' }'
293
+ end
294
+ puts ' ]'
295
+ end
296
+ end
297
+ end
298
+ end
@@ -122,6 +122,7 @@ module Belt
122
122
  #{@app_name}/lambda/lib/routes
123
123
  #{@app_name}/lambda/config
124
124
  #{@app_name}/lambda/spec
125
+ #{@app_name}/config/lambda
125
126
  #{@app_name}/infrastructure/modules/app
126
127
  ]
127
128
  end
@@ -136,8 +137,9 @@ module Belt
136
137
  'lambda/controllers/application_controller.rb.erb' =>
137
138
  "#{@app_name}/lambda/controllers/api/application_controller.rb",
138
139
  'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
139
- 'infrastructure/routes.tf.rb.erb' => "#{@app_name}/infrastructure/routes.tf.rb",
140
- 'infrastructure/schema.tf.rb.erb' => "#{@app_name}/infrastructure/schema.tf.rb",
140
+ 'config/routes.tf.rb.erb' => "#{@app_name}/config/routes.tf.rb",
141
+ 'config/schema.tf.rb.erb' => "#{@app_name}/config/schema.tf.rb",
142
+ 'config/lambda/api.yml.erb' => "#{@app_name}/config/lambda/api.yml",
141
143
  'README.md.erb' => "#{@app_name}/README.md",
142
144
  'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
143
145
  'gitignore.erb' => "#{@app_name}/.gitignore"
@@ -28,6 +28,11 @@ module Belt
28
28
  unless schema_file
29
29
  routes_dir = File.dirname(File.expand_path(routes_file))
30
30
  schema_file = File.join(routes_dir, 'schema.tf.rb')
31
+ # Fall back to infrastructure/ if not found in same directory as routes
32
+ unless File.exist?(schema_file)
33
+ alt = 'infrastructure/schema.tf.rb'
34
+ schema_file = alt if File.exist?(alt)
35
+ end
31
36
  end
32
37
  schema_file
33
38
  end
@@ -25,7 +25,10 @@ module Belt
25
25
 
26
26
  def run
27
27
  routes_file = find_routes_file
28
- abort 'Error: No routes file found. Expected infrastructure/routes.tf.rb' unless routes_file
28
+ unless routes_file
29
+ abort 'Error: No routes file found. ' \
30
+ 'Expected config/routes.tf.rb (or infrastructure/routes.tf.rb)'
31
+ end
29
32
 
30
33
  dsl = load_routes(routes_file)
31
34
  @table_inference = TableInference.new(@options[:tables_file])
@@ -84,8 +87,8 @@ module Belt
84
87
  end
85
88
 
86
89
  def find_routes_file
87
- path = 'infrastructure/routes.tf.rb'
88
- File.exist?(path) ? path : nil
90
+ candidates = ['config/routes.tf.rb', 'infrastructure/routes.tf.rb']
91
+ candidates.find { |f| File.exist?(f) }
89
92
  end
90
93
 
91
94
  def load_routes(file)
@@ -8,7 +8,7 @@ require_relative '../inflector'
8
8
  module Belt
9
9
  module CLI
10
10
  class TablesCommand
11
- SCHEMA_FILE = 'infrastructure/schema.tf.rb'
11
+ SCHEMA_FILE_CANDIDATES = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].freeze
12
12
  MODULE_DIR = 'infrastructure/modules/app'
13
13
 
14
14
  include AppDetection
@@ -33,12 +33,16 @@ module Belt
33
33
  # Automatically sync dynamodb.tf in the app module.
34
34
  # Called by generators after updating schema.tf.rb.
35
35
  def self.sync_all_environments
36
- return unless File.exist?(SCHEMA_FILE)
36
+ return unless schema_file_path
37
37
 
38
38
  # With the module approach, we only need to generate once into modules/app/
39
39
  new(nil, quiet: true).run
40
40
  end
41
41
 
42
+ def self.schema_file_path
43
+ SCHEMA_FILE_CANDIDATES.find { |f| File.exist?(f) }
44
+ end
45
+
42
46
  def initialize(env, quiet: false)
43
47
  @env = env
44
48
  @quiet = quiet
@@ -59,8 +63,12 @@ module Belt
59
63
  private
60
64
 
61
65
  def validate!
62
- unless File.exist?(SCHEMA_FILE)
63
- abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless @quiet
66
+ schema_file = self.class.schema_file_path
67
+ unless schema_file
68
+ unless @quiet
69
+ abort 'Error: No schema.tf.rb found (checked config/ and infrastructure/). ' \
70
+ 'Run `belt generate resource` first.'
71
+ end
64
72
  return
65
73
  end
66
74
  return if Dir.exist?(MODULE_DIR)
@@ -72,10 +80,11 @@ module Belt
72
80
  end
73
81
 
74
82
  def parse_schema
75
- return [] unless File.exist?(SCHEMA_FILE)
83
+ schema_file = self.class.schema_file_path
84
+ return [] unless schema_file && File.exist?(schema_file)
76
85
 
77
86
  parser = SchemaParser.new
78
- schema_content = File.read(SCHEMA_FILE)
87
+ schema_content = File.read(schema_file)
79
88
 
80
89
  # Replace DSL wrapper with direct parser call
81
90
  inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
@@ -83,7 +92,7 @@ module Belt
83
92
 
84
93
  return [] if inner.empty? || inner.match?(/\A\s*\z/m)
85
94
 
86
- parser.instance_eval(inner, SCHEMA_FILE)
95
+ parser.instance_eval(inner, schema_file)
87
96
  parser.models
88
97
  end
89
98
 
@@ -32,8 +32,8 @@ module Belt
32
32
  end
33
33
 
34
34
  def self.read_schema_fields(name)
35
- schema_file = 'infrastructure/schema.tf.rb'
36
- return [] unless File.exist?(schema_file)
35
+ schema_file = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].find { |f| File.exist?(f) }
36
+ return [] unless schema_file
37
37
 
38
38
  content = File.read(schema_file)
39
39
  singular = Belt::Inflector.singularize(name)
data/lib/belt/cli.rb CHANGED
@@ -16,6 +16,7 @@ require_relative 'cli/terraform_command'
16
16
  require_relative 'cli/deploy_command'
17
17
  require_relative 'cli/server_command'
18
18
  require_relative 'cli/routes_command'
19
+ require_relative 'cli/lambda_config_command'
19
20
  require_relative 'cli/tasks_command'
20
21
  require_relative 'cli/console_command'
21
22
 
@@ -26,6 +27,7 @@ module Belt
26
27
  %w[generate g] => Belt::CLI::GenerateCommand,
27
28
  %w[destroy d] => Belt::CLI::DestroyCommand,
28
29
  'routes' => Belt::CLI::RoutesCommand,
30
+ 'lambda-config' => Belt::CLI::LambdaConfigCommand,
29
31
  %w[console c] => Belt::CLI::ConsoleCommand,
30
32
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
31
33
  'setup' => Belt::CLI::SetupCommand,
@@ -101,6 +103,7 @@ module Belt
101
103
  deploy frontend <env> Build and deploy frontend to AWS
102
104
  frontend env <env> Write frontend/.env from terraform outputs
103
105
  routes [-g PATTERN] [-f json] Show route definitions
106
+ lambda-config [-e ENV] [-f json|terraform] Show merged lambda configuration
104
107
 
105
108
  console Start an interactive console (IRB)
106
109
  c Alias for console
data/lib/belt/root.rb CHANGED
@@ -9,9 +9,33 @@ module Belt
9
9
  @root = path
10
10
  end
11
11
 
12
+ # Resolves the path to routes.tf.rb, checking config/ first then infrastructure/ (legacy).
13
+ def self.routes_file
14
+ candidates = [
15
+ File.join(root, 'config/routes.tf.rb'),
16
+ File.join(root, 'infrastructure/routes.tf.rb')
17
+ ]
18
+ candidates.find { |f| File.exist?(f) }
19
+ end
20
+
21
+ # Resolves the path to schema.tf.rb, checking config/ first then infrastructure/ (legacy).
22
+ def self.schema_file
23
+ candidates = [
24
+ File.join(root, 'config/schema.tf.rb'),
25
+ File.join(root, 'infrastructure/schema.tf.rb')
26
+ ]
27
+ candidates.find { |f| File.exist?(f) }
28
+ end
29
+
30
+ # Resolves the lambda config directory.
31
+ def self.lambda_config_dir
32
+ File.join(root, 'config/lambda')
33
+ end
34
+
12
35
  def self.detect_root
13
36
  dir = Dir.pwd
14
37
  loop do
38
+ return dir if File.exist?(File.join(dir, 'config/routes.tf.rb'))
15
39
  return dir if File.exist?(File.join(dir, 'infrastructure/routes.tf.rb'))
16
40
 
17
41
  parent = File.dirname(dir)
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.1.13'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -13,7 +13,7 @@ terraform {
13
13
  }
14
14
  conveyor-belt = {
15
15
  source = "stowzilla/conveyor-belt"
16
- version = "~> 0.0.1"
16
+ version = "~> 0.0.2"
17
17
  }
18
18
  }
19
19
  }
@@ -21,7 +21,7 @@ terraform {
21
21
  resource "conveyor_belt" "main" {
22
22
  provider = conveyor-belt
23
23
 
24
- source = "${path.module}/../../routes.tf.rb"
24
+ source = "${path.module}/../../config/routes.tf.rb"
25
25
  app_name = var.app_name
26
26
  lambda_source_dir = "${path.module}/../../../lambda"
27
27
  lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
@@ -30,13 +30,12 @@ resource "conveyor_belt" "main" {
30
30
 
31
31
  custom_domain_name = local.dns_enabled ? local.api_domain : ""
32
32
 
33
- # Per-lambda configuration: env vars, timeouts, memory
34
- lambda_config = {
35
- api = {
36
- env_vars = {
37
- WELCOME_TITLE = "Welcome to ${var.app_name}"
38
- WELCOME_SUBTITLE = "API Gateway → Lambda → DynamoDB — all connected."
39
- }
40
- }
41
- }
33
+ # Per-lambda configuration from config/lambda/*.yml (database.yml style)
34
+ lambda_config_dir = "${path.module}/../../config/lambda"
35
+
36
+ # Dynamic value references — YAML env_vars use ref(name) to pull these in
37
+ lambda_env_refs = var.lambda_env_refs
38
+
39
+ # Terraform overrides for values that need interpolation (${var.app_name}, module outputs, etc.)
40
+ lambda_config = var.lambda_config
42
41
  }
@@ -25,3 +25,15 @@ variable "frontend_urls" {
25
25
  type = list(string)
26
26
  default = []
27
27
  }
28
+
29
+ variable "lambda_env_refs" {
30
+ description = "Map of reference names to dynamic Terraform values. YAML env_vars use ref(name) to resolve these."
31
+ type = map(string)
32
+ default = {}
33
+ }
34
+
35
+ variable "lambda_config" {
36
+ description = "Additional lambda configuration overrides (for dynamic Terraform values)"
37
+ type = any
38
+ default = {}
39
+ }
@@ -22,9 +22,12 @@ This file explains the project structure, tooling, and conventions for AI agents
22
22
  │ │ └── concerns/ # Shared model concerns
23
23
  │ ├── lib/routes/ # Route manifests (auto-generated)
24
24
  │ └── Gemfile # Lambda-specific dependencies
25
- ├── infrastructure/
25
+ ├── config/
26
26
  │ ├── routes.tf.rb # API routes (Conveyor Belt DSL)
27
27
  │ ├── schema.tf.rb # Model schema definitions
28
+ │ └── lambda/ # Per-lambda config (like database.yml)
29
+ │ └── api.yml # Lambda timeout, memory, env vars
30
+ ├── infrastructure/
28
31
  │ └── <env>/ # Per-environment Terraform configs
29
32
  ├── Gemfile # Project-level dependencies
30
33
  └── AGENTS.md # This file
@@ -50,7 +53,7 @@ belt output <env> # terraform output
50
53
 
51
54
  ## How Routing Works
52
55
 
53
- 1. `infrastructure/routes.tf.rb` defines routes using a DSL:
56
+ 1. `config/routes.tf.rb` defines routes using a DSL:
54
57
  ```ruby
55
58
  Belt.application.routes.draw do
56
59
  namespace :<%= @app_name %> do
@@ -5,9 +5,12 @@ A serverless application built with [Belt](https://github.com/stowzilla/belt) an
5
5
  ## Structure
6
6
 
7
7
  ```
8
- ├── infrastructure/
8
+ ├── config/
9
9
  │ ├── routes.tf.rb # API route definitions
10
- └── schema.tf.rb # Model schema definitions
10
+ ├── schema.tf.rb # Model schema definitions
11
+ │ └── lambda/ # Per-lambda config (timeout, memory, env vars)
12
+ │ └── api.yml
13
+ ├── infrastructure/ # Terraform environments
11
14
  └── lambda/
12
15
  ├── api.rb # Lambda entry point
13
16
  ├── controllers/<%= @app_name %>/ # Controllers
@@ -0,0 +1,32 @@
1
+ # Lambda configuration for the api function.
2
+ # Works like Rails' database.yml — define defaults and override per environment.
3
+ #
4
+ # Keys:
5
+ # timeout Lambda timeout in seconds (default: 30)
6
+ # memory_size Lambda memory in MB (default: 256)
7
+ # env_vars Environment variables (static values or ref() for Terraform values)
8
+ # dynamodb_tables DynamoDB table access (by name — ARNs built by convention)
9
+ # s3_buckets S3 bucket access declarations
10
+ # sns_triggers SNS topic triggers
11
+ # sqs_triggers SQS queue triggers
12
+ #
13
+ # ref(name) references are resolved from lambda_env_refs in Terraform.
14
+ # DynamoDB tables use shorthand (table: [permissions]) or expanded form.
15
+ #
16
+ # Usage:
17
+ # belt lambda-config -e prod # View merged config for prod
18
+ # belt lambda-config -f terraform # Output as Terraform-ready lambda_config
19
+
20
+ default: &default
21
+ timeout: 30
22
+ memory_size: 256
23
+ env_vars:
24
+ WELCOME_TITLE: "Welcome to <%= @app_name %>"
25
+ WELCOME_SUBTITLE: "API Gateway → Lambda → DynamoDB — all connected."
26
+
27
+ dev:
28
+ <<: *default
29
+
30
+ prod:
31
+ <<: *default
32
+ memory_size: 512
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.1.13
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -83,6 +83,7 @@ files:
83
83
  - lib/belt/cli/frontend_setup_command.rb
84
84
  - lib/belt/cli/generate_command.rb
85
85
  - lib/belt/cli/generator_registry.rb
86
+ - lib/belt/cli/lambda_config_command.rb
86
87
  - lib/belt/cli/new_command.rb
87
88
  - lib/belt/cli/routes_command.rb
88
89
  - lib/belt/cli/routes_command/route_inference.rb
@@ -134,9 +135,10 @@ files:
134
135
  - lib/templates/new_app/Gemfile.erb
135
136
  - lib/templates/new_app/README.md.erb
136
137
  - lib/templates/new_app/Rakefile.erb
138
+ - lib/templates/new_app/config/lambda/api.yml.erb
139
+ - lib/templates/new_app/config/routes.tf.rb.erb
140
+ - lib/templates/new_app/config/schema.tf.rb.erb
137
141
  - lib/templates/new_app/gitignore.erb
138
- - lib/templates/new_app/infrastructure/routes.tf.rb.erb
139
- - lib/templates/new_app/infrastructure/schema.tf.rb.erb
140
142
  - lib/templates/new_app/lambda/api.rb.erb
141
143
  - lib/templates/new_app/lambda/config/environment.rb.erb
142
144
  - lib/templates/new_app/lambda/controllers/application_controller.rb.erb