web47core 0.0.10 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Value object for a cron tab entry
5
+ #
6
+ module Cron
7
+ class Tab
8
+ include StandardModel
9
+ include SearchAble
10
+ #
11
+ # Constants
12
+ #
13
+ WILDCARD = '*' unless defined? WILDCARD
14
+ TIME_UNITS = %i[min hour wday mday month].freeze unless defined? TIME_UNITS
15
+ #
16
+ # Fields
17
+ #
18
+ field :name, type: String
19
+ field :enabled, type: Boolean, default: true
20
+ field :min, type: String, default: 0
21
+ field :hour, type: String, default: 0
22
+ field :wday, type: String, default: WILDCARD
23
+ field :mday, type: String, default: WILDCARD
24
+ field :month, type: String, default: WILDCARD
25
+ field :last_run_at, type: Time
26
+ #
27
+ # Validations
28
+ #
29
+ validates :name, presence: true, uniqueness: true
30
+ validate :valid_values
31
+
32
+ #
33
+ # The method might look a bit obtuse, but basically we want to compare the time values for
34
+ # * Minute
35
+ # * Hour
36
+ # * Day of Week
37
+ # * Day of Month
38
+ # * Month
39
+ #
40
+ def time_to_run?(time)
41
+ enabled? && TIME_UNITS.collect { |unit| valid_time?(time, unit, send(unit)) }.all?
42
+ end
43
+
44
+ #
45
+ # For completion of class, but must be implemented by child class
46
+ #
47
+ def run
48
+ set last_run_at: Time.now.utc
49
+ end
50
+
51
+ private
52
+
53
+ #
54
+ # Check that all values are within the range
55
+ #
56
+ def valid_values
57
+ valid_range :min, 0..59
58
+ valid_range :hour, 0..23
59
+ valid_range :month, 0..11
60
+ valid_range :wday, 0..6
61
+ valid_range :mday, 0..30
62
+ end
63
+
64
+ def valid_range(field, range)
65
+ value = send(field)
66
+ valid = case value
67
+ when WILDCARD
68
+ true
69
+ when Integer
70
+ range.include?(value)
71
+ else
72
+ if value.include?('/')
73
+ (numerator, divisor) = value.split('/')
74
+ range.include?(divisor.to_i) && numerator.eql?(WILDCARD)
75
+ elsif value.include?(',')
76
+ options = value.split(',')
77
+ options.collect { |o| range.include?(o.to_i) }.all?
78
+ else
79
+ range.include?(value.to_i)
80
+ end
81
+ end
82
+ errors.add(field, "Invalid value, allowed range: #{range}") unless valid
83
+ end
84
+
85
+ #
86
+ # Test if the target value matches the time unit or the wild card, or the comma separated list
87
+ # 0 - matches the zero value
88
+ # * - Wildcard, any value matches
89
+ # */15 - matches any value where dividing by 15 is even, or the modulus is zero
90
+ # 5,10,15 - matches on 5, 10 and 15 values
91
+ #
92
+ def valid_time?(time, unit, target)
93
+ case target
94
+ when WILDCARD
95
+ true
96
+ when Integer
97
+ time.send(unit).eql?(target)
98
+ else
99
+ if target.include?('/')
100
+ divisor = target.split('/').last.to_i
101
+ (time.send(unit) % divisor).zero?
102
+ elsif target.include?(',')
103
+ options = target.split(',')
104
+ options.collect { |o| time.send(unit.eql?(o.to_i)) }.any?
105
+ else
106
+ time.send(unit).eql?(target.to_i)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cron
4
+ #
5
+ # Clean up the collection items that have not been updated in 30 days
6
+ #
7
+ class TrimCollection < Job
8
+ cron_tab_entry :daily
9
+ #
10
+ # Fetch each item and delete it if hasn't been updated in 30 days
11
+ #
12
+ def perform
13
+ # Rails.cache.reconnect
14
+ count = 0
15
+ total = collection.count
16
+ while count <= total
17
+ collection.limit(250).skip(count).each { |item| item.destroy if archive?(item) }
18
+ count += 250
19
+ end
20
+ end
21
+
22
+ #
23
+ # Test if this should be archived
24
+ #
25
+ def archive?(item)
26
+ item.updated_at < allowed_time
27
+ end
28
+
29
+ #
30
+ # Allowed time the amount of time allowed to exists before deleting
31
+ #
32
+ def allowed_time
33
+ 30.days.ago
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cron
4
+ #
5
+ # Clean up Audit Logs that have not been updated in 90 days
6
+ #
7
+ class TrimCronServers < TrimCollection
8
+ #
9
+ # Fetch each Audit Log and delete it if hasn't been updated in 90 days
10
+ #
11
+ def collection
12
+ Cron::Server.all
13
+ end
14
+
15
+ #
16
+ # Check if the cron job server hasn't reported in a while
17
+ #
18
+ def archive?(item)
19
+ item.dead?
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,40 @@
1
+ module Cron
2
+ #
3
+ # Automatically update Product Version Lifecycle State to EOL If it is past it's EOL date
4
+ # Runs Daily
5
+ #
6
+ class TrimFailedDelayedJobs < TrimCollection
7
+ #
8
+ # Return the failed collection
9
+ #
10
+ def collection
11
+ Delayed::Backend::Mongoid::Job.where(:failed_at.exists => true)
12
+ end
13
+
14
+ def archive?(job)
15
+ if 'Delayed::PerformableMethod'.eql?(job.name)
16
+
17
+ data = { object: job.failed_object.inspect,
18
+ method: job.failed_method_name,
19
+ args: job.failed_args,
20
+ failed_at: job.failed_at,
21
+ attempts: job.attempts,
22
+ locked_by: job.locked_by }
23
+ SlackNotification.say(data, template: :failed_delayed_job)
24
+ true
25
+ else
26
+ false
27
+ end
28
+ rescue StandardError => error
29
+ log_error "Unable to determine if we should archive job: #{job}", error
30
+ false
31
+ end
32
+
33
+ #
34
+ # If the slack API is not present, don't delete jobs or we wont know about it.
35
+ #
36
+ def self.valid_environment?
37
+ SystemConfiguration.slack_api_url.present? && Delayed::Backend::Mongoid::Job.count.positive?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cron
4
+ #
5
+ # Trim notifications in system after 30 days.
6
+ #
7
+ # This was historically done by a database index, however with the introduction
8
+ # of adding an email attachment, and thus another collection, this needed to move
9
+ # to a daily job here so that:
10
+ # 1. the object hierarchy is deleted
11
+ # 2. The paperclip call backs are fired to remove the file from S3
12
+ #
13
+ # In case you are wondering why not make it an embedded class and allow the index
14
+ # method to still work, the answer is that it won't clean up the S3 files.
15
+ #
16
+ class TrimNotifications < TrimCollection
17
+ #
18
+ # Return the collection
19
+ #
20
+ def collection
21
+ Notification.all
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,70 @@
1
+ require 'timeout'
2
+ require 'active_support/dependencies'
3
+ require 'active_support/core_ext/numeric/time'
4
+ require 'active_support/core_ext/class/attribute_accessors'
5
+ require 'active_support/hash_with_indifferent_access'
6
+ require 'active_support/core_ext/hash/indifferent_access'
7
+ require 'logger'
8
+ require 'benchmark'
9
+
10
+ module Cron
11
+ class Worker # rubocop:disable ClassLength
12
+ DEFAULT_LOG_LEVEL = 'info'.freeze
13
+
14
+ cattr_accessor :logger, :default_log_level
15
+
16
+ def self.reset
17
+ self.default_log_level ||= DEFAULT_LOG_LEVEL
18
+ end
19
+
20
+ def self.reload_app?
21
+ defined?(ActionDispatch::Reloader) && Rails.application.config.cache_classes == false
22
+ end
23
+
24
+ def initialize(options = {})
25
+ super()
26
+ @exit = options[:exit_on_complete].presence
27
+ end
28
+
29
+ # Every worker has a unique name which by default is the pid of the process. There are some
30
+ # advantages to overriding this with something which survives worker restarts: Workers can
31
+ # safely resume working on tasks which are locked by themselves. The worker will assume that
32
+ # it crashed before.
33
+ def name
34
+ @name ||= "CronServer:#{Socket.gethostname} pid:#{Process.pid}"
35
+ end
36
+
37
+ def start # rubocop:disable CyclomaticComplexity, PerceivedComplexity
38
+ trap('TERM') { stop }
39
+ trap('INT') { stop }
40
+
41
+ say 'Starting cron jobs server'
42
+
43
+ self.class.lifecycle.run_callbacks(:execute, self) do
44
+ loop do
45
+ sleep Cron::Server.find_or_create_server.execute
46
+ break if stop?
47
+ end
48
+ end
49
+
50
+ say 'Exiting cron jobs server...'
51
+ end
52
+
53
+ def stop
54
+ @exit = true
55
+ end
56
+
57
+ def stop?
58
+ !!@exit
59
+ end
60
+
61
+ def say(text, level = default_log_level)
62
+ text = "[CronServer(#{name})] #{text}"
63
+ return if logger.blank?
64
+
65
+ logger.send(level, "#{Time.now.strftime('%FT%T%z')}: #{text}")
66
+ end
67
+ end
68
+ end
69
+
70
+ Cron::Worker.reset
@@ -0,0 +1,130 @@
1
+ #
2
+ # Objects that can be synced to switchboard. They must implement the following methods
3
+ # * switchboard_name - the name of the object in switchboard land, apps, members, etc..
4
+ # * switchboard_payload - the payload to send to switchboard when updating or deleting
5
+ #
6
+ module SwitchboardAble
7
+ extend ActiveSupport::Concern
8
+
9
+ def self.included(base)
10
+ base.class_eval do
11
+ field :switchboard_id, type: String
12
+ after_create :switchboard_upsert
13
+ # after_update :switchboard_upsert
14
+ before_destroy :switchboard_delete
15
+ end
16
+ end
17
+
18
+ #
19
+ # Return the name in switchboard, must be implemented by the class
20
+ #
21
+ def switchboard_name
22
+ raise 'Method (switchboard_name) must be implemented by the concrete class'
23
+ end
24
+
25
+ #
26
+ # Return the payload in switchboard, must be implemented by the class
27
+ #
28
+ def switchboard_payload
29
+ raise 'Method (switchboard_payload) must be implemented by the concrete class'
30
+ end
31
+
32
+ #
33
+ # Generic handler to determine if we should insert or update this object
34
+ #
35
+ def switchboard_upsert
36
+ return unless community_access?
37
+
38
+ switchboard_id.present? ? switchboard_update : switchboard_insert
39
+ end
40
+
41
+ handle_asynchronously :switchboard_upsert
42
+
43
+ private
44
+
45
+ #
46
+ # Return false if this object should not uploaded to switchboard, true otherwise
47
+ #
48
+ def community_access?
49
+ true
50
+ end
51
+
52
+ #
53
+ # Delete the object from switchboard
54
+ #
55
+ def switchboard_delete
56
+ return unless SystemConfiguration.switchboard_configured? && switchboard_id.present?
57
+
58
+ RestClient.delete(switchboard_url, ACCESS_TOKEN: sw_api_token) do |response, _request, _result, &block|
59
+ case response.code
60
+ when 200
61
+ App47Logger.log_debug "Switchboard deleted #{inspect}"
62
+ else
63
+ App47Logger.log_error "Unable to delete the switchboard object #{inspect}, #{response.inspect}"
64
+ response.return!(&block)
65
+ end
66
+ end
67
+ end
68
+
69
+ #
70
+ # Update the object in switchboard
71
+ #
72
+ def switchboard_update
73
+ return unless SystemConfiguration.switchboard_configured? && switchboard_id.present?
74
+
75
+ RestClient.put(switchboard_url,
76
+ switchboard_payload.to_json,
77
+ ACCESS_TOKEN: sw_api_token,
78
+ content_type: 'application/json') do |response, _request, _result, &block|
79
+ case response.code
80
+ when 200
81
+ App47Logger.log_debug "Switchboard updated #{inspect}"
82
+ when 404, 302
83
+ unset :switchboard_id
84
+ else
85
+ App47Logger.log_error "Unable to update the switchboard object #{inspect}, #{response.inspect}"
86
+ response.return!(&block)
87
+ end
88
+ end
89
+ end
90
+
91
+ #
92
+ # Insert the object into switchboard
93
+ #
94
+ def switchboard_insert
95
+ return unless SystemConfiguration.switchboard_configured?
96
+
97
+ RestClient.post(switchboard_url,
98
+ switchboard_payload.to_json,
99
+ ACCESS_TOKEN: sw_api_token,
100
+ content_type: 'application/json') do |response, _request, _result, &block|
101
+ case response.code
102
+ when 200
103
+ json = JSON.parse(response.body)
104
+ set switchboard_id: json['results'][switchboard_name.singularize]['id']
105
+ else
106
+ App47Logger.log_error "Unable to insert to switchboard object #{inspect}, #{response.inspect}"
107
+ response.return!(&block)
108
+ end
109
+ end
110
+ end
111
+
112
+ #
113
+ # The URL for this switchboard item
114
+ #
115
+ def switchboard_url
116
+ components = [SystemConfiguration.switchboard_base_url,
117
+ 'stacks',
118
+ SystemConfiguration.switchboard_stack_id,
119
+ switchboard_name]
120
+ components << switchboard_id if switchboard_id.present?
121
+ components.join('/') + '.json'
122
+ end
123
+
124
+ #
125
+ # Return the api token
126
+ #
127
+ def sw_api_token
128
+ @sw_api_token ||= SystemConfiguration.switchboard_stack_api_token
129
+ end
130
+ end
@@ -0,0 +1,80 @@
1
+ module Delayed
2
+ module Backend
3
+ module Mongoid
4
+ #
5
+ # Extend the Mongoid delayed job to add additional methods
6
+ #
7
+ class Job
8
+ #
9
+ # Helper to describe the status of the job
10
+ #
11
+ def status_description
12
+ return 'Failed' if failed?
13
+
14
+ locked_by.present? ? locked_by : "Scheduled (#{attempts})"
15
+ end
16
+
17
+ #
18
+ # Display name for the job
19
+ #
20
+ def display_name
21
+ payload_object.job_data['job_class']
22
+ rescue StandardError
23
+ name
24
+ end
25
+
26
+ #
27
+ # Safely get the payload object
28
+ #
29
+ def job_payload
30
+ payload_object.inspect
31
+ rescue StandardError => error
32
+ "Unable to retrieve payload: #{error.message}"
33
+ end
34
+
35
+ #
36
+ # Resubmit the job for processing
37
+ #
38
+ def resubmit
39
+ set locked_at: nil, locked_by: nil, failed_at: nil, last_error: nil, run_at: Time.now.utc
40
+ end
41
+
42
+ #
43
+ # Return the failed object out of the YAML
44
+ #
45
+ def failed_object
46
+ failed_yaml.object
47
+ rescue StandardError => error
48
+ "Unknown object: #{error.message}"
49
+ end
50
+
51
+ #
52
+ # Return the failed method out of the YAML
53
+ #
54
+ def failed_method_name
55
+ failed_yaml.method_name
56
+ rescue StandardError => error
57
+ "Unknown method_name: #{error.message}"
58
+ end
59
+
60
+ #
61
+ # Return the failed arguments out of the YAML
62
+ #
63
+ def failed_args
64
+ failed_yaml.args
65
+ rescue StandardError => error
66
+ "Unknown args: #{error.message}"
67
+ end
68
+
69
+ #
70
+ # Return the failed YAML
71
+ #
72
+ def failed_yaml
73
+ @failed_yaml = YAML.load(handler)
74
+ rescue StandardError
75
+ ''
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,7 @@
1
+ *Delayed Jobs Failed* due to missing document, ~removing from queue!~
2
+ *Object:* ```{{object}}```
3
+ *Method:* `{{method_name}}`
4
+ *Arguments:* `{{args}}`
5
+ *Failed At:* `{{failed_at}}`
6
+ *Attempts:* `{{attempts}}`
7
+ *Last Run By:* `{{locked_by}}`
data/lib/web47core.rb CHANGED
@@ -6,8 +6,10 @@ require 'app/models/concerns/time_zone_able'
6
6
  require 'app/models/concerns/standard_model'
7
7
  require 'app/models/concerns/core_system_configuration'
8
8
  require 'app/models/concerns/core_account'
9
+ require 'app/models/delayed_job'
9
10
  require 'app/models/redis_configuration'
10
11
  require 'app/models/notification'
12
+ require 'app/models/sms_notification'
11
13
  require 'app/models/template'
12
14
  require 'app/models/notification_template'
13
15
  require 'app/models/email_notification'
@@ -15,3 +17,33 @@ require 'app/models/email_template'
15
17
  require 'app/models/slack_notification'
16
18
  require 'app/models/smtp_configuration'
17
19
  require 'app/models/sms_notification'
20
+ #
21
+ # Cron
22
+ #
23
+ require 'app/jobs/application_job'
24
+ require 'app/jobs/cron/job'
25
+ require 'app/jobs/cron/tab'
26
+ require 'app/jobs/cron/job_tab'
27
+ require 'app/jobs/cron/command'
28
+ require 'app/jobs/cron/worker'
29
+ require 'app/jobs/cron/server'
30
+ require 'app/jobs/cron/trim_collection'
31
+ require 'app/jobs/cron/switchboard_sync_configuration'
32
+ require 'app/jobs/cron/switchboard_sync_models'
33
+ require 'app/jobs/cron/trim_notifications'
34
+ require 'app/jobs/cron/trim_cron_servers'
35
+ require 'app/jobs/cron/trim_failed_delayed_jobs'
36
+
37
+ class Web47core
38
+ include Singleton
39
+ attr_accessor :email_able_models, :switchboard_able_models
40
+
41
+ def initialize
42
+ @email_able_models = []
43
+ @switchboard_able_models = []
44
+ end
45
+
46
+ def self.config
47
+ instance
48
+ end
49
+ end
@@ -0,0 +1,64 @@
1
+ require 'test_helper'
2
+
3
+ module Cron
4
+ class SwitchboardSyncConfigurationTest < ActiveSupport::TestCase
5
+ setup do
6
+ @config = SystemConfiguration.configuration
7
+ end
8
+
9
+ context 'valid_environment?' do
10
+ should 'not run' do
11
+ refute Cron::SwitchboardSyncConfiguration.valid_environment?
12
+ end
13
+
14
+ should 'run' do
15
+ @config.switchboard_stack_api_token = 'abc123'
16
+ @config.switchboard_stack_id = 'abc123'
17
+ assert @config.save
18
+ assert Cron::SwitchboardSyncConfiguration.valid_environment?
19
+ end
20
+ end
21
+
22
+ context 'update system configuration' do
23
+ setup do
24
+ @config.switchboard_base_url = 'https://switchboard.test.com'
25
+ @config.switchboard_stack_api_token = 'abc124w'
26
+ @config.switchboard_stack_id = 'sid'
27
+ assert @config.save
28
+ end
29
+
30
+ should 'update system configuration' do
31
+ stub = stub_request(:get, "#{SystemConfiguration.switchboard_base_url}/stacks/sid/items.json").to_return(status: 200, body: { results: { cdn_url: 'https://cdn.nowhere.com' } }.to_json)
32
+ Cron::SwitchboardSyncConfiguration.new.perform
33
+ assert_not_nil @config.reload
34
+ assert_equal 'https://cdn.nowhere.com', @config.cdn_url
35
+ assert_requested stub
36
+ end
37
+
38
+ should 'update crontab' do
39
+ App47Logger.expects(:log_debug).once
40
+ Cron::JobTab.ensure_cron_tabs
41
+ tab = Cron::JobTab.find_by name: 'switchboard_sync_models'
42
+ assert_equal '0 0 * * *', tab.to_string
43
+ stub = stub_request(:get, "#{SystemConfiguration.switchboard_base_url}/stacks/sid/items.json").to_return(status: 200, body: { results: { switchboard_sync_models_crontab: '1 2 3 4 5' } }.to_json)
44
+ Cron::SwitchboardSyncConfiguration.new.perform
45
+ assert_not_nil tab.reload
46
+ assert_equal '1 2 3 4 5', tab.to_string
47
+ assert_requested stub
48
+ end
49
+ should 'deal with unknown' do
50
+ App47Logger.expects(:log_debug).never
51
+ App47Logger.expects(:log_warn).once
52
+ Cron::JobTab.ensure_cron_tabs
53
+ tab = Cron::JobTab.find_by name: 'switchboard_sync_models'
54
+ assert_equal '0 0 * * *', tab.to_string
55
+ stub = stub_request(:get, "#{SystemConfiguration.switchboard_base_url}/stacks/sid/items.json").to_return(status: 200, body: { results: { sync_knowledge_base_job_crontab: '1 2 3 4 5' } }.to_json)
56
+ Cron::SwitchboardSyncConfiguration.new.perform
57
+ assert_nil Cron::JobTab.where(name: 'sync_knowledge_base_job_crontab').first
58
+ assert_not_nil tab.reload
59
+ assert_equal '0 0 * * *', tab.to_string
60
+ assert_requested stub
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,28 @@
1
+ require 'test_helper'
2
+
3
+ module Cron
4
+ class TrimCronServersTest < ActiveSupport::TestCase
5
+ context 'Execute job not deleting servers' do
6
+ should 'not throw exceptions if no servers ' do
7
+ assert_nothing_raised { Cron::TrimCronServers.perform_now }
8
+ end
9
+ should 'not delete any servers due to last updated' do
10
+ 10.times.each { |n| Cron::Server.create!(host_name: "ip-#{n}", pid: n.to_s).set(last_check_in_at: Time.now.utc) }
11
+ assert_equal 10, Cron::Server.all.count
12
+ assert_no_difference 'Cron::Server.all.count' do
13
+ assert_nothing_raised { Cron::TrimCronServers.perform_now }
14
+ end
15
+ end
16
+ end
17
+ context 'Execute jobs deleting servers' do
18
+ should 'delete five server logs' do
19
+ 5.times.each { |n| Cron::Server.create!(host_name: "ip-#{n}", pid: n.to_s).set(last_check_in_at: Time.now.utc) }
20
+ 5.times.each { |n| Cron::Server.create!(host_name: "ip-#{n}", pid: (100+n).to_s).set(last_check_in_at: 1.hour.ago.utc) }
21
+ assert_equal 10, Cron::Server.all.count
22
+ assert_difference 'Cron::Server.all.count', -5 do
23
+ assert_nothing_raised { Cron::TrimCronServers.perform_now }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end