backup_minister 0.0.1
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/bin/backup_minister +27 -0
- data/lib/backup_minister/agent.rb +198 -0
- data/lib/backup_minister/core/string.rb +10 -0
- data/lib/backup_minister/server.rb +105 -0
- data/lib/backup_minister.rb +107 -0
- metadata +77 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: af89f01465c72c52b25a8e609c50519a15fe6ab6
|
4
|
+
data.tar.gz: 62c62db31e310a4e6b22abdab8bfb69c2732f69f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 411b3d4bcb3c1a841d7440f5bc8d3904fc26b7da9afce45feee939116c4e9b5b51259a79d16a95e1df3a39b0225e0bce8437470b53d9b3cde5557ea6dcd65204
|
7
|
+
data.tar.gz: 1bd6a245ba4a09435506db87cf257251ebe873bdbd897a63136452d18114f480dc9219f10902f5fb11fcf0eed571928528bbe48436a7a71db7c2f841ec5d1185
|
data/bin/backup_minister
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'backup_minister'
|
3
|
+
require 'thor'
|
4
|
+
|
5
|
+
class CLI < Thor
|
6
|
+
desc 'backup_database', 'Move database TAR GZ archive to destination directory'
|
7
|
+
option :project_name, type: :string, required: true, aliases: :name
|
8
|
+
option :file, type: :string, required: true, banner: '</Path/to/database/backup.tar.gz>'
|
9
|
+
def backup_database
|
10
|
+
server = BackupMinister::Server.new
|
11
|
+
server.place_database_backup(options[:project_name], options[:file])
|
12
|
+
end
|
13
|
+
|
14
|
+
desc 'sync_path', 'Server\'s path for files'
|
15
|
+
option :project_name, type: :string, required: true, aliases: :name
|
16
|
+
def sync_path
|
17
|
+
server = BackupMinister::Server.new
|
18
|
+
path = server.project_files_location(options[:project_name])
|
19
|
+
if path.nil?
|
20
|
+
exit!(true)
|
21
|
+
else
|
22
|
+
puts path
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
CLI.start(ARGV)
|
@@ -0,0 +1,198 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'rubygems/package'
|
3
|
+
require 'tempfile'
|
4
|
+
|
5
|
+
CONFIG_FILE_NAME = 'config.yml'
|
6
|
+
DATABASE_DRIVERS = %i(docker)
|
7
|
+
|
8
|
+
class BackupMinister::Agent < BackupMinister
|
9
|
+
@projects = nil
|
10
|
+
@config_file_name = nil
|
11
|
+
|
12
|
+
def initialize(file_name = nil)
|
13
|
+
super
|
14
|
+
load_projects(config)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get setting item value
|
18
|
+
#
|
19
|
+
# @param name [String] Settings name
|
20
|
+
#
|
21
|
+
# @return [Object, nil] return settings value if present
|
22
|
+
def setting(name)
|
23
|
+
if @config['settings'].nil?
|
24
|
+
LOGGER.error 'Settings are empty'
|
25
|
+
elsif @config['settings'][name].nil?
|
26
|
+
LOGGER.error "Setting #{name} is missing."
|
27
|
+
else
|
28
|
+
@config['settings'][name]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Create TAR GZ archive with database backup
|
33
|
+
#
|
34
|
+
# @param project_name [String]
|
35
|
+
#
|
36
|
+
# @return [String, nil]
|
37
|
+
def backup_database(project_name)
|
38
|
+
base_name = project_name + '_' + Time.new.strftime('%Y%m%d_%H%M')
|
39
|
+
if project_config(name: project_name)['database']
|
40
|
+
backup_file = make_database_backup(project_config(name: project_name)['database'], base_name)
|
41
|
+
if backup_file.nil?
|
42
|
+
LOGGER.error "Can't create database backup for project #{project_name}."
|
43
|
+
else
|
44
|
+
archive_file_name = archive_and_remove_file("#{base_name}.tar.gz", backup_file)
|
45
|
+
if archive_file_name
|
46
|
+
LOGGER.debug "Database archive file #{archive_file_name} created."
|
47
|
+
return archive_file_name
|
48
|
+
end
|
49
|
+
end
|
50
|
+
else
|
51
|
+
LOGGER.error "No database config found for #{project_name}."
|
52
|
+
end
|
53
|
+
|
54
|
+
nil
|
55
|
+
end
|
56
|
+
|
57
|
+
# Check connection settings
|
58
|
+
#
|
59
|
+
# @return [Bool] is it possible to establish connection?
|
60
|
+
def check_server_requirements
|
61
|
+
result = false
|
62
|
+
host = system_config('server', 'host')
|
63
|
+
user = system_config('server', 'user')
|
64
|
+
|
65
|
+
if user and host
|
66
|
+
begin
|
67
|
+
connection = Net::SSH.start(host, user)
|
68
|
+
LOGGER.info "Connection with server #{user}@#{host} established."
|
69
|
+
|
70
|
+
remote_install = connection.exec!("gem list -i #{APP_NAME}")
|
71
|
+
if remote_install == 'true'
|
72
|
+
LOGGER.debug "#{APP_NAME} installed on remote server."
|
73
|
+
result = true
|
74
|
+
else
|
75
|
+
LOGGER.error "#{APP_NAME} is not installed on remote server: `#{remote_install.strip}`."
|
76
|
+
end
|
77
|
+
rescue Exception => error
|
78
|
+
LOGGER.error "Could not establish connection: #{error.message}"
|
79
|
+
result = false
|
80
|
+
ensure
|
81
|
+
connection.close if !connection.nil? and !connection.closed?
|
82
|
+
end
|
83
|
+
else
|
84
|
+
LOGGER.error 'Server user or host (or both of them) are not defined.'
|
85
|
+
end
|
86
|
+
|
87
|
+
result
|
88
|
+
end
|
89
|
+
|
90
|
+
# Load projects list if exists
|
91
|
+
def load_projects(config)
|
92
|
+
if config['projects'].nil? or config['projects'].empty?
|
93
|
+
LOGGER.warn 'No projects found. Nothing to do.'
|
94
|
+
else
|
95
|
+
@projects = config['projects'].keys
|
96
|
+
LOGGER.info "Found projects: #{@projects.join(', ')}."
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Generate database SQL backup file
|
101
|
+
#
|
102
|
+
# @param database_config [Hash] connection database settings
|
103
|
+
# @param base_name [String] name for backup
|
104
|
+
#
|
105
|
+
# @return [File, nil] path to SQL file
|
106
|
+
def make_database_backup(database_config, base_name, container_name = nil)
|
107
|
+
raise ArgumentError, "Driver #{database_config['driver']} not supported." unless DATABASE_DRIVERS.include?(database_config['driver'])
|
108
|
+
|
109
|
+
container_name = setting('database_container')
|
110
|
+
if container_name
|
111
|
+
backup_file_name = base_name + '.sql'
|
112
|
+
command = "docker exec -i #{container_name} pg_dump #{database_config['name']}"
|
113
|
+
command += " -U#{database_config['user']}" if database_config['user']
|
114
|
+
command += " > #{backup_file_name}"
|
115
|
+
if execute(command)
|
116
|
+
if File.exist?(backup_file_name)
|
117
|
+
LOGGER.debug "Database backup file #{backup_file_name} created."
|
118
|
+
file = File.open(backup_file_name, 'r')
|
119
|
+
database_file_valid?(file) ? file : nil
|
120
|
+
else
|
121
|
+
LOGGER.error "Can't create database backup file `#{backup_file_name}`."
|
122
|
+
end
|
123
|
+
end
|
124
|
+
else
|
125
|
+
LOGGER.error 'Database container name is missing. Can\'t backup.'
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Check basic content of SQL backup file
|
130
|
+
#
|
131
|
+
# @param file [File]
|
132
|
+
#
|
133
|
+
# @return [Bool]
|
134
|
+
def database_file_valid?(file)
|
135
|
+
if file.size
|
136
|
+
LOGGER.debug "File #{file.path} is #{file.size} bytes."
|
137
|
+
content = file.read
|
138
|
+
if Regexp.new('(.)+(PostgreSQL database dump)(.)+(PostgreSQL database dump complete)(.)+', Regexp::MULTILINE).match(content).to_a.count >= 4
|
139
|
+
LOGGER.debug "File #{file.path} looks like DB dump."
|
140
|
+
result = true
|
141
|
+
else
|
142
|
+
LOGGER.warn "File #{file.path} doesn't looks like DB dump."
|
143
|
+
result = false
|
144
|
+
end
|
145
|
+
else
|
146
|
+
LOGGER.warn "File #{file.path} has 0 length or doesn't exists."
|
147
|
+
result = false
|
148
|
+
end
|
149
|
+
|
150
|
+
result
|
151
|
+
end
|
152
|
+
|
153
|
+
# Create TAR GZ single file archive
|
154
|
+
#
|
155
|
+
# @param file_name [String] file name for new archive (including extension)
|
156
|
+
# @param file [String, nil] target file name to archive
|
157
|
+
def archive_file(file_name, file)
|
158
|
+
target = Tempfile.new(file.path)
|
159
|
+
Gem::Package::TarWriter.new(target) do |tar|
|
160
|
+
file = File.open(file.path, 'r')
|
161
|
+
tar.add_file(file.path, file.stat.mode) do |io|
|
162
|
+
io.write(file.read)
|
163
|
+
end
|
164
|
+
file.close
|
165
|
+
end
|
166
|
+
target.close
|
167
|
+
|
168
|
+
final_file_name = "#{File.expand_path(File.dirname(file.path))}/#{file_name}"
|
169
|
+
Zlib::GzipWriter.open(final_file_name) do |gz|
|
170
|
+
gz.mtime = File.mtime(target.path)
|
171
|
+
gz.orig_name = final_file_name
|
172
|
+
gz.write IO.binread(target.path)
|
173
|
+
end
|
174
|
+
|
175
|
+
if File.exist?(file_name)
|
176
|
+
file_name
|
177
|
+
else
|
178
|
+
nil
|
179
|
+
end
|
180
|
+
ensure
|
181
|
+
target.close if target and !target.closed?
|
182
|
+
nil
|
183
|
+
end
|
184
|
+
|
185
|
+
# Create TAR GZ archive and delete original file
|
186
|
+
# @see #archive_file for arguments description
|
187
|
+
def archive_and_remove_file(file_name, file)
|
188
|
+
archive_name = archive_file(file_name, file)
|
189
|
+
begin
|
190
|
+
File.delete(file)
|
191
|
+
# LOGGER.log "File #{file.path} was deleted."
|
192
|
+
archive_name
|
193
|
+
rescue StandardError => error
|
194
|
+
LOGGER.warn "Can't delete file #{file.path} with error: #{error}."
|
195
|
+
nil
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
class BackupMinister::Server < BackupMinister
|
2
|
+
|
3
|
+
# Is it possible to wright to target backups location?
|
4
|
+
#
|
5
|
+
# @return [Bool]
|
6
|
+
def location_accessible?
|
7
|
+
result = false
|
8
|
+
|
9
|
+
location = system_config('server', 'location')
|
10
|
+
if location.nil?
|
11
|
+
LOGGER.error 'Location path is not set.'
|
12
|
+
else
|
13
|
+
if File.directory?(location)
|
14
|
+
LOGGER.debug "Directory `#{location}` exists."
|
15
|
+
result = true
|
16
|
+
else
|
17
|
+
LOGGER.debug "Directory `#{location}` doesn't exists. Will try to create it."
|
18
|
+
result = create_nested_directory(location)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
result
|
23
|
+
end
|
24
|
+
|
25
|
+
# @param project_name [String]
|
26
|
+
# @param backup_file_path [String]
|
27
|
+
# @param project_config [Hash]
|
28
|
+
def place_database_backup(project_name, backup_file_path, project_config = {})
|
29
|
+
return false unless location_accessible?
|
30
|
+
|
31
|
+
result = false
|
32
|
+
project_location = project_location(project_name, project_config)
|
33
|
+
result = place_file(backup_file_path, "#{project_location}/database") if project_location
|
34
|
+
|
35
|
+
result
|
36
|
+
end
|
37
|
+
|
38
|
+
# Place file to directory
|
39
|
+
#
|
40
|
+
# @param file [String]
|
41
|
+
# @param location [String]
|
42
|
+
def place_file(file, location)
|
43
|
+
result = false
|
44
|
+
|
45
|
+
if File.exist?(file)
|
46
|
+
if File.directory?(location) or create_nested_directory(location)
|
47
|
+
begin
|
48
|
+
FileUtils.move file, location
|
49
|
+
LOGGER.debug "File #{file} moved to #{location}."
|
50
|
+
result = true
|
51
|
+
rescue Error => error
|
52
|
+
LOGGER.warn "Can't move file with error: #{error.message}."
|
53
|
+
end
|
54
|
+
end
|
55
|
+
else
|
56
|
+
LOGGER.warn "No such file #{file}."
|
57
|
+
end
|
58
|
+
|
59
|
+
result
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
# Path to root project's directory
|
64
|
+
#
|
65
|
+
# @param project_name [String]
|
66
|
+
# @param project_config [Hash]
|
67
|
+
#
|
68
|
+
# @return [String, nil]
|
69
|
+
def project_location(project_name, project_config = {})
|
70
|
+
result = nil
|
71
|
+
|
72
|
+
project_dir = project_config['remote_project_location'] || project_name.underscore
|
73
|
+
project_dir = /[^\/]([a-zA-Z0-9\-_])+([\/])?\z/.match(project_dir).to_a.first
|
74
|
+
if project_dir
|
75
|
+
project_full_path = system_config('server', 'location') + '/' + project_dir
|
76
|
+
if File.directory?(project_full_path) or create_nested_directory(project_full_path)
|
77
|
+
LOGGER.debug "Project location is #{project_full_path}."
|
78
|
+
result = project_full_path
|
79
|
+
end
|
80
|
+
else
|
81
|
+
LOGGER.error "Could not get project location for #{project_name}."
|
82
|
+
end
|
83
|
+
|
84
|
+
result
|
85
|
+
end
|
86
|
+
|
87
|
+
# Path to project directory for files
|
88
|
+
#
|
89
|
+
# @param project_name [String]
|
90
|
+
# @param project_config [Hash]
|
91
|
+
#
|
92
|
+
# @return [String, nil]
|
93
|
+
def project_files_location(project_name, project_config = {})
|
94
|
+
result = nil
|
95
|
+
|
96
|
+
project_dir = project_location(project_name, project_config)
|
97
|
+
if project_dir
|
98
|
+
files_path = '/files'
|
99
|
+
full_files_path = project_dir + files_path
|
100
|
+
result = full_files_path if File.directory?(full_files_path) or create_nested_directory(full_files_path)
|
101
|
+
end
|
102
|
+
|
103
|
+
result
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
require 'open3'
|
3
|
+
require 'logger'
|
4
|
+
require 'fileutils'
|
5
|
+
|
6
|
+
LOGGER = Logger.new(STDOUT)
|
7
|
+
APP_NAME = 'backup_minister'
|
8
|
+
|
9
|
+
class BackupMinister
|
10
|
+
@projects = nil
|
11
|
+
@config_file_name = nil
|
12
|
+
|
13
|
+
def initialize(file_name = nil)
|
14
|
+
@config_file_name = file_name || CONFIG_FILE_NAME
|
15
|
+
@config = check_config_file_exists
|
16
|
+
end
|
17
|
+
|
18
|
+
def system_config(*path)
|
19
|
+
path.unshift 'settings'
|
20
|
+
@config.dig(*path)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get project configuration
|
24
|
+
#
|
25
|
+
# @param name: [Symbol] Project name
|
26
|
+
# @param index: [Integer] Project index
|
27
|
+
#
|
28
|
+
# @return [Hash]
|
29
|
+
def project_config(name: nil, index: nil)
|
30
|
+
raise ArgumentError, 'At least one of arguments required' if name.nil? and index.nil?
|
31
|
+
project_name = name.nil? ? @projects[index] : name.to_s
|
32
|
+
|
33
|
+
if project_name and @config['projects'][project_name]
|
34
|
+
@config['projects'][project_name]
|
35
|
+
else
|
36
|
+
raise ArgumentError, "No project #{name ? name : index} found."
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Check if backup_minister is installed
|
41
|
+
#
|
42
|
+
# @return [Bool]
|
43
|
+
def backup_minister_installed?
|
44
|
+
software_installed?(APP_NAME)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Check if software is installed
|
48
|
+
#
|
49
|
+
# @return [Bool]
|
50
|
+
def software_installed?(name)
|
51
|
+
result = execute_with_result('type', name)
|
52
|
+
result and /\A(#{name})(.)+(#{name})$/.match(result).to_a.count >= 3
|
53
|
+
end
|
54
|
+
|
55
|
+
# Wrapper for `system` with check
|
56
|
+
#
|
57
|
+
# @return [Bool] true if exit status is 0
|
58
|
+
def execute(command)
|
59
|
+
system command
|
60
|
+
code = $?.exitstatus
|
61
|
+
if code > 0
|
62
|
+
LOGGER.warn "Failed to execute command `#{command}` with code #{code}."
|
63
|
+
false
|
64
|
+
else
|
65
|
+
true
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# @return [Bool]
|
70
|
+
def create_nested_directory(path)
|
71
|
+
result = false
|
72
|
+
|
73
|
+
begin
|
74
|
+
FileUtils::mkdir_p(path)
|
75
|
+
LOGGER.debug "Directory `#{path}` created."
|
76
|
+
result = true
|
77
|
+
rescue Error => error
|
78
|
+
LOGGER.error "Can't create directory `#{path}` with error #{error.message}."
|
79
|
+
end
|
80
|
+
|
81
|
+
result
|
82
|
+
end
|
83
|
+
|
84
|
+
def execute_with_result(command, arguments = [])
|
85
|
+
out, st = Open3.capture2(command, arguments)
|
86
|
+
LOGGER.warn "Failed to execute command `#{command}` with code #{st.exitstatus}." unless st.success?
|
87
|
+
out
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
attr_accessor :config
|
93
|
+
|
94
|
+
def check_config_file_exists
|
95
|
+
if File.exists?(@config_file_name)
|
96
|
+
YAML.load File.read(@config_file_name)
|
97
|
+
else
|
98
|
+
raise RuntimeError, "No config file #{@config_file_name} found"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
require 'backup_minister/agent'
|
104
|
+
require 'backup_minister/server'
|
105
|
+
|
106
|
+
# Extensions
|
107
|
+
require 'backup_minister/core/string'
|
metadata
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: backup_minister
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ilya Krigouzov
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-09-14 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: net-ssh
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: thor
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.20'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.20'
|
41
|
+
description: Provide tools for multi-server backups with docker support
|
42
|
+
email: webmaster@oniksfly.com
|
43
|
+
executables:
|
44
|
+
- backup_minister
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- bin/backup_minister
|
49
|
+
- lib/backup_minister.rb
|
50
|
+
- lib/backup_minister/agent.rb
|
51
|
+
- lib/backup_minister/core/string.rb
|
52
|
+
- lib/backup_minister/server.rb
|
53
|
+
homepage: https://github.com/oniksfly/backup-minister
|
54
|
+
licenses:
|
55
|
+
- Nonstandard
|
56
|
+
metadata: {}
|
57
|
+
post_install_message:
|
58
|
+
rdoc_options: []
|
59
|
+
require_paths:
|
60
|
+
- lib
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
62
|
+
requirements:
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '2.0'
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
requirements: []
|
72
|
+
rubyforge_project:
|
73
|
+
rubygems_version: 2.6.11
|
74
|
+
signing_key:
|
75
|
+
specification_version: 4
|
76
|
+
summary: Backup Minister
|
77
|
+
test_files: []
|