Initial Commit

This commit is contained in:
James Paterni 2025-04-14 09:39:37 -04:00
parent baa561c56e
commit 8430ff9304
19 changed files with 562 additions and 1 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
LICENSE
README.md
docker-compose.yml
./examples/*
.tool-versions

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
ruby 3.3.5

35
Dockerfile Normal file
View file

@ -0,0 +1,35 @@
FROM ruby:3.3-alpine
RUN mkdir /backups
# Install dependencies
RUN apk add --no-cache \
build-base \
libffi-dev \
linux-headers \
postgresql-dev \
tzdata \
git \
curl
RUN gem install bundler -v 2.6.7
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
# Set the working directory
WORKDIR /app
# Copy the Gemfile and Gemfile.lock
COPY ./app/Gemfile ./app/Gemfile.lock /app/
# Install the gems
RUN bundle config set --local deployment 'true' && \
bundle config set --local path 'vendor/bundle' && \
bundle config set --local without 'development test' && \
bundle install
COPY ./app/ /app/
ENTRYPOINT ["/entrypoint.sh"]
CMD ["bundle", "exec", "ruby", "./app.rb"]

View file

@ -1,3 +1,64 @@
# baktainer
Easily backup databases running in docker containers.
## Features
- Backup MySQL, PostgreSQL, MongoDB, and SQLite databases
- Run on a schedule using cron expressions
- Backup databases running in docker containers
- Define which databases to backup using docker labels
## Installation
```yaml
services:
baktainer:
image: jamez01/baktainer:latest
container_name: baktainer
restart: unless-stopped
volumes:
- ./backups:/backups
- /var/run/docker.sock:/var/run/docker.sock
environment:
- BT_CRON="0 0 * * *" # Backup every day at midnight
- "BT_DOCKER_URL=unix:///var/run/docker.sock"
- BT_THREADS=4
- BT_BACKUP_DIR=/backups
- BT_LOG_LEVEL=info
# Enable if using SSL over tcp
#- BT_SSL = true
#- BT_CA
#- BT_CERT
#- BT_KEY
```
Easily backup your docker databases
## Environment Variables
| Variable | Description | Default |
| -------- | ----------- | ------- |
| BT_CRON | Cron expression for scheduling backups | 0 0 * * * |
| BT_THREADS | Number of threads to use for backups | 4 |
| BT_BACKUP_DIR | Directory to store backups | /backups |
| BT_LOG_LEVEL | Log level (debug, info, warn, error) | info |
| BT_SSL | Enable SSL for docker connection | false |
| BT_CA | Path to CA certificate | none |
| BT_CERT | Path to client certificate | none |
| BT_KEY | Path to client key | none |
| BT_DOCKER_URL | Docker URL | unix:///var/run/docker.sock |
## Usage
Add labels to your docker containers to specify which databases to backup.
```yaml
services:
db:
image: postgres:17
container_name: my-db
restart: unless-stopped
volumes:
- db:/var/lib/postgresql/data
environment:
POSTGRES_DB: "${DB_BASE:-database}"
POSTGRES_USER: "${DB_USER:-user}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-StrongPassword}"
labels:
- baktainer.backup: "true"
- baktainer.db.name: "my-db"
- baktainer.db.password: "StrongPassword"
- baktainer.db.engine: "postgres"
- baktainer.name: "MyApp"
```

7
app/Gemfile Normal file
View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'base64', '~> 0.2.0'
gem 'concurrent-ruby', '~> 1.3.5'
gem 'docker-api', '~> 2.4.0'
gem 'cron_calc', '~> 1.0.0'

26
app/Gemfile.lock Normal file
View file

@ -0,0 +1,26 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.2.0)
concurrent-ruby (1.3.5)
cron_calc (1.0.0)
docker-api (2.4.0)
excon (>= 0.64.0)
multi_json
excon (1.2.5)
logger
logger (1.7.0)
multi_json (1.15.0)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
base64 (~> 0.2.0)
concurrent-ruby (~> 1.3.5)
cron_calc (~> 1.0.0)
docker-api (~> 2.4.0)
BUNDLED WITH
2.6.2

33
app/app.rb Normal file
View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'rubygems'
$LOAD_PATH.unshift "#{File.dirname(__FILE__)}/lib"
require 'bundler/setup'
require 'baktainer'
require 'baktainer/logger'
require 'baktainer/container'
require 'baktainer/backup_command'
require 'optparse'
options = {}
OptionParser.new do |opts|
opts.banner = 'Usage: baktainer.rb [options]'
opts.on('-N', '--now', 'Run immediately and exit.') do
options[:now] = true
end
end.parse!
LOGGER.info('Starting')
baktainer = Baktainer::Runner.new(
url: ENV['BT_DOCKER_URL'] || 'unix:///var/run/docker.sock',
ssl: ENV['BT_SSL'] || false,
ssl_options: {
ca_file: ENV['BT_CA'],
client_cert: ENV['BT_CERT'],
client_key: ENV['BT_KEY']
}
)
baktainer.run

107
app/lib/baktainer.rb Normal file
View file

@ -0,0 +1,107 @@
# 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/logger'
require 'baktainer/container'
require 'baktainer/backup_command'
STDOUT.sync = true
class Baktainer::Runner
SUPPORTED_ENGINES = %w[postgres postgres-all sqlite mongodb mysql mariadb].freeze
def initialize(url: 'unix:///var/run/docker.sock', ssl: false, ssl_options: {}, threads: 5)
@pool = Concurrent::FixedThreadPool.new(threads)
@url = url
@ssl = ssl
@ssl_options = ssl_options
Docker.url = @url
setup_ssl
LOGGER.level = ENV['LOG_LEVEL'] || :debug
end
def perform_backup
LOGGER.info('Starting backup process.')
LOGGER.debug('Docker Searching for containers.')
Containers.find_all.each do |container|
# @pool.post do
begin
LOGGER.info("Backing up container #{container.name} with engine #{container.engine}.")
container.backup
LOGGER.info("Backup completed for container #{container.name}.")
rescue StandardError => e
LOGGER.error("Error backing up container #{container.name}: #{e.message}")
LOGGER.debug(e.backtrace.join("\n"))
end
# end
end
end
def run
run_at = ENV['BT_CRON'] || '0 0 * * *'
begin
@cron = CronCalc.new(run_at)
rescue
LOGGER.error("Invalid cron format for BT_CRON: #{run_at}.")
end
loop do
now = Time.now
next_run = @cron.next.first
# sleep_duration = next_run - now
sleep_duration = 5
LOGGER.info("Sleeping for #{sleep_duration} seconds until #{next_run}.")
sleep(sleep_duration)
perform_backup
end
end
private
def setup_ssl
return unless @ssl
@cert_store = OpenSSL::X509::Store.new
@cerificate = OpenSSL::X509::Certificate.new(ENV['BT_CA'])
@cert_store.add_cert(@cerificate)
Docker.options = {
client_cert_data: ENV['BT_CERT'],
client_key_data: ENV['BT_KEY'],
ssl_cert_store: @cert_store,
scheme: 'https'
}
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'baktainer/mysql'
require 'baktainer/postgres'
require 'baktainer/mariadb'
require 'baktainer/sqlite'
# This class is responsible for generating the backup command for the database engine
# It uses the environment variables to set the necessary parameters for the backup command
# The class methods return a hash with the environment variables and the command to run
# The class methods are used in the Baktainer::Container class to generate the backup command
class Baktainer::BackupCommand
def custom(command: nil)
{
env: [],
cmd: command.split(/\s+/)
}
end
end

View file

@ -0,0 +1,111 @@
# frozen_string_literal: true
# The `Container` class represents a container abstraction within the Baktainer application.
# It is responsible for encapsulating the logic and behavior related to managing containers.
# This class serves as a core component of the application, providing methods and attributes
# to interact with and manipulate container instances.
require 'fileutils'
require 'date'
class Baktainer::Container
def initialize(container)
@container = container
@backup_command = Baktainer::BackupCommand.new
end
def id
@container.id
end
def labels
@container.info['Labels']
end
def name
labels["baktainer.name"] || @container.info['Names'].first
end
def state
@container.info['State']
end
def running?
state == 'running'
end
def engine
labels['baktainer.db.engine']&.downcase
end
def login
labels['baktainer.db.user'] || nil
end
def password
labels['baktainer.db.password'] || nil
end
def database
labels['baktainer.db.name'] || nil
end
def validdate
return raise 'Unable to parse container' if @container.nil?
return raise 'Container not running' if state.nil? || state != 'running'
return raise 'Use docker labels to define db settings' if labels.nil? || labels.empty?
if labels['baktainer.backup']&.downcase != 'true'
return raise 'Backup not enabled for this container. Set docker label baktainer.backup=true'
end
LOGGER.debug("Container labels['baktainer.db.engine']: #{labels['baktainer.db.engine']}")
if engine.nil? || !@backup_command.respond_to?(engine.to_sym)
return raise 'DB Engine not defined. Set docker label baktainer.engine.'
end
true
end
def backup
LOGGER.debug("Starting backup for container #{name} with engine #{engine}.")
return unless validdate
LOGGER.debug("Container #{name} is valid for backup.")
backup_dir = "/backups/#{Date.today}"
FileUtils.mkdir_p("/backups/#{Date.today}") unless Dir.exist?(backup_dir)
sql_dump = File.open("/backups/#{Date.today}/#{name}-#{Time.now.to_i}.sql", 'w')
command = backup_command
LOGGER.debug("Backup command environment variables: #{command[:env].inspect}")
@container.exec(command[:cmd], env: command[:env]) do |stream, chunk|
sql_dump.write(chunk) if stream == :stdout
LOGGER.warn("#{name} stderr: #{chunk}") if stream == :stderr
end
sql_dump.close
LOGGER.debug("Backup completed for container #{name}.")
end
private
def backup_command
if @backup_command.respond_to?(engine.to_sym)
return @backup_command.send(engine.to_sym, login: login, password: password, database: database)
elsif engine == 'custom'
return @backup_command.custom(command: labels['baktainer.command']) || raise('Custom command not defined. Set docker label bt_command.')
else
raise "Unsupported engine: #{engine}"
end
end
end
# :NODOC:
class Containers
def self.find_all
LOGGER.debug('Searching for containers with backup labels.')
containers = Docker::Container.all.select do |container|
container.info['Labels']['baktainer.backup'] == 'true'
end
LOGGER.debug("Found #{containers.size} containers with backup labels.")
LOGGER.debug(containers.first.class)
containers.map do |container|
Baktainer::Container.new(container)
end
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'logger'
require 'json'
# Log messages in JSON format
class JsonLogger < Logger
def format_message(severity, timestamp, progname, msg)
{
severity: severity,
timestamp: timestamp,
progname: progname || 'backtainer',
message: msg
}.to_json + "\n"
end
def initialize
super(STDOUT)
end
end
LOGGER = JsonLogger.new

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
# mariadb backup command generator
class Baktainer::BackupCommand
def mariadb(login:, password:, database:)
{
env: [],
cmd: ['mariadb-dump', "-u#{login}", "-p#{password}", '--databases', database]
}
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
# MySQL backup command generator
class Baktainer::BackupCommand
def mysql(login:, password:, database:)
{
env: [],
cmd: ['mysqldump', '-u', login, "-p#{password}", database]
}
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
# Postgres backup command generator
class Baktainer::BackupCommand
def postgres(login: 'postgres', password: nil, database: nil, all: false)
{
env: [
"PGPASSWORD=#{password}",
"PGUSER=#{login}",
"PGDATABASE=#{database}",
'PGAPPNAME=Baktainer'
],
cmd: [all ? 'pg_dumpall' : 'pg_dump']
}
end
def postgres_all(login: 'postgres', password: nil, database: nil)
posgres(login: login, password: password, database: database, all: true)
end
def postgresql(*args)
postgres(*args)
end
def postgresql_all(*args)
postgres_all(*args)
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
# sqlite backup command generator
class Baktainer::BackupCommand
class << self
def sqlite(database:, _login: nil, _password: nil)
{
env: [],
cmd: ['sqlite3', database, '.dump']
}
end
end
end

View file

@ -0,0 +1,23 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 17.4
-- Dumped by pg_dump version 17.4
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- PostgreSQL database dump complete
--

View file

@ -0,0 +1,23 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 17.4
-- Dumped by pg_dump version 17.4
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- PostgreSQL database dump complete
--

23
docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
services:
baktainer:
build: .
image: jamez01/baktainer:latest
container_name: baktainer
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./backups:/backups
- ./config:/config
- /var/run/docker.sock:/var/run/docker.sock
environment:
- "BT_DOCKER_URL=unix:///var/run/docker.sock"
- BT_CRON=* * * * * # Backup every day at midnight
- BT_THREADS=4 # Number of threads to use for backups
- BT_BACKUP_DIR=/backups
- BT_LOG_LEVEL=info
# Enable if using SSL over tcp
#- BT_SSL = true
#- BT_CA
#- BT_CERT
#- BT_KEY

2
entrypoint.sh Normal file
View file

@ -0,0 +1,2 @@
#!/bin/sh
exec $@