backup_nexus 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +82 -0
- data/Rakefile +10 -0
- data/app/controllers/backup_nexus/application_controller.rb +31 -0
- data/app/controllers/backup_nexus/backup_controller.rb +119 -0
- data/app/helpers/backup_nexus/application_helper.rb +51 -0
- data/app/jobs/backup_nexus/backup_job.rb +18 -0
- data/app/models/backup_nexus/backup.rb +107 -0
- data/app/models/backup_nexus/backup_config.rb +189 -0
- data/app/services/backup_nexus/backup_runner.rb +62 -0
- data/app/services/backup_nexus/backup_service.rb +817 -0
- data/app/views/backup_nexus/backup/_form.html.erb +334 -0
- data/app/views/backup_nexus/backup/config_history.html.erb +67 -0
- data/app/views/backup_nexus/backup/edit.html.erb +15 -0
- data/app/views/backup_nexus/backup/history.html.erb +75 -0
- data/app/views/backup_nexus/backup/index.html.erb +112 -0
- data/app/views/backup_nexus/backup/new.html.erb +15 -0
- data/backup_nexus.gemspec +30 -0
- data/config/routes.rb +15 -0
- data/db/seeds/backup_configs_safe.rb +61 -0
- data/lib/backup_nexus/base_record.rb +7 -0
- data/lib/backup_nexus/configuration.rb +19 -0
- data/lib/backup_nexus/engine.rb +51 -0
- data/lib/backup_nexus/version.rb +3 -0
- data/lib/backup_nexus.rb +26 -0
- data/lib/generators/backup_nexus/backup_generator.rb +70 -0
- data/lib/generators/backup_nexus/install_generator.rb +27 -0
- data/lib/generators/backup_nexus/templates/backup/backup/backup_helper.sh +152 -0
- data/lib/generators/backup_nexus/templates/backup/backup/config.rb +27 -0
- data/lib/generators/backup_nexus/templates/backup/backup/models/daily_backup.rb +24 -0
- data/lib/generators/backup_nexus/templates/backup/backup/models/full_backup.rb +35 -0
- data/lib/generators/backup_nexus/templates/backup/backup/models/sync_backup.rb +43 -0
- data/lib/generators/backup_nexus/templates/backup/backup/mysql-config/db_config.cnf +15 -0
- data/lib/generators/backup_nexus/templates/backup/backup/schedule.rb +28 -0
- data/lib/generators/backup_nexus/templates/migration.rb +105 -0
- data/lib/tasks/backup_nexus.rake +81 -0
- metadata +138 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8eb79bf4aa85274b82292897294246021d89ee85ae8c5f09e080ae82bd526129
|
|
4
|
+
data.tar.gz: a087f50f451ca3e635830042c04e7ccaa56c541b75d0dbbf88f3a27717bea340
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8b2338600d937308f2ca82d7bdf7b1c3d769bbf339b40800a1535a9f65e3cf5a7d1658e14cd0aa6f25a10669c73cd04b29912f0060ee02ef18b0b84fa7478871
|
|
7
|
+
data.tar.gz: 37829dbe4fa5b948f9c00fff551b15d67d6ae27a6bbecf841af7ee43170d0d83342d272b915808930ef5197e8afc9b68f2c58d8b5d2476c7f4d75000cc5e909d
|
data/CHANGELOG.md
ADDED
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Tamiru Hailu
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# BackupNexus
|
|
2
|
+
|
|
3
|
+
Backup management for Rails applications, extracted from RailsNexus.
|
|
4
|
+
|
|
5
|
+
BackupNexus keeps the same RailsNexus layout and stylesheet while owning the
|
|
6
|
+
backup dashboard, database models, backup runner, generators, migrations, and
|
|
7
|
+
rake tasks in a separate gem.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "rails_nexus"
|
|
13
|
+
gem "backup_nexus"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Run the installer:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bin/rails generate backup_nexus:install
|
|
20
|
+
bin/rails db:migrate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The installer mounts the engine at `/backup_nexus`. Configure authorization
|
|
24
|
+
through `RailsNexus.configure`, then enable the backup dashboard:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
BackupNexus.configure do |config|
|
|
28
|
+
config.enabled = true
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run the safe example seed with:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bundle exec rake backup_nexus:seed_backups
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The migration renames legacy `rails_nexus_backups` and
|
|
39
|
+
`rails_nexus_backup_configs` tables when they exist, preserving existing
|
|
40
|
+
backup records during the extraction.
|
|
41
|
+
|
|
42
|
+
## Official Backup gem integration
|
|
43
|
+
|
|
44
|
+
BackupNexus includes its existing safe native runner by default and also
|
|
45
|
+
generates models compatible with the official [Backup](https://github.com/backup/backup)
|
|
46
|
+
DSL. The upstream project is not currently under active development and
|
|
47
|
+
targets older Rails dependencies, so it remains optional instead of being a
|
|
48
|
+
hard runtime dependency.
|
|
49
|
+
|
|
50
|
+
To use the DSL templates:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
gem "backup", "~> 4.4"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then run:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bin/rails generate backup_nexus:backup
|
|
60
|
+
bundle exec backup perform -t daily_backup
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
bundle exec rails test
|
|
67
|
+
bundle exec rake test
|
|
68
|
+
gem build backup_nexus.gemspec
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## CI/CD
|
|
72
|
+
|
|
73
|
+
GitHub Actions runs the test suite on pushes and pull requests. A matching
|
|
74
|
+
version tag publishes the gem to RubyGems and creates a GitHub release.
|
|
75
|
+
|
|
76
|
+
Create a scoped RubyGems API key that can push only the `backup_nexus` gem,
|
|
77
|
+
then add it to the GitHub repository (or its `release` environment) as an
|
|
78
|
+
Actions secret named `RUBYGEMS_API_KEY`. Never commit the key to the
|
|
79
|
+
repository.
|
|
80
|
+
|
|
81
|
+
To release a version, update `lib/backup_nexus/version.rb`, merge the change to
|
|
82
|
+
`main`, and push a matching tag such as `v0.1.0`.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BackupNexus
|
|
4
|
+
class ApplicationController < RailsNexus::ApplicationController
|
|
5
|
+
include BackupNexus::ApplicationHelper
|
|
6
|
+
|
|
7
|
+
layout "layouts/rails_nexus/application"
|
|
8
|
+
|
|
9
|
+
helper BackupNexus::ApplicationHelper
|
|
10
|
+
helper BackupNexus::Engine.helpers
|
|
11
|
+
helper BackupNexus::Engine.routes.url_helpers
|
|
12
|
+
helper RailsNexus::Engine.helpers
|
|
13
|
+
helper RailsNexus::Engine.routes.url_helpers
|
|
14
|
+
|
|
15
|
+
helper_method :rails_nexus
|
|
16
|
+
|
|
17
|
+
before_action :backup_nexus_require_enabled
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def rails_nexus
|
|
22
|
+
RailsNexus::Engine.routes.url_helpers
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def backup_nexus_require_enabled
|
|
26
|
+
return if BackupNexus.configuration.enabled
|
|
27
|
+
|
|
28
|
+
render plain: "Backup management not enabled", status: :forbidden
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BackupNexus
|
|
4
|
+
class BackupController < ApplicationController
|
|
5
|
+
before_action :check_backup_enabled
|
|
6
|
+
before_action :set_config, only: %i[edit update destroy trigger]
|
|
7
|
+
|
|
8
|
+
# GET /backup_nexus/backup
|
|
9
|
+
def index
|
|
10
|
+
@configs = BackupNexus::BackupConfig.order(:name)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /backup_nexus/backup/new
|
|
14
|
+
def new
|
|
15
|
+
@config = BackupNexus::BackupConfig.new(
|
|
16
|
+
adapter: "mysql",
|
|
17
|
+
host: "localhost",
|
|
18
|
+
port: 3306,
|
|
19
|
+
storage_path: "~/dumps",
|
|
20
|
+
keep_count: 30,
|
|
21
|
+
compress: true
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# POST /backup_nexus/backup
|
|
26
|
+
def create
|
|
27
|
+
@config = BackupNexus::BackupConfig.new(config_params)
|
|
28
|
+
|
|
29
|
+
if @config.save
|
|
30
|
+
redirect_to backup_path, notice: "Backup config '#{@config.name}' created."
|
|
31
|
+
else
|
|
32
|
+
render :new, status: :unprocessable_entity
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# GET /backup_nexus/backup/:id/edit
|
|
37
|
+
def edit; end
|
|
38
|
+
|
|
39
|
+
# PATCH /backup_nexus/backup/:id
|
|
40
|
+
def update
|
|
41
|
+
# Strip blank password fields so they don't overwrite stored values
|
|
42
|
+
filtered = config_params
|
|
43
|
+
%w[password encryption_password gpg_password].each do |attr|
|
|
44
|
+
filtered = filtered.except(attr) if filtered[attr].blank?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if @config.update(filtered)
|
|
48
|
+
redirect_to backup_path, notice: "Backup config '#{@config.name}' updated."
|
|
49
|
+
else
|
|
50
|
+
render :edit, status: :unprocessable_entity
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# DELETE /backup_nexus/backup/:id
|
|
55
|
+
def destroy
|
|
56
|
+
name = @config.name
|
|
57
|
+
@config.destroy
|
|
58
|
+
redirect_to backup_path, notice: "Backup config '#{name}' deleted."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# POST /backup_nexus/backup/:id/trigger
|
|
62
|
+
def trigger
|
|
63
|
+
begin
|
|
64
|
+
BackupNexus::BackupJob.perform_later(@config.id)
|
|
65
|
+
redirect_to backup_path, notice: "Backup '#{@config.name}' started in background."
|
|
66
|
+
rescue
|
|
67
|
+
# Fallback to synchronous if Active Job is not configured
|
|
68
|
+
result = BackupNexus::BackupService.run(@config)
|
|
69
|
+
if result[:success]
|
|
70
|
+
redirect_to backup_path, notice: "Backup '#{@config.name}' completed successfully."
|
|
71
|
+
else
|
|
72
|
+
redirect_to backup_path, alert: "Backup failed: #{result[:error]}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# GET /backup_nexus/backup/:id/history
|
|
78
|
+
def history
|
|
79
|
+
@config = BackupNexus::BackupConfig.find(params[:id])
|
|
80
|
+
@records = BackupNexus::Backup.where(config_name: @config.name)
|
|
81
|
+
.order(started_at: :desc)
|
|
82
|
+
.limit(50)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# GET /backup_nexus/backup/history
|
|
86
|
+
def all_history
|
|
87
|
+
@records = BackupNexus::Backup.order(started_at: :desc).limit(100)
|
|
88
|
+
render :history
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def set_config
|
|
94
|
+
@config = BackupNexus::BackupConfig.find(params[:id])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def config_params
|
|
98
|
+
params.require(:backup_config).permit(
|
|
99
|
+
:name, :description, :database_name, :adapter, :host, :port,
|
|
100
|
+
:username, :password, :storage_path, :keep_count,
|
|
101
|
+
:compress, :encrypted, :encryption_password,
|
|
102
|
+
:rsync_enabled, :rsync_host, :rsync_port, :rsync_user, :rsync_path, :rsync_mirror, :rsync_archive, :rsync_directories, :rsync_excludes,
|
|
103
|
+
:notify_command, :notify_on_success, :notify_on_failure,
|
|
104
|
+
:schedule_cron, :enabled,
|
|
105
|
+
:s3_enabled, :s3_access_key, :s3_secret_key, :s3_bucket, :s3_region, :s3_prefix,
|
|
106
|
+
:gpg_enabled, :gpg_password,
|
|
107
|
+
:email_notify, :email_to,
|
|
108
|
+
:archive_enabled, :bzip2_compress, :split_chunks, :encrypt_base64, :mysql_additional_options,
|
|
109
|
+
skip_tables: [], archive_paths: [], archive_excludes: []
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check_backup_enabled
|
|
114
|
+
unless BackupNexus.configuration.enabled
|
|
115
|
+
render plain: "Backup management not enabled", status: :forbidden
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BackupNexus
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
include RailsNexus::ApplicationHelper
|
|
6
|
+
include BackupNexus::Engine.routes.url_helpers
|
|
7
|
+
|
|
8
|
+
def backup_path(_resource = nil)
|
|
9
|
+
backup_nexus_route_path("/backup")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def new_backup_path
|
|
13
|
+
backup_nexus_route_path("/backup/new")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def backup_history_path
|
|
17
|
+
backup_nexus_route_path("/backup/history")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def edit_backup_path(resource)
|
|
21
|
+
backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}/edit")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def backup_destroy_path(resource)
|
|
25
|
+
backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def backup_trigger_path(resource)
|
|
29
|
+
backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}/trigger")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def backup_config_history_path(resource)
|
|
33
|
+
backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}/history")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def backup_update_path(resource)
|
|
37
|
+
backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def backup_nexus_route_path(path)
|
|
43
|
+
prefix = request.respond_to?(:script_name) && request.script_name.present? ? request.script_name.chomp("/") : "/backup_nexus"
|
|
44
|
+
"#{prefix}#{path}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def backup_nexus_param(resource)
|
|
48
|
+
resource.respond_to?(:to_param) ? resource.to_param : resource
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BackupNexus
|
|
4
|
+
class BackupJob < ApplicationJob
|
|
5
|
+
queue_as :backup_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?(BackupNexus::BackupConfig)
|
|
10
|
+
backup_config_id_or_record
|
|
11
|
+
else
|
|
12
|
+
BackupNexus::BackupConfig.find(backup_config_id_or_record)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
BackupNexus::BackupService.run(config)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BackupNexus
|
|
4
|
+
class Backup < BaseRecord
|
|
5
|
+
self.table_name = "backup_nexus_backups"
|
|
6
|
+
|
|
7
|
+
validates :config_name, presence: true
|
|
8
|
+
validates :status, presence: true, inclusion: { in: %w[running success failed] }
|
|
9
|
+
validates :started_at, presence: true
|
|
10
|
+
|
|
11
|
+
scope :recent, ->(hours = 24) { where("started_at >= ?", hours.hours.ago) }
|
|
12
|
+
scope :successful, -> { where(status: "success") }
|
|
13
|
+
scope :failed, -> { where(status: "failed") }
|
|
14
|
+
scope :running, -> { where(status: "running") }
|
|
15
|
+
scope :by_config, ->(name) { where(config_name: name) }
|
|
16
|
+
scope :latest_first, -> { order(started_at: :desc) }
|
|
17
|
+
|
|
18
|
+
# Start tracking a backup run
|
|
19
|
+
def self.start!(config_name:, triggered_by: "system")
|
|
20
|
+
create!(
|
|
21
|
+
config_name: config_name,
|
|
22
|
+
status: "running",
|
|
23
|
+
triggered_by: triggered_by,
|
|
24
|
+
started_at: Time.current
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Mark backup as completed
|
|
29
|
+
def succeed!(file_path: nil, file_size: nil)
|
|
30
|
+
update!(
|
|
31
|
+
status: "success",
|
|
32
|
+
file_path: file_path,
|
|
33
|
+
file_size: file_size,
|
|
34
|
+
duration: (Time.current - started_at).round(2),
|
|
35
|
+
completed_at: Time.current
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Mark backup as failed
|
|
40
|
+
def fail!(error_message:)
|
|
41
|
+
update!(
|
|
42
|
+
status: "failed",
|
|
43
|
+
error_message: error_message,
|
|
44
|
+
duration: (Time.current - started_at).round(2),
|
|
45
|
+
completed_at: Time.current
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get success rate for a model over the last N days
|
|
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
|
+
return 0 if records.empty?
|
|
53
|
+
(records.successful.count.to_f / records.count * 100).round(1)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get backup health summary
|
|
57
|
+
def self.health_summary(alert_threshold_hours: 24)
|
|
58
|
+
configs = BackupNexus::BackupConfig.pluck(:name)
|
|
59
|
+
results = configs.map do |config_name|
|
|
60
|
+
latest = by_config(config_name).successful.latest_first.first
|
|
61
|
+
age_hours = latest ? ((Time.current - latest.started_at) / 3600).round(1) : nil
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
config_name: config_name,
|
|
65
|
+
last_backup: latest&.started_at,
|
|
66
|
+
age_hours: age_hours,
|
|
67
|
+
status: age_hours.nil? ? "unknown" : (age_hours < alert_threshold_hours ? "healthy" : "stale"),
|
|
68
|
+
success_rate: success_rate(config_name: config_name),
|
|
69
|
+
last_file_size: latest&.file_size
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
healthy: results.all? { |r| r[:status] == "healthy" },
|
|
75
|
+
models: results,
|
|
76
|
+
alerts: results.select { |r| r[:status] == "stale" || r[:status] == "unknown" }
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Cleanup old backup records
|
|
81
|
+
def self.cleanup!(retention_days: 30)
|
|
82
|
+
where("started_at < ?", retention_days.days.ago).delete_all
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Format file size
|
|
86
|
+
def file_size_human
|
|
87
|
+
return "—" unless file_size
|
|
88
|
+
units = ["B", "KB", "MB", "GB"]
|
|
89
|
+
size = file_size.to_f
|
|
90
|
+
units.each do |unit|
|
|
91
|
+
return "#{size.round(1)} #{unit}" if size < 1024
|
|
92
|
+
size /= 1024
|
|
93
|
+
end
|
|
94
|
+
"#{size.round(1)} TB"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Duration formatted
|
|
98
|
+
def duration_human
|
|
99
|
+
return "—" unless duration
|
|
100
|
+
if duration < 60
|
|
101
|
+
"#{duration.round(1)}s"
|
|
102
|
+
else
|
|
103
|
+
"#{(duration / 60).floor}m #{(duration % 60).round}s"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BackupNexus
|
|
4
|
+
class BackupConfig < BaseRecord
|
|
5
|
+
self.table_name = "backup_nexus_backup_configs"
|
|
6
|
+
|
|
7
|
+
ADAPTERS = %w[mysql postgresql sqlite mongodb redis].freeze
|
|
8
|
+
|
|
9
|
+
# ─── Boolean defaults ──────────────────────────────────────────
|
|
10
|
+
# Rails doesn't apply DB defaults to new in-memory records, so
|
|
11
|
+
# check_box helpers in form_for render unchecked. These declarations
|
|
12
|
+
# ensure the form reflects the intended defaults.
|
|
13
|
+
attribute :enabled, :boolean, default: true
|
|
14
|
+
attribute :compress, :boolean, default: true
|
|
15
|
+
attribute :encrypted, :boolean, default: false
|
|
16
|
+
attribute :encrypt_base64, :boolean, default: false
|
|
17
|
+
attribute :rsync_enabled, :boolean, default: false
|
|
18
|
+
attribute :rsync_mirror, :boolean, default: false
|
|
19
|
+
attribute :notify_on_success, :boolean, default: true
|
|
20
|
+
attribute :notify_on_failure, :boolean, default: true
|
|
21
|
+
attribute :s3_enabled, :boolean, default: false
|
|
22
|
+
attribute :gpg_enabled, :boolean, default: false
|
|
23
|
+
attribute :email_notify, :boolean, default: false
|
|
24
|
+
attribute :archive_enabled, :boolean, default: false
|
|
25
|
+
attribute :bzip2_compress, :boolean, default: false
|
|
26
|
+
attribute :split_chunks, :boolean, default: false
|
|
27
|
+
|
|
28
|
+
validates :name, presence: true, uniqueness: true
|
|
29
|
+
validates :name, format: {
|
|
30
|
+
with: /\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/,
|
|
31
|
+
message: "may contain only letters, numbers, dots, underscores, dollar signs, and hyphens"
|
|
32
|
+
}
|
|
33
|
+
validates :database_name, presence: true, if: -> { adapter != "redis" }
|
|
34
|
+
validates :adapter, presence: true, inclusion: { in: ADAPTERS }
|
|
35
|
+
validates :storage_path, presence: true
|
|
36
|
+
validates :keep_count, numericality: { greater_than: 0 }
|
|
37
|
+
validates :encryption_password, presence: true, if: :encrypted?
|
|
38
|
+
validates :rsync_host, presence: true, if: :rsync_enabled?
|
|
39
|
+
validates :rsync_user, presence: true, if: :rsync_enabled?
|
|
40
|
+
validates :s3_bucket, presence: true, if: :s3_enabled?
|
|
41
|
+
validates :s3_access_key, presence: true, if: :s3_enabled?
|
|
42
|
+
validates :s3_secret_key, presence: true, if: :s3_enabled?
|
|
43
|
+
validates :gpg_password, presence: true, if: :gpg_enabled?
|
|
44
|
+
validates :email_to, presence: true, if: :email_notify?
|
|
45
|
+
validates :port, :rsync_port,
|
|
46
|
+
numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 65_535 },
|
|
47
|
+
allow_nil: true
|
|
48
|
+
validate :database_identifier_is_safe
|
|
49
|
+
validate :remote_sync_values_are_safe
|
|
50
|
+
|
|
51
|
+
scope :enabled, -> { where(enabled: true) }
|
|
52
|
+
scope :disabled, -> { where(enabled: false) }
|
|
53
|
+
|
|
54
|
+
serialize :skip_tables, coder: JSON
|
|
55
|
+
serialize :archive_paths, coder: JSON
|
|
56
|
+
serialize :archive_excludes, coder: JSON
|
|
57
|
+
|
|
58
|
+
# ─── Skip Tables ───────────────────────────────────────────────
|
|
59
|
+
def skip_tables_list
|
|
60
|
+
return [] if skip_tables.blank?
|
|
61
|
+
skip_tables.is_a?(Array) ? skip_tables : JSON.parse(skip_tables.to_s) rescue []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def skip_tables_list=(value)
|
|
65
|
+
return if value.blank?
|
|
66
|
+
self.skip_tables = if value.is_a?(String)
|
|
67
|
+
value.split(",").map(&:strip).reject(&:blank?)
|
|
68
|
+
else
|
|
69
|
+
value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ─── Archive Paths ─────────────────────────────────────────────
|
|
74
|
+
def archive_paths_list
|
|
75
|
+
return [] if archive_paths.blank?
|
|
76
|
+
archive_paths.is_a?(Array) ? archive_paths : JSON.parse(archive_paths.to_s) rescue []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def archive_paths_list=(value)
|
|
80
|
+
return if value.blank?
|
|
81
|
+
self.archive_paths = if value.is_a?(String)
|
|
82
|
+
value.split(",").map(&:strip).reject(&:blank?)
|
|
83
|
+
else
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def archive_excludes_list
|
|
89
|
+
return [] if archive_excludes.blank?
|
|
90
|
+
archive_excludes.is_a?(Array) ? archive_excludes : JSON.parse(archive_excludes.to_s) rescue []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def archive_excludes_list=(value)
|
|
94
|
+
return if value.blank?
|
|
95
|
+
self.archive_excludes = if value.is_a?(String)
|
|
96
|
+
value.split(",").map(&:strip).reject(&:blank?)
|
|
97
|
+
else
|
|
98
|
+
value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ─── Adapter checks ────────────────────────────────────────────
|
|
103
|
+
def mysql?; adapter == "mysql"; end
|
|
104
|
+
def postgresql?; adapter == "postgresql"; end
|
|
105
|
+
def sqlite?; adapter == "sqlite"; end
|
|
106
|
+
def mongodb?; adapter == "mongodb"; end
|
|
107
|
+
def redis?; adapter == "redis"; end
|
|
108
|
+
|
|
109
|
+
# ─── Path helpers ──────────────────────────────────────────────
|
|
110
|
+
def storage_path_expanded
|
|
111
|
+
storage_path.gsub("~", Dir.home)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def dump_filename
|
|
115
|
+
timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
|
|
116
|
+
ext = dump_extension
|
|
117
|
+
"#{name}_#{timestamp}.#{ext}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def dump_filepath
|
|
121
|
+
File.join(storage_path_expanded, dump_filename)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ─── Stats ─────────────────────────────────────────────────────
|
|
125
|
+
def recent_backups(limit: 10)
|
|
126
|
+
BackupNexus::Backup.where(config_name: name).order(started_at: :desc).limit(limit)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def last_failure
|
|
130
|
+
BackupNexus::Backup.where(config_name: name, status: "failed").order(started_at: :desc).first
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def stats
|
|
134
|
+
backups = BackupNexus::Backup.where(config_name: name)
|
|
135
|
+
recent = backups.where("started_at >= ?", 7.days.ago)
|
|
136
|
+
{
|
|
137
|
+
total: backups.count,
|
|
138
|
+
successful: backups.successful.count,
|
|
139
|
+
failed: backups.failed.count,
|
|
140
|
+
recent_count: recent.count,
|
|
141
|
+
recent_success: recent.successful.count,
|
|
142
|
+
recent_failed: recent.failed.count,
|
|
143
|
+
last_backup: backups.successful.latest_first.first,
|
|
144
|
+
total_size: backups.sum(:file_size).to_i,
|
|
145
|
+
total_size_human: human_size(backups.sum(:file_size).to_i)
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def database_identifier_is_safe
|
|
152
|
+
return if database_name.blank? || sqlite?
|
|
153
|
+
|
|
154
|
+
unless database_name.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !database_name.include?("..")
|
|
155
|
+
errors.add(:database_name, "contains unsupported characters")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def remote_sync_values_are_safe
|
|
160
|
+
return unless rsync_enabled?
|
|
161
|
+
|
|
162
|
+
unless rsync_host.to_s.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
|
|
163
|
+
errors.add(:rsync_host, "is not a valid hostname")
|
|
164
|
+
end
|
|
165
|
+
unless rsync_user.to_s.match?(/\A[a-zA-Z0-9_][a-zA-Z0-9_.-]*\z/)
|
|
166
|
+
errors.add(:rsync_user, "contains unsupported characters")
|
|
167
|
+
end
|
|
168
|
+
unless rsync_path.to_s.start_with?("/") && !rsync_path.to_s.match?(/[\0\r\n]/)
|
|
169
|
+
errors.add(:rsync_path, "must be an absolute remote path")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def dump_extension
|
|
174
|
+
parts = ["sql"]
|
|
175
|
+
parts << "gz" if compress? && !bzip2_compress?
|
|
176
|
+
parts << "bz2" if bzip2_compress?
|
|
177
|
+
parts << "enc" if encrypted?
|
|
178
|
+
parts.join(".")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def human_size(bytes)
|
|
182
|
+
return "0 B" if bytes.zero?
|
|
183
|
+
units = %w[B KB MB GB TB]
|
|
184
|
+
exp = (Math.log(bytes) / Math.log(1024)).to_i
|
|
185
|
+
exp = units.size - 1 if exp >= units.size
|
|
186
|
+
"%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|