backup_minister 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|