belt-jobs 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3acaa084dabde8ed9d6cb0dd4ef975f2be00e7c81232d15e2c62c27a01f87be6
4
+ data.tar.gz: ed45b7ecf991df5f91b846dff0d453d8f6b8a09b1f18f0164dfee9bf83e2425d
5
+ SHA512:
6
+ metadata.gz: 0704ada74c04c57dc117d139ee457b5d061f6837ba878ab781e48ca6371ec5a1b99c683f853dc4ee1501d38f675d480fe416a047a601df54029eed73f38c2b87
7
+ data.tar.gz: 757b0bd862efe1a9d83babcaa23818d901a34221a1700cac8dd4c5371ed66bd3020979e8d4d55670a15884709a4f1d71f70d87a34d6ef5e921cfca97e8483f95
data/AGENTS.md ADDED
@@ -0,0 +1,53 @@
1
+ # AGENTS.md — belt-jobs
2
+
3
+ This is the Belt Active Job plugin. Host apps own their job classes and generated Terraform; runtime behavior stays in this gem.
4
+
5
+ ## Public contract
6
+
7
+ - `ApplicationJob < ActiveJob::Base`
8
+ - `perform_now`, `perform_later`, `set(wait:)`, `set(wait_until:)`
9
+ - `retry_on`, `discard_on`, callbacks, and Active Job serialization
10
+ - `belt g jobs`, `belt d jobs`, `belt g job NAME`, `belt d job NAME`
11
+
12
+ Immediate and <=900-second delays use SQS. Longer delays use one-time EventBridge Scheduler schedules targeting the same queue. Recurring Terraform schedules also target SQS. Execution is at least once.
13
+
14
+ ## Layout
15
+
16
+ | Path | Responsibility |
17
+ |---|---|
18
+ | `lib/belt/jobs/queue_adapter.rb` | Active Job enqueue and delay routing |
19
+ | `lib/belt/jobs/worker.rb` | allowlisted execution + partial batch failures |
20
+ | `lib/belt/jobs/lambda_handler.rb` | Lambda Loadout lifecycle |
21
+ | `lib/belt/jobs/schedule.rb` | friendly schedule compiler |
22
+ | `lib/belt/jobs/recurring_config.rb` | recurring YAML validation |
23
+ | `lib/belt/generators/jobs_generator.rb` | shared infra installer/destroyer |
24
+ | `lib/belt/generators/job_generator.rb` | singular class/schedule generator |
25
+ | `lib/belt/jobs/templates/` | host-owned Terraform/config/Lambda files |
26
+
27
+ Belt discovers both generator files by path and class name; do not add a separate registry.
28
+
29
+ ## Development
30
+
31
+ ```sh
32
+ bundle install
33
+ bundle exec rspec
34
+ bundle exec rubocop
35
+ bundle exec rake build
36
+ ```
37
+
38
+ For generator smoke testing, create a temporary standard Belt app module containing `infrastructure/modules/app/main.tf` and `lambda/config/environment.rb`, invoke the generators from that root, run `terraform fmt -check`, then exercise both destroy paths.
39
+
40
+ ## Invariants
41
+
42
+ 1. Never exceed the configured 256 KiB serialized payload cap.
43
+ 2. Keep the exact 900-second SQS/Scheduler routing boundary.
44
+ 3. Validate a job class name against loaded Active Job descendants before constant resolution.
45
+ 4. Return only failed SQS message IDs in `batchItemFailures`.
46
+ 5. Scheduler targets SQS, never Lambda directly.
47
+ 6. Recurring config accepts job classes and JSON-like args only—never eval or arbitrary commands.
48
+ 7. Generator-owned host edits must be marked and reversible.
49
+ 8. Preserve `retry_on` / `discard_on` by executing through Active Job.
50
+
51
+ ## Deferred beyond 0.0.1
52
+
53
+ FIFO queues, numeric priority, distributed keyed concurrency, workflows/chains, dynamic schedule CRUD/UI, and automatic DLQ redrive are intentionally out of scope.
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 - 2026-09-30
4
+
5
+ ### Added
6
+
7
+ - Active Job queue adapter backed by SQS for immediate and <=15-minute delayed jobs.
8
+ - One-time EventBridge Scheduler delivery for longer `wait` / `wait_until` jobs.
9
+ - Lambda worker with loaded-class allowlisting and per-record `batchItemFailures`.
10
+ - Belt Lambda Loadout logging and `JobEnqueued`, `JobSucceeded`, and `JobFailed` metrics.
11
+ - `belt g jobs` installer for SQS, execution and Scheduler DLQs, IAM, alarms, Lambda config, and reversible host wiring.
12
+ - `belt g job NAME` generator with queue, friendly/native schedule, and timezone options.
13
+ - Validated `config/recurring.yml` definitions compiled to EventBridge Scheduler resources.
14
+ - 256 KiB payload limit and documented at-least-once/idempotency contract.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stowzilla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # belt-jobs
2
+
3
+ Active Job for Belt applications on AWS Lambda: SQS-backed execution, delayed jobs, recurring EventBridge schedules, DLQs, alarms, and Rails-style generators.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem "belt-jobs", "0.0.1"
10
+ ```
11
+
12
+ ```sh
13
+ bundle install
14
+ belt g jobs
15
+ belt g job nightly_cleanup
16
+ ```
17
+
18
+ `belt g jobs` installs and wires the queue, worker Lambda, Scheduler IAM, recurring schedules, DLQs, alarms, and `ApplicationJob`. `belt g job` auto-installs that shared infrastructure if needed.
19
+
20
+ ## Active Job API
21
+
22
+ Generated jobs are ordinary Active Job classes:
23
+
24
+ ```ruby
25
+ class NightlyCleanupJob < ApplicationJob
26
+ queue_as :default
27
+ retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 5
28
+
29
+ def perform(account_id)
30
+ # Keep jobs idempotent: SQS and Lambda provide at-least-once delivery.
31
+ end
32
+ end
33
+ ```
34
+
35
+ Use the standard API from controllers, models, the console, or other jobs:
36
+
37
+ ```ruby
38
+ NightlyCleanupJob.perform_now(account.id)
39
+ NightlyCleanupJob.perform_later(account.id)
40
+ NightlyCleanupJob.set(wait: 10.minutes).perform_later(account.id)
41
+ NightlyCleanupJob.set(wait_until: tomorrow.noon).perform_later(account.id)
42
+ ```
43
+
44
+ Immediate jobs and delays up to 15 minutes use SQS directly. Longer delays create a one-time EventBridge Scheduler schedule that deletes itself after delivery. Every path targets the same SQS queue and worker Lambda.
45
+
46
+ ## Recurring jobs
47
+
48
+ Generate and schedule in one command:
49
+
50
+ ```sh
51
+ belt g job daily_digest \
52
+ --schedule "every day at 9am" \
53
+ --timezone America/New_York
54
+ ```
55
+
56
+ Supported friendly schedules are `every N minutes|hours|days` and `every day at 9am` / `daily 09:00`. Native AWS `cron(...)` and `rate(...)` expressions are accepted directly. The generator writes `config/recurring.yml`:
57
+
58
+ ```yaml
59
+ daily_digest:
60
+ class: DailyDigestJob
61
+ args: []
62
+ schedule: cron(0 9 * * ? *)
63
+ timezone: America/New_York
64
+ queue: default
65
+ enabled: true
66
+ ```
67
+
68
+ Recurring arguments must be YAML/JSON values. There is deliberately no arbitrary `command:` evaluation.
69
+
70
+ ## Generated files
71
+
72
+ ```text
73
+ infrastructure/modules/jobs/ SQS, DLQs, Scheduler, IAM, alarms
74
+ config/lambda/jobs.yml 15-minute worker + partial batch failures
75
+ config/recurring.yml recurring definitions
76
+ lambda/jobs.rb Lambda entrypoint
77
+ lambda/jobs/application_job.rb app-owned Active Job base
78
+ lambda/jobs/*_job.rb generated jobs
79
+ ```
80
+
81
+ The installer updates the standard `infrastructure/modules/app/main.tf` and `lambda/config/environment.rb`. It is idempotent, supports `--force`, and `belt d jobs` removes its files and marked wiring. `belt d job NAME` removes one class and its recurring entry.
82
+
83
+ ## Configuration
84
+
85
+ Terraform supplies production settings through environment variables. Tests and unusual deployments can configure the runtime directly:
86
+
87
+ ```ruby
88
+ Belt::Jobs.configure do |config|
89
+ config.queue_url = "https://sqs.us-east-1.amazonaws.com/123/jobs"
90
+ config.queue_arn = "arn:aws:sqs:us-east-1:123:jobs"
91
+ config.scheduler_role_arn = "arn:aws:iam::123:role/jobs-scheduler"
92
+ config.allowed_job_classes = [NightlyCleanupJob]
93
+ end
94
+ Belt::Jobs.configure_active_job!
95
+ ```
96
+
97
+ | Environment variable | Purpose |
98
+ |---|---|
99
+ | `BELT_JOBS_QUEUE_URL` | SQS enqueue URL |
100
+ | `BELT_JOBS_QUEUE_ARN` | Scheduler target ARN |
101
+ | `BELT_JOBS_SCHEDULER_ROLE_ARN` | Role Scheduler assumes to send to SQS |
102
+ | `BELT_JOBS_SCHEDULER_DLQ_ARN` | Failed Scheduler delivery queue |
103
+ | `BELT_JOBS_SCHEDULE_GROUP_NAME` | Group for one-time and recurring schedules |
104
+
105
+ By default, the worker allowlists the loaded concrete `ApplicationJob` descendants before resolving a class name. Set `allowed_job_classes` to tighten this further.
106
+
107
+ ## Reliability contract
108
+
109
+ - Delivery is **at least once**. Job code must be idempotent.
110
+ - `retry_on`, `discard_on`, callbacks, serialization, and `perform_now` are Active Job behavior.
111
+ - Handled `retry_on` errors enqueue a replacement and acknowledge the current SQS record.
112
+ - Unhandled, malformed, or disallowed records are returned in `batchItemFailures`; SQS retries them and then moves them to the execution DLQ.
113
+ - Serialized payloads are capped at 256 KiB. Prefer IDs over large object graphs.
114
+ - Jobs have Lambda's 900-second execution ceiling.
115
+ - Long and recurring schedules have EventBridge Scheduler's one-minute delivery precision.
116
+ - The generated worker uses bounded concurrency and emits Belt `JobEnqueued`, `JobSucceeded`, and `JobFailed` metrics.
117
+
118
+ ## Removing
119
+
120
+ ```sh
121
+ belt d job nightly_cleanup
122
+ belt d jobs
123
+ ```
124
+
125
+ ## Development
126
+
127
+ ```sh
128
+ bundle install
129
+ bundle exec rspec
130
+ bundle exec rubocop
131
+ bundle exec rake build
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ require_relative 'jobs_generator'
6
+ require_relative '../jobs'
7
+
8
+ module Belt
9
+ module Generators
10
+ class JobGenerator
11
+ def self.description
12
+ 'Generate an Active Job class (optionally recurring)'
13
+ end
14
+
15
+ def self.run(args)
16
+ return print_help if args.include?('--help') || args.include?('-h')
17
+
18
+ new(args).generate
19
+ end
20
+
21
+ def self.destroy(args)
22
+ new(args).destroy
23
+ end
24
+
25
+ def self.print_help
26
+ puts <<~HELP
27
+ Generate an Active Job class.
28
+
29
+ Usage: belt generate job NAME [options]
30
+
31
+ Options:
32
+ --queue NAME Active Job queue name (default: default)
33
+ --schedule SCHEDULE cron(...), rate(...), "every N minutes", or "every day at 9am"
34
+ --timezone ZONE EventBridge schedule timezone (default: UTC)
35
+ --force Overwrite an existing job
36
+
37
+ Examples:
38
+ belt g job nightly_cleanup
39
+ belt g job daily_digest --schedule "every day at 9am" --timezone America/New_York
40
+ belt d job daily_digest
41
+ HELP
42
+ end
43
+
44
+ def initialize(args)
45
+ @options = parse(args)
46
+ @name = @options.delete(:name)
47
+ @force = @options.delete(:force)
48
+ end
49
+
50
+ def generate
51
+ abort_with_usage unless @name
52
+ JobsGenerator.run([]) unless File.exist?('lambda/jobs/application_job.rb')
53
+ destination = "lambda/jobs/#{underscored_name}_job.rb"
54
+ if File.exist?(destination) && !@force
55
+ puts " skip #{destination} (already exists, use --force to overwrite)"
56
+ else
57
+ FileUtils.mkdir_p(File.dirname(destination))
58
+ File.write(destination, job_source)
59
+ puts " create #{destination}"
60
+ end
61
+ add_recurring_schedule if @options[:schedule]
62
+ puts "\n✓ Generated #{class_name}"
63
+ end
64
+
65
+ def destroy
66
+ abort_with_usage unless @name
67
+ destination = "lambda/jobs/#{underscored_name}_job.rb"
68
+ FileUtils.rm_f(destination)
69
+ remove_recurring_schedule
70
+ puts "\n✓ Removed #{class_name}"
71
+ end
72
+
73
+ private
74
+
75
+ def parse(args)
76
+ options = { queue: 'default', timezone: 'UTC', force: false }
77
+ index = 0
78
+ while index < args.length
79
+ argument = args[index]
80
+ case argument
81
+ when '--queue', '--schedule', '--timezone'
82
+ index += 1
83
+ options[argument.delete_prefix('--').to_sym] = args[index]
84
+ when /\A--(queue|schedule|timezone)=(.*)\z/
85
+ options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
86
+ when '--force'
87
+ options[:force] = true
88
+ else
89
+ raise Belt::Jobs::ConfigurationError, "unknown option #{argument}" if argument.start_with?('-')
90
+
91
+ options[:name] ||= argument
92
+ end
93
+ index += 1
94
+ end
95
+ options
96
+ end
97
+
98
+ def underscored_name
99
+ @name.to_s.gsub('::', '/').gsub(/([a-z\d])([A-Z])/, '\\1_\\2').tr('-', '_').downcase.sub(/_job\z/, '')
100
+ end
101
+
102
+ def class_name
103
+ "#{underscored_name.split('/').map do |part|
104
+ part.split('_').map(&:capitalize).join
105
+ end.join('::')}Job"
106
+ end
107
+
108
+ def schedule_key
109
+ underscored_name.tr('/', '_')
110
+ end
111
+
112
+ def job_source
113
+ modules = class_name.split('::')
114
+ klass = modules.pop
115
+ indent = ''
116
+ source = +"# frozen_string_literal: true\n\n"
117
+ modules.each do |mod|
118
+ source << "#{indent}module #{mod}\n"
119
+ indent += ' '
120
+ end
121
+ source << <<~RUBY.gsub(/^/, indent)
122
+ class #{klass} < ApplicationJob
123
+ queue_as :#{@options[:queue]}
124
+
125
+ def perform(*args)
126
+ # Do work here. Jobs are at-least-once; keep this method idempotent.
127
+ end
128
+ end
129
+ RUBY
130
+ modules.reverse_each do
131
+ indent = indent[0...-2]
132
+ source << "#{indent}end\n"
133
+ end
134
+ source
135
+ end
136
+
137
+ def add_recurring_schedule
138
+ path = 'config/recurring.yml'
139
+ config = File.exist?(path) ? (YAML.safe_load_file(path, aliases: true) || {}) : {}
140
+ config[schedule_key] = {
141
+ 'class' => class_name,
142
+ 'args' => [],
143
+ 'schedule' => Belt::Jobs::Schedule.compile(@options[:schedule]),
144
+ 'timezone' => @options[:timezone],
145
+ 'queue' => @options[:queue],
146
+ 'enabled' => true
147
+ }
148
+ Belt::Jobs::RecurringConfig.new(config).validate!
149
+ FileUtils.mkdir_p(File.dirname(path))
150
+ File.write(path, YAML.dump(config))
151
+ puts " update #{path}"
152
+ end
153
+
154
+ def remove_recurring_schedule
155
+ path = 'config/recurring.yml'
156
+ return unless File.exist?(path)
157
+
158
+ config = YAML.safe_load_file(path, aliases: true) || {}
159
+ return unless config.delete(schedule_key)
160
+
161
+ File.write(path, YAML.dump(config))
162
+ puts " update #{path}"
163
+ end
164
+
165
+ def abort_with_usage
166
+ raise Belt::Jobs::ConfigurationError, 'Usage: belt generate job NAME [options]'
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+
6
+ module Belt
7
+ module Generators
8
+ class JobsGenerator
9
+ TEMPLATE_DIR = File.expand_path('../jobs/templates', __dir__)
10
+ APP_MODULE = 'infrastructure/modules/app/main.tf'
11
+ ENVIRONMENT = 'lambda/config/environment.rb'
12
+ JOBS_MODULE_MARKER = /\n?# belt-jobs:module:begin\n.*?# belt-jobs:module:end\n?/m
13
+ ENV_REFS_MARKER = /\s*# belt-jobs:env-refs:begin\n.*?# belt-jobs:env-refs:end/m
14
+ SHARED_IAM_MARKER = /\s*# belt-jobs:shared-iam:begin\n.*?# belt-jobs:shared-iam:end/m
15
+ BOOT_MARKER = /\n# belt-jobs:boot:begin\n.*?# belt-jobs:boot:end\n?/m
16
+
17
+ def self.description
18
+ 'Install Active Job on SQS + Lambda with delayed and recurring schedules'
19
+ end
20
+
21
+ def self.run(args)
22
+ return print_help if args.include?('--help') || args.include?('-h')
23
+
24
+ new(args).generate
25
+ end
26
+
27
+ def self.destroy(args)
28
+ new(args).destroy
29
+ end
30
+
31
+ def self.print_help
32
+ puts <<~HELP
33
+ Install Active Job infrastructure for a Belt application.
34
+
35
+ Usage: belt generate jobs [options]
36
+
37
+ Options:
38
+ --force Overwrite generated files
39
+ -h, --help Show this help
40
+
41
+ Creates:
42
+ infrastructure/modules/jobs/ SQS, DLQs, Scheduler, IAM, alarms
43
+ config/lambda/jobs.yml Worker Lambda config and SQS trigger
44
+ config/recurring.yml Recurring jobs map
45
+ lambda/jobs.rb SQS Lambda entry point
46
+ lambda/jobs/application_job.rb Active Job base class
47
+
48
+ The generator also wires the jobs module, Lambda environment references,
49
+ producer IAM, shared job packaging, and app boot into the standard Belt app module.
50
+
51
+ Examples:
52
+ belt g jobs
53
+ belt g job nightly_cleanup
54
+ belt d jobs
55
+ HELP
56
+ end
57
+
58
+ def initialize(args)
59
+ @force = args.include?('--force')
60
+ end
61
+
62
+ def generate
63
+ generate_files
64
+ wire_app_module
65
+ wire_environment
66
+ puts <<~SUCCESS
67
+
68
+ ✓ Jobs installed!
69
+
70
+ Generate a job:
71
+ belt g job nightly_cleanup
72
+ belt g job daily_digest --schedule "every day at 9am" --timezone America/New_York
73
+
74
+ Then deploy normally:
75
+ belt deploy <env>
76
+ SUCCESS
77
+ end
78
+
79
+ def destroy
80
+ FileUtils.rm_rf('infrastructure/modules/jobs')
81
+ FileUtils.rm_rf('lambda/jobs')
82
+ %w[config/lambda/jobs.yml config/recurring.yml lambda/jobs.rb].each do |path|
83
+ FileUtils.rm_f(path)
84
+ end
85
+ unwire_app_module
86
+ unwire_environment
87
+ puts "\n✓ Jobs removed!"
88
+ end
89
+
90
+ private
91
+
92
+ def generate_files
93
+ {
94
+ 'terraform/main.tf.erb' => 'infrastructure/modules/jobs/main.tf',
95
+ 'terraform/variables.tf.erb' => 'infrastructure/modules/jobs/variables.tf',
96
+ 'terraform/outputs.tf.erb' => 'infrastructure/modules/jobs/outputs.tf',
97
+ 'config/jobs.yml.erb' => 'config/lambda/jobs.yml',
98
+ 'config/recurring.yml.erb' => 'config/recurring.yml',
99
+ 'lambda/jobs.rb.erb' => 'lambda/jobs.rb',
100
+ 'lambda/application_job.rb.erb' => 'lambda/jobs/application_job.rb'
101
+ }.each { |template, destination| write_template(template, destination) }
102
+ end
103
+
104
+ def wire_app_module
105
+ return warn_missing(APP_MODULE) unless File.exist?(APP_MODULE)
106
+
107
+ content = File.read(APP_MODULE)
108
+ original = content.dup
109
+ unless content.include?('# belt-jobs:module:begin')
110
+ block = <<~HCL
111
+ # belt-jobs:module:begin
112
+ module "jobs" {
113
+ source = "../jobs"
114
+ app_name = var.app_name
115
+ environment = var.environment
116
+ aws_region = var.aws_region
117
+ }
118
+ # belt-jobs:module:end
119
+
120
+ HCL
121
+ content.sub!('resource "conveyor_belt" "main" {', "#{block}resource \"conveyor_belt\" \"main\" {")
122
+ end
123
+
124
+ unless content.include?('# belt-jobs:env-refs:begin')
125
+ content.sub!(/^(\s*)lambda_env_refs\s*=\s*var\.lambda_env_refs\s*$/) do
126
+ indent = Regexp.last_match(1)
127
+ <<~HCL.chomp
128
+ #{indent}# belt-jobs:env-refs:begin
129
+ #{indent}lambda_env_refs = merge(var.lambda_env_refs, {
130
+ #{indent} belt_jobs_queue_url = module.jobs.queue_url
131
+ #{indent} belt_jobs_queue_arn = module.jobs.queue_arn
132
+ #{indent} belt_jobs_scheduler_role_arn = module.jobs.scheduler_role_arn
133
+ #{indent} belt_jobs_scheduler_dlq_arn = module.jobs.scheduler_dlq_arn
134
+ #{indent} belt_jobs_schedule_group = module.jobs.schedule_group_name
135
+ #{indent}})
136
+ #{indent}# belt-jobs:env-refs:end
137
+ HCL
138
+ end
139
+ end
140
+
141
+ unless content.include?('shared_iam_policy_arns')
142
+ content.sub!(/\n}\s*\z/, <<~HCL)
143
+
144
+ # belt-jobs:shared-iam:begin
145
+ shared_iam_policy_arns = [module.jobs.producer_policy_arn]
146
+ # belt-jobs:shared-iam:end
147
+ }
148
+ HCL
149
+ end
150
+
151
+ content.sub!(/lambda_shared_dirs\s*=\s*\[([^\]]*)\]/) do |match|
152
+ next match if match.include?('"jobs"')
153
+
154
+ "#{match.sub(/\]\z/, ', "jobs"]')} # belt-jobs:shared-dir"
155
+ end
156
+ write_update(APP_MODULE, original, content)
157
+ end
158
+
159
+ def wire_environment
160
+ return warn_missing(ENVIRONMENT) unless File.exist?(ENVIRONMENT)
161
+
162
+ content = File.read(ENVIRONMENT)
163
+ return if content.include?('jobs_dir =') || content.include?('# belt-jobs:boot:begin')
164
+
165
+ original = content.dup
166
+ content << <<~RUBY
167
+
168
+ # belt-jobs:boot:begin
169
+ jobs_dir = File.join(__dir__, '..', 'jobs')
170
+ all_jobs = Dir[File.join(jobs_dir, '**', '*.rb')].sort
171
+ application_job = all_jobs.find { |file| File.basename(file) == 'application_job.rb' }
172
+ require application_job if application_job
173
+ (all_jobs - [application_job].compact).each { |file| require file }
174
+ # belt-jobs:boot:end
175
+ RUBY
176
+ write_update(ENVIRONMENT, original, content)
177
+ end
178
+
179
+ def unwire_app_module
180
+ return unless File.exist?(APP_MODULE)
181
+
182
+ content = File.read(APP_MODULE)
183
+ original = content.dup
184
+ content.gsub!(JOBS_MODULE_MARKER, '')
185
+ content.gsub!(ENV_REFS_MARKER, "\n lambda_env_refs = var.lambda_env_refs")
186
+ content.gsub!(SHARED_IAM_MARKER, '')
187
+ content.gsub!(/,\s*"jobs"\]\s*# belt-jobs:shared-dir/, ']')
188
+ write_update(APP_MODULE, original, content)
189
+ end
190
+
191
+ def unwire_environment
192
+ return unless File.exist?(ENVIRONMENT)
193
+
194
+ content = File.read(ENVIRONMENT)
195
+ original = content.dup
196
+ content.gsub!(BOOT_MARKER, "\n")
197
+ write_update(ENVIRONMENT, original, content)
198
+ end
199
+
200
+ def write_template(template, destination)
201
+ if File.exist?(destination) && !@force
202
+ puts " skip #{destination} (already exists)"
203
+ return
204
+ end
205
+
206
+ source = File.join(TEMPLATE_DIR, template)
207
+ FileUtils.mkdir_p(File.dirname(destination))
208
+ File.write(destination, ERB.new(File.read(source), trim_mode: '-').result(binding))
209
+ puts " create #{destination}"
210
+ end
211
+
212
+ def write_update(path, original, content)
213
+ return if original == content
214
+
215
+ File.write(path, content)
216
+ puts " update #{path}"
217
+ end
218
+
219
+ def warn_missing(path)
220
+ puts " warn #{path} not found; wire the jobs module manually"
221
+ end
222
+ end
223
+ end
224
+ end