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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/lib/optional_dependencies.rb +30 -0
  4. data/lib/sql_cmd/agent.rb +32 -0
  5. data/lib/sql_cmd/always_on.rb +267 -0
  6. data/lib/sql_cmd/azure.rb +80 -0
  7. data/lib/sql_cmd/backups.rb +276 -0
  8. data/lib/sql_cmd/config.rb +62 -0
  9. data/lib/sql_cmd/database.rb +618 -0
  10. data/lib/sql_cmd/format.rb +124 -0
  11. data/lib/sql_cmd/query.rb +350 -0
  12. data/lib/sql_cmd/security.rb +21 -0
  13. data/lib/sql_cmd/sql_helper.ps1 +89 -0
  14. data/lib/sql_cmd.rb +44 -0
  15. data/sql_scripts/Agent/CreateSQLJob.sql +81 -0
  16. data/sql_scripts/Agent/JobLastRunInfo.sql +70 -0
  17. data/sql_scripts/Agent/JobRunStatus.sql +21 -0
  18. data/sql_scripts/Agent/SQLAgentStatus.sql +8 -0
  19. data/sql_scripts/AlwaysOn/AddDatabaseToAvailabilityGroupOnSecondary.sql +72 -0
  20. data/sql_scripts/AlwaysOn/AddDatabaseToPrimaryAvailabilityGroup.sql +16 -0
  21. data/sql_scripts/AlwaysOn/AutomaticSeedingProgress.sql +34 -0
  22. data/sql_scripts/AlwaysOn/ConfigurePrimaryForAutomaticSeeding.sql +2 -0
  23. data/sql_scripts/AlwaysOn/ConfigurePrimaryForManualSeeding.sql +2 -0
  24. data/sql_scripts/AlwaysOn/ConfigureSecondaryForAutomaticSeeding.sql +1 -0
  25. data/sql_scripts/AlwaysOn/DropSecondary.sql +58 -0
  26. data/sql_scripts/AlwaysOn/RemoveDatabaseFromGroup.sql +2 -0
  27. data/sql_scripts/AlwaysOn/SynchronizationState.sql +14 -0
  28. data/sql_scripts/Database/BackupDatabase.sql +95 -0
  29. data/sql_scripts/Database/CompressAllTables.sql +100 -0
  30. data/sql_scripts/Database/CreateLogin.sql +16 -0
  31. data/sql_scripts/Database/DropDatabase.sql +51 -0
  32. data/sql_scripts/Database/GetBackupFiles.sql +31 -0
  33. data/sql_scripts/Database/GetBackupHeaders.sql +94 -0
  34. data/sql_scripts/Database/GetFileInfoFromBackup.sql +9 -0
  35. data/sql_scripts/Database/RestoreDatabase.sql +185 -0
  36. data/sql_scripts/Database/SetFullRecovery.sql +19 -0
  37. data/sql_scripts/Database/SetSQLCompatibility.sql +33 -0
  38. data/sql_scripts/Security/AssignDatabaseRoles.sql +44 -0
  39. data/sql_scripts/Security/CreateOrUpdateCredential.sql +11 -0
  40. data/sql_scripts/Security/CreateSqlLogin.sql +20 -0
  41. data/sql_scripts/Security/ExportDatabasePermissions.sql +757 -0
  42. data/sql_scripts/Security/GenerateCreateLoginsScript.sql +144 -0
  43. data/sql_scripts/Security/GenerateValidateLoginsScript.sql +83 -0
  44. data/sql_scripts/Security/GetUserSID.sql +3 -0
  45. data/sql_scripts/Security/UpdateSqlPassword.sql +24 -0
  46. data/sql_scripts/Security/ValidateDatabaseRoles.sql +12 -0
  47. data/sql_scripts/Status/ANSINullsOffTableCount.sql +13 -0
  48. data/sql_scripts/Status/ANSINullsOffTables.sql +9 -0
  49. data/sql_scripts/Status/BackupProgress.sql +17 -0
  50. data/sql_scripts/Status/DatabaseInfo.sql +199 -0
  51. data/sql_scripts/Status/DatabaseSize.sql +26 -0
  52. data/sql_scripts/Status/DiskSpace.sql +14 -0
  53. data/sql_scripts/Status/RestoreProgress.sql +17 -0
  54. data/sql_scripts/Status/SQLSettings.sql +182 -0
  55. data/sql_scripts/Status/UncompressedTableCount.sql +27 -0
  56. 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