web47core 0.0.10 → 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,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