backup_minister 0.0.1

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