belt 0.1.8 → 0.1.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.
@@ -61,7 +61,7 @@ module Belt
61
61
  exit 1
62
62
  end
63
63
 
64
- # Re-resolve bucket name with account ID suffix now that we have credentials
64
+ # Resolve final bucket name now that we have credentials
65
65
  @bucket_name = resolve_bucket_name unless @custom_bucket
66
66
 
67
67
  @bucket_name = interactive_bucket_selection if @select_mode
@@ -142,13 +142,7 @@ module Belt
142
142
  if @custom_bucket
143
143
  @custom_bucket
144
144
  else
145
- base = if @env_name
146
- "#{@app_name}-terraform-state-#{@env_name}"
147
- else
148
- "#{@app_name}-terraform-state"
149
- end
150
- base = "#{base}-#{@aws_account_id}-#{@region}" if @aws_account_id
151
- s3_safe_name(base)
145
+ 'belt-terraform-state'
152
146
  end
153
147
  end
154
148
 
@@ -9,6 +9,7 @@ module Belt
9
9
  module CLI
10
10
  class TablesCommand
11
11
  SCHEMA_FILE = 'infrastructure/schema.tf.rb'
12
+ MODULE_DIR = 'infrastructure/modules/app'
12
13
 
13
14
  include AppDetection
14
15
 
@@ -16,37 +17,32 @@ module Belt
16
17
  env = EnvResolver.resolve(args)
17
18
 
18
19
  if env.nil?
19
- puts 'Usage: belt setup tables <environment>'
20
- puts "\nReads schema.tf.rb and generates dynamodb.tf in the environment directory."
20
+ puts 'Usage: belt setup tables [environment]'
21
+ puts "\nReads schema.tf.rb and generates dynamodb.tf in the app module."
21
22
  puts 'You can also set BELT_ENV to skip the environment argument.'
22
23
  puts "\nExamples:"
23
- puts ' belt setup tables wups'
24
- puts ' belt setup tables dev01'
25
- puts ' BELT_ENV=wups belt setup tables'
24
+ puts ' belt setup tables'
25
+ puts ' belt setup tables dev'
26
+ puts ' BELT_ENV=dev belt setup tables'
26
27
  exit 1
27
28
  end
28
29
 
29
30
  new(env).run
30
31
  end
31
32
 
32
- # Automatically sync dynamodb.tf for all existing environments.
33
+ # Automatically sync dynamodb.tf in the app module.
33
34
  # Called by generators after updating schema.tf.rb.
34
35
  def self.sync_all_environments
35
36
  return unless File.exist?(SCHEMA_FILE)
36
37
 
37
- environments = TerraformCommand.list_environments
38
- return if environments.empty?
39
-
40
- environments.each do |env|
41
- new(env, quiet: true).run
42
- end
38
+ # With the module approach, we only need to generate once into modules/app/
39
+ new(nil, quiet: true).run
43
40
  end
44
41
 
45
42
  def initialize(env, quiet: false)
46
43
  @env = env
47
44
  @quiet = quiet
48
45
  @app_name = detect_app_name
49
- @env_dir = "infrastructure/#{@env}"
50
46
  end
51
47
 
52
48
  def run
@@ -67,11 +63,11 @@ module Belt
67
63
  abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless @quiet
68
64
  return
69
65
  end
70
- return if Dir.exist?(@env_dir)
66
+ return if Dir.exist?(MODULE_DIR)
71
67
 
72
68
  unless @quiet
73
- abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
74
- "Create it with: belt generate environment #{@env}"
69
+ abort "Error: Module directory not found at #{MODULE_DIR}/.\n" \
70
+ "Run `belt new` to create a project with the correct structure."
75
71
  end
76
72
  end
77
73
 
@@ -82,7 +78,6 @@ module Belt
82
78
  schema_content = File.read(SCHEMA_FILE)
83
79
 
84
80
  # Replace DSL wrapper with direct parser call
85
- # Belt.application.schema.draw do ... end → parser.instance_eval do ... end
86
81
  inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
87
82
  inner.strip!
88
83
 
@@ -93,7 +88,7 @@ module Belt
93
88
  end
94
89
 
95
90
  def generate_dynamodb_tf(models)
96
- dest = File.join(@env_dir, 'dynamodb.tf')
91
+ dest = File.join(MODULE_DIR, 'dynamodb.tf')
97
92
  existing_content = File.exist?(dest) ? File.read(dest) : nil
98
93
  new_content = render_dynamodb(models)
99
94
 
@@ -108,7 +103,7 @@ module Belt
108
103
  else
109
104
  puts " create #{dest}"
110
105
  puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
111
- models.each { |m| puts " • #{table_name(m[:name])}" }
106
+ models.each { |m| puts " • #{Belt::Inflector.pluralize(m[:name])}" }
112
107
  puts "\nRun `belt deploy` to create them."
113
108
  end
114
109
  end
@@ -116,7 +111,7 @@ module Belt
116
111
  def render_dynamodb(models)
117
112
  blocks = models.map { |m| render_table(m) }
118
113
  "# Auto-generated by Belt from schema.tf.rb\n" \
119
- "# Do not edit manually — re-run `belt setup tables #{@env}`\n\n#{blocks.join("\n\n")}\n"
114
+ "# Do not edit manually — re-run `belt setup tables`\n\n#{blocks.join("\n\n")}\n"
120
115
  end
121
116
 
122
117
  def render_table(model)
@@ -142,7 +137,7 @@ module Belt
142
137
  end
143
138
 
144
139
  def table_name(model_name)
145
- "#{@app_name}-#{@env}-#{Belt::Inflector.pluralize(model_name)}"
140
+ "#{@app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
146
141
  end
147
142
 
148
143
  # Minimal DSL parser for schema.tf.rb
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.8'
4
+ VERSION = '0.1.9'
5
5
  end
@@ -1,7 +1,7 @@
1
1
  terraform {
2
2
  backend "s3" {
3
- bucket = "<%= s3_safe_name(@app_name) %>-terraform-state"
4
- key = "<%= @env_name %>/terraform.tfstate"
3
+ bucket = "belt-terraform-state"
4
+ key = "<%= s3_safe_name(@app_name) %>/<%= @env_name %>/terraform.tfstate"
5
5
  region = "us-east-1"
6
6
  encrypt = true
7
7
  }
@@ -32,25 +32,21 @@ provider "conveyor-belt" {
32
32
  aws_region = var.aws_region
33
33
  }
34
34
 
35
- resource "conveyor_belt" "main" {
36
- provider = conveyor-belt
37
- source = "${path.module}/../routes.tf.rb"
38
- app_name = var.app_name
39
- lambda_source_dir = "${path.module}/../../lambda"
40
- lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
41
- frontend_urls = var.environment == "prod" ? [] : ["http://localhost:3000"]
42
- friendly_errors = var.environment != "prod"
35
+ module "app" {
36
+ source = "../modules/app"
43
37
 
44
- # Per-lambda configuration: env vars, timeouts, memory
45
- lambda_config = {
46
- api = {
47
- # timeout = 30
48
- # memory_size = 256
38
+ app_name = var.app_name
39
+ environment = var.environment
40
+ aws_region = var.aws_region
41
+ domain = var.domain
42
+ frontend_urls = concat(
43
+ var.domain != "" ? ["https://${var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"}"] : [],
44
+ var.environment == "prod" ? [] : ["http://localhost:3000"]
45
+ )
49
46
 
50
- env_vars = {
51
- WELCOME_TITLE = "Welcome to ${var.app_name}"
52
- WELCOME_SUBTITLE = "API Gateway → Lambda → DynamoDB — all connected."
53
- }
54
- }
47
+ providers = {
48
+ aws = aws
49
+ random = random
50
+ conveyor-belt = conveyor-belt
55
51
  }
56
52
  }
@@ -1,14 +1,39 @@
1
1
  output "api_url" {
2
2
  description = "API Gateway base URL"
3
- value = values(conveyor_belt.main.api_gateway_urls)[0]
3
+ value = module.app.api_url
4
4
  }
5
5
 
6
6
  output "api_urls" {
7
7
  description = "Map of gateway names to API Gateway URLs"
8
- value = conveyor_belt.main.api_gateway_urls
8
+ value = module.app.api_urls
9
9
  }
10
10
 
11
11
  output "lambda_functions" {
12
12
  description = "Map of action names to Lambda function ARNs"
13
- value = conveyor_belt.main.lambda_functions
13
+ value = module.app.lambda_functions
14
+ }
15
+
16
+ output "app_domain" {
17
+ description = "The application domain for this environment"
18
+ value = module.app.app_domain
19
+ }
20
+
21
+ output "name_servers" {
22
+ description = "NS records to configure at your domain registrar (only when domain is set)"
23
+ value = try(module.app.name_servers, [])
24
+ }
25
+
26
+ output "frontend_bucket_name" {
27
+ description = "S3 bucket for frontend assets"
28
+ value = try(module.app.frontend_bucket_name, "")
29
+ }
30
+
31
+ output "frontend_distribution_id" {
32
+ description = "CloudFront distribution ID"
33
+ value = try(module.app.frontend_distribution_id, "")
34
+ }
35
+
36
+ output "frontend_url" {
37
+ description = "Frontend URL"
38
+ value = try(module.app.frontend_url, "")
14
39
  }
@@ -1 +1,6 @@
1
1
  environment = "<%= @env_name %>"
2
+ <% if @domain && !@domain.empty? -%>
3
+ domain = "<%= @domain %>"
4
+ <% else -%>
5
+ # domain = "myapp.com"
6
+ <% end -%>
@@ -14,3 +14,9 @@ variable "aws_region" {
14
14
  type = string
15
15
  default = "us-east-1"
16
16
  }
17
+
18
+ variable "domain" {
19
+ description = "Root domain (e.g., myapp.com). Leave empty to use default CloudFront/API Gateway URLs."
20
+ type = string
21
+ default = ""
22
+ }
@@ -0,0 +1,83 @@
1
+ # DNS configuration for Belt application
2
+ # Convention: prod → mydomain.com, other envs → <env>.mydomain.com
3
+
4
+ locals {
5
+ # Determine the domain for this environment
6
+ app_domain = var.domain != "" ? (
7
+ var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"
8
+ ) : ""
9
+
10
+ # API subdomain: api.mydomain.com (prod) or api.<env>.mydomain.com (non-prod)
11
+ api_domain = var.domain != "" ? "api.${local.app_domain}" : ""
12
+
13
+ # Whether DNS is enabled
14
+ dns_enabled = var.domain != ""
15
+ }
16
+
17
+ # --- Route53 Hosted Zone ---
18
+ # One zone per environment subdomain (or root domain for prod)
19
+ resource "aws_route53_zone" "app" {
20
+ count = local.dns_enabled ? 1 : 0
21
+ name = local.app_domain
22
+ }
23
+
24
+ # --- ACM Certificate ---
25
+ # Wildcard cert covers the app domain + all subdomains (api.*, www.*, etc.)
26
+ resource "aws_acm_certificate" "app" {
27
+ count = local.dns_enabled ? 1 : 0
28
+ domain_name = local.app_domain
29
+ subject_alternative_names = ["*.${local.app_domain}"]
30
+ validation_method = "DNS"
31
+
32
+ lifecycle {
33
+ create_before_destroy = true
34
+ }
35
+ }
36
+
37
+ # DNS validation record for the certificate
38
+ resource "aws_route53_record" "cert_validation" {
39
+ for_each = local.dns_enabled ? {
40
+ for dvo in aws_acm_certificate.app[0].domain_validation_options : dvo.domain_name => {
41
+ name = dvo.resource_record_name
42
+ type = dvo.resource_record_type
43
+ record = dvo.resource_record_value
44
+ }
45
+ } : {}
46
+
47
+ zone_id = aws_route53_zone.app[0].zone_id
48
+ name = each.value.name
49
+ type = each.value.type
50
+ ttl = 300
51
+ records = [each.value.record]
52
+ }
53
+
54
+ resource "aws_acm_certificate_validation" "app" {
55
+ count = local.dns_enabled ? 1 : 0
56
+ certificate_arn = aws_acm_certificate.app[0].arn
57
+ validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
58
+ }
59
+
60
+ # --- API Gateway Custom Domain ---
61
+ resource "aws_api_gateway_domain_name" "api" {
62
+ count = local.dns_enabled ? 1 : 0
63
+ domain_name = local.api_domain
64
+ regional_certificate_arn = aws_acm_certificate_validation.app[0].certificate_arn
65
+
66
+ endpoint_configuration {
67
+ types = ["REGIONAL"]
68
+ }
69
+ }
70
+
71
+ # Route53 A record for api.<domain>
72
+ resource "aws_route53_record" "api" {
73
+ count = local.dns_enabled ? 1 : 0
74
+ zone_id = aws_route53_zone.app[0].zone_id
75
+ name = local.api_domain
76
+ type = "A"
77
+
78
+ alias {
79
+ name = aws_api_gateway_domain_name.api[0].regional_domain_name
80
+ zone_id = aws_api_gateway_domain_name.api[0].regional_zone_id
81
+ evaluate_target_health = false
82
+ }
83
+ }
@@ -0,0 +1,179 @@
1
+ # Frontend hosting — S3 + CloudFront
2
+ # Custom domain attached when var.domain is set
3
+
4
+ resource "random_string" "frontend_suffix" {
5
+ length = 8
6
+ special = false
7
+ upper = false
8
+ }
9
+
10
+ # S3 bucket for frontend static assets
11
+ resource "aws_s3_bucket" "frontend" {
12
+ bucket = "${var.app_name}-frontend-${var.environment}-${random_string.frontend_suffix.result}"
13
+
14
+ lifecycle {
15
+ ignore_changes = [bucket]
16
+ }
17
+ }
18
+
19
+ resource "aws_s3_bucket_public_access_block" "frontend" {
20
+ bucket = aws_s3_bucket.frontend.id
21
+
22
+ block_public_acls = true
23
+ block_public_policy = true
24
+ ignore_public_acls = true
25
+ restrict_public_buckets = true
26
+ }
27
+
28
+ resource "aws_s3_bucket_server_side_encryption_configuration" "frontend" {
29
+ bucket = aws_s3_bucket.frontend.id
30
+
31
+ rule {
32
+ apply_server_side_encryption_by_default {
33
+ sse_algorithm = "AES256"
34
+ }
35
+ bucket_key_enabled = true
36
+ }
37
+ }
38
+
39
+ # CloudFront OAC
40
+ resource "aws_cloudfront_origin_access_control" "frontend" {
41
+ name = "${var.app_name}-${var.environment}-frontend-oac"
42
+ description = "OAC for frontend bucket"
43
+ origin_access_control_origin_type = "s3"
44
+ signing_behavior = "always"
45
+ signing_protocol = "sigv4"
46
+ }
47
+
48
+ # CloudFront distribution
49
+ resource "aws_cloudfront_distribution" "frontend" {
50
+ origin {
51
+ domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
52
+ origin_id = "S3-${aws_s3_bucket.frontend.id}"
53
+ origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
54
+ }
55
+
56
+ enabled = true
57
+ is_ipv6_enabled = true
58
+ default_root_object = "index.html"
59
+
60
+ # Custom domain aliases (if DNS configured)
61
+ aliases = local.dns_enabled ? compact([
62
+ local.app_domain,
63
+ var.environment == "prod" ? "www.${var.domain}" : ""
64
+ ]) : []
65
+
66
+ default_cache_behavior {
67
+ allowed_methods = ["GET", "HEAD", "OPTIONS"]
68
+ cached_methods = ["GET", "HEAD"]
69
+ target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
70
+ compress = true
71
+ viewer_protocol_policy = "redirect-to-https"
72
+
73
+ forwarded_values {
74
+ query_string = false
75
+ cookies {
76
+ forward = "none"
77
+ }
78
+ }
79
+ }
80
+
81
+ # SPA routing — serve index.html for 404/403
82
+ custom_error_response {
83
+ error_code = 404
84
+ response_code = 200
85
+ response_page_path = "/index.html"
86
+ }
87
+
88
+ custom_error_response {
89
+ error_code = 403
90
+ response_code = 200
91
+ response_page_path = "/index.html"
92
+ }
93
+
94
+ restrictions {
95
+ geo_restriction {
96
+ restriction_type = "none"
97
+ }
98
+ }
99
+
100
+ viewer_certificate {
101
+ cloudfront_default_certificate = !local.dns_enabled
102
+ acm_certificate_arn = local.dns_enabled ? aws_acm_certificate_validation.app[0].certificate_arn : null
103
+ ssl_support_method = local.dns_enabled ? "sni-only" : null
104
+ minimum_protocol_version = local.dns_enabled ? "TLSv1.2_2021" : null
105
+ }
106
+
107
+ tags = {
108
+ Name = "${var.app_name}-${var.environment}-frontend"
109
+ Environment = var.environment
110
+ ManagedBy = "Terraform"
111
+ }
112
+ }
113
+
114
+ # S3 bucket policy — CloudFront OAC access only
115
+ resource "aws_s3_bucket_policy" "frontend" {
116
+ bucket = aws_s3_bucket.frontend.id
117
+
118
+ policy = jsonencode({
119
+ Version = "2012-10-17"
120
+ Statement = [
121
+ {
122
+ Sid = "AllowCloudFrontServicePrincipal"
123
+ Effect = "Allow"
124
+ Principal = {
125
+ Service = "cloudfront.amazonaws.com"
126
+ }
127
+ Action = "s3:GetObject"
128
+ Resource = "${aws_s3_bucket.frontend.arn}/*"
129
+ Condition = {
130
+ StringEquals = {
131
+ "AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
132
+ }
133
+ }
134
+ }
135
+ ]
136
+ })
137
+
138
+ depends_on = [aws_s3_bucket_public_access_block.frontend]
139
+ }
140
+
141
+ # Route53 record for frontend (app domain → CloudFront)
142
+ resource "aws_route53_record" "frontend" {
143
+ count = local.dns_enabled ? 1 : 0
144
+ zone_id = aws_route53_zone.app[0].zone_id
145
+ name = local.app_domain
146
+ type = "A"
147
+
148
+ alias {
149
+ name = aws_cloudfront_distribution.frontend.domain_name
150
+ zone_id = aws_cloudfront_distribution.frontend.hosted_zone_id
151
+ evaluate_target_health = false
152
+ }
153
+ }
154
+
155
+ # www record for prod only
156
+ resource "aws_route53_record" "frontend_www" {
157
+ count = local.dns_enabled && var.environment == "prod" ? 1 : 0
158
+ zone_id = aws_route53_zone.app[0].zone_id
159
+ name = "www.${var.domain}"
160
+ type = "A"
161
+
162
+ alias {
163
+ name = aws_cloudfront_distribution.frontend.domain_name
164
+ zone_id = aws_cloudfront_distribution.frontend.hosted_zone_id
165
+ evaluate_target_health = false
166
+ }
167
+ }
168
+
169
+ output "frontend_bucket_name" {
170
+ value = aws_s3_bucket.frontend.id
171
+ }
172
+
173
+ output "frontend_distribution_id" {
174
+ value = aws_cloudfront_distribution.frontend.id
175
+ }
176
+
177
+ output "frontend_url" {
178
+ value = local.dns_enabled ? "https://${local.app_domain}" : "https://${aws_cloudfront_distribution.frontend.domain_name}"
179
+ }
@@ -0,0 +1,42 @@
1
+ # Belt application module — defines all shared infrastructure.
2
+ # Environments call this module with different variables.
3
+
4
+ terraform {
5
+ required_providers {
6
+ aws = {
7
+ source = "hashicorp/aws"
8
+ version = "~> 5.0"
9
+ }
10
+ random = {
11
+ source = "hashicorp/random"
12
+ version = "~> 3.0"
13
+ }
14
+ conveyor-belt = {
15
+ source = "stowzilla/conveyor-belt"
16
+ version = "~> 0.0.1"
17
+ }
18
+ }
19
+ }
20
+
21
+ resource "conveyor_belt" "main" {
22
+ provider = conveyor-belt
23
+
24
+ source = "${path.module}/../../routes.tf.rb"
25
+ app_name = var.app_name
26
+ lambda_source_dir = "${path.module}/../../../lambda"
27
+ lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
28
+ frontend_urls = var.frontend_urls
29
+ friendly_errors = var.environment != "prod"
30
+
31
+ custom_domain_name = local.dns_enabled ? local.api_domain : ""
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
+ }
42
+ }
@@ -0,0 +1,24 @@
1
+ output "api_url" {
2
+ description = "API Gateway base URL (custom domain if configured, otherwise default)"
3
+ value = var.domain != "" ? "https://${local.api_domain}" : values(conveyor_belt.main.api_gateway_urls)[0]
4
+ }
5
+
6
+ output "api_urls" {
7
+ description = "Map of gateway names to API Gateway URLs"
8
+ value = conveyor_belt.main.api_gateway_urls
9
+ }
10
+
11
+ output "lambda_functions" {
12
+ description = "Map of action names to Lambda function ARNs"
13
+ value = conveyor_belt.main.lambda_functions
14
+ }
15
+
16
+ output "app_domain" {
17
+ description = "The application domain for this environment"
18
+ value = local.app_domain
19
+ }
20
+
21
+ output "name_servers" {
22
+ description = "NS records to configure at your registrar"
23
+ value = local.dns_enabled ? aws_route53_zone.app[0].name_servers : []
24
+ }
@@ -0,0 +1,27 @@
1
+ variable "app_name" {
2
+ description = "Name of the application"
3
+ type = string
4
+ }
5
+
6
+ variable "environment" {
7
+ description = "Environment name (e.g., dev, prod, staging)"
8
+ type = string
9
+ }
10
+
11
+ variable "aws_region" {
12
+ description = "AWS region"
13
+ type = string
14
+ default = "us-east-1"
15
+ }
16
+
17
+ variable "domain" {
18
+ description = "Root domain for the application (e.g., myapp.com). Leave empty to skip DNS."
19
+ type = string
20
+ default = ""
21
+ }
22
+
23
+ variable "frontend_urls" {
24
+ description = "URLs allowed for CORS frontend access"
25
+ type = list(string)
26
+ default = []
27
+ }