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 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
@@ -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,10 @@
1
+ class String
2
+ def underscore
3
+ gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').
4
+ gsub(/([a-z\d])([A-Z])/, '\1_\2').
5
+ tr('-', '_').
6
+ gsub(/\s/, '_').
7
+ gsub(/__+/, '_').
8
+ downcase
9
+ end
10
+ 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: []