capistrano-cron 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8a87a247ec33d72a9a62f6afe1fb81c1e549c381a979d3d0a05025bc6176813b
4
+ data.tar.gz: 14e5b4ac459edd04996a2bb4e94dd90bc2190fb727fbbb4b093785dd0f314d6c
5
+ SHA512:
6
+ metadata.gz: 6515d52f18b6b4342b4deb3249c25ffda09d93b41984e0e852a0c9c02335359781fa1df4d507a0b6f64f07358f6fda742f2fb0a2280f1f9610ac80bd7cd3b3a8
7
+ data.tar.gz: 6b0b9e542c6697f39fdb43f976ecbec578cc0cd03667ca4b33dc5fd3cf46b170cf07227c454d0e15ebbcd1fc7c72c15b637ffaf84268f422b51ca511ea7dd4d6
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.0
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-11-05
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Brice TEXIER
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.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # Capistrano::Cron
2
+
3
+ The gem is based on [whenever](https://github.com/javan/whenever) code.
4
+ The difference is that crontab file is generated and uploaded to the server during deployment.
5
+ No `bin/whenever` is needed on the server. It handles well `asdf` too.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `capistrano-cron` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ $ bundle add capistrano-cron
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ $ gem install capistrano-cron
18
+
19
+ In your Capfile, require the library:
20
+
21
+ ```ruby
22
+ require 'capistrano/cron'
23
+ install_plugin Capistrano::Cron
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```sh
29
+ cap <stage> cron:update
30
+ cap <stage> cron:clear
31
+ ```
32
+
33
+ ## Development
34
+
35
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
36
+
37
+ 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
38
+
39
+ ## Contributing
40
+
41
+ Bug reports and pull requests are welcome on GitHub at https://github.com/codeur/capistrano-cron. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/codeur/capistrano-cron/blob/main/CODE_OF_CONDUCT.md).
42
+
43
+ ## License
44
+
45
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
46
+
47
+ ## Code of Conduct
48
+
49
+ Everyone interacting in the Capistrano::Cron project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/codeur/capistrano-cron/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
@@ -0,0 +1,55 @@
1
+ require "shellwords"
2
+
3
+ class Capistrano::Cron
4
+ class Job
5
+ attr_reader :at, :roles, :mailto
6
+
7
+ def initialize(options = {})
8
+ @options = options
9
+ @at = options.delete(:at)
10
+ @template = options.delete(:template)
11
+ @mailto = options.fetch(:mailto, :default_mailto)
12
+ @job_template = options.delete(:job_template) || ":job"
13
+ @roles = Array(options.delete(:roles))
14
+ @options[:output] = options.has_key?(:output) ? Capistrano::Cron::Output::Redirection.new(options[:output]).to_s : ""
15
+ @options[:environment_variable] ||= "RAILS_ENV"
16
+ @options[:environment] ||= :production
17
+ @options[:path] = Shellwords.shellescape(@options[:path])
18
+ end
19
+
20
+ def output
21
+ job = process_template(@template, @options)
22
+ out = process_template(@job_template, @options.merge(job: job))
23
+ out.gsub("%", '\%')
24
+ end
25
+
26
+ def has_role?(role)
27
+ roles.empty? || roles.include?(role)
28
+ end
29
+
30
+ protected
31
+
32
+ def process_template(template, options)
33
+ template.gsub(/:\w+/) do |key|
34
+ before_and_after = [$`[-1..-1], $'[0..0]]
35
+ option = options[key.sub(":", "").to_sym] || key
36
+
37
+ if before_and_after.all? { |c| c == "'" }
38
+ escape_single_quotes(option)
39
+ elsif before_and_after.all? { |c| c == '"' }
40
+ escape_double_quotes(option)
41
+ else
42
+ option
43
+ end
44
+ end.gsub(/\s+/m, " ").strip
45
+ end
46
+
47
+ def escape_single_quotes(str)
48
+ str.gsub("'") { "'\\''" }
49
+ end
50
+
51
+ def escape_double_quotes(str)
52
+ str.gsub('"') { '\"' }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,193 @@
1
+ require_relative "job"
2
+ require_relative "output/cron"
3
+ require_relative "output/redirection"
4
+
5
+ class Capistrano::Cron
6
+ class JobList
7
+ attr_reader :roles
8
+
9
+ def initialize(options)
10
+ @jobs, @env, @set_variables, @pre_set_variables = {}, {}, {}, {}
11
+
12
+ if options.is_a? String
13
+ options = {string: options}
14
+ end
15
+
16
+ pre_set(options[:set])
17
+ set(:bundle_command, options[:bundle_command]) if options[:bundle_command]
18
+ set(:path, options[:path]) if options[:path]
19
+ set(:environment, options[:environment]) if options[:environment]
20
+ set(:environment_variable, "RAILS_ENV")
21
+
22
+ @roles = options[:roles] || []
23
+
24
+ setup_file = File.expand_path("../setup.rb", __FILE__)
25
+ setup = File.read(setup_file)
26
+ schedule = if options[:string]
27
+ options[:string]
28
+ elsif options[:file]
29
+ File.read(options[:file])
30
+ end
31
+
32
+ instance_eval(setup, setup_file)
33
+ instance_eval(schedule, options[:file] || "<eval>")
34
+ end
35
+
36
+ def set(variable, value)
37
+ variable = variable.to_sym
38
+ return if @pre_set_variables[variable]
39
+
40
+ instance_variable_set(:"@#{variable}", value)
41
+ @set_variables[variable] = value
42
+ end
43
+
44
+ def method_missing(name, *args, &block)
45
+ @set_variables.has_key?(name) ? @set_variables[name] : super
46
+ end
47
+
48
+ def self.respond_to?(name, include_private = false)
49
+ @set_variables.has_key?(name) || super
50
+ end
51
+
52
+ def env(variable, value)
53
+ @env[variable.to_s] = value
54
+ end
55
+
56
+ def every(frequency, options = {})
57
+ @current_time_scope = frequency
58
+ @options = options
59
+ yield
60
+ end
61
+
62
+ def job_type(name, template)
63
+ singleton_class.class_eval do
64
+ define_method(name) do |task, *args|
65
+ options = {task: task, template: template}
66
+ options.merge!(args[0]) if args[0].is_a? Hash
67
+
68
+ options[:mailto] ||= @options.fetch(:mailto, :default_mailto)
69
+
70
+ # :cron_log was an old option for output redirection, it remains for backwards compatibility
71
+ options[:output] = (options[:cron_log] || @cron_log) if defined?(@cron_log) || options.has_key?(:cron_log)
72
+ # :output is the newer, more flexible option.
73
+ options[:output] = @output if defined?(@output) && !options.has_key?(:output)
74
+
75
+ @jobs[options.fetch(:mailto)] ||= {}
76
+ @jobs[options.fetch(:mailto)][@current_time_scope] ||= []
77
+ @jobs[options.fetch(:mailto)][@current_time_scope] << Capistrano::Cron::Job.new(@options.merge(@set_variables).merge(options))
78
+ end
79
+ end
80
+ end
81
+
82
+ def generate_cron_output
83
+ [environment_variables, cron_jobs].compact.join
84
+ end
85
+
86
+ private
87
+
88
+ #
89
+ # Takes a string like: "variable1=something&variable2=somethingelse"
90
+ # and breaks it into variable/value pairs. Used for setting variables at runtime from the command line.
91
+ # Only works for setting values as strings.
92
+ #
93
+ def pre_set(variable_string = nil)
94
+ return if variable_string.nil? || variable_string == ""
95
+
96
+ pairs = variable_string.split("&")
97
+ pairs.each do |pair|
98
+ next unless pair.index("=")
99
+ variable, value = *pair.split("=")
100
+ unless variable.nil? || variable == "" || value.nil? || value == ""
101
+ variable = variable.strip.to_sym
102
+ set(variable, value.strip)
103
+ @pre_set_variables[variable] = value
104
+ end
105
+ end
106
+ end
107
+
108
+ def environment_variables
109
+ return if @env.empty?
110
+
111
+ output = []
112
+ @env.each do |key, val|
113
+ output << "#{key}=#{(val.nil? || val == "") ? '""' : val}\n"
114
+ end
115
+ output << "\n"
116
+
117
+ output.join
118
+ end
119
+
120
+ #
121
+ # Takes the standard cron output that Capistrano::Cron generates and finds
122
+ # similar entries that can be combined. For example: If a job should run
123
+ # at 3:02am and 4:02am, instead of creating two jobs this method combines
124
+ # them into one that runs on the 2nd minute at the 3rd and 4th hour.
125
+ #
126
+ def combine(entries)
127
+ entries.map! { |entry| entry.split(/ +/, 6) }
128
+ 0.upto(4) do |f|
129
+ (entries.length - 1).downto(1) do |i|
130
+ next if entries[i][f] == "*"
131
+ comparison = entries[i][0...f] + entries[i][f + 1..-1]
132
+ (i - 1).downto(0) do |j|
133
+ next if entries[j][f] == "*"
134
+ if comparison == entries[j][0...f] + entries[j][f + 1..-1]
135
+ entries[j][f] += "," + entries[i][f]
136
+ entries.delete_at(i)
137
+ break
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ entries.map { |entry| entry.join(" ") }
144
+ end
145
+
146
+ def cron_jobs_of_time(time, jobs)
147
+ shortcut_jobs, regular_jobs = [], []
148
+
149
+ jobs.each do |job|
150
+ next unless roles.empty? || roles.any? do |r|
151
+ job.has_role?(r)
152
+ end
153
+ Capistrano::Cron::Output::Cron.output(time, job, chronic_options: @chronic_options) do |cron|
154
+ cron << "\n"
155
+
156
+ if cron[0, 1] == "@"
157
+ shortcut_jobs << cron
158
+ else
159
+ regular_jobs << cron
160
+ end
161
+ end
162
+ end
163
+
164
+ shortcut_jobs.join + combine(regular_jobs).join
165
+ end
166
+
167
+ def cron_jobs
168
+ return if @jobs.empty?
169
+
170
+ output = []
171
+
172
+ # jobs with default mailto's must be output before the ones with non-default mailto's.
173
+ @jobs.delete(:default_mailto) { {} }.each do |time, jobs|
174
+ output << cron_jobs_of_time(time, jobs)
175
+ end
176
+
177
+ @jobs.each do |mailto, time_and_jobs|
178
+ output_jobs = []
179
+
180
+ time_and_jobs.each do |time, jobs|
181
+ output_jobs << cron_jobs_of_time(time, jobs)
182
+ end
183
+
184
+ output_jobs.reject! { |output_job| output_job.empty? }
185
+
186
+ output << "MAILTO=#{mailto}\n" unless output_jobs.empty?
187
+ output << output_jobs
188
+ end
189
+
190
+ output.join
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,62 @@
1
+ class Capistrano::Cron
2
+ class NumericSeconds
3
+ attr_reader :number
4
+
5
+ def self.seconds(number, units)
6
+ new(number).send(units)
7
+ end
8
+
9
+ def initialize(number)
10
+ @number = number.to_i
11
+ end
12
+
13
+ def seconds
14
+ number
15
+ end
16
+ alias_method :second, :seconds
17
+
18
+ def minutes
19
+ number * 60
20
+ end
21
+ alias_method :minute, :minutes
22
+
23
+ def hours
24
+ number * 3_600
25
+ end
26
+ alias_method :hour, :hours
27
+
28
+ def days
29
+ number * 86_400
30
+ end
31
+ alias_method :day, :days
32
+
33
+ def weeks
34
+ number * 604_800
35
+ end
36
+ alias_method :week, :weeks
37
+
38
+ def months
39
+ number * 2_592_000
40
+ end
41
+ alias_method :month, :months
42
+
43
+ def years
44
+ number * 31_557_600
45
+ end
46
+ alias_method :year, :years
47
+ end
48
+ end
49
+
50
+ Numeric.class_eval do
51
+ def respond_to?(method, include_private = false)
52
+ super || Capistrano::Cron::NumericSeconds.public_method_defined?(method)
53
+ end
54
+
55
+ def method_missing(method, *args, &block)
56
+ if Capistrano::Cron::NumericSeconds.public_method_defined?(method)
57
+ Capistrano::Cron::NumericSeconds.new(self).send(method)
58
+ else
59
+ super
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,186 @@
1
+ require "chronic"
2
+
3
+ class Capistrano::Cron
4
+ module Output
5
+ class Cron
6
+ DAYS = %w[sun mon tue wed thu fri sat]
7
+ MONTHS = %w[jan feb mar apr may jun jul aug sep oct nov dec]
8
+ KEYWORDS = [:reboot, :yearly, :annually, :monthly, :weekly, :daily, :midnight, :hourly]
9
+ REGEX = /^(@(#{KEYWORDS.join "|"})|((\*?[\d\/,\-]*)\s){3}(\*?([\d\/,\-]|(#{MONTHS.join "|"}))*\s)(\*?([\d\/,\-]|(#{DAYS.join "|"}))*))$/i
10
+
11
+ attr_accessor :time, :task
12
+
13
+ def initialize(time = nil, task = nil, at = nil, options = {})
14
+ chronic_options = options[:chronic_options] || {}
15
+
16
+ @at_given = at
17
+ @time = time
18
+ @task = task
19
+ @at = at.is_a?(String) ? (Chronic.parse(at, chronic_options) || 0) : (at || 0)
20
+ end
21
+
22
+ def self.enumerate(item, detect_cron = true)
23
+ if item and item.is_a?(String)
24
+ items =
25
+ if detect_cron && item =~ REGEX
26
+ [item]
27
+ else
28
+ item.split(",")
29
+ end
30
+ else
31
+ items = item
32
+ items = [items] unless items and items.respond_to?(:each)
33
+ end
34
+ items
35
+ end
36
+
37
+ def self.output(times, job, options = {})
38
+ enumerate(times).each do |time|
39
+ enumerate(job.at, false).each do |at|
40
+ yield new(time, job.output, at, options).output
41
+ end
42
+ end
43
+ end
44
+
45
+ def output
46
+ [time_in_cron_syntax, task].compact.join(" ").strip
47
+ end
48
+
49
+ def time_in_cron_syntax
50
+ @time = @time.to_i if @time.is_a?(Numeric) # Compatibility with `1.day` format using ruby 2.3 and activesupport
51
+ case @time
52
+ when REGEX then @time # raw cron syntax given
53
+ when Symbol then parse_symbol
54
+ when String then parse_as_string
55
+ else parse_time
56
+ end
57
+ end
58
+
59
+ protected
60
+
61
+ def seconds(number, units)
62
+ Capistrano::Cron::NumericSeconds.seconds(number, units)
63
+ end
64
+
65
+ def day_given?
66
+ @at_given.is_a?(String) && (MONTHS.any? { |m| @at_given.downcase.index(m) } || @at_given[/\d\/\d/])
67
+ end
68
+
69
+ def parse_symbol
70
+ shortcut = case @time
71
+ when *KEYWORDS then "@#{@time}" # :reboot => '@reboot'
72
+ when :year then seconds(1, :year)
73
+ when :day then seconds(1, :day)
74
+ when :month then seconds(1, :month)
75
+ when :week then seconds(1, :week)
76
+ when :hour then seconds(1, :hour)
77
+ when :minute then seconds(1, :minute)
78
+ end
79
+
80
+ if shortcut.is_a?(Numeric)
81
+ @time = shortcut
82
+ parse_time
83
+ elsif shortcut
84
+ if @at.is_a?(Time) || (@at.is_a?(Numeric) && @at > 0)
85
+ raise ArgumentError, "You cannot specify an ':at' when using the shortcuts for times."
86
+ else
87
+ shortcut
88
+ end
89
+ else
90
+ parse_as_string
91
+ end
92
+ end
93
+
94
+ def parse_time
95
+ timing = Array.new(5, "*")
96
+ case @time
97
+ when seconds(0, :seconds)...seconds(1, :minute)
98
+ raise ArgumentError, "Time must be in minutes or higher"
99
+ when seconds(1, :minute)...seconds(1, :hour)
100
+ minute_frequency = @time / 60
101
+ timing[0] = comma_separated_timing(minute_frequency, 59, @at || 0)
102
+ when seconds(1, :hour)...seconds(1, :day)
103
+ hour_frequency = (@time / 60 / 60).round
104
+ timing[0] = @at.is_a?(Time) ? @at.min : range_or_integer(@at, 0..59, "Minute")
105
+ timing[1] = comma_separated_timing(hour_frequency, 23)
106
+ when seconds(1, :day)...seconds(1, :month)
107
+ day_frequency = (@time / 24 / 60 / 60).round
108
+ timing[0] = @at.is_a?(Time) ? @at.min : 0
109
+ timing[1] = @at.is_a?(Time) ? @at.hour : range_or_integer(@at, 0..23, "Hour")
110
+ timing[2] = comma_separated_timing(day_frequency, 31, 1)
111
+ when seconds(1, :month)...seconds(1, :year)
112
+ month_frequency = (@time / 30 / 24 / 60 / 60).round
113
+ timing[0] = @at.is_a?(Time) ? @at.min : 0
114
+ timing[1] = @at.is_a?(Time) ? @at.hour : 0
115
+ timing[2] = if @at.is_a?(Time)
116
+ day_given? ? @at.day : 1
117
+ else
118
+ (@at == 0) ? 1 : range_or_integer(@at, 1..31, "Day")
119
+ end
120
+ timing[3] = comma_separated_timing(month_frequency, 12, 1)
121
+ when seconds(1, :year)
122
+ timing[0] = @at.is_a?(Time) ? @at.min : 0
123
+ timing[1] = @at.is_a?(Time) ? @at.hour : 0
124
+ timing[2] = if @at.is_a?(Time)
125
+ day_given? ? @at.day : 1
126
+ else
127
+ 1
128
+ end
129
+ timing[3] = if @at.is_a?(Time)
130
+ day_given? ? @at.month : 1
131
+ else
132
+ (@at == 0) ? 1 : range_or_integer(@at, 1..12, "Month")
133
+ end
134
+ else
135
+ return parse_as_string
136
+ end
137
+ timing.join(" ")
138
+ end
139
+
140
+ def parse_as_string
141
+ return unless @time
142
+ string = @time.to_s
143
+
144
+ timing = Array.new(4, "*")
145
+ timing[0] = @at.is_a?(Time) ? @at.min : 0
146
+ timing[1] = @at.is_a?(Time) ? @at.hour : 0
147
+
148
+ return (timing << "1-5") * " " if string.downcase.index("weekday")
149
+ return (timing << "6,0") * " " if string.downcase.index("weekend")
150
+
151
+ DAYS.each_with_index do |day, i|
152
+ return (timing << i) * " " if string.downcase.index(day)
153
+ end
154
+
155
+ raise ArgumentError, "Couldn't parse: #{@time.inspect}"
156
+ end
157
+
158
+ def range_or_integer(at, valid_range, name)
159
+ must_be_between = "#{name} must be between #{valid_range.min}-#{valid_range.max}"
160
+ if at.is_a?(Range)
161
+ raise ArgumentError, "#{must_be_between}, #{at.min} given" unless valid_range.include?(at.min)
162
+ raise ArgumentError, "#{must_be_between}, #{at.max} given" unless valid_range.include?(at.max)
163
+ return "#{at.min}-#{at.max}"
164
+ end
165
+ raise ArgumentError, "#{must_be_between}, #{at} given" unless valid_range.include?(at)
166
+ at
167
+ end
168
+
169
+ def comma_separated_timing(frequency, max, start = 0)
170
+ return start if frequency.nil? || frequency == "" || frequency.zero?
171
+ return "*" if frequency == 1
172
+ return frequency if frequency > (max * 0.5).ceil
173
+
174
+ original_start = start
175
+
176
+ start += frequency unless (max + 1).modulo(frequency).zero? || start > 0
177
+ output = (start..max).step(frequency).to_a
178
+
179
+ max_occurances = (max.to_f / frequency.to_f).round
180
+ max_occurances += 1 if original_start.zero?
181
+
182
+ output[0, max_occurances].join(",")
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,56 @@
1
+ class Capistrano::Cron
2
+ module Output
3
+ class Redirection
4
+ def initialize(output)
5
+ @output = output
6
+ end
7
+
8
+ def to_s
9
+ return "" unless defined?(@output)
10
+ case @output
11
+ when String then redirect_from_string
12
+ when Hash then redirect_from_hash
13
+ when NilClass then ">> /dev/null 2>&1"
14
+ when Proc then @output.call
15
+ else ""
16
+ end
17
+ end
18
+
19
+ protected
20
+
21
+ def stdout
22
+ return unless @output.has_key?(:standard)
23
+ @output[:standard].nil? ? "/dev/null" : @output[:standard]
24
+ end
25
+
26
+ def stderr
27
+ return unless @output.has_key?(:error)
28
+ @output[:error].nil? ? "/dev/null" : @output[:error]
29
+ end
30
+
31
+ def redirect_from_hash
32
+ if stdout == "/dev/null" && stderr == "/dev/null"
33
+ "> /dev/null 2>&1"
34
+ elsif stdout && stderr == "/dev/null"
35
+ ">> #{stdout} 2> /dev/null"
36
+ elsif stdout && stderr
37
+ ">> #{stdout} 2>> #{stderr}"
38
+ elsif stderr == "/dev/null"
39
+ "2> /dev/null"
40
+ elsif stderr
41
+ "2>> #{stderr}"
42
+ elsif stdout == "/dev/null"
43
+ "> /dev/null"
44
+ elsif stdout
45
+ ">> #{stdout}"
46
+ else
47
+ ""
48
+ end
49
+ end
50
+
51
+ def redirect_from_string
52
+ ">> #{@output} 2>&1"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,9 @@
1
+ # All jobs are wrapped in this template.
2
+ # http://blog.scoutapp.com/articles/2010/09/07/rvm-and-cron-in-production
3
+ set :job_template, "/bin/bash -l -c ':job'"
4
+
5
+ set :runner_command, "rails runner"
6
+
7
+ job_type :command, ":task :output"
8
+ job_type :rake, "cd :path && :environment_variable=:environment :bundle_command exec rake :task --silent :output"
9
+ job_type :runner, "cd :path && :bundle_command exec :runner_command -e :environment ':task' :output"
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capistrano/plugin"
4
+
5
+ module Capistrano
6
+ class Cron < Capistrano::Plugin
7
+ VERSION = "0.1.0"
8
+
9
+ def set_defaults
10
+ set_if_empty :cron_roles, %w[db]
11
+ set_if_empty :cron_identifier, -> { "#{fetch(:application)}_#{fetch(:stage)}" }
12
+ set_if_empty :cron_environment, -> { fetch(:stage) }
13
+ set_if_empty :cron_schedule_path, "config/schedule.rb"
14
+ end
15
+
16
+ def define_tasks
17
+ eval_rakefile File.expand_path("../tasks/cron.rake", __FILE__)
18
+ end
19
+
20
+ def register_hooks
21
+ after "deploy:updated", "cron:update"
22
+ after "deploy:reverted", "cron:update"
23
+ end
24
+
25
+ def update_crontab
26
+ install_crontab(schedule_cron)
27
+ end
28
+
29
+ def clear_crontab
30
+ install_crontab("")
31
+ end
32
+
33
+ def identifier
34
+ fetch(:cron_identifier)
35
+ end
36
+
37
+ private
38
+
39
+ def current_crontab
40
+ @current_crontab ||= begin
41
+ contents = backend.capture(:crontab, "-l")
42
+
43
+ # Strip n lines from the top of the file as specified by the :cut option.
44
+ # Use split with a -1 limit option to ensure the join is able to rebuild
45
+ # the file with all of the original seperators in-tact.
46
+ stripped_contents = contents.split($/, -1)[0..-1].join($/)
47
+
48
+ # Some cron implementations require all non-comment lines to be newline-
49
+ # terminated. (issue #95) Strip all newlines and replace with the default
50
+ # platform record seperator ($/)
51
+ stripped_contents.gsub(/\s+$/, $/)
52
+ end
53
+ end
54
+
55
+ def install_crontab(content)
56
+ file = release_path.join("tmp", "#{identifier}.cron")
57
+ backend.upload! StringIO.new(updated_crontab(content)), file
58
+ backend.execute :cat, file, "| crontab -"
59
+ end
60
+
61
+ def schedule_cron
62
+ @schedule_cron ||= [comment_open, job_list.generate_cron_output.strip, comment_close].compact.join("\n") + "\n"
63
+ end
64
+
65
+ def job_list
66
+ @job_list ||= JobList.new(
67
+ file: fetch(:cron_schedule_path),
68
+ identifier: identifier,
69
+ roles: fetch(:cron_roles),
70
+ path: current_path,
71
+ environment: fetch(:cron_environment),
72
+ bundle_command: expanded_bundle_command
73
+ )
74
+ end
75
+
76
+ def expanded_bundle_command
77
+ backend.capture(:echo, SSHKit.config.command_map[:bundle]).strip
78
+ end
79
+
80
+ def updated_crontab(cron)
81
+ # Check for unopened or unclosed identifier blocks
82
+ if current_crontab =~ Regexp.new("^#{comment_open}\s*$") && (current_crontab =~ Regexp.new("^#{comment_close}\s*$")).nil?
83
+ fail "Unclosed indentifier; Your crontab file contains '#{comment_open}', but no '#{comment_close}'"
84
+ elsif (current_crontab =~ Regexp.new("^#{comment_open}\s*$")).nil? && current_crontab =~ Regexp.new("^#{comment_close}\s*$")
85
+ fail "Unopened indentifier; Your crontab file contains '#{comment_close}', but no '#{comment_open}'"
86
+ end
87
+
88
+ # If an existing identifier block is found, replace it with the new cron entries
89
+ if current_crontab =~ Regexp.new("^#{comment_open}\s*$") && current_crontab =~ Regexp.new("^#{comment_close}\s*$")
90
+ # If the existing crontab file contains backslashes they get lost going through gsub.
91
+ # .gsub('\\', '\\\\\\') preserves them. Go figure.
92
+ current_crontab.gsub(Regexp.new("^#{comment_open}\s*$.+^#{comment_close}\s*$", Regexp::MULTILINE), cron.chomp.gsub("\\", "\\\\\\"))
93
+ else # Otherwise, append the new cron entries after any existing ones
94
+ [current_crontab, cron].join("\n\n")
95
+ end.gsub(/\n{3,}/, "\n\n") # More than two newlines becomes just two.
96
+ end
97
+
98
+ def comment_open
99
+ "# >>> #{identifier} jobs generated by capistrano-cron"
100
+ end
101
+
102
+ def comment_close
103
+ "# <<< #{identifier} jobs"
104
+ end
105
+ end
106
+ end
107
+
108
+ require_relative "cron/numeric_ext"
109
+ require_relative "cron/job_list"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ plugin = self
4
+
5
+ namespace :cron do # rubocop:disable Metrics
6
+ desc "Update cron configuration"
7
+ task :update do
8
+ on roles(fetch(:cron_roles)) do
9
+ plugin.update_crontab
10
+ end
11
+ end
12
+
13
+ desc "Clear cron configuration"
14
+ task :clear do
15
+ on roles(fetch(:cron_roles)) do
16
+ plugin.clear_crontab
17
+ end
18
+ end
19
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: capistrano-cron
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Codeur SAS
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-11-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: capistrano
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: chronic
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.10'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.10'
41
+ description: Capistrano plugin to manage cron jobs created from the whenever gem
42
+ email:
43
+ - dev@codeur.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".standard.yml"
49
+ - CHANGELOG.md
50
+ - CODE_OF_CONDUCT.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - lib/capistrano/cron.rb
55
+ - lib/capistrano/cron/job.rb
56
+ - lib/capistrano/cron/job_list.rb
57
+ - lib/capistrano/cron/numeric_ext.rb
58
+ - lib/capistrano/cron/output/cron.rb
59
+ - lib/capistrano/cron/output/redirection.rb
60
+ - lib/capistrano/cron/setup.rb
61
+ - lib/capistrano/tasks/cron.rake
62
+ homepage: https://github.com/codeur/capistrano-cron
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ allowed_push_host: https://rubygems.org
67
+ homepage_uri: https://github.com/codeur/capistrano-cron
68
+ source_code_uri: https://github.com/codeur/capistrano-cron
69
+ changelog_uri: https://github.com/codeur/capistrano-cron/blob/main/CHANGELOG.md
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.0.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.3.26
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Capistrano plugin to manage cron jobs
89
+ test_files: []