├── .github └── workflows │ └── build.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── elastic_whenever.gemspec ├── exe └── elastic_whenever ├── lib ├── elastic_whenever.rb └── elastic_whenever │ ├── cli.rb │ ├── logger.rb │ ├── option.rb │ ├── schedule.rb │ ├── task.rb │ ├── task │ ├── cluster.rb │ ├── definition.rb │ ├── role.rb │ ├── rule.rb │ └── target.rb │ └── version.rb └── spec ├── cli_spec.rb ├── fixtures ├── schedule.rb └── unsupported_schedule.rb ├── option_spec.rb ├── schedule_spec.rb ├── spec_helper.rb ├── task_spec.rb └── tasks ├── cluster_spec.rb ├── definition_spec.rb ├── role_spec.rb ├── rule_spec.rb └── target_spec.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | jobs: 14 | spec: 15 | name: Ruby ${{ matrix.ruby_version }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | ruby_version: ['3.2', '3.3', '3.4'] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby_version }} 25 | bundler-cache: true 26 | - run: bundle exec rake 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.0.1 (2024-01-27) 2 | 3 | ### Bug Fixes 4 | 5 | - [#70](https://github.com/wata727/elastic_whenever/pull/70): Fix week converion issue in cron syntax 6 | 7 | ## v1.0.0 (2024-01-07) 8 | 9 | Although this is a major version release, there are no notable incompatibilities. This means that the current behavior is stable enough that no major future changes are planned at this time. 10 | 11 | ### Enhancements 12 | 13 | - [#69](https://github.com/wata727/elastic_whenever/pull/69): Improve profile support ([@mfittko](https://github.com/mfittko)) 14 | - Previously, a profile passed with the `--profile` flag was always used only for shared credentials, and could not be used to switch profiles for credentials issued by IAM Identity Center, etc. 15 | - With this change, the passed profile will be used correctly to other credentials as well. 16 | 17 | ### Chores 18 | 19 | - [#60](https://github.com/wata727/elastic_whenever/pull/60) [#64](https://github.com/wata727/elastic_whenever/pull/64) [#68](https://github.com/wata727/elastic_whenever/pull/68): CI against Ruby 3.1, 3.2, 3.3 20 | - [#62](https://github.com/wata727/elastic_whenever/pull/62): fix small typo ([@kijimaD](https://github.com/kijimaD)) 21 | - [#65](https://github.com/wata727/elastic_whenever/pull/65): fix/typos-documentation ([@jotolo](https://github.com/jotolo)) 22 | 23 | ## v0.7.0 (2021-09-25) 24 | 25 | This release contains a major change to the behavior when updating tasks. In most cases, this change has no effect, but be aware of the change in behavior when omitting a revision of the task definition. In particular, if you are building a deployment workflow where the update timing of task definitions and the update timing of scheduled tasks are different, the revisions that are executed may be different. 26 | 27 | ### Breaking Changes 28 | 29 | - [#57](https://github.com/wata727/elastic_whenever/pull/57): Selective Updates ([@HistoireDeBabar](https://github.com/HistoireDeBabar)) 30 | - Previously, Elastic Whenever recreates all scheduled tasks after deleting all tasks when updating tasks. However, in this case, there is a risk that frequently invoked tasks will not be executed, so this change now creates/deletes only those tasks that have changed. 31 | - Due to this change, the naming convention for scheduled tasks has changed. When updating from v0.6, all rules will be deleted and recreated due to different naming conventions, and the behavior will be the same as before. After that, the task will not change if the names are the same. 32 | - The breaking change is that the behavior when omitting a revision of a task definition has changed. Previously, when you created a task, you resolved the latest revision at that time, so even if the task definition was updated, the revision that was executed was always the same. In v0.7 and later, revisions are not resolved when you created a task, so the latest revision is always adopted. 33 | 34 | ### Chores 35 | 36 | - [#55](https://github.com/wata727/elastic_whenever/pull/55): CI against Ruby 3.0 37 | - [#56](https://github.com/wata727/elastic_whenever/pull/56): Add the rexml dependency explictly 38 | - [#58](https://github.com/wata727/elastic_whenever/pull/58): Fix typo 39 | 40 | ## v0.6.1 (2020-11-08) 41 | 42 | ### BugFixes 43 | 44 | - [#51](https://github.com/wata727/elastic_whenever/pull/51): Avoid hitting rate limits fetching credentials when running under ECS or with an IAM profile ([@stevenwilliamson](https://github.com/stevenwilliamson)) 45 | 46 | ### Chores 47 | 48 | - [#53](https://github.com/wata727/elastic_whenever/pull/53): Migrate CI to GitHub Actions from Travis CI 49 | 50 | ## v0.6.0 (2019-10-16) 51 | 52 | ### Enhancements 53 | 54 | - [#46](https://github.com/wata727/elastic_whenever/pull/46): Add option to disable rule ([@tobscher](https://github.com/tobscher)) 55 | 56 | ## v0.5.1 (2019-10-09) 57 | 58 | ### Enhancements 59 | 60 | - [#45](https://github.com/wata727/elastic_whenever/pull/45): Add description to CloudWatch rule ([@tobscher](https://github.com/tobscher)) 61 | 62 | ## v0.5.0 (2019-09-19) 63 | 64 | ### Enhancements 65 | 66 | - [#44](https://github.com/wata727/elastic_whenever/pull/44): Make CloudWatch Events IAM role name configurable ([@domcleal](https://github.com/domcleal)) 67 | 68 | ## v0.4.2 (2019-09-17) 69 | 70 | ### BugFixes 71 | 72 | - [#43](https://github.com/wata727/elastic_whenever/pull/43): Add expression to task rule name hash computation ([@korbin](https://github.com/korbin)) 73 | 74 | ### Chore 75 | 76 | - [#42](https://github.com/wata727/elastic_whenever/pull/42): Fix typo in clear task log line ([@HistoireDeBabar](https://github.com/HistoireDeBabar)) 77 | 78 | ## v0.4.1 (2019-07-23) 79 | 80 | ### BugFixes 81 | 82 | - Retry for concurrent modification ([#41](https://github.com/wata727/elastic_whenever/pull/41)) 83 | 84 | ### Chore 85 | 86 | - CI against Ruby 2.6 ([#40](https://github.com/wata727/elastic_whenever/pull/40)) 87 | 88 | ## v0.4.0 (2018-12-19) 89 | 90 | Elastic Whenever now supports Fargate launch type. Thanks @avinson. 91 | 92 | From this release, ECS parameters must be passed as arguments. Previously, it supported schedule file variables, but it will be ignored. 93 | 94 | ``` 95 | # Before 96 | $ elastic_whenever --set 'cluster=ecs-test&task_definition=oneoff-application:2&container=oneoff' 97 | 98 | # After 99 | $ elastic_whenever --cluster ecs-test --task-definition oneoff-application:2 --container oneoff 100 | ``` 101 | 102 | ### Enhancements 103 | 104 | - update elastic_whenever for FARGATE launch type ([#34](https://github.com/wata727/elastic_whenever/pull/34)) 105 | 106 | ### Changes 107 | 108 | - Bump aws-sdk-cloudwatchevents dependency ([#36](https://github.com/wata727/elastic_whenever/pull/36)) 109 | - Pass ECS params as an argument ([#37](https://github.com/wata727/elastic_whenever/pull/37)) 110 | 111 | ### Chore 112 | 113 | - CI against Ruby 2.4.5 and 2.5.3 ([#35](https://github.com/wata727/elastic_whenever/pull/35)) 114 | - Set nil as verbose mode ([#38](https://github.com/wata727/elastic_whenever/pull/38)) 115 | - Revise task's target ([#39](https://github.com/wata727/elastic_whenever/pull/39)) 116 | 117 | ## v0.3.2 (2018-06-25) 118 | 119 | ### BugFix 120 | 121 | - fix: `Task::Role#exists?` always return true ([#33](https://github.com/wata727/elastic_whenever/pull/33)) 122 | 123 | ## v0.3.1 (2018-06-25) 124 | 125 | ### BugFix 126 | 127 | - add `attr_reader :enviroment` ([#32](https://github.com/wata727/elastic_whenever/pull/32)) 128 | 129 | ### Others 130 | 131 | - CI against Ruby 2.5 ([#30](https://github.com/wata727/elastic_whenever/pull/30)) 132 | - Use `File.exist?` instead of `File.exists?` ([#31](https://github.com/wata727/elastic_whenever/pull/31)) 133 | 134 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in elastic_whenever.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Kazuma Watanabe 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elastic Whenever 2 | [![Build Status](https://github.com/wata727/elastic_whenever/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/wata727/elastic_whenever/actions) 3 | [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE.txt) 4 | [![Gem Version](https://badge.fury.io/rb/elastic_whenever.svg)](https://badge.fury.io/rb/elastic_whenever) 5 | 6 | Manage ECS scheduled tasks like [Whenever](https://github.com/javan/whenever) gem. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'elastic_whenever' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install elastic_whenever 23 | 24 | ## Usage 25 | 26 | You can use it almost like Whenever. However, please be aware that you must specify an identifier. 27 | 28 | ``` 29 | $ elastic_whenever --help 30 | Usage: elastic_whenever [options] 31 | -i, --update identifier Clear and create scheduled tasks by schedule file 32 | -c, --clear identifier Clear scheduled tasks 33 | -l, --list identifier List scheduled tasks 34 | -s, --set variables Example: --set 'environment=staging' 35 | --cluster cluster ECS cluster to run tasks 36 | --task-definition task_definition 37 | Task definition name, If omit a revision, use the latest revision of the family automatically. Example: --task-definition oneoff-application:2 38 | --container container Container name defined in the task definition 39 | --launch-type launch_type Launch type. EC2 or FARGATE. Default: EC2 40 | --assign-public-ip Assign a public IP. Default: DISABLED (FARGATE only) 41 | --security-groups groups Example: --security-groups 'sg-2c503655,sg-72f0cb0a' (FARGATE only) 42 | --subnets subnets Example: --subnets 'subnet-4973d63f,subnet-45827d1d' (FARGATE only) 43 | --platform-version version Optionally specify the platform version. Default: LATEST (FARGATE only) 44 | -f, --file schedule_file Default: config/schedule.rb 45 | --iam-role name IAM role name used by CloudWatch Events. Default: ecsEventsRole 46 | --rule-state state The state of the CloudWatch Events Rule (ENABLED or DISABLED), default: ENABLED 47 | --profile profile_name AWS shared profile name 48 | --access-key aws_access_key_id 49 | AWS access key ID 50 | --secret-key aws_secret_access_key 51 | AWS secret access key 52 | --region region AWS region 53 | -v, --version Print version 54 | -V, --verbose Run rake jobs without --silent 55 | ``` 56 | 57 | NOTE: Currently, Elastic Whenever supports the Whenever syntax partially. We strongly encourage to use dry-run mode for verifying tasks to be created. 58 | 59 | ``` 60 | $ elastic_whenever --cluster ecs-test --task-definition example:2 --container cron 61 | cron(0 3 * * ? *) ecs-test example:2 cron bundle exec rake hoge:run 62 | 63 | ## [message] Above is your schedule file converted to scheduled tasks; your scheduled tasks was not updated. 64 | ## [message] Run `elastic_whenever --help' for more options. 65 | ``` 66 | 67 | ### Setting variables 68 | Elastic Whenever supports setting variables via the `--set` option [as Whenever does](https://github.com/javan/whenever/wiki/Setting-variables-on-the-fly). 69 | 70 | Example: 71 | 72 | `elastic_whenever --set 'environment=staging&some_var=foo'` 73 | 74 | ```ruby 75 | if @environment == 'staging' 76 | every '0 1 * * *' do 77 | rake 'some_task_on_staging' 78 | end 79 | elsif @some_var == 'foo' 80 | every '0 10 * * *' do 81 | rake 'some_task' 82 | end 83 | end 84 | ``` 85 | 86 | Especially, `@environment` defaults to `"production"`. 87 | 88 | ## How it works 89 | Elastic Whenever creates CloudWatch Events for every command. Each rule has a one to one mapping to a target. 90 | for example, the following input will generate two Rules each with one Target. 91 | 92 | ```ruby 93 | every '0 0 * * *' do 94 | rake "hoge:run" 95 | command "awesome" 96 | end 97 | ``` 98 | 99 | The scheduled task's name is a digest value calculated from an identifier, commands, and so on. 100 | 101 | NOTE: You should not use the same identifier across different clusters because CloudWatch Events rule names are unique across all clusters. 102 | 103 | ## Compatibility with Whenever 104 | ### `job_type` 105 | Whenever supports custom job type with `job_type` method, but Elastic Whenever doesn't support it. 106 | 107 | ```ruby 108 | # [warn] Skipping unsupported method: job_type 109 | job_type :awesome, '/usr/local/bin/awesome :task :fun_level' 110 | ``` 111 | 112 | ### `env` 113 | Whenever supports environment variables with `env` method, but Elastic Whenever doesn't support it. 114 | You should use task definitions to set environment variables. 115 | 116 | ```ruby 117 | # [warn] Skipping unsupported method: env 118 | env "VERSION", "v1" 119 | ``` 120 | 121 | ### `:job_template` 122 | Whenever has a template to describe as cron, but Elastic Whenever doesn't have the template. 123 | Therefore, `:job_template` option is ignored. 124 | 125 | ```ruby 126 | set :job_template, "/bin/zsh -l -c ':job'" # ignored 127 | ``` 128 | 129 | ### Frequency 130 | Elastic Whenever processes frequency passed to `every` block almost like Whenever. 131 | 132 | ```ruby 133 | # Whenever 134 | # 0 15 * * * /bin/bash -l -c 'cd /home/user/app && RAILS_ENV=production bundle exec rake hoge:run --silent' 135 | # 136 | # Elastic Whenever 137 | # cron(0 15 * * ? *) ecs-test myapp:2 web bundle exec rake hoge:run --silent 138 | # 139 | every :day, at: "3:00" do 140 | rake "hoge:run" 141 | end 142 | 143 | # Whenever 144 | # 0,10,20,30,40,50 * * * * /bin/bash -l -c 'awesome' 145 | # 146 | # Elastic Whenever 147 | # cron(0,10,20,30,40,50 * * * ? *) ecs-test myapp:2 web awesome 148 | # 149 | every 10.minutes do 150 | command "awesome" 151 | end 152 | ``` 153 | 154 | However, handling of the day of week is partially different because it follows scheduled expression. 155 | 156 | ```ruby 157 | # Whenever 158 | # 0 0 * * 1 /bin/bash -l -c 'awesome' 159 | # 160 | # Elastic Whenever 161 | # cron(0 0 ? * 2 *) ecs-test myapp:2 web awesome 162 | # 163 | every :monday do 164 | command "awesome" 165 | end 166 | ``` 167 | 168 | Therefore, cron syntax is converted to scheduled expression like the following: 169 | 170 | ```ruby 171 | # cron(0 0 ? * 2 *) ecs-test myapp:2 web awesome 172 | every "0 0 * * 1" do 173 | command "awesome" 174 | end 175 | ``` 176 | 177 | Absolutely, you can also write scheduled expression. 178 | 179 | ```ruby 180 | # cron(0 0 ? * 2 *) ecs-test myapp:2 web awesome 181 | every "0 0 ? * 2 *" do 182 | command "awesome" 183 | end 184 | ``` 185 | 186 | #### `:reboot` 187 | Whenever supports `:reboot` as a cron option, but Elastic Whenever doesn't support it. 188 | 189 | ```ruby 190 | # [warn] `reboot` is not supported option. Ignore this task. 191 | every :reboot do 192 | rake "hoge:run" 193 | end 194 | ``` 195 | 196 | ### Bundle commands 197 | Whenever checks if the application uses bundler and automatically adds a prefix to commands. 198 | However, Elastic Whenever always adds a prefix on a premise that the application is using bundler. 199 | 200 | ```ruby 201 | # Whenever 202 | # With bundler -> bundle exec rake hoge:run 203 | # Without bundler -> rake hoge:run 204 | # 205 | # Elastic Whenever 206 | # bundle exec rake hoge:run 207 | # 208 | rake "hoge:run" 209 | ``` 210 | 211 | If you don't want to add the prefix, set `bundle_command` to empty as follows: 212 | 213 | ```ruby 214 | set :bundle_command, "" 215 | ``` 216 | 217 | ### Rails 218 | Whenever supports `runner` job with old Rails versions, but Elastic Whenever supports Rails 4 and above only. 219 | 220 | ```ruby 221 | # Whenever 222 | # Before them -> script/runner Hoge.run 223 | # Rails 3 -> script/rails runner Hoge.run 224 | # Rails 4 -> bin/rails runner Hoge.run 225 | # 226 | # Elastic Whenever 227 | # bin/rails runner Hoge.run 228 | # 229 | runner "Hoge.run" 230 | ``` 231 | 232 | ## Development 233 | 234 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 235 | 236 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 237 | 238 | ## Contributing 239 | 240 | Bug reports and pull requests are welcome on GitHub at https://github.com/wata727/elastic_whenever. 241 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "elastic_whenever" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /elastic_whenever.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "elastic_whenever/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "elastic_whenever" 8 | spec.version = ElasticWhenever::VERSION 9 | spec.authors = ["Kazuma Watanabe"] 10 | spec.email = ["watassbass@gmail.com"] 11 | 12 | spec.summary = %q{Manage ECS Scheduled Tasks like Whenever} 13 | spec.description = %q{Manage ECS Scheduled Tasks like Whenever} 14 | spec.homepage = "https://github.com/wata727/elastic_whenever" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.bindir = "exe" 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ["lib"] 22 | 23 | spec.add_development_dependency "bundler", "~> 2.0" 24 | spec.add_development_dependency "rake", "~> 13.0" 25 | spec.add_development_dependency "rspec", "~> 3.0" 26 | 27 | spec.add_dependency "aws-sdk-ecs", "~> 1.0" 28 | spec.add_dependency "aws-sdk-cloudwatchevents", "~> 1.5" 29 | spec.add_dependency "aws-sdk-iam", "~> 1.0" 30 | spec.add_dependency "chronic", "~> 0.10" 31 | spec.add_dependency "retryable", "~> 3.0" 32 | spec.add_dependency "rexml", ">= 0" 33 | end 34 | -------------------------------------------------------------------------------- /exe/elastic_whenever: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "elastic_whenever" 4 | 5 | exit ElasticWhenever::CLI.new(ARGV).run 6 | -------------------------------------------------------------------------------- /lib/elastic_whenever.rb: -------------------------------------------------------------------------------- 1 | require "optparse" 2 | require "aws-sdk-ecs" 3 | require "aws-sdk-cloudwatchevents" 4 | require "aws-sdk-iam" 5 | require "chronic" 6 | require "singleton" 7 | require "json" 8 | require "retryable" 9 | 10 | require "elastic_whenever/version" 11 | require "elastic_whenever/cli" 12 | require "elastic_whenever/logger" 13 | require "elastic_whenever/option" 14 | require "elastic_whenever/schedule" 15 | require "elastic_whenever/task" 16 | require "elastic_whenever/task/cluster" 17 | require "elastic_whenever/task/definition" 18 | require "elastic_whenever/task/role" 19 | require "elastic_whenever/task/rule" 20 | require "elastic_whenever/task/target" 21 | -------------------------------------------------------------------------------- /lib/elastic_whenever/cli.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class CLI 3 | SUCCESS_EXIT_CODE = 0 4 | ERROR_EXIT_CODE = 1 5 | 6 | attr_reader :args, :option 7 | 8 | def initialize(args) 9 | @args = args 10 | @option = Option.new(args) 11 | end 12 | 13 | def run 14 | case option.mode 15 | when Option::DRYRUN_MODE 16 | option.validate! 17 | update_tasks(dry_run: true) 18 | Logger.instance.message("Above is your schedule file converted to scheduled tasks; your scheduled tasks was not updated.") 19 | Logger.instance.message("Run `elastic_whenever --help' for more options.") 20 | when Option::UPDATE_MODE 21 | option.validate! 22 | with_concurrent_modification_handling do 23 | update_tasks(dry_run: false) 24 | end 25 | Logger.instance.log("write", "scheduled tasks updated") 26 | when Option::CLEAR_MODE 27 | with_concurrent_modification_handling do 28 | clear_tasks 29 | end 30 | Logger.instance.log("write", "scheduled tasks cleared") 31 | when Option::LIST_MODE 32 | list_tasks 33 | Logger.instance.message("Above is your scheduled tasks.") 34 | Logger.instance.message("Run `elastic_whenever --help` for more options.") 35 | when Option::PRINT_VERSION_MODE 36 | print_version 37 | end 38 | 39 | SUCCESS_EXIT_CODE 40 | rescue Aws::Errors::MissingRegionError 41 | Logger.instance.fail("missing region error occurred; please use `--region` option or export `AWS_REGION` environment variable.") 42 | ERROR_EXIT_CODE 43 | rescue Aws::Errors::MissingCredentialsError => e 44 | 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") 45 | ERROR_EXIT_CODE 46 | rescue OptionParser::MissingArgument, 47 | Option::InvalidOptionException, 48 | Task::Target::InvalidContainerException => exn 49 | 50 | Logger.instance.fail(exn.message) 51 | ERROR_EXIT_CODE 52 | end 53 | 54 | private 55 | 56 | def update_tasks(dry_run:) 57 | schedule = Schedule.new(option.schedule_file, option.verbose, option.variables) 58 | 59 | cluster = Task::Cluster.new(option, option.cluster) 60 | definition = Task::Definition.new(option, option.task_definition) 61 | role = Task::Role.new(option) 62 | if !role.exists? && !dry_run 63 | role.create 64 | end 65 | 66 | targets = schedule.tasks.map do |task| 67 | task.commands.map do |command| 68 | Task::Target.new( 69 | option, 70 | cluster: cluster, 71 | definition: definition, 72 | container: option.container, 73 | commands: command, 74 | rule: Task::Rule.convert(option, task.expression, command), 75 | role: role, 76 | ) 77 | end 78 | end.flatten 79 | 80 | if dry_run 81 | print_task(targets) 82 | else 83 | create_missing_rules_from_targets(targets) 84 | delete_unused_rules_from_targets(targets) 85 | end 86 | end 87 | 88 | def remote_rules 89 | Task::Rule.fetch(option) 90 | end 91 | 92 | # Creates a rule but only persists the rule remotely if it does not exist 93 | def create_missing_rules_from_targets(targets) 94 | cached_remote_rules = remote_rules 95 | targets.each do |target| 96 | exists = cached_remote_rules.any? do |remote_rule| 97 | target.rule.name == remote_rule.name 98 | end 99 | 100 | unless exists 101 | target.rule.create 102 | target.create 103 | end 104 | end 105 | end 106 | 107 | def delete_unused_rules_from_targets(targets) 108 | remote_rules.each do |remote_rule| 109 | rule_exists_in_schedule = targets.any? do |target| 110 | target.rule.name == remote_rule.name 111 | end 112 | 113 | remote_rule.delete unless rule_exists_in_schedule 114 | end 115 | end 116 | 117 | def clear_tasks 118 | Task::Rule.fetch(option).each(&:delete) 119 | end 120 | 121 | def list_tasks 122 | Task::Rule.fetch(option).each do |rule| 123 | targets = Task::Target.fetch(option, rule) 124 | print_task(targets) 125 | end 126 | end 127 | 128 | def print_version 129 | puts "Elastic Whenever v#{ElasticWhenever::VERSION}" 130 | end 131 | 132 | def print_task(targets) 133 | targets.each do |target| 134 | puts "#{target.rule.expression} #{target.cluster.name} #{target.definition.name} #{target.container} #{target.commands.join(" ")}" 135 | puts 136 | end 137 | end 138 | 139 | def with_concurrent_modification_handling 140 | Retryable.retryable( 141 | tries: 5, 142 | on: Aws::CloudWatchEvents::Errors::ConcurrentModificationException, 143 | sleep: lambda { |_n| rand(1..10) }, 144 | ) do |retries, exn| 145 | if retries > 0 146 | Logger.instance.warn("concurrent modification detected; Retrying...") 147 | end 148 | yield 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /lib/elastic_whenever/logger.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Logger 3 | include Singleton 4 | 5 | def fail(message) 6 | STDERR.puts "[fail] #{message}" 7 | end 8 | 9 | def warn(message) 10 | STDERR.puts "[warn] #{message}" 11 | end 12 | 13 | def log(event, message) 14 | puts "[#{event}] #{message}" 15 | end 16 | 17 | def message(message) 18 | puts "## [message] #{message}" 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/elastic_whenever/option.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Option 3 | POSSIBLE_RULE_STATES = %w[ENABLED DISABLED].freeze 4 | 5 | DRYRUN_MODE = 1 6 | UPDATE_MODE = 2 7 | CLEAR_MODE = 3 8 | LIST_MODE = 4 9 | PRINT_VERSION_MODE = 5 10 | 11 | attr_reader :identifier 12 | attr_reader :mode 13 | attr_reader :verbose 14 | attr_reader :variables 15 | attr_reader :cluster 16 | attr_reader :task_definition 17 | attr_reader :container 18 | attr_reader :assign_public_ip 19 | attr_reader :launch_type 20 | attr_reader :platform_version 21 | attr_reader :security_groups 22 | attr_reader :subnets 23 | attr_reader :schedule_file 24 | attr_reader :iam_role 25 | attr_reader :rule_state 26 | attr_reader :aws_config 27 | attr_reader :ecs_client 28 | attr_reader :iam_client 29 | attr_reader :cloudwatch_events_client 30 | 31 | class InvalidOptionException < StandardError; end 32 | 33 | def initialize(args) 34 | @identifier = nil 35 | @mode = DRYRUN_MODE 36 | @verbose = false 37 | @variables = [] 38 | @cluster = nil 39 | @task_definition = nil 40 | @container = nil 41 | @assign_public_ip = 'DISABLED' 42 | @launch_type = 'EC2' 43 | @platform_version = 'LATEST' 44 | @security_groups = [] 45 | @subnets = [] 46 | @schedule_file = 'config/schedule.rb' 47 | @iam_role = 'ecsEventsRole' 48 | @rule_state = 'ENABLED' 49 | @profile = nil 50 | @access_key = nil 51 | @secret_key = nil 52 | @region = nil 53 | 54 | OptionParser.new do |opts| 55 | opts.on('-i', '--update identifier', 'Creates and deletes tasks as needed by schedule file') do |identifier| 56 | @identifier = identifier 57 | @mode = UPDATE_MODE 58 | end 59 | opts.on('-c', '--clear identifier', 'Clear scheduled tasks') do |identifier| 60 | @identifier = identifier 61 | @mode = CLEAR_MODE 62 | end 63 | opts.on('-l', '--list identifier', 'List scheduled tasks') do |identifier| 64 | @identifier = identifier 65 | @mode = LIST_MODE 66 | end 67 | opts.on('-s' ,'--set variables', "Example: --set 'environment=staging'") do |set| 68 | pairs = set.split('&') 69 | pairs.each do |pair| 70 | unless pair.include?('=') 71 | Logger.instance.warn("Ignore variable set: #{pair}") 72 | next 73 | end 74 | key, value = pair.split('=') 75 | @variables << { key: key, value: value } 76 | end 77 | end 78 | opts.on('--cluster cluster', 'ECS cluster to run tasks') do |cluster| 79 | @cluster = cluster 80 | end 81 | opts.on('--task-definition task_definition', 'Task definition name, If omit a revision, use the latest revision of the family automatically. Example: --task-definition oneoff-application:2') do |definition| 82 | @task_definition = definition 83 | end 84 | opts.on('--container container', 'Container name defined in the task definition') do |container| 85 | @container = container 86 | end 87 | opts.on('--launch-type launch_type', 'Launch type. EC2 or FARGATE. Default: EC2') do |launch_type| 88 | @launch_type = launch_type 89 | end 90 | opts.on('--assign-public-ip', 'Assign a public IP. Default: DISABLED (FARGATE only)') do 91 | @assign_public_ip = 'ENABLED' 92 | end 93 | opts.on('--security-groups groups', "Example: --security-groups 'sg-2c503655,sg-72f0cb0a' (FARGATE only)") do |groups| 94 | @security_groups = groups.split(',') 95 | end 96 | opts.on('--subnets subnets', "Example: --subnets 'subnet-4973d63f,subnet-45827d1d' (FARGATE only)") do |subnets| 97 | @subnets = subnets.split(',') 98 | end 99 | opts.on('--platform-version version', "Optionally specify the platform version. Default: LATEST (FARGATE only)") do |version| 100 | @platform_version = version 101 | end 102 | opts.on('-f', '--file schedule_file', 'Default: config/schedule.rb') do |file| 103 | @schedule_file = file 104 | end 105 | opts.on('--iam-role name', 'IAM role name used by CloudWatch Events. Default: ecsEventsRole') do |role| 106 | @iam_role = role 107 | end 108 | opts.on('--rule-state state', 'The state of the CloudWatch Events Rule. Default: ENABLED') do |state| 109 | @rule_state = state 110 | end 111 | opts.on('--profile profile_name', 'AWS shared profile name') do |profile| 112 | @profile = profile 113 | end 114 | opts.on('--access-key aws_access_key_id', 'AWS access key ID') do |key| 115 | @access_key = key 116 | end 117 | opts.on('--secret-key aws_secret_access_key', 'AWS secret access key') do |key| 118 | @secret_key = key 119 | end 120 | opts.on('--region region', 'AWS region') do |region| 121 | @region = region 122 | end 123 | opts.on('-v', '--version', 'Print version') do 124 | @mode = PRINT_VERSION_MODE 125 | end 126 | opts.on('-V', '--verbose', 'Run rake jobs without --silent') do 127 | @verbose = true 128 | end 129 | end.parse(args) 130 | 131 | @credentials = if access_key && secret_key 132 | Aws::Credentials.new(access_key, secret_key) 133 | end 134 | end 135 | 136 | def aws_config 137 | @aws_config ||= { credentials: credentials, region: region, profile: profile }.delete_if { |_k, v| v.nil? } 138 | end 139 | 140 | def ecs_client 141 | @ecs_client ||= Aws::ECS::Client.new(aws_config) 142 | end 143 | 144 | def iam_client 145 | @iam_client ||= Aws::IAM::Client.new(aws_config) 146 | end 147 | 148 | def cloudwatch_events_client 149 | @cloudwatch_events_client ||= Aws::CloudWatchEvents::Client.new(aws_config) 150 | end 151 | 152 | def validate! 153 | raise InvalidOptionException.new("Can't find file: #{schedule_file}") unless File.exist?(schedule_file) 154 | raise InvalidOptionException.new("You must set cluster") unless cluster 155 | raise InvalidOptionException.new("You must set task definition") unless task_definition 156 | raise InvalidOptionException.new("You must set container") unless container 157 | raise InvalidOptionException.new("Invalid rule state. Possible values are #{POSSIBLE_RULE_STATES.join(", ")}") unless POSSIBLE_RULE_STATES.include?(rule_state) 158 | end 159 | 160 | def key 161 | Digest::SHA1.hexdigest( 162 | [ 163 | identifier, 164 | variables, 165 | cluster, 166 | task_definition, 167 | container, 168 | assign_public_ip, 169 | launch_type, 170 | platform_version, 171 | security_groups, 172 | subnets, 173 | iam_role, 174 | rule_state, 175 | ].join 176 | ) 177 | end 178 | 179 | private 180 | 181 | attr_reader :profile 182 | attr_reader :access_key 183 | attr_reader :secret_key 184 | attr_reader :region 185 | attr_reader :credentials 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/elastic_whenever/schedule.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Schedule 3 | attr_reader :tasks 4 | attr_reader :chronic_options 5 | attr_reader :bundle_command 6 | attr_reader :environment 7 | 8 | class UnsupportedFrequencyException < StandardError; end 9 | 10 | module WheneverNumeric 11 | refine Numeric do 12 | def seconds 13 | self 14 | end 15 | alias :second :seconds 16 | 17 | def minutes 18 | self * 60 19 | end 20 | alias :minute :minutes 21 | 22 | def hours 23 | (self * 60).minutes 24 | end 25 | alias :hour :hours 26 | 27 | def days 28 | (self * 24).hours 29 | end 30 | alias :day :days 31 | 32 | def weeks 33 | (self * 7).days 34 | end 35 | alias :week :weeks 36 | 37 | def months 38 | (self * 30).days 39 | end 40 | alias :month :months 41 | 42 | def years 43 | (self * 365.25).days 44 | end 45 | alias :year :years 46 | end 47 | end 48 | using WheneverNumeric 49 | 50 | def initialize(file, verbose, variables) 51 | @environment = "production" 52 | @verbose = verbose 53 | @tasks = [] 54 | @chronic_options = {} 55 | @bundle_command = "bundle exec" 56 | 57 | variables.each { |var| set(var[:key], var[:value]) } 58 | instance_eval(File.read(file), file) 59 | end 60 | 61 | def set(key, value) 62 | instance_variable_set("@#{key}", value) unless key == 'tasks' 63 | end 64 | 65 | def every(frequency, options = {}, &block) 66 | @tasks << Task.new(@environment, @verbose, @bundle_command, schedule_expression(frequency, options)).tap do |task| 67 | task.instance_eval(&block) 68 | end 69 | rescue UnsupportedFrequencyException => exn 70 | Logger.instance.warn(exn.message) 71 | end 72 | 73 | def schedule_expression(frequency, options) 74 | opts = { :now => Time.new(2017, 12, 1, 0, 0, 0) }.merge(@chronic_options) 75 | time = Chronic.parse(options[:at], opts) || Time.new(2017, 12, 1, 0, 0, 0) 76 | 77 | case frequency 78 | when 1.minute 79 | "cron(* * * * ? *)" 80 | when :hour, 1.hour 81 | "cron(#{time.min} * * * ? *)" 82 | when :day, 1.day 83 | "cron(#{time.min} #{time.hour} * * ? *)" 84 | when :month, 1.month 85 | "cron(#{time.min} #{time.hour} #{time.day} * ? *)" 86 | when :year, 1.year 87 | "cron(#{time.min} #{time.hour} #{time.day} #{time.month} ? *)" 88 | when :sunday 89 | "cron(#{time.min} #{time.hour} ? * 1 *)" 90 | when :monday 91 | "cron(#{time.min} #{time.hour} ? * 2 *)" 92 | when :tuesday 93 | "cron(#{time.min} #{time.hour} ? * 3 *)" 94 | when :wednesday 95 | "cron(#{time.min} #{time.hour} ? * 4 *)" 96 | when :thursday 97 | "cron(#{time.min} #{time.hour} ? * 5 *)" 98 | when :friday 99 | "cron(#{time.min} #{time.hour} ? * 6 *)" 100 | when :saturday 101 | "cron(#{time.min} #{time.hour} ? * 7 *)" 102 | when :weekend 103 | "cron(#{time.min} #{time.hour} ? * 1,7 *)" 104 | when :weekday 105 | "cron(#{time.min} #{time.hour} ? * 2-6 *)" 106 | when 1.second...1.minute 107 | raise UnsupportedFrequencyException.new("Time must be in minutes or higher. Ignore this task.") 108 | when 1.minute...1.hour 109 | step = (frequency / 60).round 110 | min = [] 111 | (60 % step == 0 ? 0 : step).step(59, step) { |i| min << i } 112 | "cron(#{min.join(",")} * * * ? *)" 113 | when 1.hour...1.day 114 | step = (frequency / 60 / 60).round 115 | hour = [] 116 | (24 % step == 0 ? 0 : step).step(23, step) { |i| hour << i } 117 | "cron(#{time.min} #{hour.join(",")} * * ? *)" 118 | when 1.day...1.month 119 | step = (frequency / 24 / 60 / 60).round 120 | day = [] 121 | (step <= 16 ? 1 : step).step(30, step) { |i| day << i } 122 | "cron(#{time.min} #{time.hour} #{day.join(",")} * ? *)" 123 | when 1.month...12.months 124 | step = (frequency / 30 / 24 / 60 / 60).round 125 | month = [] 126 | (step <= 6 ? 1 : step).step(12, step) { |i| month << i } 127 | "cron(#{time.min} #{time.hour} #{time.day} #{month.join(",")} ? *)" 128 | when 12.months...Float::INFINITY 129 | raise UnsupportedFrequencyException.new("Time must be in months or lower. Ignore this task.") 130 | # cron syntax 131 | when /^((\*?[\d\/,\-]*)\s*){5}$/ 132 | min, hour, day, mon, week, year = frequency.split(" ") 133 | # You can't specify the Day-of-month and Day-of-week fields in the same Cron expression. 134 | # If you specify a value in one of the fields, you must use a ? (question mark) in the other. 135 | week.gsub!("*", "?") if day != "?" 136 | day.gsub!("*", "?") if week != "?" 137 | # cron syntax: sunday -> 0 138 | # scheduled expression: sunday -> 1 139 | week.gsub!(/(\d)/) { Integer($1) + 1 } 140 | year = year || "*" 141 | "cron(#{min} #{hour} #{day} #{mon} #{week} #{year})" 142 | # schedule expression syntax 143 | when /^((\*?\??L?W?[\d\/,\-]*)\s*){6}$/ 144 | "cron(#{frequency})" 145 | else 146 | raise UnsupportedFrequencyException.new("`#{frequency}` is not supported option. Ignore this task.") 147 | end 148 | end 149 | 150 | def method_missing(name, *args) 151 | Logger.instance.warn("Skipping unsupported method: #{name}") 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/elastic_whenever/task.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Task 3 | attr_reader :commands 4 | attr_reader :expression 5 | 6 | def initialize(environment, verbose, bundle_command, expression) 7 | @environment = environment 8 | @verbose_mode = verbose ? nil : "--silent" 9 | @bundle_command = bundle_command.split(" ") 10 | @expression = expression 11 | @commands = [] 12 | end 13 | 14 | def command(task) 15 | @commands << task.split(" ") 16 | end 17 | 18 | def rake(task) 19 | @commands << [@bundle_command, "rake", task, @verbose_mode].flatten.compact 20 | end 21 | 22 | def runner(src) 23 | @commands << [@bundle_command, "bin/rails", "runner", "-e", @environment, src].flatten 24 | end 25 | 26 | def script(script) 27 | @commands << [@bundle_command, "script/#{script}"].flatten 28 | end 29 | 30 | def method_missing(name, *args) 31 | Logger.instance.warn("Skipping unsupported method: #{name}") 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/elastic_whenever/task/cluster.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Task 3 | class Cluster 4 | class InvalidInputException < StandardError; end 5 | 6 | def initialize(option, name) 7 | @client = option.ecs_client 8 | @cluster = client.describe_clusters( 9 | clusters: [name] 10 | ).clusters.first 11 | end 12 | 13 | def name 14 | cluster.cluster_name 15 | end 16 | 17 | def arn 18 | cluster.cluster_arn 19 | end 20 | 21 | private 22 | 23 | attr_reader :client 24 | attr_reader :cluster 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /lib/elastic_whenever/task/definition.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Task 3 | class Definition 4 | def initialize(option, family) 5 | @client = option.ecs_client 6 | @family = family 7 | @definition = client.describe_task_definition( 8 | task_definition: family 9 | ).task_definition 10 | end 11 | 12 | def name 13 | "#{definition.family}:#{definition.revision}" if definition 14 | end 15 | 16 | def arn 17 | arn = definition&.task_definition_arn 18 | if family_with_revision? 19 | arn 20 | else 21 | remove_revision(arn) 22 | end 23 | end 24 | 25 | def containers 26 | definition&.container_definitions&.map(&:name) 27 | end 28 | 29 | private 30 | 31 | attr_reader :client 32 | attr_reader :definition 33 | 34 | def family_with_revision? 35 | @family.include?(":") 36 | end 37 | 38 | def remove_revision(arn) 39 | arn.split(":")[0...-1].join(":") 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/elastic_whenever/task/role.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Task 3 | class Role 4 | def initialize(option) 5 | client = option.iam_client 6 | @resource = Aws::IAM::Resource.new(client: client) 7 | @role_name = option.iam_role 8 | @role = resource.role(@role_name) 9 | end 10 | 11 | def create 12 | @role = resource.create_role( 13 | role_name: @role_name, 14 | assume_role_policy_document: role_json, 15 | ) 16 | role.attach_policy( 17 | policy_arn: "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceEventsRole" 18 | ) 19 | end 20 | 21 | def exists? 22 | !!arn 23 | rescue Aws::IAM::Errors::NoSuchEntity 24 | false 25 | end 26 | 27 | def arn 28 | role&.arn 29 | end 30 | 31 | private 32 | 33 | attr_reader :resource 34 | attr_reader :role 35 | 36 | def role_json 37 | { 38 | Version: "2012-10-17", 39 | Statement: [ 40 | { 41 | Sid: "", 42 | Effect: "Allow", 43 | Principal: { 44 | Service: "events.amazonaws.com", 45 | }, 46 | Action: "sts:AssumeRole", 47 | } 48 | ], 49 | }.to_json 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/elastic_whenever/task/rule.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Task 3 | class Rule 4 | attr_reader :option 5 | attr_reader :name 6 | attr_reader :expression 7 | attr_reader :description 8 | 9 | class UnsupportedOptionException < StandardError; end 10 | 11 | def self.fetch(option, rules: [], next_token: nil) 12 | client = option.cloudwatch_events_client 13 | prefix = option.identifier 14 | 15 | response = client.list_rules(name_prefix: prefix, next_token: next_token) 16 | response.rules.each do |rule| 17 | rules << self.new( 18 | option, 19 | name: rule.name, 20 | expression: rule.schedule_expression, 21 | description: rule.description, 22 | client: client 23 | ) 24 | end 25 | if response.next_token.nil? 26 | rules 27 | else 28 | fetch(option, rules: rules, next_token: response.next_token) 29 | end 30 | end 31 | 32 | def self.convert(option, expression, command) 33 | self.new( 34 | option, 35 | name: rule_name(option, expression, command), 36 | expression: expression, 37 | description: rule_description(option.identifier, expression, command) 38 | ) 39 | end 40 | 41 | def initialize(option, name:, expression:, description:, client: nil) 42 | @option = option 43 | @name = name 44 | @expression = expression 45 | @description = description 46 | if client != nil 47 | @client = client 48 | else 49 | @client = option.cloudwatch_events_client 50 | end 51 | end 52 | 53 | def create 54 | # See https://docs.aws.amazon.com/eventbridge/latest/APIReference/API_PutRule.html#API_PutRule_RequestSyntax 55 | Logger.instance.message("Creating Rule: #{name} #{expression}") 56 | client.put_rule( 57 | name: name, 58 | schedule_expression: expression, 59 | description: truncate(description, 512), 60 | state: option.rule_state, 61 | ) 62 | end 63 | 64 | def delete 65 | targets = client.list_targets_by_rule(rule: name).targets 66 | client.remove_targets(rule: name, ids: targets.map(&:id)) unless targets.empty? 67 | Logger.instance.message("Removing Rule: #{name}") 68 | client.delete_rule(name: name) 69 | end 70 | 71 | private 72 | 73 | def self.rule_name(option, expression, command) 74 | "#{option.identifier}_#{Digest::SHA1.hexdigest([option.key, expression, command.join("-")].join("-"))}" 75 | end 76 | 77 | def self.rule_description(identifier, expression, command) 78 | "#{identifier} - #{expression} - #{command.join(" ")}" 79 | end 80 | 81 | def truncate(string, max) 82 | string.length > max ? string[0...max] : string 83 | end 84 | 85 | attr_reader :client 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/elastic_whenever/task/target.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | class Task 3 | class Target 4 | attr_reader :cluster 5 | attr_reader :definition 6 | attr_reader :container 7 | attr_reader :commands 8 | attr_reader :assign_public_ip 9 | attr_reader :launch_type 10 | attr_reader :platform_version 11 | attr_reader :security_groups 12 | attr_reader :subnets 13 | attr_reader :rule 14 | 15 | class InvalidContainerException < StandardError; end 16 | 17 | def self.fetch(option, rule) 18 | client = option.cloudwatch_events_client 19 | targets = client.list_targets_by_rule(rule: rule.name).targets 20 | targets.map do |target| 21 | input = JSON.parse(target.input, symbolize_names: true) 22 | 23 | self.new( 24 | option, 25 | cluster: Cluster.new(option, target.arn), 26 | definition: Definition.new(option, target.ecs_parameters.task_definition_arn), 27 | container: input[:containerOverrides].first[:name], 28 | commands: input[:containerOverrides].first[:command], 29 | rule: rule, 30 | role: Role.new(option), 31 | ) 32 | end 33 | end 34 | 35 | def initialize(option, cluster:, definition:, container:, commands:, rule:, role:) 36 | unless definition.containers.include?(container) 37 | raise InvalidContainerException.new("#{container} is invalid container. valid=#{definition.containers.join(",")}") 38 | end 39 | 40 | @cluster = cluster 41 | @definition = definition 42 | @container = container 43 | @commands = commands 44 | @rule = rule 45 | @role = role 46 | @assign_public_ip = option.assign_public_ip 47 | @launch_type = option.launch_type 48 | @platform_version = option.platform_version 49 | @security_groups = option.security_groups 50 | @subnets = option.subnets 51 | @client = option.cloudwatch_events_client 52 | end 53 | 54 | def create 55 | client.put_targets( 56 | rule: rule.name, 57 | targets: [ 58 | { 59 | id: Digest::SHA1.hexdigest(commands.join("-")), 60 | arn: cluster.arn, 61 | input: input_json(container, commands), 62 | role_arn: role.arn, 63 | ecs_parameters: ecs_parameters, 64 | } 65 | ] 66 | ) 67 | end 68 | 69 | private 70 | 71 | def input_json(container, commands) 72 | { 73 | containerOverrides: [ 74 | { 75 | name: container, 76 | command: commands 77 | } 78 | ] 79 | }.to_json 80 | end 81 | 82 | def ecs_parameters 83 | if launch_type == "FARGATE" 84 | { 85 | launch_type: launch_type, 86 | task_definition_arn: definition.arn, 87 | task_count: 1, 88 | network_configuration: { 89 | awsvpc_configuration: { 90 | subnets: subnets, 91 | security_groups: security_groups, 92 | assign_public_ip: assign_public_ip, 93 | } 94 | }, 95 | platform_version: platform_version, 96 | } 97 | else 98 | { 99 | launch_type: launch_type, 100 | task_definition_arn: definition.arn, 101 | task_count: 1, 102 | } 103 | end 104 | end 105 | 106 | attr_reader :role 107 | attr_reader :client 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/elastic_whenever/version.rb: -------------------------------------------------------------------------------- 1 | module ElasticWhenever 2 | VERSION = "1.0.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::CLI do 4 | describe "run" do 5 | let(:task) do 6 | ElasticWhenever::Task.new("production", false, "bundle exec", "cron(0 0 * * ? *)").tap do |task| 7 | task.runner("Hoge.run") 8 | end 9 | end 10 | let(:schedule) do 11 | double( 12 | environment: "production", 13 | chronic_options: {}, 14 | tasks: [task] 15 | ) 16 | end 17 | let(:cluster) { double(arn: "arn:aws:ecs:us-east-1:123456789:cluster/test", name: "test") } 18 | let(:definition) { double(arn: "arn:aws:ecs:us-east-1:123456789:task-definition/wordpress:2", name: "wordpress:2", containers: ["testContainer"]) } 19 | let(:role) { double(arn: "arn:aws:ecs:us-east-1:123456789:role/testRole") } 20 | let(:rule) { double(name: "test_2f41f32af2d2a46d5c024f12448894066ae90036", description: "test - cron(0 0 * * ? *) - bundle exec bin/rails runner -e production Hoge.run") } 21 | before do 22 | allow(ElasticWhenever::Schedule).to receive(:new).with((Pathname(__dir__) + "fixtures/schedule.rb").to_s, boolean, kind_of(Array)).and_return(schedule) 23 | allow(ElasticWhenever::Task::Cluster).to receive(:new).with(kind_of(ElasticWhenever::Option), "test").and_return(cluster) 24 | allow(ElasticWhenever::Task::Definition).to receive(:new).with(kind_of(ElasticWhenever::Option), "wordpress:2").and_return(definition) 25 | allow(ElasticWhenever::Task::Role).to receive(:new).with(kind_of(ElasticWhenever::Option)).and_return(role) 26 | allow(role).to receive(:exists?).and_return(false) 27 | end 28 | 29 | context "with dry run mode" do 30 | let(:args) do 31 | %W( 32 | --region us-east-1 33 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 34 | --cluster test 35 | --task-definition wordpress:2 36 | --container testContainer 37 | ) 38 | end 39 | 40 | it "updates tasks with dry run" do 41 | expect(role).not_to receive(:create) 42 | expect(ElasticWhenever::CLI).not_to receive(:clear_tasks) 43 | expect_any_instance_of(ElasticWhenever::Task::Rule).not_to receive(:create) 44 | expect_any_instance_of(ElasticWhenever::Task::Target).not_to receive(:create) 45 | 46 | expect { 47 | ElasticWhenever::CLI.new(args).run 48 | }.to output(<<~OUTPUT).to_stdout 49 | cron(0 0 * * ? *) test wordpress:2 testContainer bundle exec bin/rails runner -e production Hoge.run 50 | 51 | ## [message] Above is your schedule file converted to scheduled tasks; your scheduled tasks was not updated. 52 | ## [message] Run `elastic_whenever --help' for more options. 53 | OUTPUT 54 | end 55 | 56 | it "returns success status code" do 57 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 58 | end 59 | end 60 | 61 | context "with update mode" do 62 | let(:args) do 63 | %W( 64 | -i test 65 | --region us-east-1 66 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 67 | --cluster test 68 | --task-definition wordpress:2 69 | --container testContainer 70 | ) 71 | end 72 | 73 | before do 74 | allow(role).to receive(:create) 75 | 76 | allow_any_instance_of(ElasticWhenever::Task::Rule).to receive(:create) 77 | allow_any_instance_of(ElasticWhenever::Task::Target).to receive(:create) 78 | allow(ElasticWhenever::Task::Rule).to receive(:fetch).and_return([]) 79 | end 80 | 81 | it "creates the missing tasks" do 82 | expect_any_instance_of(ElasticWhenever::Task::Rule).to receive(:create) 83 | expect_any_instance_of(ElasticWhenever::Task::Target).to receive(:create) 84 | 85 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 86 | end 87 | 88 | it "receives schedule file name and variables" do 89 | expect(ElasticWhenever::Schedule).to receive(:new).with((Pathname(__dir__) + "fixtures/schedule.rb").to_s, boolean, [{ key: "environment", value: "staging" }, { key: "foo", value: "bar" }]) 90 | 91 | ElasticWhenever::CLI.new(args.concat(%W(--set environment=staging&foo=bar))).run 92 | end 93 | 94 | it "creates the role if it doesn't exist" do 95 | expect(role).to receive(:create) 96 | 97 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 98 | end 99 | 100 | it "does not create the role if it exists" do 101 | allow(role).to receive(:exists?).and_return(true) 102 | expect(role).not_to receive(:create) 103 | 104 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 105 | end 106 | 107 | context "with an unchanged schedule" do 108 | before do 109 | expect(ElasticWhenever::Task::Rule).to receive(:fetch).and_return([rule]) 110 | end 111 | 112 | it "does not create or remove any rules" do 113 | expect_any_instance_of(ElasticWhenever::Task::Rule).to_not receive(:create) 114 | expect(rule).not_to receive(:delete) 115 | 116 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 117 | end 118 | 119 | it "does not create or remove any targets" do 120 | expect_any_instance_of(ElasticWhenever::Task::Target).to_not receive(:create) 121 | 122 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 123 | end 124 | end 125 | 126 | context "with additions to the schedule" do 127 | before do 128 | expect(ElasticWhenever::Task::Rule).to receive(:fetch).and_return([]) 129 | end 130 | 131 | it "creates the new target" do 132 | expect_any_instance_of(ElasticWhenever::Task::Target).to receive(:create) 133 | 134 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 135 | end 136 | 137 | it "creates the new rule" do 138 | expect_any_instance_of(ElasticWhenever::Task::Rule).to receive(:create) 139 | expect_any_instance_of(ElasticWhenever::Task::Rule).not_to receive(:delete) 140 | 141 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 142 | end 143 | end 144 | 145 | context "with removals from the schedule" do 146 | let(:schedule) do 147 | double(environment: "production", chronic_options: {}, tasks: []) 148 | end 149 | 150 | before do 151 | expect(ElasticWhenever::Task::Rule).to receive(:fetch).twice.and_return([rule]) 152 | end 153 | 154 | it "removes the rules that don't exist in the new schedule" do 155 | expect(rule).to receive(:delete) 156 | 157 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 158 | end 159 | end 160 | end 161 | 162 | context "with clear mode" do 163 | let(:rule) { double("Rule") } 164 | let(:args) do 165 | %W( 166 | -c test 167 | --region us-east-1 168 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 169 | ) 170 | end 171 | 172 | it "clear tasks" do 173 | expect(ElasticWhenever::Task::Rule).to receive(:fetch).with(kind_of(ElasticWhenever::Option)).and_return([rule]) 174 | expect(rule).to receive(:delete) 175 | 176 | expect(ElasticWhenever::CLI.new(args).run).to eq ElasticWhenever::CLI::SUCCESS_EXIT_CODE 177 | end 178 | end 179 | 180 | context "with list mode" do 181 | let(:expression) { "cron(0 0 * * ? *)" } 182 | let(:rule) { double(expression: expression) } 183 | let(:target) do 184 | double( 185 | cluster: cluster, 186 | definition: definition, 187 | container: "testContainer", 188 | commands: ["bundle", "exec", "bin/rails", "runner", "-e", "production", "Hoge.run"], 189 | rule: rule, 190 | ) 191 | end 192 | let(:args) do 193 | %W( 194 | -l test 195 | --region us-east-1 196 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 197 | ) 198 | end 199 | 200 | before do 201 | allow(ElasticWhenever::Task::Rule).to receive(:fetch).with(kind_of(ElasticWhenever::Option)).and_return([rule]) 202 | allow(ElasticWhenever::Task::Target).to receive(:fetch).with(kind_of(ElasticWhenever::Option), rule).and_return([target]) 203 | end 204 | 205 | it "lists tasks" do 206 | expect { 207 | ElasticWhenever::CLI.new(args).run 208 | }.to output(<<~OUTPUT).to_stdout 209 | cron(0 0 * * ? *) test wordpress:2 testContainer bundle exec bin/rails runner -e production Hoge.run 210 | 211 | ## [message] Above is your scheduled tasks. 212 | ## [message] Run `elastic_whenever --help` for more options. 213 | OUTPUT 214 | end 215 | end 216 | 217 | context "with print version mode" do 218 | it "prints version" do 219 | expect { 220 | ElasticWhenever::CLI.new(%w(-v)).run 221 | }.to output("Elastic Whenever v#{ElasticWhenever::VERSION}\n").to_stdout 222 | end 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /spec/fixtures/schedule.rb: -------------------------------------------------------------------------------- 1 | set :cluster, 'ecs-test' 2 | set :task_definition, 'example' 3 | set :container, 'cron' 4 | 5 | every :day, at: '03:00am' do 6 | runner 'Hoge.run' 7 | end 8 | 9 | every '0 0 1 * *' do 10 | rake 'hoge:run' 11 | runner 'Fuga.run' 12 | end 13 | -------------------------------------------------------------------------------- /spec/fixtures/unsupported_schedule.rb: -------------------------------------------------------------------------------- 1 | set :cluster, 'ecs-test' 2 | set :task_definition, 'example' 3 | set :container, 'cron' 4 | 5 | job_type :awesome, '/usr/local/bin/awesome :task :fun_level' 6 | 7 | every :reboot do 8 | rake "hoge:run" 9 | end 10 | -------------------------------------------------------------------------------- /spec/option_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Option do 4 | describe "#initialize" do 5 | it "has default config" do 6 | expect(ElasticWhenever::Option.new(nil)).to have_attributes( 7 | identifier: nil, 8 | mode: ElasticWhenever::Option::DRYRUN_MODE, 9 | verbose: false, 10 | assign_public_ip: 'DISABLED', 11 | launch_type: 'EC2', 12 | platform_version: 'LATEST', 13 | variables: [], 14 | subnets: [], 15 | security_groups: [], 16 | schedule_file: "config/schedule.rb", 17 | iam_role: "ecsEventsRole", 18 | rule_state: "ENABLED" 19 | ) 20 | end 21 | 22 | it "has custom config" do 23 | expect( 24 | ElasticWhenever::Option.new(%w( 25 | --set environment=staging&foo=bar 26 | -f custom_schedule.rb 27 | --cluster test 28 | --task-definition wordpress:2 29 | --container testContainer 30 | --launch_type FARGATE 31 | --assign-public-ip 32 | --security-groups sg-2c503655,sg-72f0cb0a 33 | --subnets subnet-4973d63f,subnet-45827d1d 34 | --platform-version 1.1.0 35 | --verbose 36 | --rule_state DISABLED 37 | )) 38 | ).to have_attributes( 39 | identifier: nil, 40 | mode: ElasticWhenever::Option::DRYRUN_MODE, 41 | verbose: true, 42 | assign_public_ip: 'ENABLED', 43 | launch_type: 'FARGATE', 44 | platform_version: '1.1.0', 45 | variables: [ 46 | { key: "environment", value: "staging" }, 47 | { key: "foo", value: "bar" }, 48 | ], 49 | subnets: ["subnet-4973d63f", "subnet-45827d1d"], 50 | security_groups: ["sg-2c503655", "sg-72f0cb0a"], 51 | schedule_file: "custom_schedule.rb", 52 | iam_role: "ecsEventsRole", 53 | rule_state: "DISABLED" 54 | ) 55 | end 56 | 57 | it "has update config" do 58 | expect(ElasticWhenever::Option.new(%w(-i elastic-whenever))).to have_attributes( 59 | identifier: "elastic-whenever", 60 | mode: ElasticWhenever::Option::UPDATE_MODE, 61 | variables: [], 62 | schedule_file: "config/schedule.rb" 63 | ) 64 | end 65 | 66 | it "has clear config" do 67 | expect(ElasticWhenever::Option.new(%w(-c elastic-whenever))).to have_attributes( 68 | identifier: "elastic-whenever", 69 | mode: ElasticWhenever::Option::CLEAR_MODE, 70 | variables: [], 71 | schedule_file: "config/schedule.rb" 72 | ) 73 | end 74 | 75 | it "has list config" do 76 | expect(ElasticWhenever::Option.new(%w(-l elastic-whenever))).to have_attributes( 77 | identifier: "elastic-whenever", 78 | mode: ElasticWhenever::Option::LIST_MODE, 79 | variables: [], 80 | schedule_file: "config/schedule.rb" 81 | ) 82 | end 83 | 84 | it "has version config" do 85 | expect(ElasticWhenever::Option.new(%w(--version))).to have_attributes( 86 | identifier: nil, 87 | mode: ElasticWhenever::Option::PRINT_VERSION_MODE, 88 | variables: [], 89 | schedule_file: "config/schedule.rb" 90 | ) 91 | end 92 | end 93 | 94 | describe "#aws_config" do 95 | it "has no credentials" do 96 | expect(ElasticWhenever::Option.new(nil).aws_config).to eq({}) 97 | end 98 | 99 | it "has a profile" do 100 | expect(ElasticWhenever::Option.new(%w(--profile my-account)).aws_config).to eq({profile: 'my-account'}) 101 | end 102 | 103 | it "has a region" do 104 | expect(ElasticWhenever::Option.new(%w(--region=us-east-1)).aws_config).to eq({region: 'us-east-1'}) 105 | end 106 | 107 | context 'static credentials' do 108 | let(:static_credentials) { double("Static Credentials") } 109 | 110 | before do 111 | allow(Aws::Credentials).to receive(:new).with("secret", "supersecret").and_return(static_credentials) 112 | end 113 | 114 | it "has credentials" do 115 | expect(ElasticWhenever::Option.new(%w(--access-key secret --secret-key supersecret)).aws_config).to eq(credentials: static_credentials) 116 | end 117 | 118 | it "has a region" do 119 | expect(ElasticWhenever::Option.new(%w(--access-key secret --secret-key supersecret --region=us-east-1)).aws_config).to eq({credentials: static_credentials, region: 'us-east-1'}) 120 | end 121 | end 122 | end 123 | 124 | describe "#validate!" do 125 | it "raise exception when schedule file is not found" do 126 | expect { 127 | ElasticWhenever::Option.new(%W( 128 | -f invalid/file.rb 129 | --cluster test 130 | --task-definition wordpress:2 131 | --container testContainer 132 | )).validate! 133 | }.to raise_error(ElasticWhenever::Option::InvalidOptionException, "Can't find file: invalid/file.rb") 134 | end 135 | 136 | it "raise exception when cluster is undefined" do 137 | expect { 138 | ElasticWhenever::Option.new(%W( 139 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 140 | --task-definition wordpress:2 141 | --container testContainer 142 | )).validate! 143 | }.to raise_error(ElasticWhenever::Option::InvalidOptionException, "You must set cluster") 144 | end 145 | 146 | it "raise exception when task definition is undefined" do 147 | expect { 148 | ElasticWhenever::Option.new(%W( 149 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 150 | --cluster test 151 | --container testContainer 152 | )).validate! 153 | }.to raise_error(ElasticWhenever::Option::InvalidOptionException, "You must set task definition") 154 | end 155 | 156 | it "raise exception when container is undefined" do 157 | expect { 158 | ElasticWhenever::Option.new(%W( 159 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 160 | --cluster test 161 | --task-definition wordpress:2 162 | )).validate! 163 | }.to raise_error(ElasticWhenever::Option::InvalidOptionException, "You must set container") 164 | end 165 | 166 | it "raises an exception if the rule state is invalid" do 167 | expect { 168 | ElasticWhenever::Option.new(%W( 169 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 170 | --cluster test 171 | --task-definition wordpress:2 172 | --container testContainer 173 | --rule-state FOO 174 | )).validate! 175 | }.to raise_error(ElasticWhenever::Option::InvalidOptionException, "Invalid rule state. Possible values are ENABLED, DISABLED") 176 | end 177 | 178 | it "doesn't raise exception" do 179 | ElasticWhenever::Option.new(%W( 180 | -f #{Pathname(__dir__) + "fixtures/schedule.rb"} 181 | --cluster test 182 | --task-definition wordpress:2 183 | --container testContainer 184 | )).validate! 185 | end 186 | end 187 | 188 | describe "#key" do 189 | let(:configuration) { %w( 190 | --set environment=staging&foo=bar 191 | --cluster testCluster 192 | --task-definition wordpress:2 193 | --container testContainer 194 | --launch_type FARGATE 195 | --assign-public-ip 196 | --security-groups sg-2c503655,sg-72f0cb0a 197 | --subnets subnet-4973d63f,subnet-45827d1d 198 | --iam-role schedule-test 199 | --platform-version 1.1.0 200 | --rule_state DISABLED 201 | -i testId).freeze 202 | } 203 | 204 | it "creates a unique key for configuration options" do 205 | options = [ 206 | configuration, 207 | replace_item(configuration, "environment=staging&foo=bar", "environment=test&baz=qux"), 208 | replace_item(configuration, "testCluster", "testCluster1"), 209 | replace_item(configuration, "testContainer", "testContainer2"), 210 | replace_item(configuration, "FARGATE", "EC2"), 211 | replace_item(configuration, "--assign-public-ip", ""), 212 | replace_item(configuration, "sg-2c503655,sg-72f0cb0a", "sg-2c503645,sg-72f0cbas"), 213 | replace_item(configuration, "subnet-4973d63f,subnet-45827d1d", "subnet-12345f,subnet-647382d"), 214 | replace_item(configuration, "schedule-test", "new-schedule-test"), 215 | replace_item(configuration, "1.1.0", "1.2.0"), 216 | replace_item(configuration, "DISABLED", "ENABLED"), 217 | replace_item(configuration, "testId", "testId2"), 218 | ].map { |conf| ElasticWhenever::Option.new(conf).key } 219 | 220 | expect(options.uniq).to eql(options) 221 | expect(options.uniq.length).to eql(12) 222 | end 223 | 224 | def replace_item(configuration, old_value, replacement_value) 225 | configuration.map { |val| val == old_value ? replacement_value : val } 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /spec/schedule_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Schedule do 4 | let(:schedule) { ElasticWhenever::Schedule.new((Pathname(__dir__) + "fixtures/schedule.rb").to_s, false, []) } 5 | 6 | describe "#initialize" do 7 | it "has attributes" do 8 | expect(schedule).to have_attributes(chronic_options: {}) 9 | end 10 | 11 | context "when received variables from cli" do 12 | let(:schedule) { ElasticWhenever::Schedule.new((Pathname(__dir__) + "fixtures/schedule.rb").to_s, false, [{ key: "environment", value: "staging" }]) } 13 | 14 | it "overrides attributes" do 15 | expect(schedule.instance_variable_get(:@environment)).to eq "staging" 16 | end 17 | end 18 | 19 | context "when received verbose from cli" do 20 | let(:schedule) { ElasticWhenever::Schedule.new((Pathname(__dir__) + "fixtures/schedule.rb").to_s, true, []) } 21 | 22 | it "set verbose flag" do 23 | expect(schedule.instance_variable_get(:@verbose)).to be true 24 | end 25 | end 26 | 27 | it "has tasks" do 28 | expect(schedule.tasks.count).to eq(2) 29 | expect(schedule.tasks[0]).to have_attributes( 30 | expression: "cron(0 3 * * ? *)", 31 | commands: [ 32 | %w(bundle exec bin/rails runner -e production Hoge.run) 33 | ] 34 | ) 35 | expect(schedule.tasks[1]).to have_attributes( 36 | expression: "cron(0 0 1 * ? *)", 37 | commands: [ 38 | %w(bundle exec rake hoge:run --silent), 39 | %w(bundle exec bin/rails runner -e production Fuga.run) 40 | ] 41 | ) 42 | end 43 | 44 | context "when use unsupported method" do 45 | let(:schedule) { ElasticWhenever::Schedule.new((Pathname(__dir__) + "fixtures/unsupported_schedule.rb").to_s, false, []) } 46 | 47 | it "does not have tasks" do 48 | expect(schedule.tasks.count).to eq(0) 49 | end 50 | end 51 | end 52 | 53 | describe "WheneverNumeric" do 54 | before do 55 | allow(File).to receive(:read).and_return(file) 56 | end 57 | 58 | context "when use 1.minute" do 59 | let(:file) do 60 | <<~FILE 61 | every 1.minute do 62 | rake "hoge:run" 63 | end 64 | FILE 65 | end 66 | 67 | it "has expression" do 68 | expect(schedule.tasks.first).to have_attributes(expression: "cron(* * * * ? *)") 69 | end 70 | end 71 | 72 | context "when use 5.minutes" do 73 | let(:file) do 74 | <<~FILE 75 | every 5.minutes do 76 | rake "hoge:run" 77 | end 78 | FILE 79 | end 80 | 81 | it "has expression" do 82 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0,5,10,15,20,25,30,35,40,45,50,55 * * * ? *)") 83 | end 84 | end 85 | 86 | context "when use 21.minutes" do 87 | let(:file) do 88 | <<~FILE 89 | every 21.minutes do 90 | rake "hoge:run" 91 | end 92 | FILE 93 | end 94 | 95 | it "has expression" do 96 | expect(schedule.tasks.first).to have_attributes(expression: "cron(21,42 * * * ? *)") 97 | end 98 | end 99 | 100 | context "when use 120.minutes" do 101 | let(:file) do 102 | <<~FILE 103 | every 120.minutes do 104 | rake "hoge:run" 105 | end 106 | FILE 107 | end 108 | 109 | it "has expression" do 110 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0,2,4,6,8,10,12,14,16,18,20,22 * * ? *)") 111 | end 112 | end 113 | 114 | context "when use 1.hour" do 115 | let(:file) do 116 | <<~FILE 117 | every 1.hour do 118 | rake "hoge:run" 119 | end 120 | FILE 121 | end 122 | 123 | it "has expression" do 124 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 * * * ? *)") 125 | end 126 | end 127 | 128 | context "when use 4.hours" do 129 | let(:file) do 130 | <<~FILE 131 | every 4.hours do 132 | rake "hoge:run" 133 | end 134 | FILE 135 | end 136 | 137 | it "has expression" do 138 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0,4,8,12,16,20 * * ? *)") 139 | end 140 | end 141 | 142 | context "when use 11.hours" do 143 | let(:file) do 144 | <<~FILE 145 | every 11.hours do 146 | rake "hoge:run" 147 | end 148 | FILE 149 | end 150 | 151 | it "has expression" do 152 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 11,22 * * ? *)") 153 | end 154 | end 155 | 156 | context "when use 1.day" do 157 | let(:file) do 158 | <<~FILE 159 | every 1.day do 160 | rake "hoge:run" 161 | end 162 | FILE 163 | end 164 | 165 | it "has expression" do 166 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0 * * ? *)") 167 | end 168 | end 169 | 170 | context "when use 10.days" do 171 | let(:file) do 172 | <<~FILE 173 | every 10.days do 174 | rake "hoge:run" 175 | end 176 | FILE 177 | end 178 | 179 | it "has expression" do 180 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0 1,11,21 * ? *)") 181 | end 182 | end 183 | 184 | context "when use 17.days" do 185 | let(:file) do 186 | <<~FILE 187 | every 17.days do 188 | rake "hoge:run" 189 | end 190 | FILE 191 | end 192 | 193 | it "has expression" do 194 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0 17 * ? *)") 195 | end 196 | end 197 | 198 | context "when use 1.month" do 199 | let(:file) do 200 | <<~FILE 201 | every 1.month do 202 | rake "hoge:run" 203 | end 204 | FILE 205 | end 206 | 207 | it "has expression" do 208 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0 1 * ? *)") 209 | end 210 | end 211 | 212 | context "when use 2.months" do 213 | let(:file) do 214 | <<~FILE 215 | every 2.months do 216 | rake "hoge:run" 217 | end 218 | FILE 219 | end 220 | 221 | it "has expression" do 222 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0 1 1,3,5,7,9,11 ? *)") 223 | end 224 | end 225 | 226 | context "when use 2.months with `at` option" do 227 | let(:file) do 228 | <<~FILE 229 | every 2.months, :at => "3:00" do 230 | rake "hoge:run" 231 | end 232 | FILE 233 | end 234 | 235 | it "has expression" do 236 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 15 1 1,3,5,7,9,11 ? *)") 237 | end 238 | end 239 | 240 | context "when use 7.months" do 241 | let(:file) do 242 | <<~FILE 243 | every 7.months do 244 | rake "hoge:run" 245 | end 246 | FILE 247 | end 248 | 249 | it "has expression" do 250 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0 1 7 ? *)") 251 | end 252 | end 253 | 254 | context "when use 1.year" do 255 | let(:file) do 256 | <<~FILE 257 | every 1.year do 258 | rake "hoge:run" 259 | end 260 | FILE 261 | end 262 | 263 | it "has expression" do 264 | expect(schedule.tasks.first).to have_attributes(expression: "cron(0 0 1 12 ? *)") 265 | end 266 | end 267 | end 268 | 269 | describe "#set" do 270 | it "sets value" do 271 | expect { 272 | schedule.set("foo", "bar") 273 | }.to change { schedule.instance_variable_get("@foo") }.from(nil).to("bar") 274 | end 275 | 276 | it "doesnt set `tasks` value" do 277 | expect { 278 | schedule.set("tasks", "some value") 279 | }.not_to change { schedule.tasks } 280 | end 281 | end 282 | 283 | describe "#schedule_expression" do 284 | it "converts from schedule expression" do 285 | expect(schedule.schedule_expression("0 0 * * ? *", {})).to eq "cron(0 0 * * ? *)" 286 | end 287 | 288 | it "converts from cron syntax" do 289 | expect(schedule.schedule_expression("0 0 * * *", {})).to eq "cron(0 0 * * ? *)" 290 | end 291 | 292 | it "converts from cron syntax specified week" do 293 | expect(schedule.schedule_expression("0 0 * * 0,1,2,3,4,5,6", {})).to eq "cron(0 0 ? * 1,2,3,4,5,6,7 *)" 294 | end 295 | 296 | it "converts from day shortcuts" do 297 | expect(schedule.schedule_expression(:day, {})).to eq "cron(0 0 * * ? *)" 298 | end 299 | 300 | it "converts from day shortcuts with `at` option" do 301 | expect(schedule.schedule_expression(:day, at: "2:00")).to eq "cron(0 14 * * ? *)" 302 | end 303 | 304 | it "converts from day shortcuts with `at` option and chronic option" do 305 | schedule.instance_variable_set(:@chronic_options, { :hours24 => true }) 306 | expect(schedule.schedule_expression(:day, at: "2:00")).to eq "cron(0 2 * * ? *)" 307 | end 308 | 309 | it "converts from hour shortcuts" do 310 | expect(schedule.schedule_expression(:hour, {})).to eq "cron(0 * * * ? *)" 311 | end 312 | 313 | it "converts from month shortcuts with `at` option" do 314 | expect(schedule.schedule_expression(:month, at: "3rd")).to eq "cron(0 0 3 * ? *)" 315 | end 316 | 317 | it "converts from year shortcuts" do 318 | expect(schedule.schedule_expression(:year, {})).to eq "cron(0 0 1 12 ? *)" 319 | end 320 | 321 | it "converts from sunday shortcuts" do 322 | expect(schedule.schedule_expression(:sunday, {})).to eq "cron(0 0 ? * 1 *)" 323 | end 324 | 325 | it "converts from monday shortcuts" do 326 | expect(schedule.schedule_expression(:monday, {})).to eq "cron(0 0 ? * 2 *)" 327 | end 328 | 329 | it "converts from tuesday shortcuts" do 330 | expect(schedule.schedule_expression(:tuesday, {})).to eq "cron(0 0 ? * 3 *)" 331 | end 332 | 333 | it "converts from wednesday shortcuts" do 334 | expect(schedule.schedule_expression(:wednesday, {})).to eq "cron(0 0 ? * 4 *)" 335 | end 336 | 337 | it "converts from thursday shortcuts" do 338 | expect(schedule.schedule_expression(:thursday, {})).to eq "cron(0 0 ? * 5 *)" 339 | end 340 | 341 | it "converts from friday shortcuts" do 342 | expect(schedule.schedule_expression(:friday, {})).to eq "cron(0 0 ? * 6 *)" 343 | end 344 | 345 | it "converts from saturday shortcuts" do 346 | expect(schedule.schedule_expression(:saturday, {})).to eq "cron(0 0 ? * 7 *)" 347 | end 348 | 349 | it "converts from weekday shortcuts" do 350 | expect(schedule.schedule_expression(:weekday, {})).to eq "cron(0 0 ? * 2-6 *)" 351 | end 352 | 353 | it "converts from weekend shortcuts with `at` option" do 354 | expect(schedule.schedule_expression(:weekend, at: "06:30")).to eq "cron(30 6 ? * 1,7 *)" 355 | end 356 | 357 | it "raises an exception when specified unsupported shortcuts" do 358 | expect { schedule.schedule_expression(:reboot, {}) }.to raise_error(ElasticWhenever::Schedule::UnsupportedFrequencyException) 359 | end 360 | end 361 | end 362 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "elastic_whenever" 3 | 4 | RSpec.configure do |config| 5 | # Enable flags like --only-failures and --next-failure 6 | config.example_status_persistence_file_path = ".rspec_status" 7 | 8 | # Disable RSpec exposing methods globally on `Module` and `main` 9 | config.disable_monkey_patching! 10 | 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/task_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Task do 4 | let(:task) { ElasticWhenever::Task.new("production", false, "bundle exec", "cron(0 17 * * ? *)") } 5 | 6 | describe "#initialize" do 7 | it "has attributes" do 8 | expect(task).to have_attributes(expression: "cron(0 17 * * ? *)") 9 | end 10 | end 11 | 12 | describe "#command" do 13 | it "generates commands" do 14 | task.command("hoge fuga bar:baz") 15 | expect(task.commands).to eq([%w(hoge fuga bar:baz)]) 16 | end 17 | end 18 | 19 | describe "#rake" do 20 | it "generates rake commands" do 21 | task.rake("hoge:run") 22 | expect(task.commands).to eq([%w(bundle exec rake hoge:run --silent)]) 23 | end 24 | 25 | context "when unset bundle command" do 26 | let(:task) { ElasticWhenever::Task.new("production", false, "", "cron(0 17 * * ? *)") } 27 | 28 | it "generates rake commands" do 29 | task.rake("hoge:run") 30 | expect(task.commands).to eq([%w(rake hoge:run --silent)]) 31 | end 32 | end 33 | 34 | context "when set verbose flag" do 35 | let(:task) { ElasticWhenever::Task.new("production", true, "bundle exec", "cron(0 17 * * ? *)") } 36 | 37 | it "generates rake commands" do 38 | task.rake("hoge:run") 39 | expect(task.commands).to eq([%w(bundle exec rake hoge:run)]) 40 | end 41 | end 42 | end 43 | 44 | describe "#runner" do 45 | it "generates rails runner commands" do 46 | task.runner("Hoge.run") 47 | expect(task.commands).to eq([%w(bundle exec bin/rails runner -e production Hoge.run)]) 48 | end 49 | 50 | context "when unset bundle command" do 51 | let(:task) { ElasticWhenever::Task.new("production", false, "", "cron(0 17 * * ? *)") } 52 | 53 | it "generates rake commands" do 54 | task.runner("Hoge.run") 55 | expect(task.commands).to eq([%w(bin/rails runner -e production Hoge.run)]) 56 | end 57 | end 58 | end 59 | 60 | describe "script" do 61 | it "generates script commands" do 62 | task.script("runner.rb") 63 | expect(task.commands).to eq([%w(bundle exec script/runner.rb)]) 64 | end 65 | 66 | context "when unset bundle command" do 67 | let(:task) { ElasticWhenever::Task.new("production", false, "", "cron(0 17 * * ? *)") } 68 | 69 | it "generates rake commands" do 70 | task.script("runner.rb") 71 | expect(task.commands).to eq([%w(script/runner.rb)]) 72 | end 73 | end 74 | end 75 | 76 | describe "unsupported method" do 77 | it "does not change commands" do 78 | expect { 79 | task.unsupported("hoge") 80 | }.not_to change { task.commands } 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/tasks/cluster_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Task::Cluster do 4 | describe "cluster attributes" do 5 | let(:client) { double("client") } 6 | let(:option) { ElasticWhenever::Option.new(nil) } 7 | let(:cluster) { double(cluster_name: "ecs-test", cluster_arn: "arn:aws:ecs:us-east-1:1234567890:cluster/ecs-test") } 8 | 9 | before do 10 | allow(Aws::ECS::Client).to receive(:new).and_return(client) 11 | allow(client).to receive(:describe_clusters).with(clusters: ["ecs-test"]).and_return(double(clusters: [cluster])) 12 | end 13 | 14 | it "has cluster" do 15 | expect(ElasticWhenever::Task::Cluster.new(option, "ecs-test")).to have_attributes( 16 | name: "ecs-test", 17 | arn: "arn:aws:ecs:us-east-1:1234567890:cluster/ecs-test", 18 | ) 19 | end 20 | end 21 | end -------------------------------------------------------------------------------- /spec/tasks/definition_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Task::Definition do 4 | describe "definition attributes" do 5 | let(:client) { double("client") } 6 | let(:option) { ElasticWhenever::Option.new(nil) } 7 | let(:family) { "wordpress" } 8 | let(:definition) do 9 | double( 10 | task_definition_arn: "arn:aws:ecs:us-east-1:1234567890:task_definition/wordpress:1", 11 | family: "wordpress", 12 | revision: 1, 13 | container_definitions: [double(name: "testContainer")] 14 | ) 15 | end 16 | 17 | before do 18 | allow(Aws::ECS::Client).to receive(:new).and_return(client) 19 | allow(client).to receive(:describe_task_definition).with(task_definition: family).and_return(double(task_definition: definition)) 20 | end 21 | 22 | it "has task definition" do 23 | expect(ElasticWhenever::Task::Definition.new(option, family)).to have_attributes( 24 | name: "wordpress:1", 25 | arn: "arn:aws:ecs:us-east-1:1234567890:task_definition/wordpress", 26 | containers: ["testContainer"] 27 | ) 28 | end 29 | 30 | context "with revision specified" do 31 | let(:family) { "wordpress:2" } 32 | let(:definition) do 33 | double( 34 | task_definition_arn: "arn:aws:ecs:us-east-1:1234567890:task_definition/wordpress:2", 35 | family: "wordpress", 36 | revision: 2, 37 | container_definitions: [double(name: "testContainer")] 38 | ) 39 | end 40 | 41 | it "has task definition" do 42 | expect(ElasticWhenever::Task::Definition.new(option, family)).to have_attributes( 43 | name: "wordpress:2", 44 | arn: "arn:aws:ecs:us-east-1:1234567890:task_definition/wordpress:2", 45 | containers: ["testContainer"] 46 | ) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/tasks/role_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Task::Role do 4 | let(:resource) { double("resource") } 5 | let(:option) { ElasticWhenever::Option.new(%w(--region us-east-1)) } 6 | let(:role_name) { "ecsEventsRole" } 7 | let(:role) { double(arn: "arn:aws:iam::123456789:role/#{role_name}") } 8 | 9 | before do 10 | allow(Aws::IAM::Resource).to receive(:new).and_return(resource) 11 | allow(resource).to receive(:role).with(role_name).and_return(role) 12 | end 13 | 14 | describe "#initialize" do 15 | it "has role" do 16 | expect(ElasticWhenever::Task::Role.new(option)).to have_attributes(arn: "arn:aws:iam::123456789:role/ecsEventsRole") 17 | end 18 | 19 | context "with custom role name" do 20 | let(:role_name) { "cloudwatch-events-ecs" } 21 | let(:option) { ElasticWhenever::Option.new(%w(--region us-east-1 --iam-role cloudwatch-events-ecs)) } 22 | 23 | it "has role" do 24 | expect(ElasticWhenever::Task::Role.new(option)).to have_attributes(arn: "arn:aws:iam::123456789:role/cloudwatch-events-ecs") 25 | end 26 | end 27 | end 28 | 29 | describe "#create" do 30 | it "creates IAM role" do 31 | expect(resource).to receive(:create_role).with({ 32 | role_name: role_name, 33 | assume_role_policy_document: { 34 | Version: "2012-10-17", 35 | Statement: [ 36 | { 37 | Sid: "", 38 | Effect: "Allow", 39 | Principal: { 40 | Service: "events.amazonaws.com", 41 | }, 42 | Action: "sts:AssumeRole", 43 | } 44 | ], 45 | }.to_json 46 | }).and_return(role) 47 | expect(role).to receive(:attach_policy).with(policy_arn: "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceEventsRole") 48 | 49 | role = ElasticWhenever::Task::Role.new(option) 50 | role.create 51 | end 52 | end 53 | 54 | describe "#exists?" do 55 | it "returns true" do 56 | expect(ElasticWhenever::Task::Role.new(option)).to be_exists 57 | end 58 | 59 | context "when role not found" do 60 | before do 61 | allow(role).to receive(:arn).and_raise(Aws::IAM::Errors::NoSuchEntity.new('context','error')) 62 | end 63 | 64 | it "returns false" do 65 | expect(ElasticWhenever::Task::Role.new(option)).not_to be_exists 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/tasks/rule_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Task::Rule do 4 | let(:client) { double("client") } 5 | let(:option) { ElasticWhenever::Option.new(%w(-i test)) } 6 | before { allow(Aws::CloudWatchEvents::Client).to receive(:new).and_return(client) } 7 | 8 | describe "fetch" do 9 | let(:rule_call_1) do 10 | double( 11 | rules: [ 12 | double(name: "example0", schedule_expression: "cron(0 0 * * ? *)", description: "test0"), 13 | double(name: "example1", schedule_expression: "cron(1 0 * * ? *)", description: "test1") 14 | ], 15 | next_token: "1", 16 | ) 17 | end 18 | let(:rule_call_2) do 19 | double( 20 | rules: [ 21 | double(name: "example2", schedule_expression: "cron(2 0 * * ? *)", description: "test2"), 22 | double(name: "example3", schedule_expression: "cron(3 0 * * ? *)", description: "test3") 23 | ], 24 | next_token: "2", 25 | ) 26 | end 27 | let(:rule_call_3) do 28 | double( 29 | rules: [ 30 | double(name: "example4", schedule_expression: "cron(4 0 * * ? *)", description: "test4"), 31 | ], 32 | next_token: nil, 33 | ) 34 | end 35 | before do 36 | allow(client).to receive(:list_rules).with(name_prefix: "test", next_token: nil).and_return(rule_call_1) 37 | allow(client).to receive(:list_rules).with(name_prefix: "test", next_token: "1").and_return(rule_call_2) 38 | allow(client).to receive(:list_rules).with(name_prefix: "test", next_token: "2").and_return(rule_call_3) 39 | end 40 | 41 | it "fetches rule" do 42 | rules = ElasticWhenever::Task::Rule.fetch(option) 43 | expect(rules.count).to eq 5 44 | rules.each_with_index do |rule, i| 45 | expect(rules[i]).to have_attributes(name: "example#{i}", expression: "cron(#{i} 0 * * ? *)", description: "test#{i}") 46 | end 47 | end 48 | end 49 | 50 | describe "convert" do 51 | it "converts scheduled task syntax" do 52 | task = ElasticWhenever::Task.new("production", false, "bundle exec", "cron(0 0 * * ? *)") 53 | task.rake "hoge:run" 54 | 55 | expect(ElasticWhenever::Task::Rule.convert(option, task.expression, task.commands.first)).to have_attributes( 56 | name: "test_6a6abf21a362cde702bd39f4679704598fad7ead", 57 | expression: "cron(0 0 * * ? *)", 58 | description: "test - cron(0 0 * * ? *) - bundle exec rake hoge:run --silent" 59 | ) 60 | end 61 | end 62 | 63 | describe "#create" do 64 | it "creates new rule" do 65 | expect(client).to receive(:put_rule).with(name: "example", schedule_expression: "cron(0 0 * * ? *)", description: "test", state: "ENABLED") 66 | ElasticWhenever::Task::Rule.new(option, name: "example", expression: "cron(0 0 * * ? *)", description: "test").create 67 | end 68 | 69 | it "truncates the description at 512 characters" do 70 | expect(client).to receive(:put_rule).with(name: "example", schedule_expression: "cron(0 0 * * ? *)", description: "a" * 512, state: "ENABLED") 71 | ElasticWhenever::Task::Rule.new(option, name: "example", expression: "cron(0 0 * * ? *)", description: "a" * 600).create 72 | end 73 | 74 | context "with custom rule state" do 75 | let(:option) { ElasticWhenever::Option.new(%w(-i test --rule-state DISABLED)) } 76 | 77 | it "uses the rule state when creating the rule" do 78 | expect(client).to receive(:put_rule).with(name: "example", schedule_expression: "cron(0 0 * * ? *)", description: "test", state: "DISABLED") 79 | ElasticWhenever::Task::Rule.new(option, name: "example", expression: "cron(0 0 * * ? *)", description: "test").create 80 | end 81 | end 82 | end 83 | 84 | describe "#delete" do 85 | let(:targets) { [double(id: "example_id")] } 86 | before do 87 | allow(client).to receive(:list_targets_by_rule).with(rule: "example").and_return(double(targets: targets)) 88 | end 89 | 90 | it "remove rule and targets" do 91 | expect(client).to receive(:remove_targets).with(rule: "example", ids: ["example_id"]) 92 | expect(client).to receive(:delete_rule).with(name: "example") 93 | ElasticWhenever::Task::Rule.new(option, name: "example", expression: "cron(0 0 * * ? *)", description: "test").delete 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/tasks/target_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ElasticWhenever::Task::Target do 4 | let(:client) { double("client") } 5 | let(:option) { ElasticWhenever::Option.new(%w(-i test)) } 6 | let(:rule) { double(name: "test_rule") } 7 | let(:cluster) { double(arn: "arn:aws:ecs:us-east-1:123456789:cluster/test") } 8 | let(:definition) { double(arn: "arn:aws:ecs:us-east-1:123456789:task-definition/wordpress:2", containers: ["testContainer"]) } 9 | let(:role) { double(arn: "arn:aws:ecs:us-east-1:123456789:role/testRole") } 10 | 11 | before do 12 | allow(Aws::CloudWatchEvents::Client).to receive(:new).and_return(client) 13 | end 14 | 15 | describe "#initialize" do 16 | it "raises exception" do 17 | expect { 18 | ElasticWhenever::Task::Target.new( 19 | option, 20 | cluster: cluster, 21 | definition: definition, 22 | container: "invalidContainer", 23 | commands: ["bundle", "exec", "rake", "spec"], 24 | rule: rule, 25 | role: role, 26 | ) 27 | }.to raise_error(ElasticWhenever::Task::Target::InvalidContainerException) 28 | end 29 | end 30 | 31 | describe "fetch" do 32 | let(:targets) do 33 | [ 34 | double( 35 | input: { 36 | containerOverrides: [ 37 | { 38 | name: "testContainer", 39 | command: ["bundle", "exec", "rake", "spec"] 40 | } 41 | ] 42 | }.to_json, 43 | arn: "arn:aws:ecs:us-east-1:123456789:cluster/test", 44 | ecs_parameters: double(task_definition_arn: "arn:aws:ecs:us-east-1:123456789:task-definition/wordpress:2"), 45 | ) 46 | ] 47 | end 48 | before do 49 | allow(ElasticWhenever::Task::Cluster).to receive(:new).with(option, "arn:aws:ecs:us-east-1:123456789:cluster/test").and_return(cluster) 50 | allow(ElasticWhenever::Task::Definition).to receive(:new).with(option, "arn:aws:ecs:us-east-1:123456789:task-definition/wordpress:2").and_return(definition) 51 | allow(ElasticWhenever::Task::Role).to receive(:new).with(option).and_return(role) 52 | end 53 | 54 | it "fetch targets" do 55 | expect(client).to receive(:list_targets_by_rule).with(rule: "test_rule").and_return(double(targets: targets)) 56 | whenever_targets = ElasticWhenever::Task::Target.fetch(option, rule) 57 | expect(whenever_targets.count).to eq 1 58 | expect(whenever_targets.first).to have_attributes( 59 | cluster: cluster, 60 | definition: definition, 61 | container: "testContainer", 62 | commands: ["bundle", "exec", "rake", "spec"], 63 | ) 64 | end 65 | end 66 | 67 | describe "#create" do 68 | it "creates target" do 69 | expect(client).to receive(:put_targets).with( 70 | rule: "test_rule", 71 | targets: [ 72 | { 73 | id: "26d98175755bb458e8ba55a1f5cfb2dc0e10dd81", 74 | arn: "arn:aws:ecs:us-east-1:123456789:cluster/test", 75 | input: { 76 | containerOverrides: [ 77 | { 78 | name: "testContainer", 79 | command: ["bundle", "exec", "rake", "spec"] 80 | } 81 | ] 82 | }.to_json, 83 | role_arn: "arn:aws:ecs:us-east-1:123456789:role/testRole", 84 | ecs_parameters: { 85 | launch_type: "EC2", 86 | task_definition_arn: "arn:aws:ecs:us-east-1:123456789:task-definition/wordpress:2", 87 | task_count: 1, 88 | } 89 | } 90 | ] 91 | ) 92 | 93 | ElasticWhenever::Task::Target.new( 94 | option, 95 | cluster: cluster, 96 | definition: definition, 97 | container: "testContainer", 98 | commands: ["bundle", "exec", "rake", "spec"], 99 | rule: rule, 100 | role: role, 101 | ).create 102 | end 103 | 104 | context "when FARGATE launch type" do 105 | let(:option) do 106 | ElasticWhenever::Option.new(%w( 107 | -i test 108 | --launch-type FARGATE 109 | --platform-version LATEST 110 | --subnets subnet-4973d63f,subnet-45827d1d 111 | --security-groups sg-2c503655,sg-72f0cb0a 112 | --assign-public-ip 113 | )) 114 | end 115 | 116 | it "creates target" do 117 | expect(client).to receive(:put_targets).with( 118 | rule: "test_rule", 119 | targets: [ 120 | { 121 | id: "26d98175755bb458e8ba55a1f5cfb2dc0e10dd81", 122 | arn: "arn:aws:ecs:us-east-1:123456789:cluster/test", 123 | input: { 124 | containerOverrides: [ 125 | { 126 | name: "testContainer", 127 | command: ["bundle", "exec", "rake", "spec"] 128 | } 129 | ] 130 | }.to_json, 131 | role_arn: "arn:aws:ecs:us-east-1:123456789:role/testRole", 132 | ecs_parameters: { 133 | launch_type: "FARGATE", 134 | task_definition_arn: "arn:aws:ecs:us-east-1:123456789:task-definition/wordpress:2", 135 | task_count: 1, 136 | network_configuration: { 137 | awsvpc_configuration: { 138 | subnets: ["subnet-4973d63f", "subnet-45827d1d"], 139 | security_groups: ["sg-2c503655", "sg-72f0cb0a"], 140 | assign_public_ip: "ENABLED", 141 | } 142 | }, 143 | platform_version: "LATEST", 144 | } 145 | } 146 | ] 147 | ) 148 | 149 | ElasticWhenever::Task::Target.new( 150 | option, 151 | cluster: cluster, 152 | definition: definition, 153 | container: "testContainer", 154 | commands: ["bundle", "exec", "rake", "spec"], 155 | rule: rule, 156 | role: role, 157 | ).create 158 | end 159 | end 160 | end 161 | end 162 | --------------------------------------------------------------------------------