rails_nexus 2.0.6 → 2.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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/rails_nexus/controllers.js +82 -5
- data/app/assets/stylesheets/rails_nexus/application.css +170 -60
- data/app/controllers/rails_nexus/backup_controller.rb +22 -18
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- data/app/javascript/controllers/rails_nexus_controller.js +6 -2
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +71 -3
- data/app/jobs/rails_nexus/backup_job.rb +18 -0
- data/app/models/rails_nexus/backup.rb +11 -11
- data/app/models/rails_nexus/backup_config.rb +7 -2
- data/app/services/rails_nexus/backup_service.rb +130 -43
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +40 -10
- data/app/views/layouts/rails_nexus/application.html.erb +71 -13
- data/app/views/rails_nexus/backup/_form.html.erb +3 -0
- data/app/views/rails_nexus/backup/history.html.erb +17 -2
- data/app/views/rails_nexus/backup/index.html.erb +29 -72
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +12 -44
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +21 -99
- data/db/seeds/backup_configs.rb +5 -4
- data/lib/generators/rails_nexus/templates/migration.rb +8 -2
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/tasks/rails_nexus.rake +4 -4
- metadata +2 -1
|
@@ -1,16 +1,84 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
|
-
static targets = ["drawer", "overlay"]
|
|
4
|
+
static targets = ["drawer", "overlay", "sidebar", "toggleButton"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.handleResize = this.syncButtonState.bind(this)
|
|
8
|
+
window.addEventListener("resize", this.handleResize)
|
|
9
|
+
|
|
10
|
+
if (this.desktop() && this.sidebarHidden()) {
|
|
11
|
+
this.sidebarTarget.classList.add("is-hidden")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
this.syncButtonState()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
window.removeEventListener("resize", this.handleResize)
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
toggle() {
|
|
7
|
-
this.
|
|
8
|
-
|
|
22
|
+
if (this.desktop()) {
|
|
23
|
+
const hidden = this.sidebarTarget.classList.toggle("is-hidden")
|
|
24
|
+
this.storeSidebarState(hidden)
|
|
25
|
+
} else {
|
|
26
|
+
const opening = !this.drawerTarget.classList.contains("open")
|
|
27
|
+
this.drawerTarget.classList.toggle("open", opening)
|
|
28
|
+
this.overlayTarget.classList.toggle("open", opening)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.syncButtonState()
|
|
9
32
|
}
|
|
10
33
|
|
|
11
34
|
close() {
|
|
12
35
|
this.drawerTarget.classList.remove("open")
|
|
13
36
|
this.overlayTarget.classList.remove("open")
|
|
37
|
+
this.syncButtonState()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
desktop() {
|
|
41
|
+
return window.matchMedia("(min-width: 1024px)").matches
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sidebarHidden() {
|
|
45
|
+
try {
|
|
46
|
+
return localStorage.getItem("rails_nexus-sidebar-hidden") === "true"
|
|
47
|
+
} catch (_error) {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
storeSidebarState(hidden) {
|
|
53
|
+
try {
|
|
54
|
+
localStorage.setItem("rails_nexus-sidebar-hidden", hidden.toString())
|
|
55
|
+
} catch (_error) {
|
|
56
|
+
// The toggle still works when storage is unavailable.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
syncButtonState() {
|
|
61
|
+
if (!this.hasToggleButtonTarget) return
|
|
62
|
+
|
|
63
|
+
const expanded = this.desktop()
|
|
64
|
+
? !this.sidebarTarget.classList.contains("is-hidden")
|
|
65
|
+
: this.drawerTarget.classList.contains("open")
|
|
66
|
+
|
|
67
|
+
this.toggleButtonTarget.setAttribute("aria-expanded", expanded.toString())
|
|
68
|
+
this.toggleButtonTarget.setAttribute("aria-label", expanded ? "Hide navigation" : "Show navigation")
|
|
69
|
+
this.toggleButtonTarget.title = expanded ? "Hide navigation" : "Show navigation"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
closeOtherMenus(event) {
|
|
73
|
+
const currentMenu = event.currentTarget
|
|
74
|
+
if (!currentMenu.open) return
|
|
75
|
+
|
|
76
|
+
const navigation = currentMenu.closest(".rn-sidebar-nav")
|
|
77
|
+
if (!navigation) return
|
|
78
|
+
|
|
79
|
+
navigation.querySelectorAll("details.rn-nav-section[open]").forEach((menu) => {
|
|
80
|
+
if (menu !== currentMenu) menu.open = false
|
|
81
|
+
})
|
|
14
82
|
}
|
|
15
83
|
|
|
16
84
|
toggleShortcuts(event) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class BackupJob < ApplicationJob
|
|
5
|
+
queue_as :rails_nexus_backups
|
|
6
|
+
|
|
7
|
+
# Accept either a BackupConfig record ID or a BackupConfig record
|
|
8
|
+
def perform(backup_config_id_or_record)
|
|
9
|
+
config = if backup_config_id_or_record.is_a?(RailsNexus::BackupConfig)
|
|
10
|
+
backup_config_id_or_record
|
|
11
|
+
else
|
|
12
|
+
RailsNexus::BackupConfig.find(backup_config_id_or_record)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
RailsNexus::BackupService.run(config)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -4,7 +4,7 @@ module RailsNexus
|
|
|
4
4
|
class Backup < BaseRecord
|
|
5
5
|
self.table_name = "rails_nexus_backups"
|
|
6
6
|
|
|
7
|
-
validates :
|
|
7
|
+
validates :config_name, presence: true
|
|
8
8
|
validates :status, presence: true, inclusion: { in: %w[running success failed] }
|
|
9
9
|
validates :started_at, presence: true
|
|
10
10
|
|
|
@@ -12,13 +12,13 @@ module RailsNexus
|
|
|
12
12
|
scope :successful, -> { where(status: "success") }
|
|
13
13
|
scope :failed, -> { where(status: "failed") }
|
|
14
14
|
scope :running, -> { where(status: "running") }
|
|
15
|
-
scope :
|
|
15
|
+
scope :by_config, ->(name) { where(config_name: name) }
|
|
16
16
|
scope :latest_first, -> { order(started_at: :desc) }
|
|
17
17
|
|
|
18
18
|
# Start tracking a backup run
|
|
19
|
-
def self.start!(
|
|
19
|
+
def self.start!(config_name:, triggered_by: "system")
|
|
20
20
|
create!(
|
|
21
|
-
|
|
21
|
+
config_name: config_name,
|
|
22
22
|
status: "running",
|
|
23
23
|
triggered_by: triggered_by,
|
|
24
24
|
started_at: Time.current
|
|
@@ -47,25 +47,25 @@ module RailsNexus
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# Get success rate for a model over the last N days
|
|
50
|
-
def self.success_rate(
|
|
51
|
-
records =
|
|
50
|
+
def self.success_rate(config_name:, days: 7)
|
|
51
|
+
records = by_config(config_name).where("started_at >= ?", days.days.ago).where.not(status: "running")
|
|
52
52
|
return 0 if records.empty?
|
|
53
53
|
(records.successful.count.to_f / records.count * 100).round(1)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
# Get backup health summary
|
|
57
57
|
def self.health_summary(alert_threshold_hours: 24)
|
|
58
|
-
|
|
59
|
-
results =
|
|
60
|
-
latest =
|
|
58
|
+
configs = RailsNexus::BackupConfig.pluck(:name)
|
|
59
|
+
results = configs.map do |config_name|
|
|
60
|
+
latest = by_config(config_name).successful.latest_first.first
|
|
61
61
|
age_hours = latest ? ((Time.current - latest.started_at) / 3600).round(1) : nil
|
|
62
62
|
|
|
63
63
|
{
|
|
64
|
-
|
|
64
|
+
config_name: config_name,
|
|
65
65
|
last_backup: latest&.started_at,
|
|
66
66
|
age_hours: age_hours,
|
|
67
67
|
status: age_hours.nil? ? "unknown" : (age_hours < alert_threshold_hours ? "healthy" : "stale"),
|
|
68
|
-
success_rate: success_rate(
|
|
68
|
+
success_rate: success_rate(config_name: config_name),
|
|
69
69
|
last_file_size: latest&.file_size
|
|
70
70
|
}
|
|
71
71
|
end
|
|
@@ -13,6 +13,7 @@ module RailsNexus
|
|
|
13
13
|
attribute :enabled, :boolean, default: true
|
|
14
14
|
attribute :compress, :boolean, default: true
|
|
15
15
|
attribute :encrypted, :boolean, default: false
|
|
16
|
+
attribute :encrypt_base64, :boolean, default: false
|
|
16
17
|
attribute :rsync_enabled, :boolean, default: false
|
|
17
18
|
attribute :rsync_mirror, :boolean, default: false
|
|
18
19
|
attribute :notify_on_success, :boolean, default: true
|
|
@@ -113,11 +114,15 @@ module RailsNexus
|
|
|
113
114
|
|
|
114
115
|
# ─── Stats ─────────────────────────────────────────────────────
|
|
115
116
|
def recent_backups(limit: 10)
|
|
116
|
-
RailsNexus::Backup.where(
|
|
117
|
+
RailsNexus::Backup.where(config_name: name).order(started_at: :desc).limit(limit)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def last_failure
|
|
121
|
+
RailsNexus::Backup.where(config_name: name, status: "failed").order(started_at: :desc).first
|
|
117
122
|
end
|
|
118
123
|
|
|
119
124
|
def stats
|
|
120
|
-
backups = RailsNexus::Backup.where(
|
|
125
|
+
backups = RailsNexus::Backup.where(config_name: name)
|
|
121
126
|
recent = backups.where("started_at >= ?", 7.days.ago)
|
|
122
127
|
{
|
|
123
128
|
total: backups.count,
|
|
@@ -20,28 +20,33 @@ module RailsNexus
|
|
|
20
20
|
|
|
21
21
|
def run
|
|
22
22
|
@record = RailsNexus::Backup.start!(
|
|
23
|
-
|
|
23
|
+
config_name: @config.name,
|
|
24
24
|
triggered_by: "service"
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
begin
|
|
28
|
+
@stage = "preparing storage"
|
|
28
29
|
FileUtils.mkdir_p(@config.storage_path_expanded)
|
|
29
30
|
|
|
30
31
|
# Step 1: Dump database
|
|
32
|
+
@stage = "database dump"
|
|
31
33
|
dump_path = dump_database
|
|
32
34
|
raise "Database dump failed" unless dump_path
|
|
33
35
|
|
|
34
36
|
# Step 2: Create archive if enabled (tar additional files/dirs)
|
|
37
|
+
@stage = "archive creation"
|
|
35
38
|
if @config.archive_enabled? && @config.archive_paths_list.any?
|
|
36
39
|
dump_path = create_archive(dump_path)
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
# Step 3: Split into chunks if enabled
|
|
43
|
+
@stage = "file splitting"
|
|
40
44
|
if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
|
|
41
45
|
dump_path = split_file(dump_path)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# Step 4: Compress
|
|
49
|
+
@stage = "compression"
|
|
45
50
|
if @config.compress? && !@config.split_chunks?
|
|
46
51
|
dump_path = compress(dump_path)
|
|
47
52
|
elsif @config.bzip2_compress? && !@config.split_chunks?
|
|
@@ -49,6 +54,7 @@ module RailsNexus
|
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
# Step 5: Encrypt
|
|
57
|
+
@stage = "encryption"
|
|
52
58
|
if @config.encrypted?
|
|
53
59
|
dump_path = encrypt_openssl(dump_path)
|
|
54
60
|
elsif @config.gpg_enabled?
|
|
@@ -56,26 +62,32 @@ module RailsNexus
|
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
# Step 6: Sync to S3
|
|
65
|
+
@stage = "S3 sync"
|
|
59
66
|
sync_s3(dump_path) if @config.s3_enabled?
|
|
60
67
|
|
|
61
68
|
# Step 7: Sync to remote
|
|
62
|
-
|
|
69
|
+
@stage = "remote sync"
|
|
70
|
+
sync_remote(dump_path) if @config.rsync_enabled?
|
|
63
71
|
|
|
64
72
|
# Step 8: Cleanup old backups
|
|
73
|
+
@stage = "retention cleanup"
|
|
65
74
|
cleanup_old_backups
|
|
66
75
|
|
|
67
76
|
# Step 9: Record success
|
|
77
|
+
@stage = "recording success"
|
|
68
78
|
file_size = calculate_size(dump_path)
|
|
69
79
|
@record.succeed!(file_path: dump_path, file_size: file_size)
|
|
70
80
|
|
|
71
81
|
# Step 10: Notify
|
|
82
|
+
@stage = "success notification"
|
|
72
83
|
notify_success
|
|
73
84
|
|
|
74
85
|
{ success: true, record: @record, file_path: dump_path }
|
|
75
86
|
rescue StandardError => e
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
full_error = failure_message(e)
|
|
88
|
+
@record&.fail!(error_message: full_error)
|
|
89
|
+
notify_failure(full_error)
|
|
90
|
+
{ success: false, error: full_error, record: @record }
|
|
79
91
|
ensure
|
|
80
92
|
cleanup_temp_files
|
|
81
93
|
end
|
|
@@ -101,10 +113,11 @@ module RailsNexus
|
|
|
101
113
|
# ─── MySQL ─────────────────────────────────────────────────────
|
|
102
114
|
def dump_mysql
|
|
103
115
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
116
|
+
password = resolve_password(@config.password)
|
|
104
117
|
cmd = ["mysqldump"]
|
|
105
|
-
cmd << "--user=#{@config.username}"
|
|
106
|
-
cmd << "--password=#{
|
|
107
|
-
cmd << "--host=#{@config.host}" if @config.host.present?
|
|
118
|
+
cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
|
|
119
|
+
cmd << "--password=#{password}" if password.present?
|
|
120
|
+
cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
|
|
108
121
|
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
109
122
|
cmd << "--result-file=#{raw_path}"
|
|
110
123
|
cmd << "--single-transaction"
|
|
@@ -112,16 +125,22 @@ module RailsNexus
|
|
|
112
125
|
cmd << "--routines"
|
|
113
126
|
cmd << "--triggers"
|
|
114
127
|
cmd << "--events"
|
|
128
|
+
# Additional MySQL options (e.g. --defaults-extra-file)
|
|
129
|
+
additional_options = if @config.respond_to?(:mysql_additional_options)
|
|
130
|
+
@config.mysql_additional_options
|
|
131
|
+
end
|
|
132
|
+
if additional_options.present?
|
|
133
|
+
additional_options.split(" ").each { |opt| cmd << opt }
|
|
134
|
+
end
|
|
115
135
|
|
|
116
136
|
# Skip tables
|
|
117
137
|
@config.skip_tables_list.each { |t| cmd << "--ignore-table=#{@config.database_name}.#{t}" }
|
|
118
138
|
|
|
119
139
|
cmd << @config.database_name
|
|
120
140
|
|
|
121
|
-
stdout, stderr, status =
|
|
141
|
+
stdout, stderr, status = capture_command(cmd.join(" "))
|
|
122
142
|
unless status.success?
|
|
123
|
-
|
|
124
|
-
return nil
|
|
143
|
+
raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
125
144
|
end
|
|
126
145
|
register_temp_file(raw_path)
|
|
127
146
|
raw_path
|
|
@@ -130,9 +149,10 @@ module RailsNexus
|
|
|
130
149
|
# ─── PostgreSQL ────────────────────────────────────────────────
|
|
131
150
|
def dump_postgresql
|
|
132
151
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
152
|
+
password = resolve_password(@config.password)
|
|
133
153
|
cmd = ["pg_dump"]
|
|
134
|
-
cmd << "--username=#{@config.username}" if @config.username.present?
|
|
135
|
-
cmd << "--host=#{@config.host}" if @config.host.present?
|
|
154
|
+
cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
|
|
155
|
+
cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
|
|
136
156
|
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
137
157
|
cmd << "--file=#{raw_path}"
|
|
138
158
|
cmd << "--format=plain"
|
|
@@ -145,31 +165,32 @@ module RailsNexus
|
|
|
145
165
|
cmd << @config.database_name
|
|
146
166
|
|
|
147
167
|
env = {}
|
|
148
|
-
env["PGPASSWORD"] =
|
|
168
|
+
env["PGPASSWORD"] = password if password.present?
|
|
149
169
|
|
|
150
170
|
stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
|
|
151
171
|
unless status.success?
|
|
152
|
-
|
|
153
|
-
return nil
|
|
172
|
+
raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
154
173
|
end
|
|
155
174
|
register_temp_file(raw_path)
|
|
156
175
|
raw_path
|
|
157
176
|
end
|
|
158
177
|
|
|
178
|
+
def capture_command(*command)
|
|
179
|
+
Open3.capture3(*command)
|
|
180
|
+
end
|
|
181
|
+
|
|
159
182
|
# ─── SQLite ────────────────────────────────────────────────────
|
|
160
183
|
def dump_sqlite
|
|
161
184
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
162
185
|
db_path = @config.database_name
|
|
163
186
|
|
|
164
187
|
unless File.exist?(db_path)
|
|
165
|
-
|
|
166
|
-
return nil
|
|
188
|
+
raise "SQLite database not found: #{db_path}"
|
|
167
189
|
end
|
|
168
190
|
|
|
169
191
|
stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
|
|
170
192
|
unless status.success?
|
|
171
|
-
|
|
172
|
-
return nil
|
|
193
|
+
raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
173
194
|
end
|
|
174
195
|
|
|
175
196
|
File.write(raw_path, stdout)
|
|
@@ -183,13 +204,14 @@ module RailsNexus
|
|
|
183
204
|
dir_path = raw_path.gsub(/\.sql$/, "")
|
|
184
205
|
FileUtils.mkdir_p(dir_path)
|
|
185
206
|
|
|
207
|
+
password = resolve_password(@config.password)
|
|
186
208
|
cmd = ["mongodump"]
|
|
187
|
-
cmd << "--host=#{@config.host || 'localhost'}"
|
|
209
|
+
cmd << "--host=#{resolve_env(@config.host) || 'localhost'}"
|
|
188
210
|
cmd << "--port=#{@config.port || 27017}"
|
|
189
211
|
cmd << "--db=#{@config.database_name}"
|
|
190
212
|
cmd << "--out=#{dir_path}"
|
|
191
|
-
cmd << "--username=#{@config.username}" if @config.username.present?
|
|
192
|
-
cmd << "--password=#{
|
|
213
|
+
cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
|
|
214
|
+
cmd << "--password=#{password}" if password.present?
|
|
193
215
|
cmd << "--authenticationDatabase=admin" if @config.username.present?
|
|
194
216
|
|
|
195
217
|
# Skip collections
|
|
@@ -197,17 +219,15 @@ module RailsNexus
|
|
|
197
219
|
|
|
198
220
|
stdout, stderr, status = Open3.capture3(cmd.join(" "))
|
|
199
221
|
unless status.success?
|
|
200
|
-
@errors << "mongodump failed: #{stderr}"
|
|
201
222
|
FileUtils.rm_rf(dir_path) if Dir.exist?(dir_path)
|
|
202
|
-
|
|
223
|
+
raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
203
224
|
end
|
|
204
225
|
|
|
205
226
|
# Tar the dump directory
|
|
206
227
|
tar_path = "#{dir_path}.tar"
|
|
207
228
|
stdout, stderr, status = Open3.capture3("tar -cf \"#{tar_path}\" -C \"#{File.dirname(dir_path)}\" \"#{File.basename(dir_path)}\"")
|
|
208
229
|
unless status.success?
|
|
209
|
-
|
|
210
|
-
return nil
|
|
230
|
+
raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
211
231
|
end
|
|
212
232
|
|
|
213
233
|
FileUtils.rm_rf(dir_path)
|
|
@@ -222,7 +242,7 @@ module RailsNexus
|
|
|
222
242
|
|
|
223
243
|
host = @config.host || "localhost"
|
|
224
244
|
port = @config.port || 6379
|
|
225
|
-
password = @config.password
|
|
245
|
+
password = resolve_password(@config.password)
|
|
226
246
|
|
|
227
247
|
# Trigger BGSAVE first
|
|
228
248
|
cmd = "redis-cli -h #{host} -p #{port}"
|
|
@@ -353,11 +373,16 @@ module RailsNexus
|
|
|
353
373
|
|
|
354
374
|
def encrypt_openssl(file_path)
|
|
355
375
|
enc_path = "#{file_path}.enc"
|
|
356
|
-
password = @config.encryption_password
|
|
376
|
+
password = resolve_secret(@config.encryption_password)
|
|
357
377
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
378
|
+
# Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
|
|
379
|
+
# (AES-GCM not available in all OpenSSL builds)
|
|
380
|
+
cmd = "openssl enc -aes-256-cbc"
|
|
381
|
+
cmd += " -pbkdf2 -iter 600000"
|
|
382
|
+
cmd += " -salt"
|
|
383
|
+
cmd += " -in \"#{file_path}\""
|
|
384
|
+
cmd += " -out \"#{enc_path}\""
|
|
385
|
+
cmd += " -pass pass:#{password}"
|
|
361
386
|
|
|
362
387
|
stdout, stderr, status = Open3.capture3(cmd)
|
|
363
388
|
unless status.success?
|
|
@@ -371,10 +396,13 @@ module RailsNexus
|
|
|
371
396
|
|
|
372
397
|
def encrypt_gpg(file_path)
|
|
373
398
|
enc_path = "#{file_path}.gpg"
|
|
374
|
-
password = @config.gpg_password
|
|
399
|
+
password = resolve_secret(@config.gpg_password)
|
|
375
400
|
|
|
376
|
-
|
|
401
|
+
# Best practice: AES256 with s2k-mode=iter for key derivation
|
|
402
|
+
cmd = "echo #{password.shellescape} | gpg --batch --yes --symmetric " \
|
|
377
403
|
"--cipher-algo AES256 " \
|
|
404
|
+
"--s2k-mode 3 " \
|
|
405
|
+
"--s2k-count 65011712 " \
|
|
378
406
|
"--passphrase-fd 0 " \
|
|
379
407
|
"-o \"#{enc_path}\" \"#{file_path}\""
|
|
380
408
|
|
|
@@ -419,16 +447,37 @@ module RailsNexus
|
|
|
419
447
|
|
|
420
448
|
remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
|
|
421
449
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
450
|
+
# Build rsync command
|
|
451
|
+
cmd = ["rsync"]
|
|
452
|
+
cmd << "-a"
|
|
453
|
+
cmd << "-z" # compress
|
|
454
|
+
cmd << "--progress"
|
|
455
|
+
cmd << "--archive" if @config.respond_to?(:rsync_archive?) && @config.rsync_archive?
|
|
456
|
+
cmd << "--delete" if @config.respond_to?(:rsync_mirror?) && @config.rsync_mirror?
|
|
457
|
+
cmd << "-e"
|
|
458
|
+
cmd << "ssh -p #{@config.rsync_port.presence || 22}"
|
|
459
|
+
|
|
460
|
+
# Add the dump file
|
|
461
|
+
cmd << file_path
|
|
462
|
+
|
|
463
|
+
# Add additional directories if configured
|
|
464
|
+
if @config.respond_to?(:rsync_directories) && @config.rsync_directories.present?
|
|
465
|
+
@config.rsync_directories.split(",").map(&:strip).reject(&:blank?).each do |dir|
|
|
466
|
+
expanded = dir.gsub("~", Dir.home)
|
|
467
|
+
cmd << expanded
|
|
468
|
+
end
|
|
469
|
+
end
|
|
430
470
|
|
|
431
|
-
|
|
471
|
+
# Add excludes
|
|
472
|
+
if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
|
|
473
|
+
@config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
|
|
474
|
+
cmd << "--exclude=#{excl}"
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
cmd << remote
|
|
479
|
+
|
|
480
|
+
stdout, stderr, status = capture_command(*cmd)
|
|
432
481
|
unless status.success?
|
|
433
482
|
@errors << "RSync failed: #{stderr}"
|
|
434
483
|
end
|
|
@@ -588,8 +637,46 @@ module RailsNexus
|
|
|
588
637
|
@temp_files << path unless @temp_files.include?(path)
|
|
589
638
|
end
|
|
590
639
|
|
|
640
|
+
# ─── Password Resolution ──────────────────────────────────────
|
|
641
|
+
# Supports:
|
|
642
|
+
# "plain_text" → used as-is
|
|
643
|
+
# "ENV[MY_VAR]" → resolved from ENV
|
|
644
|
+
# "${MY_VAR}" → resolved from ENV
|
|
645
|
+
# "credentials[:key]" → from Rails encrypted credentials
|
|
646
|
+
# "credentials[:a][:b]" → nested credentials key
|
|
647
|
+
def resolve_password(value)
|
|
648
|
+
return nil if value.blank?
|
|
649
|
+
resolve_secret(value)
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def resolve_env(value)
|
|
653
|
+
resolve_secret(value)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def resolve_secret(value)
|
|
657
|
+
return nil if value.blank?
|
|
658
|
+
case value
|
|
659
|
+
when /^ENV\[(.+?)\]$/
|
|
660
|
+
ENV[$1]
|
|
661
|
+
when /^\$\{(.+?)\}$/
|
|
662
|
+
ENV[$1]
|
|
663
|
+
when /^credentials\[/
|
|
664
|
+
# credentials[:key] or credentials[:key][:subkey]
|
|
665
|
+
keys = value.scan(/\[:([^\]]+)\]/).flatten.map(&:to_sym)
|
|
666
|
+
Rails.application.credentials.dig(*keys)
|
|
667
|
+
else
|
|
668
|
+
value
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
591
672
|
def command_exists?(cmd)
|
|
592
673
|
system("which #{cmd} > /dev/null 2>&1")
|
|
593
674
|
end
|
|
675
|
+
|
|
676
|
+
def failure_message(exception)
|
|
677
|
+
heading = "#{@stage || 'backup'} failed: #{exception.class}: #{exception.message}"
|
|
678
|
+
trace = Array(exception.backtrace).first(5).join("\n")
|
|
679
|
+
[heading, trace.presence, @errors.join("\n").presence].compact.join("\n\n")
|
|
680
|
+
end
|
|
594
681
|
end
|
|
595
682
|
end
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<nav class="rn-sidebar-nav" aria-label="RailsNexus navigation">
|
|
2
|
-
<
|
|
3
|
-
<
|
|
2
|
+
<details class="rn-nav-section" data-action="toggle->rails_nexus-sidebar#closeOtherMenus" <%= "open" if monitor_menu_active %>>
|
|
3
|
+
<summary class="rn-nav-section-toggle">
|
|
4
|
+
<span class="rn-nav-section-title">
|
|
5
|
+
<span class="rn-nav-section-icon"><svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12h3l2.25-6 4.5 12 2.25-6H21" /></svg></span>
|
|
6
|
+
<span>Monitor</span>
|
|
7
|
+
</span>
|
|
8
|
+
<svg class="rn-nav-section-chevron" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
|
|
9
|
+
</summary>
|
|
10
|
+
<div class="rn-nav-section-items">
|
|
4
11
|
|
|
5
12
|
<%= link_to root_path,
|
|
6
13
|
class: "rn-nav-item #{request.path == rails_nexus.root_path ? 'active' : ''}" do %>
|
|
@@ -34,10 +41,18 @@
|
|
|
34
41
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg>
|
|
35
42
|
Statistics
|
|
36
43
|
<% end %>
|
|
37
|
-
|
|
44
|
+
</div>
|
|
45
|
+
</details>
|
|
38
46
|
|
|
39
|
-
<
|
|
40
|
-
<
|
|
47
|
+
<details class="rn-nav-section" data-action="toggle->rails_nexus-sidebar#closeOtherMenus" <%= "open" if operations_menu_active %>>
|
|
48
|
+
<summary class="rn-nav-section-toggle">
|
|
49
|
+
<span class="rn-nav-section-title">
|
|
50
|
+
<span class="rn-nav-section-icon"><svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 6.75h15M7.5 12h9m-6 5.25h3" /></svg></span>
|
|
51
|
+
<span>Operations</span>
|
|
52
|
+
</span>
|
|
53
|
+
<svg class="rn-nav-section-chevron" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
|
|
54
|
+
</summary>
|
|
55
|
+
<div class="rn-nav-section-items">
|
|
41
56
|
|
|
42
57
|
<%= link_to cron_jobs_path,
|
|
43
58
|
class: "rn-nav-item #{request.path.start_with?(rails_nexus.cron_jobs_path) ? 'active' : ''}" do %>
|
|
@@ -46,20 +61,34 @@
|
|
|
46
61
|
<% end %>
|
|
47
62
|
|
|
48
63
|
<%= link_to backup_path,
|
|
49
|
-
class: "rn-nav-item #{
|
|
64
|
+
class: "rn-nav-item #{controller_path == 'rails_nexus/backup' && !%w[all_history history].include?(action_name) ? 'active' : ''}" do %>
|
|
50
65
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>
|
|
51
66
|
Backups
|
|
52
67
|
<% end %>
|
|
53
68
|
|
|
69
|
+
<%= link_to backup_history_path,
|
|
70
|
+
class: "rn-nav-item #{controller_path == 'rails_nexus/backup' && %w[all_history history].include?(action_name) ? 'active' : ''}" do %>
|
|
71
|
+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
72
|
+
Backup History
|
|
73
|
+
<% end %>
|
|
74
|
+
|
|
54
75
|
<%= link_to settings_path,
|
|
55
76
|
class: "rn-nav-item #{request.path == rails_nexus.settings_path ? 'active' : ''}" do %>
|
|
56
77
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
|
57
78
|
Settings
|
|
58
79
|
<% end %>
|
|
59
|
-
|
|
80
|
+
</div>
|
|
81
|
+
</details>
|
|
60
82
|
|
|
61
|
-
<
|
|
62
|
-
<
|
|
83
|
+
<details class="rn-nav-section" data-action="toggle->rails_nexus-sidebar#closeOtherMenus" <%= "open" if resources_menu_active %>>
|
|
84
|
+
<summary class="rn-nav-section-toggle">
|
|
85
|
+
<span class="rn-nav-section-title">
|
|
86
|
+
<span class="rn-nav-section-icon"><svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6H6.75A3.75 3.75 0 003 9.75v7.5A3.75 3.75 0 006.75 21h7.5A3.75 3.75 0 0018 17.25V13.5m-6-1.5L21 3m0 0h-5.25M21 3v5.25" /></svg></span>
|
|
87
|
+
<span>Resources</span>
|
|
88
|
+
</span>
|
|
89
|
+
<svg class="rn-nav-section-chevron" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
|
|
90
|
+
</summary>
|
|
91
|
+
<div class="rn-nav-section-items">
|
|
63
92
|
|
|
64
93
|
<% if main_app.respond_to?(:root_path) %>
|
|
65
94
|
<%= link_to main_app.root_path, class: "rn-nav-item rn-nav-item--muted" do %>
|
|
@@ -79,5 +108,6 @@
|
|
|
79
108
|
<%= link[:label] %>
|
|
80
109
|
<% end %>
|
|
81
110
|
<% end %>
|
|
82
|
-
|
|
111
|
+
</div>
|
|
112
|
+
</details>
|
|
83
113
|
</nav>
|