lambda_whenever 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f2de4e951f291bea6c0661284733bc2ecc2f7f2556d0ec2c55af15666cbbe2d6
4
+ data.tar.gz: 7d8a1abe7a3c1429479dd43f6972bb9f964614192caaaf2d7b9509854827f41e
5
+ SHA512:
6
+ metadata.gz: db1740b27b33eb0341f5616e31fdb8263ed59670f3a3a3f8ebe304934b4a0e20bbfd60c602014d9f4992f63089e118daa4fcd890f7f94c16a56f0cf01048c4df
7
+ data.tar.gz: 981e2ec5f416996265692bdd7b3fa7e83cba716e3958ea7d23ec568a5f00c3d02e2a1951354e49139854c81c24629a192450b18767f23fb1c4ac332b72a45a5d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 toshichanapp
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,231 @@
1
+ # LambdaWhenever
2
+
3
+ `lambda_whenever` is a Ruby gem that allows you to create schedules with AWS EventBridge Scheduler targeting Lambda functions using the same syntax as the `whenever` gem.
4
+ This gem simplifies the management of cron job configurations and enables event-driven batch processing.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'lambda_whenever'
12
+ ```
13
+
14
+ Or install it manually with Bundler in your Gemfile:
15
+
16
+ ```shell
17
+ $ gem install lambda_whenever
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ You can use it almost like Whenever. However, please note that you must specify a `--lambda-name` and `--iam-role`.
23
+
24
+ ```shell
25
+ $ lambda_whenever --help
26
+ Usage: lambda_whenever [options]
27
+ --dryrun dry-run
28
+ --update Creates and deletes tasks as needed by schedule file
29
+ -c, --clear Clear scheduled tasks
30
+ -l, --list List scheduled tasks
31
+ -v, --version Print version
32
+ -s, --set variables Example: --set 'environment=staging'
33
+ --lambda-name name Lambda function name
34
+ --scheduler-group group_name Optionally specify event bridge scheduler group name
35
+ -f, --file schedule_file Default: config/schedule.rb
36
+ --iam-role name IAM role name used by EventBridgeScheduler.
37
+ --rule-state state The state of the EventBridgeScheduler Rule. Default: ENABLED
38
+ --region region AWS region
39
+ -V, --verbose Run rake jobs without --silent
40
+ ```
41
+
42
+ ### Setting Variables
43
+
44
+ Lambda Whenever supports setting variables via the `--set` option, similar to [how Whenever does](https://github.com/javan/whenever/wiki/Setting-variables-on-the-fly).
45
+
46
+ Example:
47
+
48
+ ```shell
49
+ lambda_whenever --set 'environment=staging&some_var=foo'
50
+ ```
51
+
52
+ ```ruby
53
+ if @environment == 'staging'
54
+ every '0 1 * * *' do
55
+ rake 'some_task_on_staging'
56
+ end
57
+ elsif @some_var == 'foo'
58
+ every '0 10 * * *' do
59
+ rake 'some_task'
60
+ end
61
+ end
62
+ ```
63
+
64
+ The `@environment` variable defaults to `"production"`.
65
+
66
+ ## How It Works
67
+
68
+ Lambda Whenever creates an EventBridge Scheduler schedule for each `every` block. Each schedule can have multiple commands.
69
+ For example, the following input will generate one schedule with two commands:
70
+
71
+ ```ruby
72
+ every '0 0 * * *' do
73
+ rake "hoge:run"
74
+ command "echo 'you can use raw cron syntax too'"
75
+ end
76
+ ```
77
+
78
+ This will result in:
79
+
80
+ ```shell
81
+ cron(0 0 * * ? *) { commands: [["bundle", "exec", "rake", "hoge:run", "--silent"], ["echo", "'you", "can", "use", "raw", "cron", "syntax", "too'"]] }
82
+ ```
83
+
84
+ In this example, one EventBridge Scheduler schedule is created, containing both the rake task and the command.
85
+ The scheduled task's name is a digest value calculated from an cron expression, commands, and other parameters.
86
+
87
+ ## Prerequisites
88
+
89
+ Before using this gem, ensure that you have the necessary IAM policies set up for EventBridge to invoke your Lambda functions.
90
+
91
+ ### IAM Policies for Executing the Gem
92
+
93
+ To use this gem, the executing entity (e.g., GitHub Actions, CI/CD pipelines, or other automated systems)
94
+ must have the necessary IAM policies to register schedules with EventBridge and obtain Lambda ARNs.
95
+ The following policy grants the required permissions:
96
+
97
+ ```json
98
+ {
99
+ "Version": "2012-10-17",
100
+ "Statement": [
101
+ {
102
+ "Effect": "Allow",
103
+ "Action": [
104
+ "scheduler:CreateScheduleGroup",
105
+ "scheduler:ListSchedules",
106
+ "scheduler:CreateSchedule",
107
+ "scheduler:DeleteSchedule",
108
+ "scheduler:UpdateSchedule",
109
+ "lambda:ListFunctions",
110
+ "lambda:GetFunction"
111
+ ],
112
+ "Resource": "*"
113
+ }
114
+ ]
115
+ }
116
+ ```
117
+
118
+ ### Assume Role Policy for EventBridge
119
+
120
+ You need an IAM role that allows EventBridge to assume the role and invoke your Lambda functions.
121
+ The following policy should be attached to the role:
122
+
123
+ ```json
124
+ {
125
+ "Version": "2012-10-17",
126
+ "Statement": [
127
+ {
128
+ "Effect": "Allow",
129
+ "Principal": {
130
+ "Service": "scheduler.amazonaws.com"
131
+ },
132
+ "Action": "sts:AssumeRole",
133
+ "Condition": {
134
+ "StringEquals": {
135
+ "aws:SourceAccount": "<your account id>"
136
+ }
137
+ }
138
+ }
139
+ ]
140
+ }
141
+ ```
142
+
143
+ ### Execute Lambda Policy
144
+
145
+ You also need a policy that allows EventBridge to execute your Lambda functions. Attach the following policy to the role:
146
+
147
+ ```json
148
+ {
149
+ "Version": "2012-10-17",
150
+ "Statement": [
151
+ {
152
+ "Effect": "Allow",
153
+ "Action": "lambda:InvokeFunction",
154
+ "Resource": [
155
+ "<your lambda arn>:*",
156
+ "<your lambda arn>"
157
+ ]
158
+ }
159
+ ]
160
+ }
161
+ ```
162
+
163
+ ## Compatibility with Whenever
164
+
165
+ ### Timezone Configuration
166
+
167
+ In `lambda_whenever`, setting the timezone is slightly different from the traditional `whenever` gem.
168
+ Instead of using `env "CRON_TZ", <zone>`, you should use the `set :timezone, <zone>` syntax to specify the timezone for your scheduled tasks.
169
+
170
+ Example:
171
+
172
+ ```ruby
173
+ set :timezone, "Asia/Tokyo"
174
+ ```
175
+
176
+ ### Methods
177
+
178
+ Whenever supports custom job types with `job_type`, `env`, and `job_template` methods, but Lambda Whenever does not support these.
179
+
180
+ ### mailto
181
+
182
+ Whenever supports the `mailto` method, but Lambda Whenever does not.
183
+ Amazon EventBridge Scheduler does not natively support email notifications.
184
+ As a result, the `mailto` option is not available in this gem.
185
+
186
+ ### Frequency
187
+
188
+ Lambda Whenever processes the frequency passed to the `every` block similarly to Whenever.
189
+
190
+ #### `:reboot`
191
+
192
+ Whenever supports `:reboot` as a cron option, but Lambda Whenever does not support it.
193
+
194
+ ### Bundle Commands
195
+
196
+ Whenever checks if the application uses Bundler and automatically adds a prefix to commands.
197
+ However, Lambda Whenever always adds a prefix, assuming the application is using Bundler.
198
+
199
+ ```ruby
200
+ # Whenever
201
+ # With bundler -> bundle exec rake hoge:run
202
+ # Without bundler -> rake hoge:run
203
+ #
204
+ # Lambda Whenever
205
+ # bundle exec rake hoge:run
206
+ #
207
+ rake "hoge:run"
208
+ ```
209
+
210
+ If you don't want to add the prefix, set `bundle_command` to an empty string as follows:
211
+
212
+ ```ruby
213
+ set :bundle_command, ""
214
+ ```
215
+
216
+ ## Development
217
+
218
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
219
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
220
+
221
+ ## Contributing
222
+
223
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/toshichanapp/lambda_whenever](https://github.com/toshichanapp/lambda_whenever).
224
+
225
+ ## License
226
+
227
+ The gem is available as open-source under the terms of the MIT License.
228
+
229
+ ## Acknowledgement
230
+
231
+ This gem is inspired by and built upon the work of the [whenever](https://github.com/javan/whenever) and [elastic_whenever](https://github.com/wata727/elastic_whenever) gems. We would like to express our gratitude to the developers and contributors of these projects for their foundational work and contributions to the Ruby community.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "lambda_whenever"
5
+
6
+ exit LambdaWhenever::CLI.new(ARGV).run
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ # The CLI class handles command-line interface interactions for the Lambda Whenever tool.
5
+ class CLI
6
+ SUCCESS_EXIT_CODE = 0
7
+ ERROR_EXIT_CODE = 1
8
+
9
+ attr_reader :args, :option
10
+
11
+ def initialize(args)
12
+ @args = args
13
+ @option = Option.new(args)
14
+ end
15
+
16
+ def run
17
+ case option.mode
18
+ when Option::DRYRUN_MODE
19
+ option.validate!
20
+ print_tasks
21
+ Logger.instance.message("Above is your schedule file converted to scheduled tasks; your scheduled tasks was not updated.")
22
+ Logger.instance.message("Run `lambda_whenever --help' for more options.")
23
+ when Option::UPDATE_MODE
24
+ option.validate!
25
+ with_concurrent_modification_handling do
26
+ update_eb_schedules
27
+ end
28
+ Logger.instance.log("write", "scheduled tasks updated")
29
+ when Option::CLEAR_MODE
30
+ with_concurrent_modification_handling do
31
+ clear_tasks
32
+ end
33
+ Logger.instance.log("write", "scheduled tasks cleared")
34
+ when Option::LIST_MODE
35
+ list_tasks
36
+ Logger.instance.message("Above is your scheduled tasks.")
37
+ when Option::PRINT_VERSION_MODE
38
+ print_version
39
+ end
40
+
41
+ SUCCESS_EXIT_CODE
42
+ rescue Aws::Errors::MissingRegionError
43
+ Logger.instance.fail("missing region error occurred; please use `--region` option or export `AWS_REGION` environment variable.")
44
+ ERROR_EXIT_CODE
45
+ rescue Aws::Errors::MissingCredentialsError
46
+ Logger.instance.fail("missing credential error occurred; please specify it with arguments, use shared credentials, or export `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variable")
47
+ ERROR_EXIT_CODE
48
+ rescue OptionParser::MissingArgument,
49
+ Option::InvalidOptionException => e
50
+
51
+ Logger.instance.fail(e.message)
52
+ ERROR_EXIT_CODE
53
+ end
54
+
55
+ private
56
+
57
+ def update_eb_schedules
58
+ schedule = Schedule.new(option.schedule_file, option.verbose, option.variables)
59
+ scheduler = EventBridgeScheduler.new(option.scheduler_client, schedule.timezone)
60
+ scheduler.create_schedule_group(option.scheduler_group)
61
+ scheduler.clean_up_schedules(option.scheduler_group)
62
+
63
+ lambda_arn = TargetLambda.fetch_arn(option.lambda_name, option.lambda_client)
64
+ schedule.tasks.map do |task|
65
+ target = TargetLambda.new(arn: lambda_arn, task: task)
66
+
67
+ scheduler.create_schedule(target, option)
68
+ end
69
+ end
70
+
71
+ def clear_tasks
72
+ scheduler = EventBridgeScheduler.new(option.scheduler_client)
73
+ scheduler.clean_up_schedules(option.scheduler_group)
74
+ end
75
+
76
+ def list_tasks
77
+ scheduler = EventBridgeScheduler.new(option.scheduler_client)
78
+ scheduler.list_schedules(option.scheduler_group)
79
+ end
80
+
81
+ def print_version
82
+ puts "Lambda Whenever v#{LambdaWhenever::VERSION}"
83
+ end
84
+
85
+ def print_tasks
86
+ schedule = Schedule.new(option.schedule_file, option.verbose, option.variables)
87
+ schedule.print_tasks
88
+ end
89
+
90
+ def with_concurrent_modification_handling
91
+ Retryable.retryable(
92
+ tries: 5,
93
+ on: Aws::Scheduler::Errors::ConflictException,
94
+ sleep: ->(_n) { rand(1..10) }
95
+ ) do |retries, _exn|
96
+ Logger.instance.warn("concurrent modification detected; Retrying...") if retries.positive?
97
+ yield
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ # The EventBridgeScheduler class is responsible for managing schedules in AWS EventBridge.
5
+ class EventBridgeScheduler
6
+ attr_reader :timezone
7
+
8
+ def initialize(client, timezone = "UTC")
9
+ @scheduler_client = client
10
+ @timezone = timezone
11
+ end
12
+
13
+ def list_schedules(group_name)
14
+ Logger.instance.message("Schedules in group '#{group_name}':")
15
+ response = @scheduler_client.list_schedules({ group_name: group_name })
16
+ response.schedules.each do |schedule|
17
+ detail = @scheduler_client.get_schedule({ group_name: group_name, name: schedule.name })
18
+ puts "#{schedule.state} #{schedule.name} #{detail.schedule_expression} #{detail.description}"
19
+ end
20
+ end
21
+
22
+ def create_schedule_group(group_name)
23
+ @scheduler_client.create_schedule_group({ name: group_name })
24
+ puts "Schedule group '#{group_name}' created."
25
+ rescue Aws::Scheduler::Errors::ConflictException
26
+ puts "Schedule group '#{group_name}' already exists."
27
+ end
28
+
29
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Scheduler/Client.html#create_schedule-instance_method
30
+ def create_schedule(target, option)
31
+ task = target.task
32
+ @scheduler_client.create_schedule({
33
+ name: schedule_name(task, option),
34
+ schedule_expression: task.expression,
35
+ schedule_expression_timezone: timezone,
36
+ flexible_time_window: {
37
+ maximum_window_in_minutes: 5,
38
+ mode: "FLEXIBLE"
39
+ },
40
+ target: {
41
+ arn: target.arn,
42
+ role_arn: IamRole.new(option).arn,
43
+ input: target.input
44
+ },
45
+ group_name: option.scheduler_group,
46
+ state: option.rule_state,
47
+ description: schedule_description(task)
48
+ })
49
+ end
50
+
51
+ def schedule_name(task, option)
52
+ Digest::SHA1.hexdigest([option.key, task.expression, *task.commands].join("-")).to_s[0, 64]
53
+ end
54
+
55
+ def schedule_description(task)
56
+ task.commands.to_s
57
+ end
58
+
59
+ def clean_up_schedules(schedule_group)
60
+ response = @scheduler_client.list_schedules({ group_name: schedule_group })
61
+ response.schedules.each do |schedule|
62
+ @scheduler_client.delete_schedule({
63
+ name: schedule.name,
64
+ group_name: schedule_group
65
+ })
66
+ end
67
+ rescue Aws::Scheduler::Errors::ResourceNotFoundException
68
+ puts "Schedule group '#{schedule_group}' does not exist."
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ # The IamRole class is responsible for interacting with AWS IAM roles.
5
+ class IamRole
6
+ def initialize(option)
7
+ client = option.iam_client
8
+ @resource = Aws::IAM::Resource.new(client: client)
9
+ @role_name = option.iam_role
10
+ @role = resource.role(@role_name)
11
+ end
12
+
13
+ def arn
14
+ role&.arn
15
+ end
16
+
17
+ def exists?
18
+ !!arn
19
+ rescue Aws::IAM::Errors::NoSuchEntity
20
+ false
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :resource, :role
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ class Logger
5
+ include Singleton
6
+
7
+ def fail(message)
8
+ Kernel.warn "[fail] #{message}"
9
+ end
10
+
11
+ def warn(message)
12
+ Kernel.warn "[warn] #{message}"
13
+ end
14
+
15
+ def log(event, message)
16
+ puts "[#{event}] #{message}"
17
+ end
18
+
19
+ def message(message)
20
+ puts "## [message] #{message}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ # The Option class handles parsing and validation of command-line options.
5
+ class Option
6
+ POSSIBLE_RULE_STATES = %w[ENABLED DISABLED].freeze
7
+
8
+ DRYRUN_MODE = 1
9
+ UPDATE_MODE = 2
10
+ CLEAR_MODE = 3
11
+ LIST_MODE = 4
12
+ PRINT_VERSION_MODE = 5
13
+
14
+ attr_reader :mode, :verbose, :variables, :schedule_file, :iam_role, :rule_state,
15
+ :lambda_name, :scheduler_group
16
+
17
+ class InvalidOptionException < StandardError; end
18
+
19
+ def initialize(args)
20
+ @mode = DRYRUN_MODE
21
+ @verbose = false
22
+ @variables = []
23
+ @schedule_file = "config/schedule.rb"
24
+ @iam_role = nil
25
+ @rule_state = "ENABLED"
26
+ @lambda_name = nil
27
+ @scheduler_group = "lambda-whenever-dev-group"
28
+ @region = nil
29
+
30
+ OptionParser.new do |opts|
31
+ opts.on("--dryrun", "dry-run") do
32
+ @mode = DRYRUN_MODE
33
+ end
34
+ opts.on("--update", "Creates and deletes tasks as needed by schedule file") do
35
+ @mode = UPDATE_MODE
36
+ end
37
+ opts.on("-c", "--clear", "Clear scheduled tasks") do
38
+ @mode = CLEAR_MODE
39
+ end
40
+ opts.on("-l", "--list", "List scheduled tasks") do
41
+ @mode = LIST_MODE
42
+ end
43
+ opts.on("-v", "--version", "Print version") do
44
+ @mode = PRINT_VERSION_MODE
45
+ end
46
+ opts.on("-s", "--set variables", "Example: --set 'environment=staging'") do |set|
47
+ pairs = set.split("&")
48
+ pairs.each do |pair|
49
+ unless pair.include?("=")
50
+ Logger.instance.warn("Ignore variable set: #{pair}")
51
+ next
52
+ end
53
+ key, value = pair.split("=")
54
+ @variables << { key: key, value: value }
55
+ end
56
+ end
57
+ opts.on("--lambda-name name", "Lambda function name") do |name|
58
+ @lambda_name = name
59
+ end
60
+ opts.on("--scheduler-group group_name",
61
+ "Optionally specify event bridge scheduler group name") do |group|
62
+ @scheduler_group = group
63
+ end
64
+ opts.on("-f", "--file schedule_file", "Default: config/schedule.rb") do |file|
65
+ @schedule_file = file
66
+ end
67
+ opts.on("--iam-role name", "IAM role name used by EventBridgeScheduler.") do |role|
68
+ @iam_role = role
69
+ end
70
+ opts.on("--rule-state state", "The state of the EventBridgeScheduler Rule. Default: ENABLED") do |state|
71
+ @rule_state = state
72
+ end
73
+ opts.on("--region region", "AWS region") do |region|
74
+ @region = region
75
+ end
76
+ opts.on("-V", "--verbose", "Run rake jobs without --silent") do
77
+ @verbose = true
78
+ end
79
+ end.parse(args)
80
+ end
81
+
82
+ def validate!
83
+ raise InvalidOptionException, "Can't find file: #{schedule_file}" unless File.exist?(schedule_file)
84
+ raise InvalidOptionException, "You must set lambda-name" unless lambda_name
85
+ raise InvalidOptionException, "You must set iam-role" unless iam_role
86
+ return if POSSIBLE_RULE_STATES.include?(rule_state)
87
+
88
+ raise InvalidOptionException, "Invalid rule state. Possible values are #{POSSIBLE_RULE_STATES.join(", ")}"
89
+ end
90
+
91
+ def aws_config
92
+ @aws_config ||= { region: region }.delete_if { |_k, v| v.nil? }
93
+ end
94
+
95
+ def iam_client
96
+ @iam_client ||= Aws::IAM::Client.new(aws_config)
97
+ end
98
+
99
+ def lambda_client
100
+ @lambda_client ||= Aws::Lambda::Client.new(aws_config)
101
+ end
102
+
103
+ def scheduler_client
104
+ @scheduler_client ||= Aws::Scheduler::Client.new(aws_config)
105
+ end
106
+
107
+ def key
108
+ Digest::SHA1.hexdigest(
109
+ [
110
+ variables,
111
+ iam_role,
112
+ rule_state,
113
+ lambda_name,
114
+ scheduler_group
115
+ ].join
116
+ )
117
+ end
118
+
119
+ private
120
+
121
+ attr_reader :region
122
+ end
123
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "whenever_numeric"
4
+
5
+ module LambdaWhenever
6
+ class Schedule
7
+ attr_reader :tasks, :chronic_options, :bundle_command, :environment, :timezone
8
+
9
+ class UnsupportedFrequencyException < StandardError; end
10
+
11
+ using WheneverNumeric
12
+
13
+ def initialize(file, verbose, variables)
14
+ @environment = "production"
15
+ @verbose = verbose
16
+ @tasks = []
17
+ @chronic_options = {}
18
+ @bundle_command = "bundle exec"
19
+
20
+ variables.each { |var| set(var[:key], var[:value]) }
21
+ instance_eval(File.read(file), file)
22
+ @timezone ||= "UTC"
23
+ end
24
+
25
+ def set(key, value)
26
+ instance_variable_set("@#{key}", value) unless key == "tasks"
27
+ end
28
+
29
+ def every(frequency, options = {}, &block)
30
+ expressions = schedule_expressions(frequency, options)
31
+ tasks = expressions.map do |expression|
32
+ Task.new(@environment, @verbose, @bundle_command, expression).tap do |task|
33
+ task.instance_eval(&block)
34
+ end
35
+ end
36
+ @tasks.concat tasks
37
+ rescue UnsupportedFrequencyException => e
38
+ Logger.instance.warn(e.message)
39
+ end
40
+
41
+ def schedule_expressions(frequency, options)
42
+ tmp_expression = expression_by_frequency(frequency, options)
43
+ return ["cron(#{tmp_expression.join(" ")})"] unless options[:at].is_a?(Array)
44
+
45
+ times = options[:at]
46
+ grouped_times = times.group_by { |time| time[/\d\d?:(\d\d?)/, 1] }
47
+
48
+ grouped_times.map do |minute, hour_list|
49
+ hours = hour_list.map { |time| time[/^\d{1,2}/] }.join(",")
50
+ _, __, *rest = tmp_expression
51
+ exp = [minute, hours, *rest]
52
+ "cron(#{exp.join(" ")})"
53
+ end
54
+ end
55
+
56
+ # index minutes: 0, hours: 1, day_of_month: 2, month: 3, day_of_week: 4, year: 5
57
+ def expression_by_frequency(frequency, options)
58
+ opts = { now: Time.new(2017, 1, 1, 0, 0, 0) }.merge(@chronic_options)
59
+ time = Chronic.parse(options[:at], opts) || Time.new(2017, 1, 1, 0, 0, 0)
60
+
61
+ case frequency
62
+ when 1.minute
63
+ ["*", "*", "*", "*", "?", "*"]
64
+ when :hour, 1.hour
65
+ [time.min.to_s, "*", "*", "*", "?", "*"]
66
+ when :day, 1.day
67
+ [time.min.to_s, time.hour.to_s, "*", "*", "?", "*"]
68
+ when :month, 1.month
69
+ [time.min.to_s, time.hour.to_s, time.day, "*", "?", "*"]
70
+ when :year, 1.year
71
+ [time.min.to_s, time.hour.to_s, time.day, time.month, "?", "*"]
72
+ when :sunday
73
+ [time.min.to_s, time.hour.to_s, "?", "*", "SUN", "*"]
74
+ when :monday
75
+ [time.min.to_s, time.hour.to_s, "?", "*", "MON", "*"]
76
+ when :tuesday
77
+ [time.min.to_s, time.hour.to_s, "?", "*", "TUE", "*"]
78
+ when :wednesday
79
+ [time.min.to_s, time.hour.to_s, "?", "*", "WED", "*"]
80
+ when :thursday
81
+ [time.min.to_s, time.hour.to_s, "?", "*", "THU", "*"]
82
+ when :friday
83
+ [time.min.to_s, time.hour.to_s, "?", "*", "FRI", "*"]
84
+ when :saturday
85
+ [time.min.to_s, time.hour.to_s, "?", "*", "SAT", "*"]
86
+ when :weekend
87
+ [time.min.to_s, time.hour.to_s, "?", "*", "SUN,SAT", "*"]
88
+ when :weekday
89
+ [time.min.to_s, time.hour.to_s, "?", "*", "MON-FRI", "*"]
90
+ when 1.second...1.minute
91
+ raise UnsupportedFrequencyException, "Time must be in minutes or higher. Ignore this task."
92
+ when 1.minute...1.hour
93
+ step = (frequency / 60).round
94
+ min = []
95
+ ((60 % step).zero? ? 0 : step).step(59, step) { |i| min << i }
96
+ [min.join(","), "*", "*", "*", "?", "*"]
97
+ when 1.hour...1.day
98
+ step = (frequency / 60 / 60).round
99
+ hour = []
100
+ ((24 % step).zero? ? 0 : step).step(23, step) { |i| hour << i }
101
+ [time.min.to_s, hour.join(","), "*", "*", "?", "*"]
102
+ when 1.day...1.month
103
+ step = (frequency / 24 / 60 / 60).round
104
+ day = []
105
+ (step <= 16 ? 1 : step).step(30, step) { |i| day << i }
106
+ [time.min.to_s, time.hour.to_s, day.join(","), "*", "?", "*"]
107
+ when 1.month...12.months
108
+ step = (frequency / 30 / 24 / 60 / 60).round
109
+ month = []
110
+ (step <= 6 ? 1 : step).step(12, step) { |i| month << i }
111
+ [time.min.to_s, time.hour.to_s, time.day, month.join(","), "?", "*"]
112
+ when 12.months...Float::INFINITY
113
+ raise UnsupportedFrequencyException, "Time must be in months or lower. Ignore this task."
114
+ when %r{^((\*?[\d/,-]*)\s*){5}$}
115
+ min, hour, day, mon, week, year = frequency.split(" ")
116
+ # You can't specify the Day-of-month and Day-of-week fields in the same Cron expression.
117
+ # If you specify a value in one of the fields, you must use a ? (question mark) in the other.
118
+ week.gsub!("*", "?") if day != "?"
119
+ day.gsub!("*", "?") if week != "?"
120
+ # cron syntax: sunday -> 0
121
+ # scheduled expression: sunday -> 1
122
+ week.gsub!(/(\d)/) { |match| Integer(match) + 1 }
123
+ year ||= "*"
124
+ [min, hour, day, mon, week, year]
125
+ when %r{^((\*?\??L?W?[\d/,-]*)\s*){6}$}
126
+ frequency.split(" ")
127
+ else
128
+ raise UnsupportedFrequencyException, "`#{frequency}` is not supported option. Ignore this task."
129
+ end
130
+ end
131
+
132
+ def print_tasks
133
+ @tasks.each do |task|
134
+ puts "#{task.expression} { commands: #{task.commands} }"
135
+ end
136
+ end
137
+
138
+ def method_missing(name, *_args)
139
+ Logger.instance.warn("Skipping unsupported method: #{name}")
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ # The TargetLambda class represents a Lambda function as a target for scheduling.
5
+ class TargetLambda
6
+ attr_reader :role_arn, :arn, :input, :name, :task
7
+
8
+ class << self
9
+ def fetch_arn(function_name, client)
10
+ response = client.get_function({
11
+ function_name: function_name
12
+ })
13
+ response.configuration.function_arn
14
+ end
15
+ end
16
+
17
+ def initialize(arn:, task:)
18
+ @arn = arn
19
+ @task = task
20
+ @input = input_json
21
+ end
22
+
23
+ # https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-context-attributes.html
24
+ def input_json
25
+ {
26
+ execution_id: "<aws.scheduler.execution-id>",
27
+ scheduled_time: "<aws.scheduler.scheduled-time>",
28
+ schedule_arn: "<aws.scheduler.schedule-arn>",
29
+ attempt_number: "<aws.scheduler.attempt-number>",
30
+ commands: task.commands
31
+ }.to_json
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ class Task
5
+ attr_reader :commands, :expression
6
+
7
+ def initialize(environment, verbose, bundle_command, expression)
8
+ @environment = environment
9
+ @verbose_mode = verbose ? nil : "--silent"
10
+ @bundle_command = bundle_command.split(" ")
11
+ @expression = expression
12
+ @commands = []
13
+ end
14
+
15
+ def command(task)
16
+ @commands << task.split(" ")
17
+ end
18
+
19
+ def rake(task)
20
+ @commands << [@bundle_command, "rake", task, @verbose_mode].flatten.compact
21
+ end
22
+
23
+ def runner(src)
24
+ @commands << [@bundle_command, "rails", "runner", "-e", @environment, src].flatten
25
+ end
26
+
27
+ def script(script)
28
+ @commands << [@bundle_command, "script/#{script}"].flatten
29
+ end
30
+
31
+ def method_missing(name, *_args)
32
+ Logger.instance.warn("Skipping unsupported method: #{name}")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaWhenever
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WheneverNumeric
4
+ refine Numeric do
5
+ def seconds
6
+ self
7
+ end
8
+ alias_method :second, :seconds
9
+
10
+ def minutes
11
+ self * 60
12
+ end
13
+ alias_method :minute, :minutes
14
+
15
+ def hours
16
+ (self * 60).minutes
17
+ end
18
+ alias_method :hour, :hours
19
+
20
+ def days
21
+ (self * 24).hours
22
+ end
23
+ alias_method :day, :days
24
+
25
+ def weeks
26
+ (self * 7).days
27
+ end
28
+ alias_method :week, :weeks
29
+
30
+ def months
31
+ (self * 30).days
32
+ end
33
+ alias_method :month, :months
34
+
35
+ def years
36
+ (self * 365.25).days
37
+ end
38
+ alias_method :year, :years
39
+ end
40
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "aws-sdk-iam"
5
+ require "aws-sdk-scheduler"
6
+ require "aws-sdk-lambda"
7
+ require "chronic"
8
+ require "singleton"
9
+ require "json"
10
+ require "retryable"
11
+
12
+ require "lambda_whenever/version"
13
+ require "lambda_whenever/cli"
14
+ require "lambda_whenever/logger"
15
+ require "lambda_whenever/option"
16
+ require "lambda_whenever/schedule"
17
+ require "lambda_whenever/event_bridge_scheduler"
18
+ require "lambda_whenever/task"
19
+ require "lambda_whenever/iam_role"
20
+ require "lambda_whenever/target_lambda"
21
+
22
+ module LambdaWhenever
23
+ class Error < StandardError; end
24
+ # Your code goes here...
25
+ end
@@ -0,0 +1,4 @@
1
+ module LambdaWhenever
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,231 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lambda_whenever
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - toshichanapp
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-09-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.21'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.21'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.21'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.21'
69
+ - !ruby/object:Gem::Dependency
70
+ name: aws-sdk-iam
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: aws-sdk-lambda
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: aws-sdk-scheduler
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: base64
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.2'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.2'
125
+ - !ruby/object:Gem::Dependency
126
+ name: bigdecimal
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '3.1'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '3.1'
139
+ - !ruby/object:Gem::Dependency
140
+ name: chronic
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.10'
146
+ type: :runtime
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.10'
153
+ - !ruby/object:Gem::Dependency
154
+ name: retryable
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '3.0'
160
+ type: :runtime
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '3.0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: rexml
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :runtime
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ description: whenever for Amazon EventBridge Scheduler.
182
+ email:
183
+ - toshichanapp@gmail.com
184
+ executables:
185
+ - lambda_whenever_dev
186
+ extensions: []
187
+ extra_rdoc_files: []
188
+ files:
189
+ - ".rspec"
190
+ - ".rubocop.yml"
191
+ - LICENSE
192
+ - README.md
193
+ - Rakefile
194
+ - exe/lambda_whenever_dev
195
+ - lib/lambda_whenever.rb
196
+ - lib/lambda_whenever/cli.rb
197
+ - lib/lambda_whenever/event_bridge_scheduler.rb
198
+ - lib/lambda_whenever/iam_role.rb
199
+ - lib/lambda_whenever/logger.rb
200
+ - lib/lambda_whenever/option.rb
201
+ - lib/lambda_whenever/schedule.rb
202
+ - lib/lambda_whenever/target_lambda.rb
203
+ - lib/lambda_whenever/task.rb
204
+ - lib/lambda_whenever/version.rb
205
+ - lib/lambda_whenever/whenever_numeric.rb
206
+ - sig/lambda_whenever.rbs
207
+ homepage: https://github.com/toshichanapp/lambda_whenever
208
+ licenses: []
209
+ metadata:
210
+ homepage_uri: https://github.com/toshichanapp/lambda_whenever
211
+ source_code_uri: https://github.com/toshichanapp/lambda_whenever
212
+ post_install_message:
213
+ rdoc_options: []
214
+ require_paths:
215
+ - lib
216
+ required_ruby_version: !ruby/object:Gem::Requirement
217
+ requirements:
218
+ - - ">="
219
+ - !ruby/object:Gem::Version
220
+ version: 3.0.0
221
+ required_rubygems_version: !ruby/object:Gem::Requirement
222
+ requirements:
223
+ - - ">="
224
+ - !ruby/object:Gem::Version
225
+ version: '0'
226
+ requirements: []
227
+ rubygems_version: 3.5.11
228
+ signing_key:
229
+ specification_version: 4
230
+ summary: whenever for Amazon EventBridge Scheduler.
231
+ test_files: []