baktainer/app/spec/unit/backup_rotation_spec.rb

303 lines
10 KiB
Ruby
Raw Normal View History

Major architectural overhaul: dependency injection, monitoring, and operational improvements This commit represents a comprehensive refactoring and enhancement of Baktainer: ## Core Architecture Improvements - Implemented comprehensive dependency injection system with DependencyContainer - Fixed critical singleton instantiation bug that was returning Procs instead of service instances - Replaced problematic Concurrent::FixedThreadPool with custom SimpleThreadPool implementation - Achieved 100% test pass rate (121 examples, 0 failures) after fixing 30+ failing tests ## New Features Implemented ### 1. Backup Rotation & Cleanup (BackupRotation) - Configurable retention policies by age, count, and disk space - Automatic cleanup with comprehensive statistics tracking - Empty directory cleanup and space monitoring ### 2. Backup Encryption (BackupEncryption) - AES-256-CBC and AES-256-GCM encryption support - Key derivation from passphrases or direct key input - Encrypted backup metadata storage ### 3. Operational Monitoring Suite - **Health Check Server**: HTTP endpoints for monitoring (/health, /status, /metrics) - **Web Dashboard**: Real-time monitoring dashboard with auto-refresh - **Prometheus Metrics**: Integration with monitoring systems - **Backup Monitor**: Comprehensive metrics tracking and performance alerts ### 4. Advanced Label Validation (LabelValidator) - Schema-based validation for all 12+ Docker labels - Engine-specific validation rules - Helpful error messages and warnings - Example generation for each database engine ### 5. Multi-Channel Notifications (NotificationSystem) - Support for Slack, Discord, Teams, webhooks, and log notifications - Event-based notifications for backups, failures, warnings, and health issues - Configurable notification thresholds ## Code Organization Improvements - Extracted responsibilities into focused classes: - ContainerValidator: Container validation logic - BackupOrchestrator: Backup workflow orchestration - FileSystemOperations: File I/O with comprehensive error handling - Configuration: Centralized environment variable management - BackupStrategy/Factory: Strategy pattern for database engines ## Testing Infrastructure - Added comprehensive unit and integration tests - Fixed timing-dependent test failures - Added RSpec coverage reporting (94.94% coverage) - Created test factories and fixtures ## Breaking Changes - Container class constructor now requires dependency injection - BackupCommand methods now use keyword arguments - Thread pool implementation changed from Concurrent to SimpleThreadPool ## Configuration New environment variables: - BT_HEALTH_SERVER_ENABLED: Enable health check server - BT_HEALTH_PORT/BT_HEALTH_BIND: Health server configuration - BT_NOTIFICATION_CHANNELS: Comma-separated notification channels - BT_ENCRYPTION_ENABLED/BT_ENCRYPTION_KEY: Backup encryption - BT_RETENTION_DAYS/COUNT: Backup retention policies This refactoring improves maintainability, testability, and adds enterprise-grade monitoring and operational features while maintaining backward compatibility for basic usage. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 22:58:26 -04:00
# frozen_string_literal: true
require 'spec_helper'
require 'baktainer/backup_rotation'
RSpec.describe Baktainer::BackupRotation do
let(:logger) { double('Logger', info: nil, debug: nil, warn: nil, error: nil) }
let(:test_backup_dir) { create_test_backup_dir }
let(:config) { double('Configuration', backup_dir: test_backup_dir) }
let(:rotation) { described_class.new(logger, config) }
before do
# Mock environment variables
stub_const('ENV', ENV.to_hash.merge(
'BT_RETENTION_DAYS' => '7',
'BT_RETENTION_COUNT' => '5',
'BT_MIN_FREE_SPACE_GB' => '1'
))
end
after do
FileUtils.rm_rf(test_backup_dir) if Dir.exist?(test_backup_dir)
end
describe '#initialize' do
it 'sets retention policies from environment' do
expect(rotation.retention_days).to eq(7)
expect(rotation.retention_count).to eq(5)
expect(rotation.min_free_space_gb).to eq(1)
end
it 'uses defaults when environment not set' do
stub_const('ENV', {})
rotation = described_class.new(logger, config)
expect(rotation.retention_days).to eq(30)
expect(rotation.retention_count).to eq(0)
expect(rotation.min_free_space_gb).to eq(10)
end
end
describe '#cleanup' do
# Each test creates its own isolated backup files
before do
# Ensure completely clean state for each test
FileUtils.rm_rf(test_backup_dir) if Dir.exist?(test_backup_dir)
FileUtils.mkdir_p(test_backup_dir)
end
before do
# Create test backup files with different ages
create_test_backups
end
context 'cleanup by age' do
let(:rotation) do
# Override environment to only test age-based cleanup
stub_const('ENV', ENV.to_hash.merge(
'BT_RETENTION_DAYS' => '7',
'BT_RETENTION_COUNT' => '0', # Disable count-based cleanup
'BT_MIN_FREE_SPACE_GB' => '0' # Disable space cleanup
))
described_class.new(logger, config)
end
it 'deletes backups older than retention days' do
# Mock get_free_space to ensure space cleanup doesn't run
allow(rotation).to receive(:get_free_space).and_return(1024 * 1024 * 1024 * 1024) # 1TB
# Count existing old files before we create our test file
files_before = Dir.glob(File.join(test_backup_dir, '**', '*.sql'))
old_files_before = files_before.select do |file|
File.mtime(file) < (Time.now - (7 * 24 * 60 * 60))
end.count
# Create an old backup (10 days ago)
old_date = (Date.today - 10).strftime('%Y-%m-%d')
old_dir = File.join(test_backup_dir, old_date)
FileUtils.mkdir_p(old_dir)
old_file = File.join(old_dir, 'test-app-1234567890.sql')
File.write(old_file, 'old backup data')
# Set file modification time to 10 days ago
old_time = Time.now - (10 * 24 * 60 * 60)
File.utime(old_time, old_time, old_file)
result = rotation.cleanup
# Expect to delete our file plus any pre-existing old files
expect(result[:deleted_count]).to eq(old_files_before + 1)
expect(File.exist?(old_file)).to be false
end
it 'keeps backups within retention period' do
# Clean up any old files from create_test_backups first
Dir.glob(File.join(test_backup_dir, '**', '*.sql')).each do |file|
File.delete(file) if File.mtime(file) < (Time.now - (7 * 24 * 60 * 60))
end
# Mock get_free_space to ensure space cleanup doesn't run
allow(rotation).to receive(:get_free_space).and_return(1024 * 1024 * 1024 * 1024) # 1TB
# Create a recent backup (2 days ago)
recent_date = (Date.today - 2).strftime('%Y-%m-%d')
recent_dir = File.join(test_backup_dir, recent_date)
FileUtils.mkdir_p(recent_dir)
recent_file = File.join(recent_dir, 'recent-app-1234567890.sql')
File.write(recent_file, 'recent backup data')
# Set file modification time to 2 days ago
recent_time = Time.now - (2 * 24 * 60 * 60)
File.utime(recent_time, recent_time, recent_file)
result = rotation.cleanup
expect(result[:deleted_count]).to eq(0)
expect(File.exist?(recent_file)).to be true
end
end
context 'cleanup by count' do
let(:rotation) do
# Override environment to only test count-based cleanup
stub_const('ENV', ENV.to_hash.merge(
'BT_RETENTION_DAYS' => '0', # Disable age-based cleanup
'BT_RETENTION_COUNT' => '5',
'BT_MIN_FREE_SPACE_GB' => '0' # Disable space cleanup
))
described_class.new(logger, config)
end
it 'keeps only specified number of recent backups per container' do
# Create 8 backups for the same container
date_dir = File.join(test_backup_dir, Date.today.strftime('%Y-%m-%d'))
FileUtils.mkdir_p(date_dir)
8.times do |i|
timestamp = Time.now.to_i - (i * 3600) # 1 hour apart
backup_file = File.join(date_dir, "myapp-#{timestamp}.sql")
File.write(backup_file, "backup data #{i}")
# Set different modification times
mtime = Time.now - (i * 3600)
File.utime(mtime, mtime, backup_file)
end
result = rotation.cleanup('myapp')
# Should keep only 5 most recent backups
expect(result[:deleted_count]).to eq(3)
remaining_files = Dir.glob(File.join(date_dir, 'myapp-*.sql'))
expect(remaining_files.length).to eq(5)
end
it 'handles multiple containers independently' do
date_dir = File.join(test_backup_dir, Date.today.strftime('%Y-%m-%d'))
FileUtils.mkdir_p(date_dir)
# Create backups for two containers
['app1', 'app2'].each do |app|
6.times do |i|
timestamp = Time.now.to_i - (i * 3600)
backup_file = File.join(date_dir, "#{app}-#{timestamp}.sql")
File.write(backup_file, "backup data")
mtime = Time.now - (i * 3600)
File.utime(mtime, mtime, backup_file)
end
end
result = rotation.cleanup
# Should delete 1 backup from each container (6 - 5 = 1)
expect(result[:deleted_count]).to eq(2)
expect(Dir.glob(File.join(date_dir, 'app1-*.sql')).length).to eq(5)
expect(Dir.glob(File.join(date_dir, 'app2-*.sql')).length).to eq(5)
end
end
context 'cleanup for space' do
it 'deletes oldest backups when disk space is low' do
# Mock low disk space
allow(rotation).to receive(:get_free_space).and_return(500 * 1024 * 1024) # 500MB
date_dir = File.join(test_backup_dir, Date.today.strftime('%Y-%m-%d'))
FileUtils.mkdir_p(date_dir)
# Create backups with different ages
3.times do |i|
timestamp = Time.now.to_i - (i * 86400) # 1 day apart
backup_file = File.join(date_dir, "app-#{timestamp}.sql")
File.write(backup_file, "backup data " * 1000) # Make it larger
mtime = Time.now - (i * 86400)
File.utime(mtime, mtime, backup_file)
end
result = rotation.cleanup
# Should delete at least one backup to free space
expect(result[:deleted_count]).to be > 0
end
end
context 'empty directory cleanup' do
it 'removes empty date directories' do
empty_dir = File.join(test_backup_dir, '2024-01-01')
FileUtils.mkdir_p(empty_dir)
rotation.cleanup
expect(Dir.exist?(empty_dir)).to be false
end
it 'keeps directories with backup files' do
date_dir = File.join(test_backup_dir, '2024-01-01')
FileUtils.mkdir_p(date_dir)
File.write(File.join(date_dir, 'app-123.sql'), 'data')
rotation.cleanup
expect(Dir.exist?(date_dir)).to be true
end
end
end
describe '#get_backup_statistics' do
before do
# Ensure clean state
FileUtils.rm_rf(test_backup_dir) if Dir.exist?(test_backup_dir)
FileUtils.mkdir_p(test_backup_dir)
# Create test backups
create_test_backup_structure
end
it 'returns comprehensive backup statistics' do
stats = rotation.get_backup_statistics
expect(stats[:total_backups]).to eq(4)
expect(stats[:total_size]).to be > 0
expect(stats[:containers].keys).to contain_exactly('app1', 'app2')
expect(stats[:containers]['app1'][:count]).to eq(2)
expect(stats[:containers]['app2'][:count]).to eq(2)
expect(stats[:oldest_backup]).to be_a(Time)
expect(stats[:newest_backup]).to be_a(Time)
end
it 'groups statistics by date' do
stats = rotation.get_backup_statistics
expect(stats[:by_date].keys.length).to eq(2)
stats[:by_date].each do |date, info|
expect(info[:count]).to be > 0
expect(info[:size]).to be > 0
end
end
end
private
def create_test_backups
# Helper to create test backup structure
dates = [Date.today, Date.today - 1, Date.today - 10]
dates.each do |date|
date_dir = File.join(test_backup_dir, date.strftime('%Y-%m-%d'))
FileUtils.mkdir_p(date_dir)
# Create backup file
timestamp = date.to_time.to_i
backup_file = File.join(date_dir, "test-app-#{timestamp}.sql")
File.write(backup_file, "backup data for #{date}")
# Set file modification time
File.utime(date.to_time, date.to_time, backup_file)
end
end
def create_test_backup_structure
# Create backups for multiple containers across multiple dates
dates = [Date.today, Date.today - 1]
containers = ['app1', 'app2']
dates.each do |date|
date_dir = File.join(test_backup_dir, date.strftime('%Y-%m-%d'))
FileUtils.mkdir_p(date_dir)
containers.each do |container|
timestamp = date.to_time.to_i
backup_file = File.join(date_dir, "#{container}-#{timestamp}.sql.gz")
File.write(backup_file, "compressed backup data")
# Create metadata file
metadata = {
container_name: container,
timestamp: date.to_time.iso8601,
compressed: true
}
File.write("#{backup_file}.meta", metadata.to_json)
end
end
end
end