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 +7 -0
- data/AGENTS.md +53 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/lib/belt/generators/job_generator.rb +170 -0
- data/lib/belt/generators/jobs_generator.rb +224 -0
- data/lib/belt/jobs/configuration.rb +97 -0
- data/lib/belt/jobs/errors.rb +10 -0
- data/lib/belt/jobs/lambda_handler.rb +30 -0
- data/lib/belt/jobs/queue_adapter.rb +88 -0
- data/lib/belt/jobs/recurring_config.rb +54 -0
- data/lib/belt/jobs/schedule.rb +51 -0
- data/lib/belt/jobs/templates/config/jobs.yml.erb +21 -0
- data/lib/belt/jobs/templates/config/recurring.yml.erb +12 -0
- data/lib/belt/jobs/templates/lambda/application_job.rb.erb +8 -0
- data/lib/belt/jobs/templates/lambda/jobs.rb.erb +5 -0
- data/lib/belt/jobs/templates/terraform/main.tf.erb +143 -0
- data/lib/belt/jobs/templates/terraform/outputs.tf.erb +27 -0
- data/lib/belt/jobs/templates/terraform/variables.tf.erb +45 -0
- data/lib/belt/jobs/version.rb +7 -0
- data/lib/belt/jobs/worker.rb +93 -0
- data/lib/belt/jobs.rb +37 -0
- data/lib/belt-jobs.rb +3 -0
- metadata +125 -0
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
|