lambda_whenever 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []