xronor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ module Xronor
2
+ class DSL
3
+ class Default
4
+ DEFAULT_TIMEZONE = "UTC"
5
+
6
+ def initialize(&block)
7
+ @result = OpenStruct.new(
8
+ cron_timezone: DEFAULT_TIMEZONE,
9
+ prefix: "",
10
+ timezone: DEFAULT_TIMEZONE,
11
+ )
12
+
13
+ instance_eval(&block)
14
+ end
15
+
16
+ %i(cron_timezone prefix timezone).each do |key|
17
+ define_method(key) do |arg|
18
+ @result.send("#{key}=", arg)
19
+ end
20
+ end
21
+
22
+ def result
23
+ @result
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,66 @@
1
+ module Xronor
2
+ class DSL
3
+ class Job
4
+ include Xronor::DSL::Checker
5
+
6
+ def initialize(frequency, options, &block)
7
+ @frequency = frequency
8
+ @options = options
9
+
10
+ schedule = case frequency
11
+ when String # cron expression
12
+ frequency
13
+ when Symbol, Numeric # DSL (:hour, 1.min, 1.hour, ...)
14
+ Xronor::DSL::ScheduleConverter.convert(frequency, options)
15
+ else
16
+ raise ArgumentError, "Invalid frequency #{frequency}"
17
+ end
18
+
19
+ @result = OpenStruct.new(
20
+ description: nil,
21
+ name: "",
22
+ schedule: schedule,
23
+ command: "",
24
+ )
25
+
26
+ instance_eval(&block)
27
+ end
28
+
29
+ %i(description name).each do |key|
30
+ define_method(key) do |arg|
31
+ @result.send("#{key}=", arg)
32
+ end
33
+ end
34
+
35
+ def process_template(template, options)
36
+ template.gsub(/:\w+/) do |key|
37
+ before_and_after = [$`[-1..-1], $'[0..0]]
38
+ option = options[key.sub(':', '').to_sym] || key
39
+
40
+ if before_and_after.all? { |c| c == "'" }
41
+ escape_single_quotes(option)
42
+ elsif before_and_after.all? { |c| c == '"' }
43
+ escape_double_quotes(option)
44
+ else
45
+ option
46
+ end
47
+ end.gsub(/\s+/m, " ").strip
48
+ end
49
+
50
+ def result
51
+ required(:name, @result.name)
52
+ @result
53
+ end
54
+
55
+ private
56
+
57
+ def escape_single_quotes(str)
58
+ str.gsub(/'/) { "'\\''" }
59
+ end
60
+
61
+ def escape_double_quotes(str)
62
+ str.gsub(/"/) { '\"' }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,75 @@
1
+ # This file is derived from Whenever, Copyright (c) 2017 Javan Makhmali
2
+ # Original code:
3
+ # https://github.com/javan/whenever/blob/1dcb91484e6f1ee91c9272daccbe84111754102b/lib/whenever/numeric_seconds.rb
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person
6
+ # obtaining a copy of this software and associated documentation
7
+ # files (the "Software"), to deal in the Software without
8
+ # restriction, including without limitation the rights to use,
9
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the
11
+ # Software is furnished to do so, subject to the following
12
+ # conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be
15
+ # included in all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ # OTHER DEALINGS IN THE SOFTWARE.
25
+ #
26
+ # Modifications are Copyright 2017 Daisuke Fujita. License under the MIT License.
27
+
28
+ module Xronor
29
+ class DSL
30
+ class NumericSeconds
31
+ def self.seconds(number, units)
32
+ self.new(number).send(units)
33
+ end
34
+
35
+ def initialize(number)
36
+ @number = number.to_i
37
+ end
38
+
39
+ def seconds
40
+ @number
41
+ end
42
+ alias :second :seconds
43
+
44
+ def minutes
45
+ @number * 60
46
+ end
47
+ alias :minute :minutes
48
+
49
+ def hours
50
+ @number * 60 * 60
51
+ end
52
+ alias :hour :hours
53
+
54
+ def days
55
+ @number * 60 * 60 * 24
56
+ end
57
+ alias :day :days
58
+
59
+ def weeks
60
+ @number * 60 * 60 * 24 * 7
61
+ end
62
+ alias :week :weeks
63
+
64
+ def months
65
+ @number * 60 * 60 * 24 * 30
66
+ end
67
+ alias :month :months
68
+
69
+ def years
70
+ @number * 60 * 60 * 24 * 365 + 60 * 60 * 6 # consider leap year
71
+ end
72
+ alias :year :years
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,147 @@
1
+ # Part of logic in this file is derived from Whenever, Copyright (c) 2017 Javan Makhmali
2
+ # Original code:
3
+ # https://github.com/javan/whenever/blob/1dcb91484e6f1ee91c9272daccbe84111754102b/lib/whenever/cron.rb
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person
6
+ # obtaining a copy of this software and associated documentation
7
+ # files (the "Software"), to deal in the Software without
8
+ # restriction, including without limitation the rights to use,
9
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the
11
+ # Software is furnished to do so, subject to the following
12
+ # conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be
15
+ # included in all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ # OTHER DEALINGS IN THE SOFTWARE.
25
+ #
26
+ # Modifications are Copyright 2017 Daisuke Fujita. License under the MIT License.
27
+
28
+ module Xronor
29
+ class DSL
30
+ class ScheduleConverter
31
+ WEEKDAYS = %i(sunday monday tuesday wednesday thursday friday saturday)
32
+
33
+ class << self
34
+ def convert(frequency, options)
35
+ self.new(frequency, options).convert
36
+ end
37
+ end
38
+
39
+ def initialize(frequency, options)
40
+ @frequency = frequency
41
+ @options = options
42
+ end
43
+
44
+ def convert
45
+ cron_at, dow_diff = case @options[:at]
46
+ when String
47
+ parse_and_convert_time
48
+ when Numeric
49
+ [@options[:at], 0]
50
+ else
51
+ [0, 0]
52
+ end
53
+
54
+ if WEEKDAYS.include?(@frequency) # :sunday, :monday, ..., :saturday
55
+ return cron_weekly(cron_at, dow_diff)
56
+ end
57
+
58
+ shortcut = case @frequency
59
+ when Numeric
60
+ @frequency
61
+ when :minute
62
+ Xronor::DSL.seconds(1, :minute)
63
+ when :hour
64
+ Xronor::DSL.seconds(1, :hour)
65
+ when :day
66
+ Xronor::DSL.seconds(1, :day)
67
+ end
68
+
69
+ raise ArgumentError, "Invalid frequency #{@frequency}" if !shortcut.is_a?(Numeric)
70
+
71
+ cron(shortcut, cron_at)
72
+ end
73
+
74
+ private
75
+
76
+ def comma_separated_timing(frequency, max, start = 0)
77
+ return start if frequency.nil? || frequency == "" || frequency == 0
78
+ return "*" if frequency == 1
79
+ return frequency if frequency > (max * 0.5) .ceil
80
+
81
+ original_start = start
82
+
83
+ start += frequency unless (max + 1).modulo(frequency).zero? || start > 0
84
+ output = (start..max).step(frequency).to_a
85
+
86
+ max_occurances = (max.to_f / (frequency.to_f)).round
87
+ max_occurances += 1 if original_start.zero?
88
+
89
+ output[0, max_occurances].join(',')
90
+ end
91
+
92
+ def cron(shortcut, cron_at)
93
+ digits = ["*", "*", "*", "*", "*"]
94
+
95
+ case shortcut
96
+ when Xronor::DSL.seconds(0, :second)...Xronor::DSL.seconds(1, :minute)
97
+ raise ArgumentError, "Time must be in minutes or higher"
98
+ when Xronor::DSL.seconds(1, :minute)...Xronor::DSL.seconds(1, :hour)
99
+ min_frequency = shortcut / 60
100
+ digits[0] = comma_separated_timing(min_frequency, 59, cron_at.is_a?(Time) ? cron_at.min : 0)
101
+ when Xronor::DSL.seconds(1, :hour)...Xronor::DSL.seconds(1, :day)
102
+ hour_frequency = (shortcut / 60 / 60).round
103
+ digits[0] = cron_at.is_a?(Time) ? cron_at.min : cron_at
104
+ digits[1] = comma_separated_timing(hour_frequency, 23, cron_at.is_a?(Time) ? cron_at.hour : 0)
105
+ when Xronor::DSL.seconds(1, :day)...Xronor::DSL.seconds(1, :month)
106
+ day_frequency = (shortcut / 24 / 60 / 60).round
107
+ digits[0] = cron_at.is_a?(Time) ? cron_at.min : 0
108
+ digits[1] = cron_at.is_a?(Time) ? cron_at.hour : cron_at
109
+ digits[2] = comma_separated_timing(day_frequency, 31, 1)
110
+ end
111
+
112
+ digits.join(" ")
113
+ end
114
+
115
+ def cron_weekly(cron_at, dow_diff)
116
+ dow = WEEKDAYS.index(@frequency)
117
+ dow += dow_diff
118
+
119
+ case dow
120
+ when -1 # Sunday -> Saturday
121
+ dow = 6
122
+ when 7 # Saturday -> Sunday
123
+ dow = 0
124
+ end
125
+
126
+ [
127
+ cron_at.is_a?(Time) ? cron_at.min : "0",
128
+ cron_at.is_a?(Time) ? cron_at.hour : "0",
129
+ "*",
130
+ "*",
131
+ dow,
132
+ ].join(" ")
133
+ end
134
+
135
+ def parse_and_convert_time
136
+ original_time_class = Chronic.time_class
137
+ Time.zone = @options[:timezone]
138
+ Chronic.time_class = Time.zone
139
+ local_at = Chronic.parse(@options[:at])
140
+ cron_at = local_at.in_time_zone(@options[:cron_timezone])
141
+ Chronic.time_class = original_time_class
142
+
143
+ return cron_at, (cron_at.wday - local_at.wday)
144
+ end
145
+ end
146
+ end
147
+ end
data/lib/xronor/dsl.rb ADDED
@@ -0,0 +1,63 @@
1
+ module Xronor
2
+ class DSL
3
+ DEFAULT_PREFIX = "scheduler-"
4
+ DEFAULT_TIMEZONE = "UTC"
5
+ DEFAULT_CRON_TIMEZONE = "UTC"
6
+ DEFAULT_JOB_TEMPLATE = ":job"
7
+
8
+ class DuplicatedError < StandardError; end
9
+
10
+ class << self
11
+ def eval(body)
12
+ self.new(body)
13
+ end
14
+
15
+ def seconds(number, units)
16
+ Xronor::DSL::NumericSeconds.seconds(number, units)
17
+ end
18
+ end
19
+
20
+ def initialize(body)
21
+ @result = OpenStruct.new(
22
+ jobs: {},
23
+ options: {
24
+ prefix: DEFAULT_PREFIX,
25
+ timezone: DEFAULT_TIMEZONE,
26
+ cron_timezone: DEFAULT_CRON_TIMEZONE,
27
+ job_template: DEFAULT_JOB_TEMPLATE,
28
+ },
29
+ )
30
+
31
+ instance_eval(body)
32
+ end
33
+
34
+ def default(&block)
35
+ @result.options.merge!(Xronor::DSL::Default.new(&block).result.to_h)
36
+ end
37
+
38
+ def every(frequency, options = {}, &block)
39
+ job = Xronor::DSL::Job.new(frequency, @result.options.merge(options), &block).result
40
+ raise Xronor::DSL::DuplicatedError, "Job \"#{job.name}\" already exists" if @result.jobs[job.name]
41
+ @result.jobs[job.name] = job
42
+ end
43
+
44
+ def job_template(template)
45
+ @result.options[:job_template] = template
46
+ end
47
+
48
+ def job_type(name, template)
49
+ Xronor::DSL::Job.class_eval do
50
+ define_method(name) do |task, *args|
51
+ options = { task: task }
52
+ options.merge!(args[0]) if args[0].is_a? Hash
53
+ job = process_template(template, options)
54
+ @result.command = process_template(@options[:job_template], options.merge({ job: job }))
55
+ end
56
+ end
57
+ end
58
+
59
+ def result
60
+ @result
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,77 @@
1
+ module Xronor
2
+ module Generator
3
+ class CloudWatchEvents
4
+ class << self
5
+ def generate(filename, options)
6
+ jobs = Xronor::Parser.parse(filename)
7
+ function_arn = lambda.retrieve_function_arn(options[:function])
8
+
9
+ current_jobs = cwe.list_jobs(options[:prefix])
10
+ add_jobs, delete_jobs = compare_jobs(options[:prefix], current_jobs, jobs)
11
+
12
+ added_rule_arns = add_jobs.map do |job|
13
+ if options[:dry_run]
14
+ puts "[DRYRUN] #{job.name} will be registered to CloudWatch Events"
15
+ else
16
+ arn = cwe.register_job(
17
+ job,
18
+ options[:prefix],
19
+ options[:cluster],
20
+ options[:task_definition],
21
+ options[:container],
22
+ function_arn,
23
+ )
24
+ puts "Registered #{arn}"
25
+ arn
26
+ end
27
+ end
28
+
29
+ if options[:dry_run]
30
+ else
31
+ dynamodb.sync_rule_arns(options[:table], added_rule_arns, [])
32
+ end
33
+
34
+ delete_jobs.each do |job|
35
+ if options[:dry_run]
36
+ puts "[DRYRUN] #{job} will be deregistered from CloudWatch Events"
37
+ else
38
+ cwe.deregister_job(job)
39
+ puts "Deregistered #{job}"
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def compare_jobs(prefix, current_jobs, next_jobs)
47
+ add_jobs, delete_jobs = [], []
48
+ next_job_names = next_jobs.map do |job|
49
+ job.cloud_watch_rule_name(prefix)
50
+ end
51
+
52
+ next_jobs.each do |job|
53
+ add_jobs << job unless current_jobs.include?(job.cloud_watch_rule_name(prefix))
54
+ end
55
+
56
+ current_jobs.each do |job|
57
+ delete_jobs << job unless next_job_names.include?(job)
58
+ end
59
+
60
+ return add_jobs, delete_jobs
61
+ end
62
+
63
+ def cwe
64
+ @cwe ||= Xronor::AWS::CloudWatchEvents.new
65
+ end
66
+
67
+ def dynamodb
68
+ @dynamodb ||= Xronor::AWS::DynamoDB.new
69
+ end
70
+
71
+ def lambda
72
+ @lambda ||= Xronor::AWS::Lambda.new
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,18 @@
1
+ module Xronor
2
+ module Generator
3
+ class Crontab
4
+ class << self
5
+ def generate(filename, options)
6
+ jobs = Xronor::Parser.parse(filename)
7
+
8
+ jobs.map do |job|
9
+ <<-EOS
10
+ # #{job.name} - #{job.description}
11
+ #{[job.schedule, job.command].join(" ")}
12
+ EOS
13
+ end.join("\n")
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ module Xronor
2
+ module Generator
3
+ class ERB
4
+ class << self
5
+ def generate_all_in_one(filename, options)
6
+ @jobs = Xronor::Parser.parse(filename)
7
+ erb = open(options[:template]).read
8
+ ::ERB.new(erb, nil, "-").result(binding)
9
+ end
10
+
11
+ def generate_per_job(filename, options)
12
+ jobs = Xronor::Parser.parse(filename)
13
+ erb = open(options[:template]).read
14
+
15
+ jobs.inject({}) do |result, job|
16
+ @job = job
17
+ result[job.name.gsub(/[^\.\-_A-Za-z0-9]/, "_").downcase] = ::ERB.new(erb, nil, "-").result(binding)
18
+ result
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/xronor/job.rb ADDED
@@ -0,0 +1,44 @@
1
+ module Xronor
2
+ class Job
3
+ DOM_INDEX = 2
4
+ DOW_INDEX = 4
5
+
6
+ def initialize(name, description, schedule, command)
7
+ @name = name
8
+ @description = description
9
+ @schedule = schedule
10
+ @command = command
11
+ end
12
+
13
+ attr_reader :command, :description, :name, :schedule
14
+
15
+ def cloud_watch_schedule
16
+ cron_fields = @schedule.split(" ")
17
+
18
+ if cron_fields[DOM_INDEX] == "*" && cron_fields[DOW_INDEX] == "*"
19
+ cron_fields[DOW_INDEX] = "?"
20
+ else
21
+ cron_fields[DOM_INDEX] = "?" if cron_fields[DOM_INDEX] == "*"
22
+
23
+ if cron_fields[DOW_INDEX] == "*"
24
+ cron_fields[DOW_INDEX] = "?"
25
+ else
26
+ cron_fields[DOW_INDEX] = cron_fields[DOW_INDEX].to_i + 1
27
+ end
28
+ end
29
+
30
+ cron_fields << "*" # Year
31
+ "cron(#{cron_fields.join(" ")})"
32
+ end
33
+
34
+ def cloud_watch_rule_name(prefix)
35
+ "#{prefix}#{@name}-#{hashcode}".gsub(/[^\.\-_A-Za-z0-9]/, "-").downcase
36
+ end
37
+
38
+ private
39
+
40
+ def hashcode
41
+ @hashcode ||= OpenSSL::Digest::SHA256.hexdigest("#{@name}\t#{@description}\t#{@schedule}\t#{@command}")[0..12]
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,13 @@
1
+ module Xronor
2
+ class Parser
3
+ def self.parse(filename)
4
+ body = open(filename).read
5
+ result = Xronor::DSL.eval(body).result
6
+
7
+ result.jobs.values.map do |job|
8
+ job.description ||= job.name
9
+ Xronor::Job.new(job.name, job.description, job.schedule, job.command)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module Xronor
2
+ VERSION = "0.1.0"
3
+ end
data/lib/xronor.rb ADDED
@@ -0,0 +1,30 @@
1
+ require "aws-sdk-core"
2
+ require "active_support/core_ext/time"
3
+ require "chronic"
4
+ require "erb"
5
+ require "openssl"
6
+ require "optparse"
7
+ require "shellwords"
8
+ require "thor"
9
+
10
+ require "xronor/aws/cloud_watch_events"
11
+ require "xronor/aws/dynamo_db"
12
+ require "xronor/aws/lambda"
13
+ require "xronor/cli"
14
+ require "xronor/core_ext/numeric"
15
+ require "xronor/dsl"
16
+ require "xronor/dsl/checker"
17
+ require "xronor/dsl/default"
18
+ require "xronor/dsl/job"
19
+ require "xronor/dsl/numeric_seconds"
20
+ require "xronor/dsl/schedule_converter"
21
+ require "xronor/generator/crontab"
22
+ require "xronor/generator/cloud_watch_events"
23
+ require "xronor/generator/erb"
24
+ require "xronor/job"
25
+ require "xronor/parser"
26
+ require "xronor/version"
27
+
28
+ module Xronor
29
+
30
+ end
data/xronor.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'xronor/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "xronor"
8
+ spec.version = Xronor::VERSION
9
+ spec.authors = ["Daisuke Fujita"]
10
+ spec.email = ["dtanshi45@gmail.com"]
11
+
12
+ spec.summary = %q{Whenever DSL -> CloudWatch Events Schedule Rule & ECS Task Parameter}
13
+ spec.description = %q{Convert Whenever DSL to CloudWatch Events Schedule Rule to invoke job on ECS}
14
+ spec.homepage = "https://github.com/dtan4/xronor"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_dependency "activesupport", "~> 5.0.2"
25
+ spec.add_dependency "aws-sdk", "~> 2.8.7"
26
+ spec.add_dependency "chronic", "~> 0.10"
27
+ spec.add_dependency "thor", "~> 0.19"
28
+
29
+ spec.add_development_dependency "bundler", "~> 1.14"
30
+ spec.add_development_dependency "rake", "~> 10.0"
31
+ spec.add_development_dependency "rspec", "~> 3.0"
32
+ end