belt 0.2.4 → 0.2.6
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 +58 -0
- data/README.md +140 -0
- data/lib/belt/cli/backup_config.rb +122 -0
- data/lib/belt/cli/backup_runner.rb +510 -0
- data/lib/belt/cli/deploy_command.rb +97 -8
- data/lib/belt/cli/generate_command.rb +22 -6
- data/lib/belt/cli/tables_command.rb +128 -85
- data/lib/belt/cli.rb +2 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/main.tf.erb +2 -0
- data/lib/templates/environment/terraform.tfvars.erb +7 -0
- data/lib/templates/environment/variables.tf.erb +12 -0
- data/lib/templates/module/variables.tf.erb +12 -0
- data/lib/templates/new_app/config/schema.tf.rb.erb +21 -8
- metadata +3 -1
|
@@ -39,8 +39,9 @@ module Belt
|
|
|
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
41
|
config/routes.tf.rb Route entry added
|
|
42
|
-
config/schema.tf.rb
|
|
42
|
+
config/schema.tf.rb API response contract added
|
|
43
43
|
lambda/lib/routes/<app>_routes.rb Route manifest updated
|
|
44
|
+
infrastructure/modules/app/dynamodb.tf DynamoDB table generated
|
|
44
45
|
frontend/src/pages/<names>/ React pages (if frontend exists)
|
|
45
46
|
|
|
46
47
|
Alias: `belt g resource` works the same way.
|
|
@@ -58,7 +59,9 @@ module Belt
|
|
|
58
59
|
],
|
|
59
60
|
notes: <<~NOTES
|
|
60
61
|
Creates:
|
|
61
|
-
lambda/models/<name>.rb
|
|
62
|
+
lambda/models/<name>.rb Model class inheriting from ApplicationRecord
|
|
63
|
+
config/schema.tf.rb API response contract added
|
|
64
|
+
infrastructure/modules/app/dynamodb.tf DynamoDB table generated
|
|
62
65
|
NOTES
|
|
63
66
|
},
|
|
64
67
|
'controller' => {
|
|
@@ -452,11 +455,12 @@ module Belt
|
|
|
452
455
|
|
|
453
456
|
content = File.read(schema_file)
|
|
454
457
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
+
# Generate OpenAPI-style model block for API response contracts
|
|
459
|
+
response_fields = @fields.map { |f| " #{schema_type_for(f[:type])} :#{f[:name]}" }
|
|
460
|
+
response_fields << ' string :created_at'
|
|
461
|
+
response_fields << ' string :updated_at'
|
|
458
462
|
|
|
459
|
-
schema_block = " model :#{@singular_name} do\n#{
|
|
463
|
+
schema_block = " model :#{@singular_name} do\n#{response_fields.join("\n")}\n end\n"
|
|
460
464
|
|
|
461
465
|
# If model already exists (force mode), replace it
|
|
462
466
|
existing_model_pattern = /^ model :#{Regexp.escape(@singular_name)} do\n.*?^ end\n/m
|
|
@@ -474,6 +478,18 @@ module Belt
|
|
|
474
478
|
puts " update #{schema_file}"
|
|
475
479
|
end
|
|
476
480
|
|
|
481
|
+
# Map generator field types to OpenAPI schema DSL types
|
|
482
|
+
def schema_type_for(field_type)
|
|
483
|
+
case field_type.to_s
|
|
484
|
+
when 'text' then 'string'
|
|
485
|
+
when 'integer' then 'integer'
|
|
486
|
+
when 'float' then 'number'
|
|
487
|
+
when 'boolean' then 'boolean'
|
|
488
|
+
when 'date', 'datetime' then 'string'
|
|
489
|
+
else 'string'
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
477
493
|
def sync_tables
|
|
478
494
|
Belt::CLI::TablesCommand.sync_all_environments
|
|
479
495
|
end
|
|
@@ -7,8 +7,8 @@ require_relative '../inflector'
|
|
|
7
7
|
module Belt
|
|
8
8
|
module CLI
|
|
9
9
|
class TablesCommand
|
|
10
|
-
SCHEMA_FILE_CANDIDATES = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].freeze
|
|
11
10
|
MODULE_DIR = 'infrastructure/modules/app'
|
|
11
|
+
MODELS_DIR = 'lambda/models'
|
|
12
12
|
|
|
13
13
|
def self.run(args)
|
|
14
14
|
# Environment argument accepted for backwards compatibility but unused —
|
|
@@ -19,26 +19,22 @@ module Belt
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Automatically sync dynamodb.tf in the app module.
|
|
22
|
-
# Called by generators after updating
|
|
22
|
+
# Called by generators after creating/updating model files.
|
|
23
23
|
def self.sync_all_environments
|
|
24
|
-
return unless
|
|
24
|
+
return unless Dir.exist?(MODELS_DIR)
|
|
25
25
|
|
|
26
26
|
new(quiet: true).run
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def self.schema_file_path
|
|
30
|
-
SCHEMA_FILE_CANDIDATES.find { |f| File.exist?(f) }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
29
|
def initialize(quiet: false)
|
|
34
30
|
@quiet = quiet
|
|
35
31
|
end
|
|
36
32
|
|
|
37
33
|
def run
|
|
38
34
|
validate!
|
|
39
|
-
models =
|
|
35
|
+
models = parse_models
|
|
40
36
|
if models.empty?
|
|
41
|
-
puts "No models found in #{
|
|
37
|
+
puts "No models found in #{MODELS_DIR}/" unless @quiet
|
|
42
38
|
return
|
|
43
39
|
end
|
|
44
40
|
|
|
@@ -48,11 +44,10 @@ module Belt
|
|
|
48
44
|
private
|
|
49
45
|
|
|
50
46
|
def validate!
|
|
51
|
-
|
|
52
|
-
unless schema_file
|
|
47
|
+
unless Dir.exist?(MODELS_DIR)
|
|
53
48
|
unless @quiet
|
|
54
|
-
abort
|
|
55
|
-
'Run `belt generate
|
|
49
|
+
abort "Error: No models directory found at #{MODELS_DIR}/. " \
|
|
50
|
+
'Run `belt generate model` to create your first model.'
|
|
56
51
|
end
|
|
57
52
|
return
|
|
58
53
|
end
|
|
@@ -64,21 +59,53 @@ module Belt
|
|
|
64
59
|
'Run `belt new` to create a project with the correct structure.'
|
|
65
60
|
end
|
|
66
61
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
def parse_models
|
|
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
|
|
67
|
+
|
|
68
|
+
model_files.filter_map { |f| parse_model_file(f) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse a model file to extract its name and index declarations.
|
|
72
|
+
# Uses lightweight regex parsing — does NOT require loading activeitem or the model.
|
|
73
|
+
def parse_model_file(file_path)
|
|
74
|
+
content = File.read(file_path)
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
# Extract class name from `class Foo < ApplicationRecord`
|
|
77
|
+
class_match = content.match(/^class\s+(\w+)\s*<\s*ApplicationRecord/)
|
|
78
|
+
return nil unless class_match
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
inner.strip!
|
|
80
|
+
class_name = class_match[1]
|
|
81
|
+
model_name = Belt::Inflector.underscore(class_name)
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
# Extract indexes() declaration
|
|
84
|
+
indexes = extract_indexes(content)
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
{ name: model_name, indexes: indexes }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def extract_indexes(content)
|
|
90
|
+
indexes = []
|
|
91
|
+
|
|
92
|
+
# Match the indexes() DSL: indexes('Name' => { partition_key: 'pk' }, ...)
|
|
93
|
+
# This handles the Ruby hash syntax used by ActiveItem
|
|
94
|
+
index_block = content.match(/^\s*indexes\(\s*\n?(.*?)\n?\s*\)/m)
|
|
95
|
+
return indexes unless index_block
|
|
96
|
+
|
|
97
|
+
index_content = index_block[1]
|
|
98
|
+
|
|
99
|
+
# Parse each index entry: 'IndexName' => { partition_key: 'key', sort_key: 'key' }
|
|
100
|
+
index_content.scan(/'([^']+)'\s*=>\s*\{([^}]+)\}/) do |name, opts|
|
|
101
|
+
partition_key = opts.match(/partition_key:\s*'([^']+)'/)&.captures&.first
|
|
102
|
+
sort_key = opts.match(/sort_key:\s*'([^']+)'/)&.captures&.first
|
|
103
|
+
next unless partition_key
|
|
104
|
+
|
|
105
|
+
indexes << { name: name, partition_key: partition_key, sort_key: sort_key }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
indexes
|
|
82
109
|
end
|
|
83
110
|
|
|
84
111
|
def generate_dynamodb_tf(models)
|
|
@@ -104,78 +131,94 @@ module Belt
|
|
|
104
131
|
|
|
105
132
|
def render_dynamodb(models)
|
|
106
133
|
blocks = models.map { |m| render_table(m) }
|
|
107
|
-
"# Auto-generated by Belt from
|
|
134
|
+
"# Auto-generated by Belt from model definitions\n" \
|
|
108
135
|
"# Do not edit manually — re-run `belt setup tables`\n\n#{blocks.join("\n\n")}\n"
|
|
109
136
|
end
|
|
110
137
|
|
|
111
138
|
def render_table(model)
|
|
112
139
|
name = table_name(model[:name])
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
attribute {
|
|
130
|
-
name = "createdAt"
|
|
131
|
-
type = "S"
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
global_secondary_index {
|
|
135
|
-
name = "RecentIndex"
|
|
136
|
-
hash_key = "_recent_pk"
|
|
137
|
-
range_key = "createdAt"
|
|
138
|
-
projection_type = "ALL"
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
tags = {
|
|
142
|
-
Name = "#{name}"
|
|
143
|
-
Environment = var.environment
|
|
144
|
-
ManagedBy = "Terraform"
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
HCL
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def table_name(model_name)
|
|
151
|
-
"${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Minimal DSL parser for schema.tf.rb
|
|
155
|
-
class SchemaParser
|
|
156
|
-
attr_reader :models
|
|
157
|
-
|
|
158
|
-
def initialize
|
|
159
|
-
@models = []
|
|
140
|
+
custom_indexes = model[:indexes] || []
|
|
141
|
+
|
|
142
|
+
# Collect all custom index attribute names, deduplicating against built-in ones
|
|
143
|
+
builtin_attrs = Set.new(%w[id _recent_pk createdAt])
|
|
144
|
+
extra_attrs = []
|
|
145
|
+
custom_indexes.each do |idx|
|
|
146
|
+
unless builtin_attrs.include?(idx[:partition_key])
|
|
147
|
+
extra_attrs << idx[:partition_key]
|
|
148
|
+
builtin_attrs.add(idx[:partition_key])
|
|
149
|
+
end
|
|
150
|
+
if idx[:sort_key] && !builtin_attrs.include?(idx[:sort_key])
|
|
151
|
+
extra_attrs << idx[:sort_key]
|
|
152
|
+
builtin_attrs.add(idx[:sort_key])
|
|
153
|
+
end
|
|
160
154
|
end
|
|
161
155
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
156
|
+
lines = []
|
|
157
|
+
lines << "resource \"aws_dynamodb_table\" \"#{Belt::Inflector.pluralize(model[:name])}\" {"
|
|
158
|
+
lines << " name = \"#{name}\""
|
|
159
|
+
lines << ' billing_mode = "PAY_PER_REQUEST"'
|
|
160
|
+
lines << ' hash_key = "id"'
|
|
161
|
+
lines << ''
|
|
162
|
+
lines << ' attribute {'
|
|
163
|
+
lines << ' name = "id"'
|
|
164
|
+
lines << ' type = "S"'
|
|
165
|
+
lines << ' }'
|
|
166
|
+
lines << ''
|
|
167
|
+
lines << ' attribute {'
|
|
168
|
+
lines << ' name = "_recent_pk"'
|
|
169
|
+
lines << ' type = "S"'
|
|
170
|
+
lines << ' }'
|
|
171
|
+
lines << ''
|
|
172
|
+
lines << ' attribute {'
|
|
173
|
+
lines << ' name = "createdAt"'
|
|
174
|
+
lines << ' type = "S"'
|
|
175
|
+
lines << ' }'
|
|
176
|
+
|
|
177
|
+
extra_attrs.each do |attr|
|
|
178
|
+
lines << ''
|
|
179
|
+
lines << ' attribute {'
|
|
180
|
+
lines << " name = \"#{attr}\""
|
|
181
|
+
lines << ' type = "S"'
|
|
182
|
+
lines << ' }'
|
|
166
183
|
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
class ModelParser
|
|
170
|
-
attr_reader :fields
|
|
171
184
|
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
lines << ''
|
|
186
|
+
lines << ' global_secondary_index {'
|
|
187
|
+
lines << ' name = "RecentIndex"'
|
|
188
|
+
lines << ' hash_key = "_recent_pk"'
|
|
189
|
+
lines << ' range_key = "createdAt"'
|
|
190
|
+
lines << ' projection_type = "ALL"'
|
|
191
|
+
lines << ' }'
|
|
192
|
+
|
|
193
|
+
custom_indexes.each do |idx|
|
|
194
|
+
lines << ''
|
|
195
|
+
lines << ' global_secondary_index {'
|
|
196
|
+
lines << " name = \"#{idx[:name]}\""
|
|
197
|
+
lines << " hash_key = \"#{idx[:partition_key]}\""
|
|
198
|
+
lines << " range_key = \"#{idx[:sort_key]}\"" if idx[:sort_key]
|
|
199
|
+
lines << ' projection_type = "ALL"'
|
|
200
|
+
lines << ' }'
|
|
174
201
|
end
|
|
175
202
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
203
|
+
lines << ''
|
|
204
|
+
lines << ' point_in_time_recovery {'
|
|
205
|
+
lines << ' enabled = var.enable_pitr'
|
|
206
|
+
lines << ' }'
|
|
207
|
+
lines << ''
|
|
208
|
+
lines << ' deletion_protection_enabled = var.deletion_protection'
|
|
209
|
+
lines << ''
|
|
210
|
+
lines << ' tags = {'
|
|
211
|
+
lines << " Name = \"#{name}\""
|
|
212
|
+
lines << ' Environment = var.environment'
|
|
213
|
+
lines << ' ManagedBy = "Terraform"'
|
|
214
|
+
lines << ' }'
|
|
215
|
+
lines << '}'
|
|
216
|
+
|
|
217
|
+
lines.join("\n")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def table_name(model_name)
|
|
221
|
+
"${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
|
|
179
222
|
end
|
|
180
223
|
end
|
|
181
224
|
end
|
data/lib/belt/cli.rb
CHANGED
|
@@ -14,6 +14,8 @@ require_relative 'cli/views_command'
|
|
|
14
14
|
require_relative 'cli/setup_command'
|
|
15
15
|
require_relative 'cli/terraform_command'
|
|
16
16
|
require_relative 'cli/deploy_command'
|
|
17
|
+
require_relative 'cli/backup_config'
|
|
18
|
+
require_relative 'cli/backup_runner'
|
|
17
19
|
require_relative 'cli/server_command'
|
|
18
20
|
require_relative 'cli/routes_command'
|
|
19
21
|
require_relative 'cli/lambda_config_command'
|
data/lib/belt/version.rb
CHANGED
|
@@ -39,6 +39,8 @@ module "app" {
|
|
|
39
39
|
environment = var.environment
|
|
40
40
|
aws_region = var.aws_region
|
|
41
41
|
domain = var.domain
|
|
42
|
+
enable_pitr = var.enable_pitr
|
|
43
|
+
deletion_protection = var.deletion_protection
|
|
42
44
|
frontend_urls = concat(
|
|
43
45
|
var.domain != "" ? ["https://${var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"}"] : [],
|
|
44
46
|
var.environment == "prod" ? [] : ["http://localhost:3000"]
|
|
@@ -4,3 +4,10 @@ domain = "<%= @domain %>"
|
|
|
4
4
|
<% else -%>
|
|
5
5
|
# domain = "myapp.com"
|
|
6
6
|
<% end -%>
|
|
7
|
+
|
|
8
|
+
# DynamoDB protection (PITR is true by default everywhere)
|
|
9
|
+
<% if @env_name == 'prod' || @env_name == 'production' -%>
|
|
10
|
+
deletion_protection = true
|
|
11
|
+
<% else -%>
|
|
12
|
+
# deletion_protection = true # Enable in production environments
|
|
13
|
+
<% end -%>
|
|
@@ -20,3 +20,15 @@ variable "domain" {
|
|
|
20
20
|
type = string
|
|
21
21
|
default = ""
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
variable "enable_pitr" {
|
|
25
|
+
description = "Enable DynamoDB Point-in-Time Recovery (PITR) for all tables"
|
|
26
|
+
type = bool
|
|
27
|
+
default = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
variable "deletion_protection" {
|
|
31
|
+
description = "Enable deletion protection on DynamoDB tables (prevents accidental terraform destroy)"
|
|
32
|
+
type = bool
|
|
33
|
+
default = false
|
|
34
|
+
}
|
|
@@ -37,3 +37,15 @@ variable "lambda_config" {
|
|
|
37
37
|
type = any
|
|
38
38
|
default = {}
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
variable "enable_pitr" {
|
|
42
|
+
description = "Enable DynamoDB Point-in-Time Recovery (PITR) for all tables"
|
|
43
|
+
type = bool
|
|
44
|
+
default = true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
variable "deletion_protection" {
|
|
48
|
+
description = "Enable deletion protection on DynamoDB tables (prevents accidental terraform destroy)"
|
|
49
|
+
type = bool
|
|
50
|
+
default = false
|
|
51
|
+
}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
Belt.application.schema.
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
1
|
+
Belt.application.schema.define do
|
|
2
|
+
# API Schema Definitions — request/response contracts
|
|
3
|
+
#
|
|
4
|
+
# request :create_thing — declares what an endpoint accepts
|
|
5
|
+
# model :thing — declares what an endpoint returns
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
# request :create_post do
|
|
9
|
+
# string :title, required: true
|
|
10
|
+
# string :body, required: true
|
|
11
|
+
# string :status
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# model :post do
|
|
15
|
+
# string :id
|
|
16
|
+
# string :title
|
|
17
|
+
# string :body
|
|
18
|
+
# string :status
|
|
19
|
+
# string :created_at
|
|
20
|
+
# string :updated_at
|
|
21
|
+
# end
|
|
9
22
|
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.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
@@ -70,6 +70,8 @@ files:
|
|
|
70
70
|
- lib/belt/assets/welcome.css
|
|
71
71
|
- lib/belt/cli.rb
|
|
72
72
|
- lib/belt/cli/app_detection.rb
|
|
73
|
+
- lib/belt/cli/backup_config.rb
|
|
74
|
+
- lib/belt/cli/backup_runner.rb
|
|
73
75
|
- lib/belt/cli/bucket_security.rb
|
|
74
76
|
- lib/belt/cli/console_command.rb
|
|
75
77
|
- lib/belt/cli/deploy_command.rb
|