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