rails_error_dashboard 0.14.1 → 0.14.3
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.
- checksums.yaml +4 -4
- data/README.md +4 -7
- data/app/controllers/rails_error_dashboard/application_controller.rb +9 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +27 -23
- data/config/locales/ja.yml +5 -5
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +14 -16
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +13 -7
- data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +41 -21
- data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +68 -64
- data/lib/rails_error_dashboard/commands/drop_all_tables.rb +77 -0
- data/lib/rails_error_dashboard/configuration.rb +104 -21
- data/lib/rails_error_dashboard/logger.rb +11 -3
- data/lib/rails_error_dashboard/queries/uninstall_plan.rb +63 -0
- data/lib/rails_error_dashboard/services/solid_queue_config_check.rb +214 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +3 -0
- data/lib/tasks/error_dashboard.rake +42 -6
- data/lib/tasks/rails_error_dashboard_tasks.rake +37 -43
- metadata +5 -3
- data/lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml +0 -55
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Commands
|
|
5
|
+
# Drops every RED table, for uninstalling. DESTRUCTIVE: all error data goes.
|
|
6
|
+
#
|
|
7
|
+
# Used by the rails_error_dashboard:db:drop task and the uninstall
|
|
8
|
+
# generator. Drops on the connection RED's models use, so a separate or
|
|
9
|
+
# shared error database is covered, in the order Queries::UninstallPlan
|
|
10
|
+
# derives from the foreign keys. Where the adapter runs DDL in a transaction
|
|
11
|
+
# (PostgreSQL, SQLite) it is all-or-nothing. Errors are raised, never
|
|
12
|
+
# swallowed: a half-finished uninstall must not report success.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# RailsErrorDashboard::Commands::DropAllTables.call
|
|
16
|
+
# # => ["rails_error_dashboard_error_comments", ...]
|
|
17
|
+
class DropAllTables
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
# The connection RED's models use.
|
|
21
|
+
#
|
|
22
|
+
# @raise [Error] when use_separate_database is on but the engine fell back
|
|
23
|
+
# to the primary database because database.yml has no entry for it:
|
|
24
|
+
# dropping there would leave the real error database untouched.
|
|
25
|
+
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
26
|
+
def self.connection
|
|
27
|
+
config = RailsErrorDashboard.configuration
|
|
28
|
+
record = RailsErrorDashboard::ErrorLogsRecord
|
|
29
|
+
|
|
30
|
+
if config.use_separate_database
|
|
31
|
+
expected = config.database.to_s
|
|
32
|
+
actual = record.connection_db_config&.name.to_s
|
|
33
|
+
if actual != expected
|
|
34
|
+
raise Error, "use_separate_database is on, but RED is connected to the '#{actual}' database, " \
|
|
35
|
+
"not '#{expected}': database.yml has no '#{expected}' entry for #{Rails.env}. " \
|
|
36
|
+
"Add it (see docs/guides/DATABASE_OPTIONS.md) and run this again, or RED's tables " \
|
|
37
|
+
"in the error database would be left behind."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
record.connection
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
45
|
+
# @return [Array<String>] the dropped tables, in drop order
|
|
46
|
+
# @raise [Error] when a host table has a foreign key into a RED table
|
|
47
|
+
def self.call(connection: self.connection)
|
|
48
|
+
tables = Queries::UninstallPlan.call(connection).map { |entry| entry[:table] }
|
|
49
|
+
return [] if tables.empty?
|
|
50
|
+
|
|
51
|
+
blocking = host_foreign_keys(connection, tables)
|
|
52
|
+
if blocking.any?
|
|
53
|
+
raise Error, "Not dropping anything: these tables have foreign keys into RED's tables. " \
|
|
54
|
+
"Remove the foreign keys first: #{blocking.join('; ')}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
drop = -> { tables.each { |table| connection.drop_table(table, if_exists: true) } }
|
|
58
|
+
if connection.supports_ddl_transactions?
|
|
59
|
+
connection.transaction(requires_new: true) { drop.call }
|
|
60
|
+
else
|
|
61
|
+
drop.call
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
tables
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.host_foreign_keys(connection, tables)
|
|
68
|
+
(connection.tables - tables).flat_map do |table|
|
|
69
|
+
connection.foreign_keys(table)
|
|
70
|
+
.select { |fk| tables.include?(fk.to_table) }
|
|
71
|
+
.map { |fk| "#{table}.#{fk.column} -> #{fk.to_table} (#{fk.name})" }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :host_foreign_keys
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
class Configuration
|
|
5
|
+
# The built-in Basic auth credentials. They are published (README, demo,
|
|
6
|
+
# gemspec), so outside development and test they are refused unless
|
|
7
|
+
# ERROR_DASHBOARD_PASSWORD explicitly supplies the password.
|
|
8
|
+
DEFAULT_DASHBOARD_USERNAME = "gandalf"
|
|
9
|
+
DEFAULT_DASHBOARD_PASSWORD = "youshallnotpass"
|
|
10
|
+
|
|
5
11
|
# Dashboard authentication (always required)
|
|
6
12
|
attr_accessor :dashboard_username
|
|
7
13
|
attr_accessor :dashboard_password
|
|
@@ -269,8 +275,8 @@ module RailsErrorDashboard
|
|
|
269
275
|
|
|
270
276
|
def initialize
|
|
271
277
|
# Default values - Authentication is ALWAYS required
|
|
272
|
-
@dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER",
|
|
273
|
-
@dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD",
|
|
278
|
+
@dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", DEFAULT_DASHBOARD_USERNAME)
|
|
279
|
+
@dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", DEFAULT_DASHBOARD_PASSWORD)
|
|
274
280
|
@authenticate_with = nil
|
|
275
281
|
|
|
276
282
|
@user_model = nil # Auto-detect if not set
|
|
@@ -489,7 +495,7 @@ module RailsErrorDashboard
|
|
|
489
495
|
|
|
490
496
|
# Internal logging defaults - SILENT by default
|
|
491
497
|
@enable_internal_logging = false # Opt-in for debugging
|
|
492
|
-
@log_level = :silent # Silent by default
|
|
498
|
+
@log_level = :silent # Silent by default; see Logger::LOG_LEVELS for the levels
|
|
493
499
|
|
|
494
500
|
# Dashboard UI
|
|
495
501
|
@accent_color = :crimson # :crimson, :ruby, :ember, :violet
|
|
@@ -536,12 +542,24 @@ module RailsErrorDashboard
|
|
|
536
542
|
# (GHSA-qhgm-3pxf-mvc6). Every future environment name a team invents is
|
|
537
543
|
# now refused by default and has to be added here on purpose.
|
|
538
544
|
#
|
|
539
|
-
# Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
+
# Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time.
|
|
546
|
+
# The login refuses the same credentials on its own, so SECRET_KEY_BASE_DUMMY
|
|
547
|
+
# left set at runtime does not reopen them.
|
|
548
|
+
if refuse_default_credentials? && ENV["SECRET_KEY_BASE_DUMMY"].blank?
|
|
549
|
+
reason = if credentials_problem == :blank
|
|
550
|
+
"the dashboard username or password is blank"
|
|
551
|
+
else
|
|
552
|
+
"the dashboard password is the published default and was not set by ERROR_DASHBOARD_PASSWORD"
|
|
553
|
+
end
|
|
554
|
+
errors << "Default or blank credentials cannot be used in #{Rails.env}: #{reason}. Only development and test may run on the built-in credentials. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables and make sure the initializer does not overwrite them, or use authenticate_with for custom auth."
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# In development the app boots on a blank credential, and the login then
|
|
558
|
+
# denies everyone: say so, or the developer is locked out with no clue
|
|
559
|
+
# why. Development only, because a test run boots on every CI job.
|
|
560
|
+
if default_credentials? && credentials_problem == :blank &&
|
|
561
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
562
|
+
warnings.concat(blank_credential_warnings)
|
|
545
563
|
end
|
|
546
564
|
|
|
547
565
|
# Validate sampling_rate (must be between 0.0 and 1.0)
|
|
@@ -776,7 +794,9 @@ module RailsErrorDashboard
|
|
|
776
794
|
|
|
777
795
|
# Validate log level (must be valid symbol)
|
|
778
796
|
if log_level
|
|
779
|
-
|
|
797
|
+
# Referenced here, not as a class-body constant: this file loads before
|
|
798
|
+
# logger.rb, and a bare Logger in the class body would be ::Logger.
|
|
799
|
+
valid_log_levels = RailsErrorDashboard::Logger::LOG_LEVELS.keys
|
|
780
800
|
unless valid_log_levels.include?(log_level)
|
|
781
801
|
errors << "log_level must be one of #{valid_log_levels.inspect} (got: #{log_level.inspect})"
|
|
782
802
|
end
|
|
@@ -925,23 +945,86 @@ module RailsErrorDashboard
|
|
|
925
945
|
list.empty? ? nil : list
|
|
926
946
|
end
|
|
927
947
|
|
|
928
|
-
#
|
|
948
|
+
# Why the Basic auth credentials cannot be trusted outside development and
|
|
949
|
+
# test, or nil when they can.
|
|
929
950
|
#
|
|
930
|
-
#
|
|
931
|
-
#
|
|
932
|
-
#
|
|
951
|
+
# - :blank - the username or password is empty or whitespace. An explicitly
|
|
952
|
+
# empty variable is not a choice: a compose file passing an unset variable
|
|
953
|
+
# through produces exactly that, and "" would match an empty login.
|
|
954
|
+
# - :published_password - the password is the published default and did not
|
|
955
|
+
# come from ERROR_DASHBOARD_PASSWORD. Setting that variable, even to the
|
|
956
|
+
# default, is a deliberate choice (the live demo runs that way). It has to
|
|
957
|
+
# be THAT variable: setting only ERROR_DASHBOARD_USER used to leave the
|
|
958
|
+
# password on the published default, and an initializer can hardcode it.
|
|
933
959
|
#
|
|
934
|
-
#
|
|
960
|
+
# Both compare values the way the login does, with to_s: a Symbol holding the
|
|
961
|
+
# published password logs in just the same.
|
|
962
|
+
#
|
|
963
|
+
# @return [Symbol, nil]
|
|
964
|
+
def credentials_problem
|
|
965
|
+
return :blank if self.class.blank_credential?(dashboard_username) ||
|
|
966
|
+
self.class.blank_credential?(dashboard_password)
|
|
967
|
+
|
|
968
|
+
if dashboard_password.to_s == DEFAULT_DASHBOARD_PASSWORD &&
|
|
969
|
+
ENV["ERROR_DASHBOARD_PASSWORD"] != DEFAULT_DASHBOARD_PASSWORD
|
|
970
|
+
return :published_password
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
nil
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# True for a credential that cannot be a secret: nil, empty, or only
|
|
977
|
+
# whitespace, including Unicode whitespace such as a no-break space, which
|
|
978
|
+
# String#strip leaves in place. The boot check and the login both use it.
|
|
979
|
+
#
|
|
980
|
+
# @return [Boolean]
|
|
981
|
+
def self.blank_credential?(value)
|
|
982
|
+
value.to_s.blank?
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# Check if basic auth is active with blank credentials or the published
|
|
986
|
+
# default password (see #credentials_problem)
|
|
987
|
+
#
|
|
988
|
+
# Basic auth is active whenever authenticate_with is falsy, not only nil:
|
|
989
|
+
# the login falls back to it for `false` too, which is what
|
|
990
|
+
# `Rails.env.production? && -> { ... }` evaluates to in staging.
|
|
991
|
+
#
|
|
992
|
+
# @return [Boolean]
|
|
935
993
|
def default_credentials?
|
|
936
|
-
|
|
994
|
+
!authenticate_with && !credentials_problem.nil?
|
|
995
|
+
end
|
|
937
996
|
|
|
938
|
-
|
|
939
|
-
|
|
997
|
+
# True where default_credentials? has to stop the dashboard: anywhere that is
|
|
998
|
+
# not local development or test. The boot check and the login both use it,
|
|
999
|
+
# so the login still refuses when the boot check was skipped.
|
|
1000
|
+
#
|
|
1001
|
+
# @return [Boolean]
|
|
1002
|
+
def refuse_default_credentials?
|
|
1003
|
+
return false unless default_credentials?
|
|
1004
|
+
return false unless defined?(Rails) && Rails.respond_to?(:env)
|
|
940
1005
|
|
|
941
|
-
|
|
942
|
-
|
|
1006
|
+
!Rails.env.development? && !Rails.env.test?
|
|
1007
|
+
end
|
|
943
1008
|
|
|
944
|
-
|
|
1009
|
+
# One warning per blank credential, naming where the blank came from: an
|
|
1010
|
+
# environment variable that is set but empty, or the initializer.
|
|
1011
|
+
#
|
|
1012
|
+
# @return [Array<String>]
|
|
1013
|
+
private def blank_credential_warnings
|
|
1014
|
+
{
|
|
1015
|
+
"ERROR_DASHBOARD_USER" => [ :dashboard_username, dashboard_username ],
|
|
1016
|
+
"ERROR_DASHBOARD_PASSWORD" => [ :dashboard_password, dashboard_password ]
|
|
1017
|
+
}.filter_map do |variable, (setting, value)|
|
|
1018
|
+
next unless self.class.blank_credential?(value)
|
|
1019
|
+
|
|
1020
|
+
if ENV.key?(variable) && ENV[variable].to_s == value.to_s
|
|
1021
|
+
"#{variable} is set but empty, so every dashboard login will be denied. " \
|
|
1022
|
+
"Unset it to use the development default, or give it a value."
|
|
1023
|
+
else
|
|
1024
|
+
"config.#{setting} is blank, so every dashboard login will be denied. " \
|
|
1025
|
+
"Remove that line from the initializer to use the development default, or give it a value."
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
945
1028
|
end
|
|
946
1029
|
|
|
947
1030
|
# Resolve the effective issue tracker provider (auto-detect from git_repository_url).
|
|
@@ -18,12 +18,17 @@ module RailsErrorDashboard
|
|
|
18
18
|
# config.log_level = :error
|
|
19
19
|
# end
|
|
20
20
|
module Logger
|
|
21
|
+
# The levels Configuration#validate! accepts: it reads this hash, so the two
|
|
22
|
+
# can't drift apart again (validate! once accepted :fatal while this hash
|
|
23
|
+
# lacked it, and every log call raised). RED emits nothing at :fatal, so it
|
|
24
|
+
# logs nothing, as with Ruby's Logger.
|
|
21
25
|
LOG_LEVELS = {
|
|
22
26
|
debug: 0,
|
|
23
27
|
info: 1,
|
|
24
28
|
warn: 2,
|
|
25
29
|
error: 3,
|
|
26
|
-
|
|
30
|
+
fatal: 4,
|
|
31
|
+
silent: 5
|
|
27
32
|
}.freeze
|
|
28
33
|
|
|
29
34
|
class << self
|
|
@@ -89,8 +94,11 @@ module RailsErrorDashboard
|
|
|
89
94
|
# @param level [Symbol] The log level to check (:debug, :info, :warn, :error)
|
|
90
95
|
# @return [Boolean]
|
|
91
96
|
def log_level_enabled?(level)
|
|
92
|
-
config_level = RailsErrorDashboard.configuration.log_level
|
|
93
|
-
LOG_LEVELS
|
|
97
|
+
config_level = RailsErrorDashboard.configuration.log_level.to_s.to_sym
|
|
98
|
+
LOG_LEVELS.fetch(level) >= LOG_LEVELS.fetch(config_level, LOG_LEVELS[:silent])
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# The logger runs inside rescue blocks on the capture path; it must never raise.
|
|
101
|
+
false
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
# Format message with gem prefix
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Every RED table on a connection, in an order that can be dropped.
|
|
6
|
+
#
|
|
7
|
+
# The tables are discovered rather than listed by hand: a hand-kept list
|
|
8
|
+
# knew 5 of 13 tables, so uninstall left the rest behind (rack_attack_events
|
|
9
|
+
# among them, which holds IP addresses). The order comes from the real
|
|
10
|
+
# foreign keys: a table that references another is dropped first.
|
|
11
|
+
#
|
|
12
|
+
# Uses the raw connection only, never the models, so no model's schema
|
|
13
|
+
# cache ever records a table as dropped.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# UninstallPlan.call(RailsErrorDashboard::ErrorLogsRecord.connection)
|
|
17
|
+
# # => [{ table: "rails_error_dashboard_error_comments", rows: 12 }, ...]
|
|
18
|
+
class UninstallPlan
|
|
19
|
+
PREFIX = "rails_error_dashboard_"
|
|
20
|
+
|
|
21
|
+
class CycleError < StandardError; end
|
|
22
|
+
|
|
23
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
24
|
+
# @return [Array<Hash>] { table: String, rows: Integer or nil } in drop order
|
|
25
|
+
def self.call(connection)
|
|
26
|
+
tables = connection.tables.select { |table| table.start_with?(PREFIX) }
|
|
27
|
+
references = tables.to_h do |table|
|
|
28
|
+
[ table, connection.foreign_keys(table).map(&:to_table) & tables ]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
order(references).map { |table| { table: table, rows: row_count(connection, table) } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param references [Hash{String => Array<String>}] each table and the tables it references
|
|
35
|
+
# @return [Array<String>] every table, each before the tables it references
|
|
36
|
+
# @raise [CycleError] when the foreign keys form a cycle
|
|
37
|
+
def self.order(references)
|
|
38
|
+
remaining = references.keys.sort
|
|
39
|
+
ordered = []
|
|
40
|
+
|
|
41
|
+
until remaining.empty?
|
|
42
|
+
# Droppable once no other remaining table references it.
|
|
43
|
+
droppable = remaining.reject do |table|
|
|
44
|
+
remaining.any? { |other| other != table && references.fetch(other, []).include?(table) }
|
|
45
|
+
end
|
|
46
|
+
raise CycleError, "foreign-key cycle among: #{remaining.join(', ')}" if droppable.empty?
|
|
47
|
+
|
|
48
|
+
ordered.concat(droppable)
|
|
49
|
+
remaining -= droppable
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ordered
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.row_count(connection, table)
|
|
56
|
+
connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table)}").to_i
|
|
57
|
+
rescue ActiveRecord::ActiveRecordError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
private_class_method :row_count
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
module Services
|
|
7
|
+
# Pure algorithm: what in a Solid Queue config would stop jobs from running
|
|
8
|
+
#
|
|
9
|
+
# Mirrors Solid Queue 1.7.0's own rules, so it reports exactly what Solid
|
|
10
|
+
# Queue would do with the file:
|
|
11
|
+
# - SolidQueue::Configuration reads the section for the environment, or the
|
|
12
|
+
# whole file when there is none, keeps workers/dispatchers/scheduler, and
|
|
13
|
+
# only falls back to its defaults (a "*" worker and a dispatcher) when
|
|
14
|
+
# none of the three is there. A section with workers and no dispatchers
|
|
15
|
+
# therefore runs NO dispatcher: nothing scheduled ever becomes ready, so
|
|
16
|
+
# no `retry_on wait:` or `perform_later(wait:)` runs, the host app's own
|
|
17
|
+
# jobs included. That is what the retired rails_error_dashboard:solid_queue
|
|
18
|
+
# generator wrote.
|
|
19
|
+
# - SolidQueue::QueueSelector treats a worker without queues as "*", "*" as
|
|
20
|
+
# every queue, and a trailing "*" as a prefix.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# path = SolidQueueConfigCheck.config_path(Rails.root)
|
|
24
|
+
# SolidQueueConfigCheck.call(path, environments: %w[development production])
|
|
25
|
+
# # => { problems: ["production: workers but no dispatcher ..."], skipped: nil }
|
|
26
|
+
class SolidQueueConfigCheck
|
|
27
|
+
DEFAULT_CONFIG_FILE = "config/queue.yml"
|
|
28
|
+
PROCESS_KEYS = %i[workers dispatchers scheduler].freeze
|
|
29
|
+
# Before 1.4.0, scheduler: was not a process key: a section with only a
|
|
30
|
+
# scheduler fell back to the defaults, a "*" worker and a dispatcher.
|
|
31
|
+
SCHEDULER_KEY_SINCE = Gem::Version.new("1.4.0")
|
|
32
|
+
GUIDE_URL = "https://anjanj.github.io/rails_error_dashboard/docs/guides/solid-queue-setup/#what-red-needs-from-solid-queue"
|
|
33
|
+
|
|
34
|
+
# The fix printed with a reported problem. It is never Solid Queue's
|
|
35
|
+
# installer: whoever sees this already uses Solid Queue, and its 1.7.0
|
|
36
|
+
# installer also rewrites production.rb to use a separate queue database.
|
|
37
|
+
FIX = [
|
|
38
|
+
"Fix: edit the file. Give each environment a dispatchers: block and a worker",
|
|
39
|
+
"for RED's queues (a \"*\" worker covers them). Guide: #{GUIDE_URL}"
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# The config file Solid Queue reads, honouring SOLID_QUEUE_CONFIG as it does.
|
|
43
|
+
#
|
|
44
|
+
# @param root [Pathname, String] the application root
|
|
45
|
+
# @return [Pathname]
|
|
46
|
+
def self.config_path(root)
|
|
47
|
+
Pathname(root.to_s).join(ENV["SOLID_QUEUE_CONFIG"].presence || DEFAULT_CONFIG_FILE)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The environments an app defines, from config/environments/*.rb.
|
|
51
|
+
#
|
|
52
|
+
# @param root [Pathname, String] the application root
|
|
53
|
+
# @return [Array<String>]
|
|
54
|
+
def self.app_environments(root)
|
|
55
|
+
Dir[Pathname(root.to_s).join("config", "environments", "*.rb").to_s]
|
|
56
|
+
.map { |file| File.basename(file, ".rb") }
|
|
57
|
+
.sort
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The queues RED's jobs are enqueued on, as this app names them
|
|
61
|
+
# (queue_name_prefix included).
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<String>]
|
|
64
|
+
def self.red_queue_names
|
|
65
|
+
[ RailsErrorDashboard::AsyncErrorLoggingJob, RailsErrorDashboard::SlackErrorNotificationJob ]
|
|
66
|
+
.map { |job| job.queue_name.to_s }
|
|
67
|
+
.uniq
|
|
68
|
+
rescue => e
|
|
69
|
+
RailsErrorDashboard::Logger.debug("SolidQueueConfigCheck could not read queue names: #{e.class}: #{e.message}")
|
|
70
|
+
%w[default error_notifications]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Read the file the way Solid Queue does (ERB, then YAML with aliases) and
|
|
74
|
+
# check each environment. Never raises: an unreadable file is reported as
|
|
75
|
+
# skipped.
|
|
76
|
+
#
|
|
77
|
+
# @param path [Pathname, String]
|
|
78
|
+
# @param environments [Array<String>]
|
|
79
|
+
# @param queue_names [Array<String>]
|
|
80
|
+
# @return [Hash] { problems: Array<String>, skipped: String or nil }
|
|
81
|
+
def self.call(path, environments:, queue_names: red_queue_names)
|
|
82
|
+
{ problems: check(parse(path), environments: environments, queue_names: queue_names), skipped: nil }
|
|
83
|
+
rescue StandardError, ScriptError => e # ScriptError: ERB in the file that doesn't compile
|
|
84
|
+
{ problems: [], skipped: "could not read #{path}: #{e.class}: #{e.message.lines.first.to_s.strip}" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param path [Pathname, String]
|
|
88
|
+
# @return [Hash] the parsed file, symbolized keys, as Solid Queue reads it
|
|
89
|
+
def self.parse(path)
|
|
90
|
+
ActiveSupport::ConfigurationFile.parse(Pathname(path.to_s)).deep_symbolize_keys
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param config [Hash] the parsed file, symbolized keys
|
|
94
|
+
# @param environments [Array<String>]
|
|
95
|
+
# @param queue_names [Array<String>]
|
|
96
|
+
# @return [Array<String>] one line per problem, prefixed with the environment
|
|
97
|
+
def self.check(config, environments:, queue_names:)
|
|
98
|
+
environments.flat_map do |env|
|
|
99
|
+
facts = processes_for(config, environment: env, queue_names: queue_names)
|
|
100
|
+
problems_in(facts, queue_names).map { |problem| "#{env}: #{problem}" }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# What Solid Queue would start for one environment. The contract spec
|
|
105
|
+
# compares this with the real SolidQueue::Configuration and QueueSelector.
|
|
106
|
+
#
|
|
107
|
+
# @param config [Hash] the parsed file, symbolized keys
|
|
108
|
+
# @param environment [String]
|
|
109
|
+
# @param queue_names [Array<String>]
|
|
110
|
+
# @param solid_queue_version [String, nil] the rules to follow; the loaded
|
|
111
|
+
# Solid Queue's by default, the newest when none is loaded
|
|
112
|
+
# @return [Hash] { dispatchers: Integer, workers: Integer,
|
|
113
|
+
# processed: Array<String> (the queue_names a worker picks up), error: String or nil }
|
|
114
|
+
def self.processes_for(config, environment:, queue_names:, solid_queue_version: loaded_solid_queue_version)
|
|
115
|
+
section = section_for(config, environment)
|
|
116
|
+
return cannot_start("the #{environment} section is not a set of settings") unless section.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
section = section.slice(*process_keys(solid_queue_version))
|
|
119
|
+
# Empty: Solid Queue runs its defaults, a "*" worker and a dispatcher.
|
|
120
|
+
return { dispatchers: 1, workers: 1, processed: queue_names.dup, error: nil } if section.empty?
|
|
121
|
+
|
|
122
|
+
error = shape_error(section)
|
|
123
|
+
return cannot_start(error) if error
|
|
124
|
+
|
|
125
|
+
# bin/jobs runs in fork mode: each worker entry starts `processes` of them.
|
|
126
|
+
workers = section.fetch(:workers, []).select { |worker| worker.fetch(:processes, 1).positive? }
|
|
127
|
+
{
|
|
128
|
+
dispatchers: section.fetch(:dispatchers, []).size,
|
|
129
|
+
workers: workers.sum { |worker| worker.fetch(:processes, 1) },
|
|
130
|
+
processed: queue_names.select { |name| workers.any? { |worker| processes?(worker, name) } },
|
|
131
|
+
error: nil
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.loaded_solid_queue_version
|
|
136
|
+
::SolidQueue::VERSION if defined?(::SolidQueue::VERSION)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.process_keys(solid_queue_version)
|
|
140
|
+
return PROCESS_KEYS if solid_queue_version.nil? || Gem::Version.new(solid_queue_version) >= SCHEDULER_KEY_SINCE
|
|
141
|
+
|
|
142
|
+
PROCESS_KEYS - [ :scheduler ]
|
|
143
|
+
rescue ArgumentError # a version string Gem::Version can't read
|
|
144
|
+
PROCESS_KEYS
|
|
145
|
+
end
|
|
146
|
+
private_class_method :process_keys
|
|
147
|
+
|
|
148
|
+
# SolidQueue::Configuration#config_from: the environment's section when
|
|
149
|
+
# present, else the whole file.
|
|
150
|
+
def self.section_for(config, env)
|
|
151
|
+
config[env.to_sym] ? config[env.to_sym] : config
|
|
152
|
+
end
|
|
153
|
+
private_class_method :section_for
|
|
154
|
+
|
|
155
|
+
# Settings Solid Queue raises on while it builds its processes, so
|
|
156
|
+
# bin/jobs can't start: a key present with no value, a map where a list
|
|
157
|
+
# belongs, a list entry that isn't a set of settings, or processes that
|
|
158
|
+
# isn't a whole number.
|
|
159
|
+
def self.shape_error(section)
|
|
160
|
+
%i[workers dispatchers].each do |key|
|
|
161
|
+
next unless section.key?(key)
|
|
162
|
+
|
|
163
|
+
list = section[key]
|
|
164
|
+
return "#{key}: must be a list, each entry starting with \"- \"" unless list.is_a?(Array)
|
|
165
|
+
return "every entry under #{key}: must be a set of settings" unless list.all?(Hash)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if section.fetch(:workers, []).any? { |worker| worker.key?(:processes) && !worker[:processes].is_a?(Integer) }
|
|
169
|
+
return "a worker's processes: must be a whole number"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
private_class_method :shape_error
|
|
175
|
+
|
|
176
|
+
def self.cannot_start(reason)
|
|
177
|
+
{ dispatchers: 0, workers: 0, processed: [], error: reason }
|
|
178
|
+
end
|
|
179
|
+
private_class_method :cannot_start
|
|
180
|
+
|
|
181
|
+
def self.problems_in(facts, queue_names)
|
|
182
|
+
return [ "Solid Queue can't start with this config: #{facts[:error]}" ] if facts[:error]
|
|
183
|
+
|
|
184
|
+
dispatchers = facts[:dispatchers]
|
|
185
|
+
workers = facts[:workers]
|
|
186
|
+
return [ "no worker and no dispatcher, so no job ever runs" ] if workers.zero? && dispatchers.zero?
|
|
187
|
+
return [ "no worker process starts (no workers, or processes: 0), so no job is ever performed" ] if workers.zero?
|
|
188
|
+
|
|
189
|
+
problems = []
|
|
190
|
+
if dispatchers.zero?
|
|
191
|
+
problems << "workers but no dispatcher, so scheduled jobs and retries " \
|
|
192
|
+
"(retry_on wait:, perform_later(wait:)) never run, the app's own jobs included"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
uncovered = queue_names - facts[:processed]
|
|
196
|
+
problems << "no worker processes #{uncovered.join(' or ')}" if uncovered.any?
|
|
197
|
+
problems
|
|
198
|
+
end
|
|
199
|
+
private_class_method :problems_in
|
|
200
|
+
|
|
201
|
+
# SolidQueue::QueueSelector: no queues means "*"; "*" is every queue; a
|
|
202
|
+
# trailing "*" is a prefix.
|
|
203
|
+
def self.processes?(worker, queue_name)
|
|
204
|
+
queues = worker.is_a?(Hash) ? Array(worker[:queues]).map { |queue| queue.to_s.strip } : []
|
|
205
|
+
queues = [ "*" ] if queues.empty?
|
|
206
|
+
|
|
207
|
+
queues.any? do |queue|
|
|
208
|
+
queue == "*" || queue == queue_name || (queue.end_with?("*") && queue_name.start_with?(queue.delete_suffix("*")))
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
private_class_method :processes?
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -57,6 +57,7 @@ require "rails_error_dashboard/services/statistical_classifier"
|
|
|
57
57
|
require "rails_error_dashboard/services/source_code_reader"
|
|
58
58
|
require "rails_error_dashboard/services/git_blame_reader"
|
|
59
59
|
require "rails_error_dashboard/services/git_head_reader"
|
|
60
|
+
require "rails_error_dashboard/services/solid_queue_config_check"
|
|
60
61
|
require "rails_error_dashboard/services/github_link_generator"
|
|
61
62
|
require "rails_error_dashboard/services/cause_chain_extractor"
|
|
62
63
|
require "rails_error_dashboard/services/environment_snapshot"
|
|
@@ -137,6 +138,8 @@ require "rails_error_dashboard/commands/flush_swallowed_exceptions"
|
|
|
137
138
|
require "rails_error_dashboard/commands/flush_rack_attack_events"
|
|
138
139
|
require "rails_error_dashboard/commands/scrub_invalid_encoding"
|
|
139
140
|
require "rails_error_dashboard/commands/backfill_resolved_at"
|
|
141
|
+
require "rails_error_dashboard/queries/uninstall_plan"
|
|
142
|
+
require "rails_error_dashboard/commands/drop_all_tables"
|
|
140
143
|
require "rails_error_dashboard/queries/errors_list"
|
|
141
144
|
# Before the queries that use it: window volume is shared by dashboard and
|
|
142
145
|
# analytics, and both would otherwise rescue a NameError into a silent zero.
|
|
@@ -171,19 +171,53 @@ namespace :error_dashboard do
|
|
|
171
171
|
if config.authenticate_with
|
|
172
172
|
puts "OK (custom authentication)"
|
|
173
173
|
checks_passed += 1
|
|
174
|
-
elsif
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
elsif (problem = config.credentials_problem)
|
|
175
|
+
# Same rule and environment allowlist as the boot check in validate!
|
|
176
|
+
label = problem == :blank ? "blank credentials" : "default credentials"
|
|
177
|
+
if config.refuse_default_credentials?
|
|
178
|
+
puts "WARNING - #{label} outside development and test!"
|
|
177
179
|
checks_failed += 1
|
|
180
|
+
elsif problem == :blank
|
|
181
|
+
# The login denies everyone on a blank credential, in every environment.
|
|
182
|
+
puts "WARNING - #{label}: every login is denied"
|
|
183
|
+
warnings += 1
|
|
178
184
|
else
|
|
179
|
-
puts "OK (
|
|
185
|
+
puts "OK (#{label} - set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD before deploying)"
|
|
180
186
|
warnings += 1
|
|
181
187
|
end
|
|
188
|
+
elsif config.dashboard_password == RailsErrorDashboard::Configuration::DEFAULT_DASHBOARD_PASSWORD
|
|
189
|
+
puts "OK (the published default password, set explicitly by ERROR_DASHBOARD_PASSWORD)"
|
|
190
|
+
warnings += 1
|
|
182
191
|
else
|
|
183
192
|
puts "OK (custom credentials)"
|
|
184
193
|
checks_passed += 1
|
|
185
194
|
end
|
|
186
195
|
|
|
196
|
+
# 9. Solid Queue config. A section with workers and no dispatchers runs no
|
|
197
|
+
# dispatcher, so no delayed job ever runs, the app's own included; the
|
|
198
|
+
# retired rails_error_dashboard:solid_queue generator wrote exactly that.
|
|
199
|
+
# Checked whenever Solid Queue is loaded, not only when it is the current
|
|
200
|
+
# adapter: Rails 8 sets it in production only, and verify usually runs locally.
|
|
201
|
+
queue_check = RailsErrorDashboard::Services::SolidQueueConfigCheck
|
|
202
|
+
queue_config = queue_check.config_path(Rails.root)
|
|
203
|
+
if defined?(SolidQueue) && queue_config.exist?
|
|
204
|
+
print " Solid Queue config... "
|
|
205
|
+
environments = queue_check.app_environments(Rails.root).presence || [ Rails.env.to_s ]
|
|
206
|
+
result = queue_check.call(queue_config, environments: environments)
|
|
207
|
+
if result[:skipped]
|
|
208
|
+
puts "SKIPPED (#{result[:skipped]})"
|
|
209
|
+
warnings += 1
|
|
210
|
+
elsif result[:problems].any?
|
|
211
|
+
puts "FAILED"
|
|
212
|
+
result[:problems].each { |problem| puts " - #{problem}" }
|
|
213
|
+
queue_check::FIX.each { |line| puts " #{line}" }
|
|
214
|
+
checks_failed += 1
|
|
215
|
+
else
|
|
216
|
+
puts "OK"
|
|
217
|
+
checks_passed += 1
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
187
221
|
# Summary
|
|
188
222
|
puts "\n" + "-" * 70
|
|
189
223
|
puts " Results: #{checks_passed} passed, #{checks_failed} failed, #{warnings} warnings"
|
|
@@ -448,7 +482,7 @@ namespace :error_dashboard do
|
|
|
448
482
|
|
|
449
483
|
# Confirm before proceeding
|
|
450
484
|
print "\nProceed with deletion? (y/N): "
|
|
451
|
-
confirmation = $stdin.gets.chomp.downcase
|
|
485
|
+
confirmation = $stdin.gets.to_s.chomp.downcase
|
|
452
486
|
|
|
453
487
|
unless confirmation == "y" || confirmation == "yes"
|
|
454
488
|
puts "\n✗ Cleanup cancelled"
|
|
@@ -458,7 +492,9 @@ namespace :error_dashboard do
|
|
|
458
492
|
|
|
459
493
|
puts "\nDeleting errors..."
|
|
460
494
|
start_time = Time.current
|
|
461
|
-
|
|
495
|
+
# Dependents first, in batches: occurrences, comments and cascade patterns
|
|
496
|
+
# hold foreign keys to the log, so a plain delete_all failed on them.
|
|
497
|
+
deleted = RailsErrorDashboard::RetentionCleanupJob.delete_with_dependents(scope)
|
|
462
498
|
# delete_all skips callbacks, and the stat cards are cached.
|
|
463
499
|
RailsErrorDashboard::Services::AnalyticsCacheManager.clear
|
|
464
500
|
elapsed = (Time.current - start_time).round(2)
|