sql_cmd 0.3.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.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/lib/optional_dependencies.rb +30 -0
- data/lib/sql_cmd/agent.rb +32 -0
- data/lib/sql_cmd/always_on.rb +267 -0
- data/lib/sql_cmd/azure.rb +80 -0
- data/lib/sql_cmd/backups.rb +276 -0
- data/lib/sql_cmd/config.rb +62 -0
- data/lib/sql_cmd/database.rb +618 -0
- data/lib/sql_cmd/format.rb +124 -0
- data/lib/sql_cmd/query.rb +350 -0
- data/lib/sql_cmd/security.rb +21 -0
- data/lib/sql_cmd/sql_helper.ps1 +89 -0
- data/lib/sql_cmd.rb +44 -0
- data/sql_scripts/Agent/CreateSQLJob.sql +81 -0
- data/sql_scripts/Agent/JobLastRunInfo.sql +70 -0
- data/sql_scripts/Agent/JobRunStatus.sql +21 -0
- data/sql_scripts/Agent/SQLAgentStatus.sql +8 -0
- data/sql_scripts/AlwaysOn/AddDatabaseToAvailabilityGroupOnSecondary.sql +72 -0
- data/sql_scripts/AlwaysOn/AddDatabaseToPrimaryAvailabilityGroup.sql +16 -0
- data/sql_scripts/AlwaysOn/AutomaticSeedingProgress.sql +34 -0
- data/sql_scripts/AlwaysOn/ConfigurePrimaryForAutomaticSeeding.sql +2 -0
- data/sql_scripts/AlwaysOn/ConfigurePrimaryForManualSeeding.sql +2 -0
- data/sql_scripts/AlwaysOn/ConfigureSecondaryForAutomaticSeeding.sql +1 -0
- data/sql_scripts/AlwaysOn/DropSecondary.sql +58 -0
- data/sql_scripts/AlwaysOn/RemoveDatabaseFromGroup.sql +2 -0
- data/sql_scripts/AlwaysOn/SynchronizationState.sql +14 -0
- data/sql_scripts/Database/BackupDatabase.sql +95 -0
- data/sql_scripts/Database/CompressAllTables.sql +100 -0
- data/sql_scripts/Database/CreateLogin.sql +16 -0
- data/sql_scripts/Database/DropDatabase.sql +51 -0
- data/sql_scripts/Database/GetBackupFiles.sql +31 -0
- data/sql_scripts/Database/GetBackupHeaders.sql +94 -0
- data/sql_scripts/Database/GetFileInfoFromBackup.sql +9 -0
- data/sql_scripts/Database/RestoreDatabase.sql +185 -0
- data/sql_scripts/Database/SetFullRecovery.sql +19 -0
- data/sql_scripts/Database/SetSQLCompatibility.sql +33 -0
- data/sql_scripts/Security/AssignDatabaseRoles.sql +44 -0
- data/sql_scripts/Security/CreateOrUpdateCredential.sql +11 -0
- data/sql_scripts/Security/CreateSqlLogin.sql +20 -0
- data/sql_scripts/Security/ExportDatabasePermissions.sql +757 -0
- data/sql_scripts/Security/GenerateCreateLoginsScript.sql +144 -0
- data/sql_scripts/Security/GenerateValidateLoginsScript.sql +83 -0
- data/sql_scripts/Security/GetUserSID.sql +3 -0
- data/sql_scripts/Security/UpdateSqlPassword.sql +24 -0
- data/sql_scripts/Security/ValidateDatabaseRoles.sql +12 -0
- data/sql_scripts/Status/ANSINullsOffTableCount.sql +13 -0
- data/sql_scripts/Status/ANSINullsOffTables.sql +9 -0
- data/sql_scripts/Status/BackupProgress.sql +17 -0
- data/sql_scripts/Status/DatabaseInfo.sql +199 -0
- data/sql_scripts/Status/DatabaseSize.sql +26 -0
- data/sql_scripts/Status/DiskSpace.sql +14 -0
- data/sql_scripts/Status/RestoreProgress.sql +17 -0
- data/sql_scripts/Status/SQLSettings.sql +182 -0
- data/sql_scripts/Status/UncompressedTableCount.sql +27 -0
- metadata +224 -0
@@ -0,0 +1,618 @@
|
|
1
|
+
module SqlCmd
|
2
|
+
module Database
|
3
|
+
module_function
|
4
|
+
|
5
|
+
def backup(backup_start_time, connection_string, database_name, backup_folder: nil, backup_url: nil, backup_basename: nil, asynchronous: false, options: {})
|
6
|
+
EasyIO.logger.header "#{options['logonly'] ? 'Log' : 'Database'} #{options['copyonly'] ? 'Full Backup' : 'Backup'}"
|
7
|
+
backup_start_time = SqlCmd.unify_start_time(backup_start_time)
|
8
|
+
database_info = SqlCmd::Database.info(connection_string, database_name) # TODO: 3 seconds
|
9
|
+
sql_server_settings = SqlCmd.get_backup_sql_server_settings(connection_string) # TODO: 3 seconds
|
10
|
+
raise "Failed to backup database! [#{database_name}] was not found on [#{sql_server_settings['ServerName']}]!" if database_info['DatabaseNotFound']
|
11
|
+
raise 'Backup attempted before scheduled time!' if Time.now < backup_start_time
|
12
|
+
|
13
|
+
options = default_backup_options.merge(options)
|
14
|
+
free_space_threshold = SqlCmd.config['sql_cmd']['backups']['free_space_threshold']
|
15
|
+
options['compressbackup'] ||= sql_server_settings['CompressBackup'] ? SqlCmd.config['sql_cmd']['backups']['compress_backups'] : false
|
16
|
+
options['credential'] ||= options['storage_account_name'] || ''
|
17
|
+
backup_basename = "#{database_name}_#{EasyTime.yyyymmdd(backup_start_time)}" if backup_basename.nil? || backup_basename.empty?
|
18
|
+
original_basename = backup_basename
|
19
|
+
|
20
|
+
# Set backup_folder to default_destination if it is not set and the backup_to_host_sql_server flag is not set
|
21
|
+
# If the backup_to_host_sql_server flag is set, use the server's default location
|
22
|
+
if (backup_folder.nil? || backup_folder.empty?) && (backup_url.nil? || backup_url.empty?)
|
23
|
+
backup_folder = SqlCmd.config['sql_cmd']['backups']['backup_to_host_sql_server'] ? sql_server_settings['BackupDir'] : SqlCmd.config['sql_cmd']['backups']['default_destination']
|
24
|
+
end
|
25
|
+
# Check if a backup is currently running
|
26
|
+
job_status = SqlCmd::Agent::Job.status(connection_string, "Backup: #{database_name}")['LastRunStatus'] # TODO: 7 seconds
|
27
|
+
monitor_backup(backup_start_time, connection_string, database_name, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename, log_only: options['logonly']) if job_status == 'Running'
|
28
|
+
|
29
|
+
# Check if there's a current backup and if so, return without creating another backup
|
30
|
+
backup_files, backup_basename = existing_backup_files(sql_server_settings, options, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename, log_only: options['logonly'])
|
31
|
+
unless backup_files.empty?
|
32
|
+
sql_backup_header = SqlCmd.get_sql_backup_headers(connection_string, backup_files, options).first # TODO: 4 seconds
|
33
|
+
return :current if SqlCmd.check_header_date(sql_backup_header, backup_start_time, :prebackup) == :current
|
34
|
+
backup_basename = "#{original_basename}_#{EasyTime.hhmmss}" # use a unique name if backup already exists
|
35
|
+
end
|
36
|
+
backup_basename = original_basename if options['init'] # use original backup name if init (overwrite) is specified
|
37
|
+
|
38
|
+
EasyIO.logger.info "Checking size of [#{database_name}] database..."
|
39
|
+
backup_size = SqlCmd.get_database_size(connection_string, database_name, log_only: options['logonly'])
|
40
|
+
EasyIO.logger.info "#{options['logonly'] ? 'Log' : 'Database'} size: #{backup_size == 0 ? 'Not found' : backup_size.round(2)} MB"
|
41
|
+
|
42
|
+
# Check disk space if not using backup url
|
43
|
+
if backup_url.nil? || backup_url.empty?
|
44
|
+
if backup_folder == sql_server_settings['BackupDir']
|
45
|
+
EasyIO.logger.info "Checking disk space on #{sql_server_settings['DataSource']}..."
|
46
|
+
sql_server_disk_space = SqlCmd.get_sql_disk_space(sql_server_settings['connection_string'], sql_server_settings['BackupDir'])
|
47
|
+
sql_server_free_space = sql_server_disk_space['Available_MB'].to_f
|
48
|
+
sql_server_disk_size = sql_server_disk_space['Total_MB'].to_f
|
49
|
+
backup_folder = sql_server_settings['default_destination'] unless sufficient_free_space?(sql_server_disk_size, sql_server_free_space, backup_size, free_space_threshold)
|
50
|
+
end
|
51
|
+
|
52
|
+
if backup_folder != sql_server_settings['BackupDir'] # If the backup folder is not the host sql box or there was not enough space on the sql box, check for free space where specified
|
53
|
+
EasyIO.logger.info "Checking free space for #{backup_folder}..."
|
54
|
+
specified_backup_folder_free_space = EasyIO::Disk.free_space(backup_folder)
|
55
|
+
specified_backup_folder_disk_size = EasyIO::Disk.size(backup_folder)
|
56
|
+
sufficient_space = sufficient_free_space?(specified_backup_folder_disk_size, specified_backup_folder_free_space, backup_size, free_space_threshold)
|
57
|
+
raise "Failed to backup database #{database_name} due to insufficient space! Must have greater than #{free_space_threshold}% space remaining after backup." unless sufficient_space
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
run_backup_as_job(connection_string, database_name, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename, options: options)
|
62
|
+
monitor_backup(backup_start_time, connection_string, database_name, options, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename, log_only: options['logonly']) unless asynchronous
|
63
|
+
end
|
64
|
+
|
65
|
+
def sufficient_free_space?(disk_size, free_space, backup_size, free_space_threshold)
|
66
|
+
free_space_percentage = disk_size.nil? ? 'unknown ' : (free_space / disk_size) * 100
|
67
|
+
free_space_post_backup = free_space - backup_size
|
68
|
+
free_space_percentage_post_backup = disk_size.nil? ? 'unknown ' : (free_space_post_backup / disk_size) * 100
|
69
|
+
EasyIO.logger.info "Free space on backup drive: #{free_space.round(2)} MB / #{free_space_percentage.round(2)}%"
|
70
|
+
EasyIO.logger.info "Estimated free space on backup drive after backup: #{free_space_post_backup.round(2)} MB / #{free_space_percentage_post_backup.round(2)}%"
|
71
|
+
free_space_percentage_post_backup >= free_space_threshold
|
72
|
+
end
|
73
|
+
|
74
|
+
def delete_backup_and_restore_history(connection_string, database_name)
|
75
|
+
sql_script = "EXEC msdb.dbo.sp_delete_database_backuphistory @database_name = N'#{database_name}'"
|
76
|
+
SqlCmd.execute_query(connection_string, sql_script, retries: 3)
|
77
|
+
end
|
78
|
+
|
79
|
+
def existing_backup_files(sql_server_settings, options = {}, backup_folder: nil, backup_url: nil, backup_basename: nil, log_only: false)
|
80
|
+
if backup_url && !backup_url.empty?
|
81
|
+
SqlCmd.get_url_backup_files(sql_server_settings, backup_url, backup_basename, options)
|
82
|
+
elsif backup_folder.nil? || backup_folder.empty?
|
83
|
+
default_destination = SqlCmd.config['sql_cmd']['backups']['default_destination']
|
84
|
+
EasyIO.logger.info "Checking for existing backup files in #{default_destination}..."
|
85
|
+
primary_backup_files, backup_basename = SqlCmd.get_unc_backup_files(sql_server_settings, default_destination, backup_basename, log_only: log_only)
|
86
|
+
return [primary_backup_files, backup_basename] unless primary_backup_files.empty?
|
87
|
+
EasyIO.logger.info "Checking for existing backup files on #{sql_server_settings['DataSource']}..."
|
88
|
+
SqlCmd.sql_server_backup_files(sql_server_settings, backup_basename, log_only: log_only)
|
89
|
+
else
|
90
|
+
SqlCmd.get_unc_backup_files(sql_server_settings, backup_folder, backup_basename, log_only: log_only)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def migrate(start_time, source_connection_string, database_name, destination_connection_string, backup_folder: nil, backup_url: nil, backup_basename: nil, permissions_only: false, force_restore: false, full_backup_method: nil, options: {})
|
95
|
+
EasyIO.logger.header 'Database Migration'
|
96
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
97
|
+
source_connection_string = SqlCmd.remove_connection_string_part(source_connection_string, :database)
|
98
|
+
database_info = SqlCmd::Database.info(source_connection_string, database_name)
|
99
|
+
destination_database_info = SqlCmd::Database.info(destination_connection_string, database_name)
|
100
|
+
destination_server_name = SqlCmd.connection_string_part(destination_connection_string, :server)
|
101
|
+
source_server_name = SqlCmd.get_sql_server_settings(source_connection_string)[destination_server_name.include?(',') ? 'DataSource' : 'ServerName']
|
102
|
+
source_sql_version = SqlCmd.get_sql_server_settings(source_connection_string)['SQLVersion']
|
103
|
+
destination_sql_version = SqlCmd.get_sql_server_settings(destination_connection_string)['SQLVersion']
|
104
|
+
validate_restorability(source_sql_version, destination_sql_version, source_type: :database) unless permissions_only
|
105
|
+
raise "Failed to migrate database! Destination and source servers are the same! (#{source_server_name})" if source_server_name =~ /#{Regexp.escape(destination_server_name)}/i
|
106
|
+
if database_info['DatabaseNotFound'] && (destination_database_info['DatabaseNotFound'] || destination_database_info['DatabaseRestoring'] || destination_database_info['LastRestoreDate'] < start_time)
|
107
|
+
raise "Failed to migrate database. Database [#{database_name}] does not exist on [#{source_server_name}]!"
|
108
|
+
end
|
109
|
+
SqlCmd::Database.duplicate(start_time, source_connection_string, database_name, destination_connection_string, database_name, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename, force_restore: force_restore, full_backup_method: full_backup_method, options: options) unless permissions_only
|
110
|
+
SqlCmd.migrate_logins(start_time, source_connection_string, destination_connection_string, database_name)
|
111
|
+
|
112
|
+
replication_active = SqlCmd::Database.info(source_connection_string, database_name)['ReplicationActive'] # Refresh database_info to see if the source still has replication enabled
|
113
|
+
if replication_active
|
114
|
+
return if options['return_at_replication_active']
|
115
|
+
raise "Replication must now be dropped from [#{source_server_name}] in order to proceed. Before dropping replication from the source server, script out replication and script it into the migrated database on [#{destination_server_name}] if you wish to preserve replication. Then rerun the migration."
|
116
|
+
end
|
117
|
+
SqlCmd::AlwaysOn.remove_from_availability_group(source_connection_string, database_name)
|
118
|
+
SqlCmd::Database.drop(source_connection_string, database_name)
|
119
|
+
end
|
120
|
+
|
121
|
+
# permissions:
|
122
|
+
# :keep_existing - If the database already exists before being restored, exports existing database permissions and imports them after restoring
|
123
|
+
# :no_permissions - Does not do anything with permissions, only restores the database
|
124
|
+
# :export_only - If the database already exists before being restored, exports exiting database permissions but does not import them after restoring
|
125
|
+
# options:
|
126
|
+
# TODO: see link
|
127
|
+
# :fail_on_synchronized_database - Raise an error if the database is a member of an AlwaysOn Availability Group
|
128
|
+
def restore(start_time, connection_string, database_name, backup_folder: nil, backup_url: nil, backup_basename: nil, full_backup_method: nil, force_restore: false, asynchronous: false, overwrite: true, permissions: :keep_existing, options: {})
|
129
|
+
raise 'backup_basename parameter is required when restoring a database!' if backup_basename.nil?
|
130
|
+
EasyIO.logger.header "#{options['logonly'] ? 'Log' : 'Database'} Restore"
|
131
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
132
|
+
backup_folder ||= SqlCmd.config['sql_cmd']['backups']['default_destination']
|
133
|
+
backup_type = backup_url.nil? || backup_url.empty? ? 'DISK' : 'URL'
|
134
|
+
backup_source = backup_type == 'DISK' ? backup_folder : backup_url
|
135
|
+
options = default_restore_options.merge(options)
|
136
|
+
options['replace'] ||= overwrite
|
137
|
+
connection_string = SqlCmd.remove_connection_string_part(connection_string, :database)
|
138
|
+
sql_server = SqlCmd.connection_string_part(connection_string, :server)
|
139
|
+
database_info = info(connection_string, database_name)
|
140
|
+
import_script_path = nil
|
141
|
+
unless database_info['DatabaseNotFound']
|
142
|
+
raise "Failed to restore database: [#{database_name}] on [#{sql_server}]! Database already exists!" unless overwrite || force_restore
|
143
|
+
unless options['logonly'] || database_info['state_desc'] != 'ONLINE' || permissions == :no_permissions
|
144
|
+
EasyIO.logger.info "Database already exists before restore on [#{sql_server}]. Saving existing database security permissions..."
|
145
|
+
import_script_path = export_security(start_time, connection_string, database_name, backup_url, options)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
free_space_threshold = SqlCmd.config['sql_cmd']['backups']['free_space_threshold']
|
149
|
+
EasyIO.logger.debug "Getting SQL server settings for #{sql_server}..."
|
150
|
+
sql_server_settings = SqlCmd.get_backup_sql_server_settings(connection_string)
|
151
|
+
EasyIO.logger.info "Reading backup files in #{backup_source}..."
|
152
|
+
non_log_backup_files = if backup_type == 'DISK'
|
153
|
+
SqlCmd.backup_sets_from_unc_path(sql_server_settings, backup_folder, backup_basename).values.first
|
154
|
+
else
|
155
|
+
SqlCmd.backup_sets_from_url(sql_server_settings, backup_url, backup_basename, options).values.first
|
156
|
+
end
|
157
|
+
raise "Backup files do not exist in #{backup_source} named #{backup_basename}.bak or .partX.bak" if non_log_backup_files.nil? || non_log_backup_files.empty?
|
158
|
+
database_backup_header = SqlCmd.get_sql_backup_headers(connection_string, non_log_backup_files, options).first
|
159
|
+
source_sql_version = "#{database_backup_header['SoftwareVersionMajor']}.#{database_backup_header['SoftwareVersionMinor']}"
|
160
|
+
destination_sql_version = sql_server_settings['SQLVersion']
|
161
|
+
validate_restorability(source_sql_version, destination_sql_version, source_type: :backup)
|
162
|
+
backup_sets = if backup_type == 'DISK'
|
163
|
+
SqlCmd.backup_sets_from_unc_path(sql_server_settings, backup_folder, backup_basename, log_only: options['logonly'], database_backup_header: database_backup_header, restored_database_lsn: database_info['LastRestoreLSN'])
|
164
|
+
else
|
165
|
+
SqlCmd.backup_sets_from_url(sql_server_settings, backup_url, backup_basename, options, log_only: options['logonly'], database_backup_header: database_backup_header, restored_database_lsn: database_info['LastRestoreLSN'])
|
166
|
+
end
|
167
|
+
backup_extension = options['logonly'] ? '.trn' : '.bak'
|
168
|
+
raise 'Log backups are not complete. Restore missing log backup or delete existing backups and try again' if backup_sets.nil?
|
169
|
+
raise "Backup files do not exist in #{backup_source} named #{backup_basename}.#{backup_extension} or .partX.#{backup_extension}" if backup_sets.empty?
|
170
|
+
if options['logonly']
|
171
|
+
raise 'No current log backups were found' if backup_sets.nil? || backup_sets.empty?
|
172
|
+
first_log_backup_lsn = SqlCmd.get_sql_backup_headers(connection_string, backup_sets.first.last, options).first['FirstLSN']
|
173
|
+
raise "First log backup is too recent to apply. First log backup LSN: #{first_log_backup_lsn} - Restored database LSN: #{database_info['LastRestoreLSN']}" if first_log_backup_lsn > [database_info['LastRestoreLSN'], database_info['LastLogRestoreLSN'] || 0].max
|
174
|
+
end
|
175
|
+
EasyIO.logger.debug "Backup sets to restore: #{JSON.pretty_generate(backup_sets)}"
|
176
|
+
backup_sets.each do |backup_set_basename, backup_files|
|
177
|
+
EasyIO.logger.info "Preparing to restore #{backup_set_basename}..."
|
178
|
+
EasyIO.logger.debug "Backup files: #{JSON.pretty_generate(backup_files)}"
|
179
|
+
raise "Backup #{backup_set_basename} does not exist! Backup file(s) source: \n #{backup_source}" if backup_files.nil? || backup_files.empty?
|
180
|
+
sql_backup_header = SqlCmd.get_sql_backup_headers(connection_string, backup_files, options).first
|
181
|
+
raise "Backup #{backup_set_basename} could not be read! It may be from a newer SQL server version or corrupt." if sql_backup_header.nil?
|
182
|
+
raise "Backup #{backup_set_basename} is not current! Backup file(s) source: \n #{backup_source}" unless SqlCmd.check_header_date(sql_backup_header, start_time, :prerestore)
|
183
|
+
|
184
|
+
minimum_restore_date = sql_backup_header['BackupFinishDate'] > start_time ? sql_backup_header['BackupFinishDate'] : start_time
|
185
|
+
next unless force_restore || !restore_up_to_date?(start_time, connection_string, database_name, backup_files, options, pre_restore: true)
|
186
|
+
|
187
|
+
raise "Unable to restore database [#{database_name}] to [#{sql_server}]! The database is being used for replication." if database_info['ReplicationActive']
|
188
|
+
if !database_info['DatabaseNotFound'] && SqlCmd::AlwaysOn.database_synchronized?(connection_string, database_name)
|
189
|
+
raise "Failed to restore database: [#{database_name}] is part of an AlwaysOn Availability group on [#{sql_server}]." if options['fail_on_synchronized_database']
|
190
|
+
unless options['secondaryreplica'] # unless we're restoring to the secondary replica, remove the DB from the AlwaysOn Availability Group
|
191
|
+
EasyIO.logger.info "Database [#{database_name}] is part of an availability group and will be removed..."
|
192
|
+
SqlCmd::AlwaysOn.remove_from_availability_group(connection_string, database_name)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
EasyIO.logger.debug "Restoring backup #{backup_set_basename} with header: #{JSON.pretty_generate(sql_backup_header)}"
|
196
|
+
|
197
|
+
unless force_restore
|
198
|
+
# Calculate disk space
|
199
|
+
database_size = options['logonly'] ? 0 : database_info['DatabaseSize'] || 0
|
200
|
+
EasyIO.logger.info "Existing target database size: #{database_size == 0 ? 'No existing database' : "#{database_size.round(2)} MB"}" unless options['logonly']
|
201
|
+
restored_backup_size = SqlCmd.get_backup_size(sql_backup_header)
|
202
|
+
EasyIO.logger.info "Size of backup #{backup_set_basename}: #{restored_backup_size.round(2)} MB"
|
203
|
+
database_size_difference = restored_backup_size - database_size
|
204
|
+
EasyIO.logger.debug "Checking disk space on [#{sql_server_settings['ServerName']}]..."
|
205
|
+
sql_server_disk_space = SqlCmd.get_sql_disk_space(sql_server_settings['connection_string'], sql_server_settings['DataDir'])
|
206
|
+
sql_server_free_space = sql_server_disk_space['Available_MB'].to_f
|
207
|
+
sql_server_disk_size = sql_server_disk_space['Total_MB'].to_f
|
208
|
+
sql_server_free_space_after_restore = sql_server_free_space - database_size_difference
|
209
|
+
sql_server_free_space_percentage = sql_server_disk_size.nil? ? 'unknown ' : (sql_server_free_space / sql_server_disk_size) * 100
|
210
|
+
sql_server_free_space_percentage_post_restore = sql_server_disk_size.nil? ? 'unknown ' : (sql_server_free_space_after_restore / sql_server_disk_size) * 100
|
211
|
+
EasyIO.logger.info "Free space on [#{sql_server_settings['ServerName']}] before restore: #{sql_server_free_space.round(2)} MB / #{sql_server_free_space_percentage.round(2)}%"
|
212
|
+
EasyIO.logger.info "Estimated free space on [#{sql_server_settings['ServerName']}] after restore: #{sql_server_free_space_after_restore.round(2)} MB / #{sql_server_free_space_percentage_post_restore.round(2)}%"
|
213
|
+
insufficient_space = sql_server_free_space_percentage_post_restore < free_space_threshold && database_size_difference > 0
|
214
|
+
raise "Insufficient free space on #{sql_server} to restore database! Must have greater than #{free_space_threshold}% space remaining after restore." if insufficient_space
|
215
|
+
end
|
216
|
+
|
217
|
+
run_restore_as_job(connection_string, sql_server_settings, backup_files, database_name, options: options)
|
218
|
+
monitor_restore(minimum_restore_date, connection_string, database_name, backup_files, options) unless asynchronous
|
219
|
+
end
|
220
|
+
import_security(connection_string, database_name, import_script_path, backup_url, options) unless import_script_path.nil? || [:no_permissions, :export_only].include?(permissions)
|
221
|
+
SqlCmd.update_sql_compatibility(connection_string, database_name, options['compatibility_level']) if options['compatibility_level']
|
222
|
+
apply_recovery_model(connection_string, database_name, options) if options['recovery_model']
|
223
|
+
SqlCmd::AlwaysOn.add_to_availability_group(connection_string, database_name, full_backup_method: full_backup_method) if sql_server_settings['AlwaysOnEnabled'] && !options['secondaryreplica'] && !options['skip_always_on']
|
224
|
+
ensure_full_backup_has_occurred(connection_string, database_name, full_backup_method: full_backup_method, database_info: database_info) unless options['secondaryreplica'] || full_backup_method == :skip
|
225
|
+
end
|
226
|
+
|
227
|
+
def validate_restorability(source_sql_version, destination_sql_version, source_type: :backup)
|
228
|
+
sql_versions_valid = ::Gem::Version.new(source_sql_version.split('.')[0...1].join('.')) <= ::Gem::Version.new(destination_sql_version.split('.')[0...1].join('.'))
|
229
|
+
not_valid_msg = "Unable to restore database. Destination server (#{destination_sql_version}) is on an older version of SQL than the source #{source_type == :backup ? 'backup' : 'server'} (#{source_sql_version})!"
|
230
|
+
raise not_valid_msg unless sql_versions_valid
|
231
|
+
end
|
232
|
+
|
233
|
+
def backup_up_to_date?(start_time, connection_string, database_name, last_backup_date_key)
|
234
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
235
|
+
(SqlCmd::Database.info(connection_string, database_name)[last_backup_date_key] || Time.at(0)) >= start_time
|
236
|
+
end
|
237
|
+
|
238
|
+
def restore_up_to_date?(start_time, connection_string, database_name, backup_files, options = {}, pre_restore: false)
|
239
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
240
|
+
database_info = info(connection_string, database_name)
|
241
|
+
restore_date_key, restore_lsn_key = backup_files.any? { |f| f =~ /\.trn/i } ? %w(LastLogRestoreDate LastLogRestoreLSN) : %w(LastRestoreDate LastRestoreLSN)
|
242
|
+
last_restore_database_backup_lsn = database_info['LastRestoreDatabaseBackupLSN'] || 0
|
243
|
+
return false if last_restore_database_backup_lsn == 0 || database_info[restore_date_key].nil?
|
244
|
+
job_status = SqlCmd::Agent::Job.status(connection_string, "Restore: #{database_name}")['LastRunStatus']
|
245
|
+
return false if job_status == 'Running'
|
246
|
+
backup_header = SqlCmd.get_sql_backup_headers(connection_string, backup_files, options).first
|
247
|
+
minimum_restore_date = backup_header['BackupFinishDate'] > start_time ? backup_header['BackupFinishDate'] : start_time
|
248
|
+
EasyIO.logger.debug "LastLSN from restored database: #{database_info[restore_lsn_key]}"
|
249
|
+
EasyIO.logger.debug "LastLSN in backup header : #{backup_header['LastLSN']}"
|
250
|
+
EasyIO.logger.debug "DatabaseBackupLSN of restored database: #{last_restore_database_backup_lsn}"
|
251
|
+
EasyIO.logger.debug "DatabaseBackupLSN of backup set: #{backup_header['DatabaseBackupLSN']}"
|
252
|
+
EasyIO.logger.debug "Restore date key: #{restore_date_key}"
|
253
|
+
up_to_date = database_info[restore_date_key] > minimum_restore_date && last_restore_database_backup_lsn == backup_header['DatabaseBackupLSN'] &&
|
254
|
+
database_info[restore_lsn_key] >= backup_header['LastLSN']
|
255
|
+
unless pre_restore
|
256
|
+
raise "The restore job status is '#{job_status}'. Check the 'Restore: #{database_name}' job history for more details." unless job_status == 'NoJob' || job_status == 'Succeeded'
|
257
|
+
EasyIO.logger.warn "The restored date in the database (#{database_info[restore_date_key]}) is older than the database backup or the scheduled start time (#{minimum_restore_date})!" unless database_info[restore_date_key] > minimum_restore_date
|
258
|
+
EasyIO.logger.warn "The DatabaseBackupLSN for the database (#{last_restore_database_backup_lsn}) doesn't match that of the database backup (#{backup_header['DatabaseBackupLSN']})!" unless last_restore_database_backup_lsn == backup_header['DatabaseBackupLSN']
|
259
|
+
EasyIO.logger.warn "The #{restore_lsn_key} for the database (#{database_info[restore_lsn_key]}) is lower than the LastLSN of the database backup (#{backup_header['LastLSN']})!" unless database_info[restore_lsn_key] >= backup_header['LastLSN']
|
260
|
+
end
|
261
|
+
EasyIO.logger.info "Restored database #{database_name} is up to date." if up_to_date
|
262
|
+
up_to_date
|
263
|
+
end
|
264
|
+
|
265
|
+
# deletes a Sql database
|
266
|
+
def drop(connection_string, database_name)
|
267
|
+
EasyIO.logger.header 'Drop Database'
|
268
|
+
connection_string = SqlCmd.remove_connection_string_part(connection_string, :database)
|
269
|
+
sql_server_settings = SqlCmd.get_sql_server_settings(connection_string)
|
270
|
+
database_information = info(connection_string, database_name)
|
271
|
+
if database_information['DatabaseNotFound']
|
272
|
+
EasyIO.logger.info 'The database was not found. Skipping drop of database...'
|
273
|
+
return
|
274
|
+
end
|
275
|
+
raise "Unable to drop database [#{database_name}] from [#{sql_server_settings['ServerName']}]! The database is being used for replication." if database_information['ReplicationActive']
|
276
|
+
EasyIO.logger.info "Dropping database [#{database_name}] from [#{sql_server_settings['ServerName']}]..."
|
277
|
+
|
278
|
+
sql_script = ::File.read("#{SqlCmd.sql_script_dir}/Database/DropDatabase.sql")
|
279
|
+
SqlCmd.execute_query(connection_string, sql_script, values: { 'databasename' => database_name }, retries: 3)
|
280
|
+
|
281
|
+
database_information = info(connection_string, database_name)
|
282
|
+
raise "Failed to drop database [#{database_name}] from [#{sql_server_settings['ServerName']}]!" unless database_information['DatabaseNotFound']
|
283
|
+
end
|
284
|
+
|
285
|
+
# Creates and starts a SQL job to backup a database
|
286
|
+
# * See default_backup_options method for default options
|
287
|
+
def run_backup_as_job(connection_string, database_name, backup_folder: nil, backup_url: nil, backup_basename:, options: {})
|
288
|
+
raise 'Backup folder or url must be specified!' if (backup_folder.nil? || backup_folder.empty?) && (backup_url.nil? || backup_url.empty?)
|
289
|
+
backup_status_script = ::File.read("#{SqlCmd.sql_script_dir}/Status/BackupProgress.sql")
|
290
|
+
return unless SqlCmd.execute_query(connection_string, backup_status_script, return_type: :first_table, values: { 'databasename' => database_name }, retries: 3).empty?
|
291
|
+
|
292
|
+
values = default_backup_options.merge(options)
|
293
|
+
values['bkupdbname'] = database_name
|
294
|
+
values['bkupname'] = backup_basename
|
295
|
+
values['bkuppartmaxsize'] ||= 58800
|
296
|
+
if backup_url.nil? || backup_url.empty?
|
297
|
+
values['bkupdest'] = backup_folder
|
298
|
+
values['bkuptype'] = 'DISK'
|
299
|
+
else
|
300
|
+
backup_url = "#{backup_url}/" unless backup_url.end_with?('/')
|
301
|
+
values['bkupdest'] = backup_url
|
302
|
+
values['bkuptype'] = 'URL'
|
303
|
+
SqlCmd::Security.create_credential(connection_string, values['credential'], options['storage_account_name'], options['storage_access_key'], options) if SqlCmd.azure_blob_storage_url?(backup_url)
|
304
|
+
end
|
305
|
+
|
306
|
+
sql_backup_script = ::File.read("#{SqlCmd.sql_script_dir}/Database/BackupDatabase.sql")
|
307
|
+
EasyIO.logger.info "Backing up #{options['logonly'] ? 'log for' : 'database'} #{database_name} to: #{values['bkupdest']}..." # TODO: Update log message with url
|
308
|
+
EasyIO.logger.debug "Backup basename: #{backup_basename}"
|
309
|
+
EasyIO.logger.debug "Database name: #{database_name}"
|
310
|
+
EasyIO.logger.debug "Compress backup: #{values['compressbackup']}"
|
311
|
+
EasyIO.logger.debug "Copy only: #{values['copyonly']}"
|
312
|
+
::FileUtils.mkdir_p(backup_folder) unless values['bkuptype'] == 'URL' || ::File.directory?(backup_folder) # create the destination folder if it does not exist
|
313
|
+
SqlCmd.run_sql_as_job(connection_string, sql_backup_script, "Backup: #{backup_basename}", values: values, retries: 1, retry_delay: 30)
|
314
|
+
end
|
315
|
+
|
316
|
+
def monitor_backup(backup_start_time, connection_string, database_name, options = {}, backup_folder: nil, backup_url: nil, backup_basename: nil, log_only: false, retries: 3, retry_delay: 10)
|
317
|
+
backup_start_time = SqlCmd.unify_start_time(backup_start_time)
|
318
|
+
backup_status_script = ::File.read("#{SqlCmd.sql_script_dir}/Status/BackupProgress.sql")
|
319
|
+
job_name = "Backup: #{database_name}"
|
320
|
+
EasyIO.logger.info 'Checking backup status...'
|
321
|
+
EasyIO.logger.debug "Backup start time: #{backup_start_time}"
|
322
|
+
sleep(3) # Give the job time to start
|
323
|
+
timeout = 60 # After the SQLAgent job exits, check the backup up to date algorithm for this long to update before failing
|
324
|
+
monitoring_start_time = Time.now
|
325
|
+
timer_interval = 15
|
326
|
+
last_backup_date_key = log_only ? 'LastLogOnlyBackupDate' : 'LastFullBackupDate'
|
327
|
+
|
328
|
+
# Initialize variables so they persists through retries
|
329
|
+
job_started ||= false
|
330
|
+
job_completion_time ||= nil
|
331
|
+
begin
|
332
|
+
loop do
|
333
|
+
job_started = true if !job_started && SqlCmd::Agent::Job.exists?(connection_string, job_name) # TODO: 5 seconds?
|
334
|
+
status_row = SqlCmd.execute_query(connection_string, backup_status_script, return_type: :first_row, values: { 'databasename' => database_name }, retries: 3) # TODO: 5 seconds?
|
335
|
+
if status_row.nil?
|
336
|
+
job_status = SqlCmd::Agent::Job.status(connection_string, job_name)['LastRunStatus'] # TODO: 5 seconds?
|
337
|
+
break if backup_up_to_date?(backup_start_time, connection_string, database_name, last_backup_date_key) # TODO: 4 seconds?
|
338
|
+
next if job_status == 'Running'
|
339
|
+
# TODO: check job history for errors if not :current
|
340
|
+
# job_message = job_history_message(connection_string, job_name) unless result == :current
|
341
|
+
if job_started # Check if job has timed out after stopping without completing
|
342
|
+
job_completion_time ||= Time.now
|
343
|
+
_raise_backup_failure(connection_string, database_name, last_backup_date_key, backup_start_time, backup_basename, job_status: job_status, job_started: job_started) if Time.now > job_completion_time + timeout
|
344
|
+
elsif Time.now > monitoring_start_time + timeout # Job never started and timed out
|
345
|
+
_raise_backup_failure(connection_string, database_name, last_backup_date_key, backup_start_time, backup_basename, job_status: job_status, job_started: job_started)
|
346
|
+
end
|
347
|
+
sleep(timer_interval)
|
348
|
+
next
|
349
|
+
end
|
350
|
+
job_started = true
|
351
|
+
EasyIO.logger.info "Percent complete: #{status_row['Percent Complete']} / Elapsed min: #{status_row['Elapsed Min']} / Min remaining: #{status_row['ETA Min']} / ETA: #{status_row['ETA Completion Time']}"
|
352
|
+
sleep(timer_interval)
|
353
|
+
false # if we got here, conditions were not met. Keep looping...
|
354
|
+
end
|
355
|
+
rescue
|
356
|
+
sleep(retry_delay)
|
357
|
+
retry if (retries -= 1) >= 0
|
358
|
+
raise
|
359
|
+
end
|
360
|
+
begin
|
361
|
+
unless backup_basename.nil? # Don't validate backup files if backup folder or basename was not provided
|
362
|
+
sql_server_settings = SqlCmd.get_sql_server_settings(connection_string) # TODO: 4 seconds
|
363
|
+
backup_destination = backup_url.nil? || backup_url.empty? ? backup_folder : backup_url
|
364
|
+
backup_files, backup_basename = SqlCmd.get_backup_files(sql_server_settings, options, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename)
|
365
|
+
if backup_files.empty?
|
366
|
+
EasyIO.logger.warn "Unable to verify backup files. No backup files found or backup destination '#{backup_destination}' is inaccessible!"
|
367
|
+
return :current
|
368
|
+
end
|
369
|
+
sql_backup_header = SqlCmd.get_sql_backup_headers(connection_string, backup_files, options).first # TODO: 3 seconds
|
370
|
+
result = SqlCmd.check_header_date(sql_backup_header, backup_start_time)
|
371
|
+
raise 'WARNING! Backup files are not current!' if result == :outdated
|
372
|
+
raise 'WARNING! Backup files could not be read!' if result == :nobackup
|
373
|
+
end
|
374
|
+
rescue
|
375
|
+
sleep(retry_delay)
|
376
|
+
retry if (retries -= 1) >= 0
|
377
|
+
raise
|
378
|
+
end
|
379
|
+
EasyIO.logger.info 'Backup complete.'
|
380
|
+
result
|
381
|
+
end
|
382
|
+
|
383
|
+
def _raise_backup_failure(connection_string, database_name, last_backup_date_key, backup_start_time, backup_basename, job_status: nil, job_started: false)
|
384
|
+
server_name = SqlCmd.connection_string_part(connection_string, :server)
|
385
|
+
if job_started
|
386
|
+
failure_message = "Backup may have failed as the backup has stopped and the last backup time shows #{SqlCmd::Database.info(connection_string, database_name)[last_backup_date_key]} " \
|
387
|
+
"but the backup should be newer than #{backup_start_time}! "
|
388
|
+
failure_message += job_status == 'NoJob' ? 'The job exited with success and so does not exist!' : "Check sql job 'Backup: #{backup_basename}' history on [#{server_name}] for details. \n"
|
389
|
+
raise failure_message + "Last backup time retrieved from: #{server_name}\\#{database_name}"
|
390
|
+
end
|
391
|
+
failure_message = "Backup appears to have failed! The last backup time shows #{SqlCmd::Database.info(connection_string, database_name)[last_backup_date_key]} " \
|
392
|
+
"but the backup should be newer than #{backup_start_time}! "
|
393
|
+
failure_message += job_status == 'NoJob' ? 'The backup job could not be found!' : "The job did not start in time. Check sql job 'Backup: #{backup_basename}' history on [#{server_name}] for details."
|
394
|
+
raise failure_message + "Last backup time retrieved from: #{server_name}\\#{database_name}"
|
395
|
+
end
|
396
|
+
|
397
|
+
# Creates and starts a SQL job to restore a database.
|
398
|
+
def run_restore_as_job(connection_string, sql_server_settings, backup_files, database_name, options: {})
|
399
|
+
disk_backup_files = SqlCmd.backup_fileset_names(backup_files)
|
400
|
+
restore_status_script = ::File.read("#{SqlCmd.sql_script_dir}/Status/RestoreProgress.sql")
|
401
|
+
server_connection_string = SqlCmd.remove_connection_string_part(connection_string, :database)
|
402
|
+
data_file_logical_name, log_file_logical_name = options['logonly'] ? ['', ''] : SqlCmd.get_backup_logical_names(server_connection_string, backup_files, options)
|
403
|
+
|
404
|
+
values = default_restore_options.merge(options)
|
405
|
+
values['databasename'] = database_name
|
406
|
+
values['bkupfiles'] = disk_backup_files
|
407
|
+
values['datafile'] ||= "#{sql_server_settings['DataDir']}#{database_name}.mdf"
|
408
|
+
values['logfile'] ||= "#{sql_server_settings['LogDir']}#{database_name}.ldf"
|
409
|
+
values['datafilelogicalname'] ||= data_file_logical_name
|
410
|
+
values['logfilelogicalname'] ||= log_file_logical_name
|
411
|
+
|
412
|
+
return unless SqlCmd.execute_query(server_connection_string, restore_status_script, return_type: :first_table, values: values, retries: 3).empty? # Do nothing if restore is in progress
|
413
|
+
|
414
|
+
# TODO: implement: Replication.script_replication(connection_string, database_name)
|
415
|
+
# TODO: implement: Replication.remove_replication(connection_string, database_name)
|
416
|
+
SqlCmd::AlwaysOn.remove_from_availability_group(server_connection_string, database_name) unless options['secondaryreplica']
|
417
|
+
sql_restore_script = ::File.read("#{SqlCmd.sql_script_dir}/Database/RestoreDatabase.sql")
|
418
|
+
EasyIO.logger.info "Restoring #{options['logonly'] ? 'log for' : 'database'} [#{database_name}] on [#{SqlCmd.connection_string_part(connection_string, :server)}]..."
|
419
|
+
SqlCmd.run_sql_as_job(server_connection_string, sql_restore_script, "Restore: #{database_name}", values: values, retries: 1, retry_delay: 30)
|
420
|
+
end
|
421
|
+
|
422
|
+
def monitor_restore(start_time, connection_string, database_name, backup_files, options = {}, retries: 3, retry_delay: 15)
|
423
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
424
|
+
restore_status_script = ::File.read("#{SqlCmd.sql_script_dir}/Status/RestoreProgress.sql")
|
425
|
+
job_name = "Restore: #{database_name}"
|
426
|
+
server_connection_string = SqlCmd.remove_connection_string_part(connection_string, :database)
|
427
|
+
values = { 'databasename' => database_name }
|
428
|
+
EasyIO.logger.info 'Checking restore status...'
|
429
|
+
sleep(5)
|
430
|
+
timeout = 60
|
431
|
+
monitoring_start_time = Time.now
|
432
|
+
timer_interval = 15
|
433
|
+
restore_date_key = backup_files.any? { |f| f =~ /\.trn/i } ? 'LastLogRestoreDate' : 'LastRestoreDate'
|
434
|
+
|
435
|
+
# Initialize variables so they persists through retries
|
436
|
+
job_started ||= false
|
437
|
+
job_completion_time ||= nil
|
438
|
+
begin
|
439
|
+
loop do
|
440
|
+
job_started = true if !job_started && SqlCmd::Agent::Job.exists?(connection_string, job_name)
|
441
|
+
status_row = SqlCmd.execute_query(server_connection_string, restore_status_script, return_type: :first_row, values: values, retries: 3)
|
442
|
+
if status_row.nil?
|
443
|
+
break if restore_up_to_date?(start_time, connection_string, database_name, backup_files, options)
|
444
|
+
job_status = SqlCmd::Agent::Job.status(connection_string, job_name)['LastRunStatus']
|
445
|
+
next if job_status == 'Running'
|
446
|
+
# TODO: check job history for errors if not :current
|
447
|
+
# job_message = job_history_message(connection_string, job_name) unless result == :current
|
448
|
+
if job_started # check if job has timed out after stopping but not completing
|
449
|
+
job_completion_time ||= Time.now
|
450
|
+
_raise_restore_failure(connection_string, database_name, restore_date_key, start_time, job_status: job_status, job_started: job_started) if Time.now > job_completion_time + timeout
|
451
|
+
elsif Time.now > monitoring_start_time + timeout # Job never started and timed out
|
452
|
+
_raise_restore_failure(connection_string, database_name, restore_date_key, start_time, job_status: job_status, job_started: job_started)
|
453
|
+
end
|
454
|
+
sleep(timer_interval)
|
455
|
+
next
|
456
|
+
end
|
457
|
+
job_started = true
|
458
|
+
EasyIO.logger.info "Percent complete: #{status_row['Percent Complete']} / Elapsed min: #{status_row['Elapsed Min']} / Min remaining: #{status_row['ETA Min']} / ETA: #{status_row['ETA Completion Time']}"
|
459
|
+
sleep(timer_interval)
|
460
|
+
false # if we got here, conditions were not met. Keep looping...
|
461
|
+
end
|
462
|
+
EasyIO.logger.info 'Restore complete.'
|
463
|
+
rescue
|
464
|
+
sleep(retry_delay)
|
465
|
+
retry if (retries -= 1) >= 0
|
466
|
+
raise
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
def _raise_restore_failure(connection_string, database_name, restore_date_key, start_time, job_status: nil, job_started: false)
|
471
|
+
server_name = SqlCmd.connection_string_part(connection_string, :server)
|
472
|
+
if job_started
|
473
|
+
failure_message = "Restore may have failed as the restore has stopped and the last restore time shows #{SqlCmd::Database.info(connection_string, database_name)[restore_date_key]} " \
|
474
|
+
"but the restore should be newer than #{start_time}! "
|
475
|
+
failure_message += job_status == 'NoJob' ? 'The job exited with success and so does not exist!' : "Check sql job 'Restore: #{database_name}' history on [#{server_name}] for details."
|
476
|
+
raise failure_message + "Last restore time retrieved from: #{server_name}\\#{database_name}"
|
477
|
+
end
|
478
|
+
failure_message = 'Restore appears to have failed! '
|
479
|
+
failure_message += job_status == 'NoJob' ? 'The job could not be found and the restored database is not up to date!' : "The job did not start in time. Check sql job 'Restore: #{database_name}' history on [#{server_name}] for details."
|
480
|
+
raise failure_message + "Restore destination: #{server_name}\\#{database_name}"
|
481
|
+
end
|
482
|
+
|
483
|
+
def check_restore_date(start_time, connection_string, database_name, messages = :none, log_only: false)
|
484
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
485
|
+
database_information = info(connection_string, database_name)
|
486
|
+
last_restore_date_key = log_only ? 'LastLogRestoreDate' : 'LastRestoreDate'
|
487
|
+
return :unknown if database_information.nil?
|
488
|
+
return :notfound if database_information['DatabaseNotFound']
|
489
|
+
return :restoring if database_information['state_desc'] == 'RESTORING'
|
490
|
+
return :nodate if database_information[last_restore_date_key].nil?
|
491
|
+
return :outdated if database_information[last_restore_date_key] < start_time
|
492
|
+
if [:prerestore].include?(messages)
|
493
|
+
EasyIO.logger.info "Last restore for [#{database_name}] completed: #{database_information[last_restore_date_key]}"
|
494
|
+
EasyIO.logger.info 'Restored database is current.'
|
495
|
+
end
|
496
|
+
:current
|
497
|
+
end
|
498
|
+
|
499
|
+
def ensure_full_backup_has_occurred(connection_string, database_name, force_backup: false, full_backup_method: nil, database_info: nil)
|
500
|
+
database_info = info(connection_string, database_name) if database_info.nil?
|
501
|
+
server_name = SqlCmd.connection_string_part(connection_string, :server)
|
502
|
+
EasyIO.logger.info "Ensuring full backup has taken place for [#{server_name}].[#{database_name}]..."
|
503
|
+
if force_backup || database_info['LastNonCopyOnlyFullBackupDate'].nil? || # Ensure last full backup occurred AFTER the DB was last restored
|
504
|
+
(!database_info['LastRestoreDate'].nil? && database_info['LastNonCopyOnlyFullBackupDate'] < database_info['LastRestoreDate'])
|
505
|
+
EasyIO.logger.info 'Running full backup...'
|
506
|
+
backup_basename = "full_backup-#{database_name}_#{EasyTime.yyyymmdd}" # If a full_backup_method was not provided, use this name for the database backup for clarity
|
507
|
+
full_backup_method.nil? ? SqlCmd::Database.backup(Time.now, connection_string, database_name, backup_basename: backup_basename, options: { 'copyonly' => false }) : full_backup_method.call
|
508
|
+
end
|
509
|
+
end
|
510
|
+
|
511
|
+
def duplicate(start_time, source_connection_string, source_database_name, destination_connection_string, destination_database_name, backup_folder: nil, backup_url: nil, backup_basename: nil, force_restore: false, full_backup_method: nil, options: {})
|
512
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
513
|
+
backup(start_time, source_connection_string, source_database_name, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename, options: options) unless info(source_connection_string, source_database_name)['DatabaseNotFound']
|
514
|
+
backup_folder, backup_basename = SqlCmd.backup_location_and_basename(start_time, source_connection_string, source_database_name, options, backup_url: backup_url) # TODO: rework for URL
|
515
|
+
if (backup_folder.nil? && backup_url.nil?) || backup_basename.nil?
|
516
|
+
source_server = SqlCmd.connection_string_part(source_connection_string, :server)
|
517
|
+
destination_server = SqlCmd.connection_string_part(destination_connection_string, :server)
|
518
|
+
database_info = SqlCmd::Database.info(destination_connection_string, destination_database_name)
|
519
|
+
raise "Backup files could not be found while duplicating #{source_database_name} from #{source_server} to #{destination_server}. Manually restore the database and run again." if database_info['LastRestoreDate'].nil? || database_info['LastRestoreDate'] < start_time
|
520
|
+
EasyIO.logger.warn 'Backup files could not be found but restore appears to be current. Skipping restore...'
|
521
|
+
return
|
522
|
+
end
|
523
|
+
restore(start_time, destination_connection_string, destination_database_name, backup_folder: backup_folder, backup_url: backup_url, backup_basename: backup_basename, force_restore: force_restore, full_backup_method: full_backup_method, options: options)
|
524
|
+
end
|
525
|
+
|
526
|
+
def info(connection_string, database_name, retries: 3, retry_delay: 5)
|
527
|
+
raise 'Failed to get database information! The database_name argument must be specified.' if database_name.nil? || database_name.empty?
|
528
|
+
raise 'Failed to get database information! The connection_string argument must be specified.' if connection_string.nil? || connection_string.empty?
|
529
|
+
sql_script = ::File.read("#{SqlCmd.sql_script_dir}/Status/DatabaseInfo.sql")
|
530
|
+
server_connection_string = SqlCmd.remove_connection_string_part(connection_string, :database)
|
531
|
+
result = SqlCmd.execute_query(server_connection_string, sql_script, return_type: :first_row, values: { 'databasename' => database_name }, readonly: true, retries: retries, retry_delay: retry_delay) || {}
|
532
|
+
return result if result.empty?
|
533
|
+
result['DatabaseName'] ||= database_name
|
534
|
+
result
|
535
|
+
end
|
536
|
+
|
537
|
+
def apply_recovery_model(connection_string, database_name, options)
|
538
|
+
return if recovery_model_set?(connection_string, database_name, options)
|
539
|
+
options['recovery_model'] ||= 'FULL'
|
540
|
+
options['rollback'] ||= 'ROLLBACK IMMEDIATE' # other options: ROLLBACK AFTER 30, NO_WAIT
|
541
|
+
sql_script = "ALTER DATABASE [#{database_name}] SET RECOVERY #{options['recovery_model']} WITH #{options['rollback']}"
|
542
|
+
SqlCmd.execute_query(connection_string, sql_script) || {}
|
543
|
+
failure_message = <<-EOS
|
544
|
+
Failed to set recovery model to '#{options['recovery_model']}'!\n
|
545
|
+
Command attempted: #{sql_script}\n
|
546
|
+
ConnectionString: '#{SqlCmd.hide_connection_string_password(connection_string)}'
|
547
|
+
#{'=' * 120}\n"
|
548
|
+
EOS
|
549
|
+
raise failure_message unless recovery_model_set?(connection_string, database_name, options)
|
550
|
+
end
|
551
|
+
|
552
|
+
def recovery_model_set?(connection_string, database_name, options)
|
553
|
+
recovery_model = options['recovery_model'] || 'FULL'
|
554
|
+
sql_script = "SELECT 1 FROM master.sys.databases WHERE recovery_model_desc LIKE '#{recovery_model}' and name = '#{database_name}'"
|
555
|
+
SqlCmd.execute_query(connection_string, sql_script, return_type: :scalar, readonly: true) || false
|
556
|
+
end
|
557
|
+
|
558
|
+
def export_security(start_time, connection_string, database_name, storage_url = nil, options = {})
|
559
|
+
start_time = SqlCmd.unify_start_time(start_time)
|
560
|
+
server_name = SqlCmd.connection_string_part(connection_string, :server)
|
561
|
+
export_folder = "#{SqlCmd.config['paths']['cache']}/sql_cmd/logins"
|
562
|
+
basename_prefix = storage_url.nil? ? "#{EasyFormat::File.windows_friendly_name(server_name)}_" : ''
|
563
|
+
import_script_path = "#{export_folder}/#{basename_prefix}#{database_name}_database_permissions_#{EasyTime.yyyymmdd(start_time)}.sql"
|
564
|
+
if ::File.exist?(import_script_path) && ::File.mtime(import_script_path) > start_time
|
565
|
+
content = ::File.read(import_script_path)
|
566
|
+
SqlCmd::Azure::AttachedStorage.upload(::File.basename(import_script_path), content, options['storage_account_name'], options['storage_access_key'], storage_url: storage_url) unless storage_url.nil?
|
567
|
+
return import_script_path
|
568
|
+
end
|
569
|
+
|
570
|
+
sql_script = ::File.read("#{SqlCmd.sql_script_dir}/Security/ExportDatabasePermissions.sql")
|
571
|
+
values = { 'databasename' => database_name, 'output' => 'CreateOnly', 'includetablepermissions' => SqlCmd.config['sql_cmd']['exports']['include_table_permissions'] ? 1 : 0 }
|
572
|
+
EasyIO.logger.info "Exporting database permissions for: [#{database_name}] on [#{server_name}]..."
|
573
|
+
import_script = SqlCmd.execute_query(connection_string, sql_script, return_type: :scalar, values: values, readonly: true, retries: 3)
|
574
|
+
return nil if import_script.nil? || import_script.empty?
|
575
|
+
FileUtils.mkdir_p(export_folder)
|
576
|
+
::File.write(import_script_path, import_script)
|
577
|
+
EasyIO.logger.info "Permissions exported to: #{import_script_path}"
|
578
|
+
EasyIO.logger.debug "Resulting import script: #{import_script}"
|
579
|
+
SqlCmd::Azure::AttachedStorage.upload(::File.basename(import_script_path), import_script, options['storage_account_name'], options['storage_access_key'], storage_url: storage_url) unless storage_url.nil?
|
580
|
+
import_script_path
|
581
|
+
end
|
582
|
+
|
583
|
+
def import_security(connection_string, database_name, import_script_path = nil, storage_url = nil, options = {})
|
584
|
+
EasyIO.logger.info 'Restoring previous security configuration...'
|
585
|
+
export_folder = "#{SqlCmd.config['paths']['cache']}/sql_cmd/logins"
|
586
|
+
start_time = options['start_time'] || SqlCmd.unify_start_time(nil)
|
587
|
+
import_script_path ||= "#{export_folder}/#{database_name}_database_permissions_#{EasyTime.yyyymmdd(start_time)}.sql"
|
588
|
+
SqlCmd::Azure::AttachedStorage.download(::File.basename(import_script_path), import_script_path, options['storage_account_name'], options['storage_access_key'], storage_url: storage_url) unless storage_url.nil?
|
589
|
+
SqlCmd.execute_script_file(connection_string, import_script_path, values: { 'databasename' => database_name })
|
590
|
+
end
|
591
|
+
|
592
|
+
def default_backup_options
|
593
|
+
{
|
594
|
+
# 'compressbackup' => false, # Uses server default if not specified
|
595
|
+
'splitfiles' => true, # Split files for large databases
|
596
|
+
'logonly' => false, # Does a log only backup
|
597
|
+
'formatbackup' => false, # Specifies that a new media set be created. Overwrites media header
|
598
|
+
'copyonly' => true, # Specifies not to affect normal sequence of backups
|
599
|
+
'init' => false, # Specifies that backup sets should be overwritten but preserves the media header
|
600
|
+
'skip' => true, # Skips checking backup expiration date and name before overwriting
|
601
|
+
'rewind' => false, # Specifies that SQL server releases and rewinds the tape
|
602
|
+
'unload' => false, # Specifies that the tape is automatically rewound and unloaded after completion
|
603
|
+
'stats' => 5, # Specifies how often sql server reports progress by percentage
|
604
|
+
}
|
605
|
+
end
|
606
|
+
|
607
|
+
def default_restore_options
|
608
|
+
{
|
609
|
+
'logonly' => false, # Restore a log backup
|
610
|
+
'recovery' => true, # Recovers the database after restoring - Should be used unless additional log files are to be restored
|
611
|
+
'replace' => false, # Overwrites the existing database
|
612
|
+
'keepreplication' => false, # Restore replication
|
613
|
+
'unload' => false, # Specifies that the tape is automatically rewound and unloaded after completion
|
614
|
+
'stats' => 5, # Specifies how often sql server reports progress by percentage
|
615
|
+
}
|
616
|
+
end
|
617
|
+
end
|
618
|
+
end
|