sg_tiny_backup 1.0.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +51 -0
  3. data/.gitignore +14 -0
  4. data/.octocov.yml +15 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +70 -0
  7. data/.ruby-version +1 -0
  8. data/.simplecov +14 -0
  9. data/CHANGELOG.md +73 -0
  10. data/Gemfile +23 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +111 -0
  14. data/Rakefile +12 -0
  15. data/bin/console +15 -0
  16. data/bin/setup +8 -0
  17. data/config/database.ci.yml +15 -0
  18. data/lib/sg_tiny_backup/commands/aws_cli.rb +32 -0
  19. data/lib/sg_tiny_backup/commands/base.rb +23 -0
  20. data/lib/sg_tiny_backup/commands/gzip.rb +20 -0
  21. data/lib/sg_tiny_backup/commands/mysql_dump.rb +38 -0
  22. data/lib/sg_tiny_backup/commands/openssl.rb +40 -0
  23. data/lib/sg_tiny_backup/commands/pg_dump.rb +38 -0
  24. data/lib/sg_tiny_backup/commands/tar.rb +68 -0
  25. data/lib/sg_tiny_backup/commands.rb +8 -0
  26. data/lib/sg_tiny_backup/config.rb +71 -0
  27. data/lib/sg_tiny_backup/error.rb +25 -0
  28. data/lib/sg_tiny_backup/pipeline.rb +62 -0
  29. data/lib/sg_tiny_backup/pipeline_builders/base.rb +37 -0
  30. data/lib/sg_tiny_backup/pipeline_builders/db.rb +70 -0
  31. data/lib/sg_tiny_backup/pipeline_builders/log.rb +36 -0
  32. data/lib/sg_tiny_backup/pipeline_builders/s3.rb +26 -0
  33. data/lib/sg_tiny_backup/railtie.rb +9 -0
  34. data/lib/sg_tiny_backup/runner.rb +77 -0
  35. data/lib/sg_tiny_backup/spawner.rb +113 -0
  36. data/lib/sg_tiny_backup/templates/sg_tiny_backup.yml +43 -0
  37. data/lib/sg_tiny_backup/utils.rb +34 -0
  38. data/lib/sg_tiny_backup/version.rb +5 -0
  39. data/lib/sg_tiny_backup.rb +21 -0
  40. data/lib/tasks/sg_tiny_backup.rake +78 -0
  41. data/sg_tiny_backup.gemspec +38 -0
  42. metadata +87 -0
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SgTinyBackup
4
+ module Commands
5
+ class Base
6
+ def command
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def env
11
+ {}
12
+ end
13
+
14
+ def success_codes
15
+ [0]
16
+ end
17
+
18
+ def warning_message
19
+ nil
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SgTinyBackup
6
+ module Commands
7
+ class Gzip < Base
8
+ def initialize(level: nil)
9
+ super()
10
+ @level = level
11
+ end
12
+
13
+ def command
14
+ parts = ["gzip"]
15
+ parts << "-#{@level}" if @level
16
+ parts.join(" ")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SgTinyBackup
6
+ module Commands
7
+ class MysqlDump < Base
8
+ attr_reader :user, :host, :port, :password, :database, :extra_options
9
+
10
+ def initialize(database:, host: nil, port: nil, user: nil, password: nil, extra_options: nil) # rubocop:disable Metrics/ParameterLists
11
+ super()
12
+ @user = user
13
+ @host = host
14
+ @port = port
15
+ @password = password
16
+ @database = database
17
+ @extra_options = extra_options
18
+ end
19
+
20
+ def command
21
+ parts = []
22
+ parts << "mysqldump"
23
+ parts << extra_options if extra_options
24
+ parts << "--user=#{Shellwords.escape(@user)}" if @user
25
+ parts << "--host=#{@host}" if @host
26
+ parts << "--port=#{@port}" if @port
27
+ parts << @database
28
+ parts.join(" ")
29
+ end
30
+
31
+ def env
32
+ {
33
+ "MYSQL_PWD" => @password,
34
+ }.compact
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SgTinyBackup
6
+ module Commands
7
+ class Openssl < Base
8
+ CIPHER = "aes-256-cbc"
9
+ ITER = 10_000
10
+
11
+ def initialize(password:, filename: nil)
12
+ super()
13
+ @password = password
14
+ @filename = filename
15
+ end
16
+
17
+ def command
18
+ parts = ["openssl enc -#{CIPHER} -pbkdf2 -iter #{ITER}"]
19
+ parts << "-pass env:SG_TINY_BACKUP_ENCRYPTION_KEY"
20
+ parts << "-out #{@filename}" if @filename
21
+ parts.join(" ")
22
+ end
23
+
24
+ def env
25
+ {
26
+ "SG_TINY_BACKUP_ENCRYPTION_KEY" => @password,
27
+ }
28
+ end
29
+
30
+ class << self
31
+ def decryption_command
32
+ parts = ["openssl enc -d -#{CIPHER} -pbkdf2 -iter #{ITER}"]
33
+ parts << "-pass pass:ENCRYPTION_KEY"
34
+ parts << "-in INPUTFILE -out OUTPUTFILE"
35
+ parts.join(" ")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SgTinyBackup
6
+ module Commands
7
+ class PgDump < Base
8
+ attr_reader :user, :host, :port, :password, :database, :extra_options
9
+
10
+ def initialize(database:, host: nil, port: nil, user: nil, password: nil, extra_options: nil) # rubocop:disable Metrics/ParameterLists
11
+ super()
12
+ @user = user
13
+ @host = host
14
+ @port = port
15
+ @password = password
16
+ @database = database
17
+ @extra_options = extra_options
18
+ end
19
+
20
+ def command
21
+ parts = []
22
+ parts << "pg_dump"
23
+ parts << extra_options if extra_options
24
+ parts << "--username=#{Shellwords.escape(@user)}" if @user
25
+ parts << "--host=#{@host}" if @host
26
+ parts << "--port=#{@port}" if @port
27
+ parts << @database
28
+ parts.join(" ")
29
+ end
30
+
31
+ def env
32
+ {
33
+ "PGPASSWORD" => @password,
34
+ }.compact
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "open3"
5
+ require_relative "base"
6
+
7
+ module SgTinyBackup
8
+ module Commands
9
+ class Tar < Base
10
+ def initialize(paths: [], optional_paths: [])
11
+ super()
12
+ @paths = paths
13
+ @optional_paths = optional_paths
14
+ end
15
+
16
+ def command
17
+ if target_file_paths.empty?
18
+ # Create empty tar archive.
19
+ "tar -c -T /dev/null"
20
+ else
21
+ cmd = ["tar -c"]
22
+ target_file_paths.map do |path|
23
+ cmd << Shellwords.escape(path)
24
+ end
25
+ cmd.join(" ")
26
+ end
27
+ end
28
+
29
+ def success_codes
30
+ if self.class.gnu_tar?
31
+ # GNU tar's exit code 1 means that some files were changed while being archived.
32
+ # See https://www.gnu.org/software/tar/manual/html_section/Synopsis.html
33
+ [0, 1]
34
+ else
35
+ [0]
36
+ end
37
+ end
38
+
39
+ def warning_message
40
+ "tar: missing files: #{missing_optinal_file_paths.join(", ")}" unless missing_optinal_file_paths.empty?
41
+ end
42
+
43
+ private
44
+
45
+ def existing_optional_file_paths
46
+ @existing_optional_file_paths ||= @optional_paths.select { |path| File.file?(path) || File.directory?(path) }
47
+ end
48
+
49
+ def missing_optinal_file_paths
50
+ @missing_optinal_file_paths ||= @optional_paths - existing_optional_file_paths
51
+ end
52
+
53
+ def target_file_paths
54
+ @target_file_paths ||= @paths + existing_optional_file_paths
55
+ end
56
+
57
+ class << self
58
+ def gnu_tar?
59
+ unless defined?(@gnu_tar)
60
+ out, _err, status = Open3.capture3("tar --version")
61
+ @gnu_tar = status.success? && out.match?(/GNU/)
62
+ end
63
+ @gnu_tar
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sg_tiny_backup/commands/aws_cli"
4
+ require "sg_tiny_backup/commands/gzip"
5
+ require "sg_tiny_backup/commands/mysql_dump"
6
+ require "sg_tiny_backup/commands/openssl"
7
+ require "sg_tiny_backup/commands/pg_dump"
8
+ require "sg_tiny_backup/commands/tar"
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SgTinyBackup
4
+ class Config
5
+ KEY_PG_DUMP = "pg_dump"
6
+ KEY_MYSQLDUMP = "mysqldump"
7
+ KEY_S3 = "s3"
8
+ KEY_DB = "db"
9
+ KEY_ENCRYPTION_KEY = "encryption_key"
10
+ KEY_EXPECTED_UPLOAD_SIZE = "expected_upload_size"
11
+ KEY_LOG = "log"
12
+ KEY_FILES = "files"
13
+ KEY_OPTIONAL_FILES = "optional_files"
14
+ KEY_GZIP = "gzip"
15
+
16
+ attr_reader :s3, :encryption_key, :pg_dump, :mysqldump, :db, :gzip
17
+
18
+ def initialize(s3:, encryption_key:, pg_dump: nil, mysqldump: nil, db: nil, log: nil, gzip: nil) # rubocop:disable Metrics/ParameterLists
19
+ @s3 = s3
20
+ @encryption_key = encryption_key
21
+ @pg_dump = pg_dump || {}
22
+ @mysqldump = mysqldump
23
+ @db = db || self.class.rails_db_config
24
+ @log = log || {}
25
+ @gzip = gzip || {}
26
+ end
27
+
28
+ def log_file_paths
29
+ @log[KEY_FILES] || []
30
+ end
31
+
32
+ def optional_log_file_paths
33
+ @log[KEY_OPTIONAL_FILES] || []
34
+ end
35
+
36
+ class << self
37
+ def read(io)
38
+ yaml = Utils.load_yaml_with_erb(io)
39
+ Config.new(
40
+ s3: yaml[KEY_S3],
41
+ encryption_key: yaml[KEY_ENCRYPTION_KEY],
42
+ db: yaml[KEY_DB],
43
+ pg_dump: yaml[KEY_PG_DUMP],
44
+ mysqldump: yaml[KEY_MYSQLDUMP],
45
+ log: yaml[KEY_LOG],
46
+ gzip: yaml[KEY_GZIP]
47
+ )
48
+ end
49
+
50
+ def read_file(path)
51
+ File.open(path, "r") do |f|
52
+ read(f)
53
+ end
54
+ end
55
+
56
+ def generate_template_file(path)
57
+ template_path = File.expand_path("./templates/sg_tiny_backup.yml", __dir__)
58
+ FileUtils.cp(template_path, path)
59
+ end
60
+
61
+ def rails_db_config
62
+ return unless defined?(Rails)
63
+
64
+ db_config = File.open(Rails.root.join("config", "database.yml"), "r") do |f|
65
+ Utils.load_yaml_with_erb(f)
66
+ end
67
+ db_config[Rails.env]
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SgTinyBackup
4
+ class Error < StandardError
5
+ end
6
+
7
+ class BackupFailed < Error
8
+ end
9
+
10
+ class BackupWarning < Error
11
+ end
12
+
13
+ class SpawnError < Error
14
+ def initialize(msg, inner_error)
15
+ inner_message = inner_error.message
16
+ inner_class_name = inner_error.class.name
17
+
18
+ message = msg.dup
19
+ message << "\n--- Inner error ---\n"
20
+ message << "#{inner_class_name}: " unless inner_message.start_with?(inner_class_name)
21
+ message << inner_message
22
+ super(message)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "sg_tiny_backup/spawner"
5
+
6
+ module SgTinyBackup
7
+ class Pipeline
8
+ extend Forwardable
9
+
10
+ delegate stdout: :@spawner
11
+ delegate stderr: :@spawner
12
+ delegate succeeded?: :@spawner
13
+
14
+ def initialize(output_path: nil)
15
+ @commands = []
16
+ @spawner = build_spawner
17
+ @output_path = output_path
18
+ end
19
+
20
+ def <<(command)
21
+ @commands << command
22
+ self
23
+ end
24
+
25
+ def run
26
+ @spawner = build_spawner
27
+ @spawner.spawn_and_wait
28
+ end
29
+
30
+ def plain_commands
31
+ @commands.map(&:command)
32
+ end
33
+
34
+ def env
35
+ @commands.map(&:env).reduce({}, &:merge)
36
+ end
37
+
38
+ def failed?
39
+ !succeeded?
40
+ end
41
+
42
+ def error_message
43
+ [@spawner.stderr_message, @spawner.exit_code_error_message].join
44
+ end
45
+
46
+ def warning_message
47
+ messages = [commands_warning_message]
48
+ messages << @spawner.stderr_message if succeeded? && !@spawner.stderr_message.empty?
49
+ messages.reject { |str| str.nil? || str.empty? }.join("\n")
50
+ end
51
+
52
+ private
53
+
54
+ def commands_warning_message
55
+ @commands.filter_map(&:warning_message).join("\n")
56
+ end
57
+
58
+ def build_spawner
59
+ Spawner.new(commands: @commands, env: env, output_path: @output_path)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "s3"
4
+
5
+ module SgTinyBackup
6
+ module PipelineBuilders
7
+ class Base
8
+ attr_reader :s3_config
9
+
10
+ def initialize(config:, basename:, local:, s3_config:, extension:)
11
+ @config = config
12
+ @basename = basename
13
+ @local = local
14
+ @s3_config = s3_config
15
+ @extension = extension
16
+ end
17
+
18
+ def base_filename
19
+ "#{@basename}.#{@extension}"
20
+ end
21
+
22
+ def destination_url
23
+ S3.build_s3_url(s3_config: s3_config, base_filename: base_filename)
24
+ end
25
+
26
+ def local?
27
+ @local
28
+ end
29
+
30
+ private
31
+
32
+ def aws_cli_command
33
+ S3.build_aws_cli_command(s3_config: s3_config, destination_url: destination_url)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sg_tiny_backup/commands"
4
+ require "sg_tiny_backup/pipeline"
5
+ require_relative "base"
6
+
7
+ module SgTinyBackup
8
+ module PipelineBuilders
9
+ class Db < Base
10
+ def initialize(config:, basename:, local:)
11
+ super(
12
+ config: config,
13
+ basename: basename,
14
+ local: local,
15
+ s3_config: config.s3["db"],
16
+ extension: "sql.gz.enc"
17
+ )
18
+ end
19
+
20
+ def build
21
+ output_path = base_filename if local?
22
+ pl = Pipeline.new(output_path: output_path)
23
+ pl << db_dump_command
24
+ pl << Commands::Gzip.new(level: @config.gzip["level"])
25
+ pl << Commands::Openssl.new(password: @config.encryption_key)
26
+ pl << aws_cli_command unless local?
27
+ pl
28
+ end
29
+
30
+ private
31
+
32
+ def db_dump_command
33
+ db_config = @config.db
34
+ adapter = db_config["adapter"]
35
+ raise "database adapter is not specified in your config." if adapter.nil?
36
+
37
+ case adapter
38
+ when "postgresql"
39
+ pg_dump_command(db_config)
40
+ when "mysql2"
41
+ mysql_dump_command(db_config)
42
+ else
43
+ raise "database adapter `#{adapter}` is not supported."
44
+ end
45
+ end
46
+
47
+ def pg_dump_command(db_config)
48
+ Commands::PgDump.new(
49
+ database: db_config["database"],
50
+ host: db_config["host"],
51
+ port: db_config["port"],
52
+ user: db_config["username"] || db_config["user"],
53
+ password: db_config["password"],
54
+ extra_options: @config.pg_dump["extra_options"]
55
+ )
56
+ end
57
+
58
+ def mysql_dump_command(db_config)
59
+ Commands::MysqlDump.new(
60
+ database: db_config["database"],
61
+ host: db_config["host"],
62
+ port: db_config["port"],
63
+ user: db_config["username"] || db_config["user"],
64
+ password: db_config["password"],
65
+ extra_options: @config.mysqldump["extra_options"]
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sg_tiny_backup/commands"
4
+ require "sg_tiny_backup/pipeline"
5
+ require_relative "base"
6
+
7
+ module SgTinyBackup
8
+ module PipelineBuilders
9
+ class Log < Base
10
+ def initialize(config:, basename:, local:)
11
+ super(
12
+ config: config,
13
+ basename: basename,
14
+ local: local,
15
+ s3_config: config.s3["log"],
16
+ extension: "tar.gz"
17
+ )
18
+ end
19
+
20
+ def build
21
+ output_path = base_filename if local?
22
+ pl = Pipeline.new(output_path: output_path)
23
+ pl << Commands::Tar.new(paths: @config.log_file_paths, optional_paths: @config.optional_log_file_paths)
24
+ pl << Commands::Gzip.new(level: @config.gzip["level"])
25
+ pl << aws_cli_command unless local?
26
+ pl
27
+ end
28
+
29
+ private
30
+
31
+ def extension
32
+ "tar.gz"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sg_tiny_backup/commands/aws_cli"
4
+
5
+ module SgTinyBackup
6
+ module PipelineBuilders
7
+ module S3
8
+ class << self
9
+ def build_aws_cli_command(s3_config:, destination_url:)
10
+ expected_size = s3_config["expected_upload_size"].to_i if s3_config["expected_upload_size"]
11
+ Commands::AwsCli.new(
12
+ destination_url: destination_url,
13
+ access_key_id: s3_config["access_key_id"],
14
+ secret_access_key: s3_config["secret_access_key"],
15
+ expected_size: expected_size
16
+ )
17
+ end
18
+
19
+ def build_s3_url(s3_config:, base_filename:)
20
+ object_path = [s3_config["prefix"], base_filename].join("_")
21
+ "s3://#{File.join(s3_config["bucket"], object_path)}"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SgTinyBackup
4
+ class Railtie < ::Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path("../tasks/sg_tiny_backup.rake", __dir__)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sg_tiny_backup/pipeline"
4
+ require "sg_tiny_backup/error"
5
+ require "sg_tiny_backup/pipeline_builders/db"
6
+ require "sg_tiny_backup/pipeline_builders/log"
7
+
8
+ module SgTinyBackup
9
+ class Runner
10
+ attr_reader :target
11
+
12
+ TARGET_DB = "db"
13
+ TARGET_LOG = "log"
14
+
15
+ def initialize(config:, basename:, target: "db", local: false, pipeline_builder: nil)
16
+ @config = config
17
+ @basename = basename
18
+ @target = target
19
+ @local = local
20
+ @pipeline_builder = pipeline_builder
21
+ end
22
+
23
+ def run # rubocop:disable Naming/PredicateMethod
24
+ pipeline.run
25
+ handle_warning(pipeline.warning_message)
26
+ handle_error(pipeline.error_message) if pipeline.failed?
27
+ pipeline.succeeded?
28
+ end
29
+
30
+ def plain_commands
31
+ pipeline.plain_commands
32
+ end
33
+
34
+ def piped_command
35
+ plain_commands.join(" | ")
36
+ end
37
+
38
+ def env
39
+ pipeline.env
40
+ end
41
+
42
+ def s3_destination_url
43
+ pipeline_builder.destination_url
44
+ end
45
+
46
+ def base_filename
47
+ pipeline_builder.base_filename
48
+ end
49
+
50
+ private
51
+
52
+ def pipeline
53
+ @pipeline ||= pipeline_builder.build
54
+ end
55
+
56
+ def pipeline_builder
57
+ @pipeline_builder ||=
58
+ case target
59
+ when TARGET_DB
60
+ SgTinyBackup::PipelineBuilders::Db.new(config: @config, basename: @basename, local: @local)
61
+ when TARGET_LOG
62
+ SgTinyBackup::PipelineBuilders::Log.new(config: @config, basename: @basename, local: @local)
63
+ end
64
+ end
65
+
66
+ def handle_warning(message)
67
+ return if message.empty?
68
+
69
+ SgTinyBackup.logger.warn message
70
+ end
71
+
72
+ def handle_error(message)
73
+ SgTinyBackup.logger.error message
74
+ raise BackupFailed, message if SgTinyBackup.raise_on_error
75
+ end
76
+ end
77
+ end