- Replace Sinatra run! calls with direct Puma::Server usage - Use Rack-compatible approach to avoid Sinatra::Wrapper issues - Remove problematic set method calls and run! method calls - Both integrated and standalone health servers now use same Puma/Rack approach - Remove outdated baktainer/logger require from main file This should resolve the undefined method errors with Sinatra::Wrapper.
364 lines
12 KiB
Ruby
364 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Baktainer is a class responsible for managing database backups using Docker containers.
|
|
#
|
|
# It supports the following database engines: PostgreSQL, MySQL, MariaDB, and Sqlite3.
|
|
#
|
|
# @example Initialize a Baktainer instance
|
|
# baktainer = Baktainer.new(url: 'unix:///var/run/docker.sock', ssl: true, ssl_options: {})
|
|
#
|
|
# @example Run the backup process
|
|
# baktainer.run
|
|
#
|
|
# @!attribute [r] SUPPORTED_ENGINES
|
|
# @return [Array<String>] The list of supported database engines.
|
|
#
|
|
# @param url [String] The Docker API URL. Defaults to 'unix:///var/run/docker.sock'.
|
|
# @param ssl [Boolean] Whether to use SSL for Docker API communication. Defaults to false.
|
|
#
|
|
# @method perform_backup
|
|
# Starts the backup process by searching for Docker containers and performing backups.
|
|
# Logs the process at various stages.
|
|
#
|
|
# @method run
|
|
# Schedules and runs the backup process at a specified time.
|
|
# If the time is invalid or not provided, defaults to 05:00.
|
|
#
|
|
# @private
|
|
# @method setup_ssl
|
|
# Configures SSL settings for Docker API communication if SSL is enabled.
|
|
# Uses environment variables `BT_CA`, `BT_CERT`, and `BT_KEY` for SSL certificates and keys.
|
|
module Baktainer
|
|
end
|
|
|
|
require 'docker-api'
|
|
require 'cron_calc'
|
|
require 'concurrent/executor/fixed_thread_pool'
|
|
require 'baktainer/container'
|
|
require 'baktainer/backup_command'
|
|
require 'baktainer/dependency_container'
|
|
|
|
STDOUT.sync = true
|
|
|
|
|
|
class Baktainer::Runner
|
|
def initialize(url: 'unix:///var/run/docker.sock', ssl: false, ssl_options: {}, threads: 5)
|
|
@dependency_container = Baktainer::DependencyContainer.new.configure
|
|
@logger = @dependency_container.get(:logger)
|
|
@pool = @dependency_container.get(:thread_pool)
|
|
@backup_monitor = @dependency_container.get(:backup_monitor)
|
|
@backup_rotation = @dependency_container.get(:backup_rotation)
|
|
@url = url
|
|
@ssl = ssl
|
|
@ssl_options = ssl_options
|
|
Docker.url = @url
|
|
|
|
# Initialize Docker client through dependency container if SSL is enabled
|
|
if @ssl
|
|
@dependency_container.get(:docker_client)
|
|
end
|
|
|
|
# Start health check server if enabled
|
|
start_health_server if ENV['BT_HEALTH_SERVER_ENABLED'] == 'true'
|
|
end
|
|
|
|
def perform_backup
|
|
@logger.info('Starting backup process.')
|
|
|
|
# Perform health check before backup
|
|
unless docker_health_check
|
|
@logger.error('Docker connection health check failed. Aborting backup.')
|
|
return { successful: [], failed: [], total: 0, error: 'Docker connection failed' }
|
|
end
|
|
|
|
@logger.debug('Docker Searching for containers.')
|
|
|
|
containers = Baktainer::Containers.find_all(@dependency_container)
|
|
backup_futures = []
|
|
backup_results = {
|
|
successful: [],
|
|
failed: [],
|
|
total: containers.size
|
|
}
|
|
|
|
containers.each do |container|
|
|
future = @pool.post do
|
|
begin
|
|
@logger.info("Backing up container #{container.name} with engine #{container.engine}.")
|
|
@backup_monitor.start_backup(container.name, container.engine)
|
|
|
|
backup_path = container.backup
|
|
|
|
@backup_monitor.complete_backup(container.name, backup_path)
|
|
@logger.info("Backup completed for container #{container.name}.")
|
|
{ container: container.name, status: :success, path: backup_path }
|
|
rescue StandardError => e
|
|
@backup_monitor.fail_backup(container.name, e.message)
|
|
@logger.error("Error backing up container #{container.name}: #{e.message}")
|
|
@logger.debug(e.backtrace.join("\n"))
|
|
{ container: container.name, status: :failed, error: e.message }
|
|
end
|
|
end
|
|
backup_futures << future
|
|
end
|
|
|
|
# Wait for all backups to complete and collect results
|
|
backup_futures.each do |future|
|
|
begin
|
|
result = future.value # This will block until the future completes
|
|
if result[:status] == :success
|
|
backup_results[:successful] << result
|
|
else
|
|
backup_results[:failed] << result
|
|
end
|
|
rescue StandardError => e
|
|
@logger.error("Thread pool error: #{e.message}")
|
|
backup_results[:failed] << { container: 'unknown', status: :failed, error: e.message }
|
|
end
|
|
end
|
|
|
|
# Log summary and metrics
|
|
@logger.info("Backup process completed. Success: #{backup_results[:successful].size}, Failed: #{backup_results[:failed].size}, Total: #{backup_results[:total]}")
|
|
|
|
# Log metrics summary
|
|
metrics = @backup_monitor.get_metrics_summary
|
|
@logger.info("Overall metrics: success_rate=#{metrics[:success_rate]}%, total_data=#{format_bytes(metrics[:total_data_backed_up])}")
|
|
|
|
# Log failed backups for monitoring
|
|
backup_results[:failed].each do |failure|
|
|
@logger.error("Failed backup for #{failure[:container]}: #{failure[:error]}")
|
|
end
|
|
|
|
# Run backup rotation/cleanup if enabled
|
|
if ENV['BT_ROTATION_ENABLED'] != 'false'
|
|
@logger.info('Running backup rotation and cleanup')
|
|
cleanup_results = @backup_rotation.cleanup
|
|
if cleanup_results[:deleted_count] > 0
|
|
@logger.info("Cleaned up #{cleanup_results[:deleted_count]} old backups, freed #{format_bytes(cleanup_results[:deleted_size])}")
|
|
end
|
|
end
|
|
|
|
backup_results
|
|
end
|
|
|
|
def run
|
|
run_at = ENV['BT_CRON'] || '0 0 * * *'
|
|
begin
|
|
@cron = CronCalc.new(run_at)
|
|
rescue => e
|
|
@logger.error("Invalid cron format for BT_CRON: #{run_at}. Error: #{e.message}")
|
|
@cron = CronCalc.new('0 0 * * *') # Fall back to default
|
|
end
|
|
|
|
loop do
|
|
now = Time.now
|
|
# CronCalc.next returns an array, get the first element
|
|
next_runs = @cron.next(now)
|
|
next_run = next_runs.is_a?(Array) ? next_runs.first : next_runs
|
|
|
|
# Convert to Time object if necessary
|
|
next_run = Time.at(next_run) if next_run.is_a?(Numeric)
|
|
|
|
sleep_duration = next_run - now
|
|
@logger.info("Sleeping for #{sleep_duration} seconds until #{next_run}.")
|
|
sleep(sleep_duration)
|
|
perform_backup
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def format_bytes(bytes)
|
|
units = ['B', 'KB', 'MB', 'GB']
|
|
unit_index = 0
|
|
size = bytes.to_f
|
|
|
|
while size >= 1024 && unit_index < units.length - 1
|
|
size /= 1024
|
|
unit_index += 1
|
|
end
|
|
|
|
"#{size.round(2)} #{units[unit_index]}"
|
|
end
|
|
|
|
def setup_ssl
|
|
return unless @ssl
|
|
|
|
begin
|
|
# Validate required SSL environment variables
|
|
validate_ssl_environment
|
|
|
|
# Load and validate CA certificate
|
|
ca_cert = load_ca_certificate
|
|
|
|
# Load and validate client certificates
|
|
client_cert, client_key = load_client_certificates
|
|
|
|
# Create certificate store and add CA
|
|
@cert_store = OpenSSL::X509::Store.new
|
|
@cert_store.add_cert(ca_cert)
|
|
|
|
# Configure Docker SSL options
|
|
Docker.options = {
|
|
client_cert_data: client_cert,
|
|
client_key_data: client_key,
|
|
ssl_cert_store: @cert_store,
|
|
ssl_verify_peer: true,
|
|
scheme: 'https'
|
|
}
|
|
|
|
@logger.info("SSL/TLS configuration completed successfully")
|
|
rescue => e
|
|
@logger.error("Failed to configure SSL/TLS: #{e.message}")
|
|
raise SecurityError, "SSL/TLS configuration failed: #{e.message}"
|
|
end
|
|
end
|
|
|
|
def validate_ssl_environment
|
|
missing_vars = []
|
|
missing_vars << 'BT_CA' unless ENV['BT_CA']
|
|
missing_vars << 'BT_CERT' unless ENV['BT_CERT']
|
|
missing_vars << 'BT_KEY' unless ENV['BT_KEY']
|
|
|
|
unless missing_vars.empty?
|
|
raise ArgumentError, "Missing required SSL environment variables: #{missing_vars.join(', ')}"
|
|
end
|
|
end
|
|
|
|
def load_ca_certificate
|
|
ca_data = ENV['BT_CA']
|
|
|
|
# Support both file paths and direct certificate data
|
|
if File.exist?(ca_data)
|
|
ca_data = File.read(ca_data)
|
|
@logger.debug("Loaded CA certificate from file: #{ENV['BT_CA']}")
|
|
else
|
|
@logger.debug("Using CA certificate data from environment variable")
|
|
end
|
|
|
|
OpenSSL::X509::Certificate.new(ca_data)
|
|
rescue OpenSSL::X509::CertificateError => e
|
|
raise SecurityError, "Invalid CA certificate: #{e.message}"
|
|
rescue Errno::ENOENT
|
|
raise SecurityError, "CA certificate file not found: #{ENV['BT_CA']}"
|
|
rescue => e
|
|
raise SecurityError, "Failed to load CA certificate: #{e.message}"
|
|
end
|
|
|
|
def load_client_certificates
|
|
cert_data = ENV['BT_CERT']
|
|
key_data = ENV['BT_KEY']
|
|
|
|
# Support both file paths and direct certificate data
|
|
if File.exist?(cert_data)
|
|
cert_data = File.read(cert_data)
|
|
@logger.debug("Loaded client certificate from file: #{ENV['BT_CERT']}")
|
|
end
|
|
|
|
if File.exist?(key_data)
|
|
key_data = File.read(key_data)
|
|
@logger.debug("Loaded client key from file: #{ENV['BT_KEY']}")
|
|
end
|
|
|
|
# Validate certificate and key
|
|
cert = OpenSSL::X509::Certificate.new(cert_data)
|
|
key = OpenSSL::PKey::RSA.new(key_data)
|
|
|
|
# Verify that the key matches the certificate
|
|
unless cert.public_key.to_pem == key.public_key.to_pem
|
|
raise SecurityError, "Client certificate and key do not match"
|
|
end
|
|
|
|
# Check certificate validity
|
|
now = Time.now
|
|
if cert.not_before > now
|
|
raise SecurityError, "Client certificate is not yet valid (valid from: #{cert.not_before})"
|
|
end
|
|
|
|
if cert.not_after < now
|
|
raise SecurityError, "Client certificate has expired (expired: #{cert.not_after})"
|
|
end
|
|
|
|
[cert_data, key_data]
|
|
rescue OpenSSL::X509::CertificateError => e
|
|
raise SecurityError, "Invalid client certificate: #{e.message}"
|
|
rescue OpenSSL::PKey::RSAError => e
|
|
raise SecurityError, "Invalid client key: #{e.message}"
|
|
rescue Errno::ENOENT => e
|
|
raise SecurityError, "Certificate file not found: #{e.message}"
|
|
rescue => e
|
|
raise SecurityError, "Failed to load client certificates: #{e.message}"
|
|
end
|
|
|
|
def verify_docker_connection
|
|
begin
|
|
@logger.debug("Verifying Docker connection to #{@url}")
|
|
Docker.version
|
|
@logger.info("Docker connection verified successfully")
|
|
rescue Docker::Error::DockerError => e
|
|
raise StandardError, "Docker connection failed: #{e.message}"
|
|
rescue StandardError => e
|
|
raise StandardError, "Docker connection error: #{e.message}"
|
|
end
|
|
end
|
|
|
|
def docker_health_check
|
|
begin
|
|
# Check Docker daemon version
|
|
version_info = Docker.version
|
|
@logger.debug("Docker daemon version: #{version_info['Version']}")
|
|
|
|
# Check if we can list containers
|
|
Docker::Container.all(limit: 1)
|
|
@logger.debug("Docker health check passed")
|
|
|
|
true
|
|
rescue Docker::Error::TimeoutError => e
|
|
@logger.error("Docker health check failed - timeout: #{e.message}")
|
|
false
|
|
rescue Docker::Error::DockerError => e
|
|
@logger.error("Docker health check failed - Docker error: #{e.message}")
|
|
false
|
|
rescue StandardError => e
|
|
@logger.error("Docker health check failed - system error: #{e.message}")
|
|
false
|
|
end
|
|
end
|
|
|
|
def start_health_server
|
|
@health_server_thread = Thread.new do
|
|
begin
|
|
port = ENV['BT_HEALTH_PORT'] || 8080
|
|
bind = ENV['BT_HEALTH_BIND'] || '0.0.0.0'
|
|
|
|
@logger.info("Starting health check server on #{bind}:#{port}")
|
|
|
|
# Use Rack to run the Sinatra app
|
|
require 'rack'
|
|
require 'puma'
|
|
|
|
app = Baktainer::HealthCheckServer.new(@dependency_container)
|
|
|
|
# Start Puma server with Rack
|
|
server = Puma::Server.new(app)
|
|
server.add_tcp_listener(bind, port.to_i)
|
|
server.run.join
|
|
rescue => e
|
|
@logger.error("Health check server error: #{e.message}")
|
|
@logger.debug(e.backtrace.join("\n"))
|
|
end
|
|
end
|
|
|
|
# Give the server a moment to start
|
|
sleep 0.5
|
|
@logger.info("Health check server started in background thread")
|
|
end
|
|
|
|
def stop_health_server
|
|
if @health_server_thread
|
|
@health_server_thread.kill
|
|
@health_server_thread = nil
|
|
@logger.info("Health check server stopped")
|
|
end
|
|
end
|
|
end
|