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,124 @@
1
+ module SqlCmd
2
+ module_function
3
+
4
+ def connection_string_part_regex(part)
5
+ case part
6
+ when :server
7
+ /(server|data source)(\s*\=\s*)([^;]*)(;)?/i
8
+ when :database
9
+ /(database|initial catalog)(\s*\=\s*)([^;]*)(;)?/i
10
+ when :user # array of user/password or integrated security
11
+ /(user id|uid)(\s*\=\s*)([^;]*)(;)?/i
12
+ when :password
13
+ /(password|pwd)(\s*\=\s*)([^;]*)(;)?/i
14
+ when :integrated
15
+ /integrated security\s*\=\s*[^;]*(;)?/i
16
+ when :applicationintent
17
+ /applicationintent\s*\=\s*[^;]*(;)?/i
18
+ else
19
+ raise "#{part} is not a supported connection string part!"
20
+ end
21
+ end
22
+
23
+ def connection_string_part(connection_string, part, value_only: true) # options: :server, :database, :credentials, :readonly
24
+ raise 'Connection string provided is nil or empty!' if connection_string.nil? || connection_string.empty?
25
+ case part
26
+ when :server, :database, :applicationintent
27
+ connection_string[connection_string_part_regex(part)]
28
+ when :user, :password
29
+ credentials = connection_string_part(connection_string, :credentials)
30
+ return nil if credentials.nil?
31
+ return credentials[part]
32
+ when :credentials # array of user/password or integrated security
33
+ connection_string[connection_string_part_regex(:user)]
34
+ user = Regexp.last_match(3)
35
+ connection_string[connection_string_part_regex(:password)]
36
+ password = Regexp.last_match(3)
37
+ return { user: user, password: password } unless user.nil? || password.nil?
38
+ return connection_string[connection_string_part_regex(:integrated)]
39
+ end
40
+ return Regexp.last_match(3) if value_only
41
+ result = (Regexp.last_match(1) || '') + '=' + (Regexp.last_match(3) || '') + (Regexp.last_match(4) || '')
42
+ result.empty? ? nil : result
43
+ end
44
+
45
+ def remove_connection_string_part(connection_string, part) # part options: :server, :database, :credentials, :applicationintent
46
+ connection_string_new = connection_string.dup
47
+ parts = part == :credentials ? [:user, :password, :integrated] : [part]
48
+ parts.each { |p| connection_string_new.gsub!(connection_string_part_regex(p), '') } # unless full_part.nil? }
49
+ connection_string_new
50
+ end
51
+
52
+ def hide_connection_string_password(connection_string)
53
+ credentials = connection_string_part(connection_string, :credentials)
54
+ credentials[:password] = credentials[:password].gsub(/(.)([^(.$)]*)(.$)/) { Regexp.last_match(1) + ('*' * Regexp.last_match(2).length) + Regexp.last_match(3) } if credentials.is_a?(Hash) && !credentials[:password].nil? && credentials[:password].length > 2
55
+ raise "Connection string missing authentication information! Connection string: '#{connection_string}'" if credentials.nil? || credentials.empty?
56
+ replace_connection_string_part(connection_string, :credentials, credentials)
57
+ end
58
+
59
+ def unify_start_time(start_time, timezone: SqlCmd.config['environment']['timezone'])
60
+ return EasyTime.at_timezone(Time.now, timezone) if start_time.nil? || start_time.to_s.strip.empty?
61
+ return EasyTime.at_timezone(start_time, timezone) if start_time.is_a?(Time)
62
+ EasyTime.stomp_timezone(start_time, timezone) # Stomp the timezone with the config timezone. If no start_time was provided, use the current time
63
+ end
64
+
65
+ # Supply entire value (EG: 'user id=value;password=value;' or 'integrated security=SSPI;') as the replacement_value if the part is :credentials
66
+ # or provide a hash containing a username and password { user: 'someuser', password: 'somepassword', }
67
+ def replace_connection_string_part(connection_string, part, replacement_value)
68
+ EasyFormat.validate_parameters(method(__method__), binding)
69
+ new_connection_string = remove_connection_string_part(connection_string, part)
70
+ new_connection_string = case part
71
+ when :credentials
72
+ replacement_value = "User Id=#{replacement_value[:user]};Password=#{replacement_value[:password]}" if replacement_value.is_a?(Hash)
73
+ "#{new_connection_string};#{replacement_value}"
74
+ else
75
+ "#{part}=#{replacement_value};#{new_connection_string};"
76
+ end
77
+ new_connection_string.gsub!(/;+/, ';')
78
+ new_connection_string
79
+ end
80
+
81
+ # converts a path to unc_path if it contains a drive letter. Uses the server name provided
82
+ def to_unc_path(path, server_name)
83
+ return nil if path.nil? || path.empty? || server_name.nil? || server_name.empty?
84
+ path.gsub(/(\p{L})+(:\\)/i) { "\\\\#{server_name}\\#{Regexp.last_match(1)}$\\" } # replace local paths with network paths
85
+ end
86
+
87
+ # get the basename of the backup based on a full file_path such as the SQL value from [backupmediafamily].[physical_device_name]
88
+ def backup_basename(backup_path)
89
+ return nil if backup_path.nil? || backup_path.empty?
90
+ ::File.basename(backup_path).gsub(/(\.part\d+)?\.(bak|trn)/i, '')
91
+ end
92
+
93
+ def connection_string_credentials_from_hash(**credentials_hash)
94
+ credentials_hash = Hashly.symbolize_all_keys(credentials_hash.dup)
95
+ windows_authentication = credentials_hash[:windows_authentication]
96
+ windows_authentication = credentials_hash[:user].nil? || credentials_hash[:password].nil? if windows_authentication.nil? # If windows authentication wasn't specified, set it if user or pass is nil
97
+ windows_authentication ? 'integrated security=SSPI;' : "user id=#{credentials_hash[:user]};password=#{credentials_hash[:password]}"
98
+ end
99
+
100
+ # generates a connection string from the hash provided. Example hash: { 'server' => 'someservername', 'database' => 'somedb', 'user' => 'someuser', 'password' => 'somepass', 'windows_authentication' => false }
101
+ def connection_string_from_hash(**connection_hash)
102
+ connection_hash = Hashly.symbolize_all_keys(connection_hash.dup)
103
+ credentials_segment = connection_string_credentials_from_hash(**connection_hash)
104
+ database_name = connection_hash[:database]
105
+ database_segment = database_name.nil? || database_name.strip.empty? ? '' : "database=#{database_name};"
106
+ "server=#{connection_hash[:server]};#{database_segment}#{credentials_segment}"
107
+ end
108
+
109
+ # Ensures a connection string is using integrated security instead of SQL Authentication.
110
+ def to_integrated_security(connection_string, server_only: false)
111
+ raise 'Failed to convert connection string to integrated security. Connection string is nil!' if connection_string.nil?
112
+ parts = connection_string.split(';')
113
+ new_connection_string = ''
114
+ ommitted_parts = ['user id', 'uid', 'password', 'pwd', 'integrated security', 'trusted_connection']
115
+ ommitted_parts += ['database', 'initial catalog'] if server_only
116
+ parts.each { |part| new_connection_string << "#{part};" unless part.downcase.strip.start_with?(*ommitted_parts) } # only keep parts not omitted
117
+ "#{new_connection_string}Integrated Security=SSPI;"
118
+ end
119
+
120
+ # Convert encoding of a string to utf8
121
+ def to_utf8(text)
122
+ text.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
123
+ end
124
+ end
@@ -0,0 +1,350 @@
1
+ module SqlCmd
2
+ @scripts_cache = "#{SqlCmd.config['paths']['cache']}/sql_cmd/scripts"
3
+ @scripts_cache_windows = @scripts_cache.tr('\\', '/')
4
+
5
+ module_function
6
+
7
+ # Execute a SQL query and return a dataset, table, row, or scalar, based on the return_type specified
8
+ # return_type: :all_tables, :first_table, :first_row, :scalar
9
+ # values: hash of values to replace sqlcmd style variables.
10
+ # EG: sql_query = SELECT * FROM $(databasename)
11
+ # values = { 'databasename' => 'my_database_name' }
12
+ # readonly: if true, sets the connection_string to readonly (Useful with AOAG)
13
+ # retries: number of times to re-attempt failed queries
14
+ # retry_delay: how many seconds to wait between retries
15
+ def execute_query(connection_string, sql_query, return_type: :all_tables, values: nil, timeout: 172_800, readonly: false, ignore_missing_values: false, at_timezone: SqlCmd.config['environment']['timezone'], string_to_time_by: '%Y-%m-%d %H:%M:%S %z', retries: 0, retry_delay: 5)
16
+ sql_query = insert_values(sql_query, values) unless values.nil?
17
+ missing_values = sql_query.reverse.scan(/(\)[0-9a-z_]+\(\$)(?!.*--)/i).uniq.join(' ,').reverse # Don't include commented variables
18
+ raise "sql_query has missing variables! Ensure that values are supplied for: #{missing_values}\n" unless missing_values.empty? || ignore_missing_values
19
+ connection_string_updated = connection_string.dup
20
+ connection_string_updated = replace_connection_string_part(connection_string, :applicationintent, 'readonly') if readonly
21
+ raise 'Connection string is nil or incomplete' if connection_string_updated.nil? || connection_string_updated.empty?
22
+ EasyIO.logger.debug "Executing query with connection_string: \n\t#{hide_connection_string_password(connection_string_updated)}"
23
+ EasyIO.logger.debug sql_query if SqlCmd.config['logging']['verbose'] && sql_query.length < 8096
24
+ start_time = Time.now.utc.strftime('%y%m%d_%H%M%S-%L')
25
+ ps_script = <<-EOS.strip
26
+ . "#{@scripts_cache_windows}\\sql_helper_#{start_time}.ps1"
27
+
28
+ $connectionString = '#{connection_string_updated}'
29
+ $sqlCommand = '#{sql_query.gsub('\'', '\'\'')}'
30
+ $dataset = Invoke-SQL -timeout #{timeout} -connectionString $connectionString -sqlCommand $sqlCommand
31
+ ConvertSqlDatasetTo-Json -dataset $dataset
32
+ EOS
33
+
34
+ ps_script_file = "#{@scripts_cache}/ps_script-thread_id-#{Thread.current.object_id}.ps1"
35
+ FileUtils.mkdir_p ::File.dirname(ps_script_file)
36
+ FileUtils.cp(SqlCmd.config['sql_cmd']['paths']['powershell_helper_script'], "#{@scripts_cache}/sql_helper_#{start_time}.ps1")
37
+ ::File.write(ps_script_file, ps_script)
38
+ retry_count = 0
39
+ begin
40
+ result = ''
41
+ exit_status = ''
42
+ Open3.popen3("powershell -File \"#{ps_script_file}\"") do |_stdin, stdout, stderr, wait_thread|
43
+ buffers = [stdout, stderr]
44
+ queued_buffers = IO.select(buffers) || [[]]
45
+ queued_buffers.first.each do |buffer|
46
+ case buffer
47
+ when stdout
48
+ while (line = buffer.gets)
49
+ stdout_split = line.split('#return_data#:')
50
+ raise "SQL exception: #{stdout_split.first} #{stdout.read} #{stderr.read}\nConnectionString: '#{hide_connection_string_password(connection_string)}'\n#{EasyIO::Terminal.line('=')}\n" if stdout_split.first =~ /error 50000, severity (1[1-9]|2[0-5])/i
51
+ EasyIO.logger.info "SQL message: #{stdout_split.first.strip}" unless stdout_split.first.empty?
52
+ result = stdout_split.last + buffer.read if stdout_split.count > 1
53
+ end
54
+ when stderr
55
+ error_message = stderr.read
56
+ raise "SQL exception: #{error_message}\nConnectionString: '#{hide_connection_string_password(connection_string)}'\n#{'=' * 120}\n" unless error_message.empty?
57
+ end
58
+ end
59
+ exit_status = wait_thread.value
60
+ end
61
+ EasyIO.logger.debug "Script exit status: #{exit_status}"
62
+ EasyIO.logger.debug "JSON result: #{result}"
63
+ rescue
64
+ retry_message = 'Executing SQL query failed! '
65
+ retry_message += if retries == 0
66
+ 'No retries specified. Will not reattempt. '
67
+ else
68
+ retry_count < retries ? "Retry #{(retry_count + 1)} of #{retries}" : "All #{retries} retries attempted."
69
+ end
70
+ EasyIO.logger.info retry_message
71
+ if (retry_count += 1) <= retries
72
+ EasyIO.logger.info "Retrying in #{retry_delay} seconds..."
73
+ sleep(retry_delay)
74
+ retry
75
+ end
76
+ raise
77
+ end
78
+
79
+ begin
80
+ convert_powershell_tables_to_hash(result, return_type, at_timezone: at_timezone, string_to_time_by: string_to_time_by)
81
+ rescue # Change it to use terminal size instead of 120 chars in the error here and above
82
+ EasyIO.logger.fatal "Failed to convert SQL data to hash! ConnectionString: '#{hide_connection_string_password(connection_string)}'\n#{EasyIO::Terminal.line('=')}\n"
83
+ raise
84
+ end
85
+ ensure
86
+ ::File.delete "#{@scripts_cache_windows}\\sql_helper_#{start_time}.ps1" if defined?(start_time) && ::File.exist?("#{@scripts_cache_windows}\\sql_helper_#{start_time}.ps1")
87
+ end
88
+
89
+ def convert_powershell_tables_to_hash(json_string, return_type = :all_tables, at_timezone: 'UTC', string_to_time_by: '%Y-%m-%d %H:%M:%S %z') # options: :all_tables, :first_table, :first_row
90
+ EasyIO.logger.debug "Output from sql command: #{json_string}" if SqlCmd.config['logging']['verbose']
91
+ parsed_json = JSON.parse(to_utf8(json_string.sub(/[^{\[]*/, ''))) # Ignore any leading characters other than '{' or '['
92
+ timezone_table = parsed_json.delete(parsed_json.keys.last)
93
+ sqlserver_timezone = timezone_table.first.values.first # The last table should be the SQL server's time zone - get the value and remove it from the dataset
94
+ result_hash = if json_string.empty?
95
+ {}
96
+ else
97
+ convert_powershell_time_objects(parsed_json, at_timezone: at_timezone, string_to_time_by: string_to_time_by, timezone_override: sqlserver_timezone)
98
+ end
99
+
100
+ raise 'No tables were returned by specified sql query!' if result_hash.values.first.nil? && return_type != :all_tables
101
+ case return_type
102
+ when :first_table
103
+ result_hash.values.first
104
+ when :first_row
105
+ result_hash.values.first.first
106
+ when :scalar
107
+ return nil if result_hash.values.first.first.nil?
108
+ result_hash.values.first.first.values.first # Return first column of first row of first table
109
+ else
110
+ result_hash.values
111
+ end
112
+ end
113
+
114
+ # at_timezone: convert all times to this time zone
115
+ # timezone_override: overwrite the timezone on the time without changing the timestamp value - this occurs before :at_timezone
116
+ # string_to_time_by: Provide the format of the string to be parsed - see https://ruby-doc.org/stdlib-2.4.1/libdoc/time/rdoc/Time.html#method-c-strptime
117
+ # - Set it to nil or false to not parse any strings
118
+ def convert_powershell_time_objects(value, at_timezone: 'UTC', string_to_time_by: '%Y-%m-%d %H:%M:%S %z', timezone_override: nil, override_strings: false, utc_column: false)
119
+ case value
120
+ when Array
121
+ value.map { |v| convert_powershell_time_objects(v, at_timezone: at_timezone, string_to_time_by: string_to_time_by, timezone_override: timezone_override, utc_column: utc_column) }
122
+ when Hash
123
+ Hash[value.map { |k, v| [k, convert_powershell_time_objects(v, at_timezone: at_timezone, string_to_time_by: string_to_time_by, timezone_override: timezone_override, utc_column: k =~ /utc/i)] }]
124
+ else
125
+ timezone_override = 'UTC' if utc_column
126
+ return value unless value.is_a?(String)
127
+ time_from_js = EasyTime.from_javascript_format(value)
128
+ if time_from_js # A java script time was found
129
+ value = EasyTime.stomp_timezone(time_from_js, timezone_override)
130
+ at_timezone ? EasyTime.at_timezone(value, at_timezone) : value
131
+ else
132
+ return value unless string_to_time_by
133
+ begin
134
+ value = Time.strptime(value, string_to_time_by)
135
+ value = EasyTime.stomp_timezone(value, timezone_override) if override_strings
136
+ at_timezone ? EasyTime.at_timezone(value, at_timezone) : value
137
+ rescue ArgumentError # If an ArgumentError is thrown, the string was not in the :string_to_time_by format expected so it's probably not a time. Return it as is.
138
+ value
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ def connection_string_accessible?(connection_string, suppress_failure: true, retries: 3, retry_delay: 5)
145
+ sql_script = 'SELECT @@SERVERNAME AS [ServerName]'
146
+ !execute_query(connection_string, sql_script, return_type: :scalar, retries: retries, retry_delay: retry_delay).nil?
147
+ rescue
148
+ raise unless suppress_failure
149
+ false
150
+ end
151
+
152
+ def get_sql_server_settings(connection_string, retries: 3, retry_delay: 15)
153
+ get_sql_settings_script = ::File.read("#{sql_script_dir}/Status/SQLSettings.sql")
154
+ sql_server_settings = execute_query(SqlCmd.remove_connection_string_part(connection_string, :database), get_sql_settings_script, return_type: :first_row, retries: retries, retry_delay: retry_delay)
155
+ EasyIO.logger.debug "sql_server_settings: \n#{JSON.pretty_generate(sql_server_settings)}"
156
+ return nil if sql_server_settings.nil? || sql_server_settings['ServerName'].nil?
157
+
158
+ direct_connection_string = connection_string.gsub(sql_server_settings['DataSource'], sql_server_settings['ServerName'])
159
+ application_connection_string = connection_string.gsub(sql_server_settings['ServerName'], sql_server_settings['DataSource'])
160
+ secondary_replica_connection_string = if sql_server_settings['SecondaryReplica'].nil?
161
+ nil
162
+ else
163
+ cnstr = SqlCmd.replace_connection_string_part(connection_string, :server, sql_server_settings['SecondaryReplica'])
164
+ SqlCmd.remove_connection_string_part(cnstr, :database)
165
+ end
166
+ sql_server_settings['direct_connection_string'] = direct_connection_string # Does not use AlwaysOn listener
167
+ sql_server_settings['connection_string'] = application_connection_string
168
+ sql_server_settings['secondary_replica_connection_string'] = secondary_replica_connection_string
169
+ sql_server_settings['DataDir'] = EasyFormat::Directory.ensure_trailing_slash(sql_server_settings['DataDir'])
170
+ sql_server_settings['LogDir'] = EasyFormat::Directory.ensure_trailing_slash(sql_server_settings['LogDir'])
171
+ sql_server_settings['BackupDir'] = EasyFormat::Directory.ensure_trailing_slash(sql_server_settings['BackupDir'])
172
+ sql_server_settings
173
+ end
174
+
175
+ # Substitute sqlcmd style variables with values provided in a hash. Don't include $()
176
+ # Example values: 'varname' => 'Some value', 'varname2' => 'some other value'
177
+ def insert_values(sql_query, values, case_sensitive: false)
178
+ return sql_query if values.nil? || values.all? { |i, _j| i.nil? || i.empty? }
179
+
180
+ EasyIO.logger.debug "Inserting variable values into query: #{JSON.pretty_generate(values)}"
181
+ sql_query = to_utf8(sql_query)
182
+ values.each do |key, value|
183
+ regexp_key = case_sensitive ? "$(#{key})" : /\$\(#{Regexp.escape(key)}\)/i
184
+ sql_query.gsub!(regexp_key) { value }
185
+ end
186
+
187
+ sql_query
188
+ end
189
+
190
+ # Returns the database size or log size in MB
191
+ def get_database_size(connection_string, database_name, log_only: false, retries: 3, retry_delay: 15)
192
+ sql_script = ::File.read("#{sql_script_dir}/Status/DatabaseSize.sql")
193
+ connection_string = remove_connection_string_part(connection_string, :database)
194
+ execute_query(connection_string, sql_script, values: { 'databasename' => database_name, 'logonly' => log_only }, return_type: :scalar, readonly: true, retries: retries, retry_delay: retry_delay).to_f
195
+ end
196
+
197
+ # Returns a hash with the following fields: Available_MB, Total_MB, Percent_Free
198
+ def get_sql_disk_space(connection_string, target_folder, retries: 3, retry_delay: 15)
199
+ sql_script = ::File.read("#{sql_script_dir}/Status/DiskSpace.sql")
200
+ execute_query(connection_string, sql_script, return_type: :first_row, values: { 'targetfolder' => target_folder }, retries: retries, retry_delay: retry_delay)
201
+ end
202
+
203
+ def create_sql_login(connection_string, user, password, update_existing: false, retries: 3, retry_delay: 5)
204
+ sid_script = ::File.read("#{sql_script_dir}/Security/GetUserSID.sql")
205
+ raise "SQL password for login [#{user}] must not be empty!" if password.nil? || password.empty?
206
+ values = { 'user' => user, 'password' => password }
207
+ EasyIO.logger.info "Checking for existing SqlLogin: #{user}..."
208
+ login_sid = execute_query(connection_string, sid_script, return_type: :scalar, values: values, readonly: true, retries: retries, retry_delay: retry_delay)
209
+ if login_sid.nil?
210
+ sql_script = ::File.read("#{sql_script_dir}/Security/CreateSqlLogin.sql")
211
+ EasyIO.logger.info "Creating SqlLogin: #{user}..."
212
+ result = execute_query(connection_string, sql_script, return_type: :first_row, values: values, retries: retries, retry_delay: retry_delay)
213
+ raise "Failed to create SQL login: [#{user}]!" if result.nil? || result['name'].nil?
214
+ login_sid = result['sid']
215
+ elsif update_existing
216
+ sql_script = ::File.read("#{sql_script_dir}/Security/UpdateSqlPassword.sql")
217
+ EasyIO.logger.info "Login [#{user}] already exists... updating password."
218
+ execute_query(connection_string, sql_script, return_type: :first_row, values: values, retries: retries, retry_delay: retry_delay)
219
+ else
220
+ EasyIO.logger.info "Login [#{user}] already exists..."
221
+ end
222
+ EasyIO.logger.debug "SqlLogin [#{user}] sid: #{login_sid}"
223
+ login_sid
224
+ end
225
+
226
+ def sql_login_exists?(connection_string, login, retries: 3, retry_delay: 15)
227
+ sid_script = ::File.read("#{sql_script_dir}/Security/GetUserSID.sql")
228
+ raise "SQL password for login [#{login}] must not be empty!" if password.nil? || password.empty?
229
+ values = { 'user' => login, 'password' => password }
230
+ EasyIO.logger.info "Checking for existing SqlLogin: #{login}..."
231
+ execute_query(connection_string, sid_script, return_type: :scalar, values: values, readonly: true, retries: retries, retry_delay: retry_delay)
232
+ end
233
+
234
+ def migrate_logins(start_time, source_connection_string, destination_connection_string, database_name)
235
+ start_time = SqlCmd.unify_start_time(start_time)
236
+ import_script_filename = export_logins(start_time, source_connection_string, database_name)
237
+ if ::File.exist?(import_script_filename)
238
+ EasyIO.logger.info "Importing logins on [#{connection_string_part(destination_connection_string, :server)}]..."
239
+ execute_script_file(destination_connection_string, import_script_filename)
240
+ else
241
+ EasyIO.logger.warn 'Unable to migrate logins. Ensure they exist or manually create them.'
242
+ end
243
+ end
244
+
245
+ def export_logins(start_time, connection_string, database_name, remove_existing_logins: true)
246
+ start_time = SqlCmd.unify_start_time(start_time)
247
+ export_folder = "#{SqlCmd.config['paths']['cache']}/sql_cmd/logins"
248
+ server_name = connection_string_part(connection_string, :server)
249
+ import_script_filename = "#{export_folder}/#{EasyFormat::File.windows_friendly_name(server_name)}_#{database_name}_logins.sql"
250
+ if SqlCmd::Database.info(connection_string, database_name)['DatabaseNotFound']
251
+ warning_message = 'Source database was not found'
252
+ unless ::File.exist?(import_script_filename)
253
+ EasyIO.logger.warn "#{warning_message}. Unable to export logins! Ensure logins are migrated or migrate them manually!"
254
+ return import_script_filename
255
+ end
256
+
257
+ warning_message += if File.mtime(import_script_filename) >= start_time
258
+ ' but the import logins script file already exists. Proceeding...'
259
+ else
260
+ ' and the import logins script file is out of date. Ensure logins are migrated or migrate them manually!'
261
+ end
262
+ EasyIO.logger.warn warning_message
263
+ return import_script_filename # TODO: attempt to create logins anyway instead of returning a non-existent script file
264
+ end
265
+ sql_script = ::File.read("#{sql_script_dir}/Security/GenerateCreateLoginsScript.sql")
266
+ values = { 'databasename' => database_name, 'removeexistinglogins' => remove_existing_logins }
267
+ EasyIO.logger.info "Exporting logins associated with database: [#{database_name}] on [#{server_name}]..."
268
+ import_script = execute_query(connection_string, sql_script, return_type: :scalar, values: values, readonly: true, retries: 3)
269
+ return nil if import_script.nil? || import_script.empty?
270
+ FileUtils.mkdir_p(export_folder)
271
+ ::File.write(import_script_filename, import_script)
272
+ import_script_filename
273
+ end
274
+
275
+ def validate_logins_script(connection_string, database_name)
276
+ server_name = connection_string_part(connection_string, :server)
277
+ sql_script = ::File.read("#{sql_script_dir}/Security/GenerateValidateLoginsScript.sql")
278
+ values = { 'databasename' => database_name }
279
+ EasyIO.logger.debug "Creating validate logins script for logins associated with database: [#{database_name}] on [#{server_name}]..."
280
+ execute_query(connection_string, sql_script, return_type: :scalar, values: values, readonly: true, retries: 3)
281
+ end
282
+
283
+ def execute_script_file(connection_string, import_script_filename, values: nil, readonly: false, retries: 0, retry_delay: 15)
284
+ return nil if import_script_filename.nil? || import_script_filename.empty?
285
+ sql_script = ::File.read(import_script_filename)
286
+ execute_query(connection_string, sql_script, values: values, readonly: readonly, retries: retries, retry_delay: retry_delay)
287
+ end
288
+
289
+ def assign_database_roles(connection_string, database_name, user, roles = ['db_owner'], retries: 3, retry_delay: 5) # roles: array of database roles
290
+ values = { 'databasename' => database_name, 'user' => user, 'databaseroles' => roles.join(',') }
291
+ sql_script = ::File.read("#{sql_script_dir}/Security/AssignDatabaseRoles.sql")
292
+ EasyIO.logger.info "Assigning #{roles.join(', ')} access to [#{user}] for database [#{database_name}]..."
293
+ result = execute_query(connection_string, sql_script, return_type: :first_row, values: values, retries: retries, retry_delay: retry_delay)
294
+ raise "Failed to assign SQL database roles for user: [#{user}]!" if result.nil? || result['name'].nil?
295
+ result
296
+ end
297
+
298
+ def database_roles_assigned?(connection_string, database_name, user, roles, sid)
299
+ values = { 'databasename' => database_name, 'user' => user, 'sid' => sid }
300
+ validation_script = ::File.read("#{sql_script_dir}/Security/ValidateDatabaseRoles.sql")
301
+ validation_result = execute_query(connection_string, validation_script, return_type: :first_table, values: values, readonly: true, retries: 3)
302
+ roles.all? { |role| validation_result.any? { |row| row['name'].casecmp(role) == 0 } }
303
+ end
304
+
305
+ def run_sql_as_job(connection_string, sql_script, sql_job_name, sql_job_owner = 'sa', values: nil, retries: 0, retry_delay: 15)
306
+ sql_script = insert_values(sql_script, values) unless values.nil?
307
+ ensure_sql_agent_is_running(connection_string)
308
+ job_values = { 'sqlquery' => sql_script.gsub('\'', '\'\''),
309
+ 'jobname' => sql_job_name,
310
+ 'jobowner' => sql_job_owner }
311
+ sql_job_script = ::File.read("#{sql_script_dir}/Agent/CreateSQLJob.sql")
312
+ execute_query(connection_string, sql_job_script, values: job_values, retries: retries, retry_delay: retry_delay)
313
+ end
314
+
315
+ def ensure_sql_agent_is_running(connection_string)
316
+ sql_script = ::File.read("#{sql_script_dir}/Agent/SQLAgentStatus.sql")
317
+ sql_server_settings = get_sql_server_settings(connection_string)
318
+ raise "SQL Agent is not running on #{sql_server_settings['ServerName']}!" if execute_query(connection_string, sql_script, return_type: :scalar, retries: 3) == 0
319
+ end
320
+
321
+ def compress_all_tables(connection_string)
322
+ uncompressed_count_script = ::File.read("#{sql_script_dir}/Status/UncompressedTableCount.sql")
323
+ compress_tables_script = ::File.read("#{sql_script_dir}/Database/CompressAllTables.sql")
324
+ EasyIO.logger.info 'Checking for uncompressed tables...'
325
+ uncompressed_count = execute_query(connection_string, uncompressed_count_script, return_type: :scalar, retries: 3)
326
+ if uncompressed_count > 0
327
+ EasyIO.logger.info "Compressing #{uncompressed_count} tables..."
328
+ execute_query(connection_string, compress_tables_script)
329
+ EasyIO.logger.info 'Compression complete.'
330
+ else
331
+ EasyIO.logger.info 'No uncompressed tables.'
332
+ end
333
+ end
334
+
335
+ def update_sql_compatibility(connection_string, database_name, compatibility_level) # compatibility_level options: :sql_2008, :sql_2012, :sql_2016, :sql_2017, :sql_2019
336
+ sql_compatibility_script = ::File.read("#{sql_script_dir}/Database/SetSQLCompatibility.sql")
337
+ compatibility_levels =
338
+ {
339
+ sql_2008: 100,
340
+ sql_2012: 110,
341
+ sql_2014: 120,
342
+ sql_2016: 130,
343
+ sql_2017: 140,
344
+ sql_2019: 150,
345
+ }
346
+ values = { 'databasename' => database_name, 'compatibility_level' => compatibility_levels[compatibility_level] }
347
+ EasyIO.logger.info "Ensuring SQL compatibility is set to #{compatibility_level}..."
348
+ compatibility_result = execute_query(connection_string, sql_compatibility_script, return_type: :scalar, values: values, retries: 3)
349
+ end
350
+ end
@@ -0,0 +1,21 @@
1
+ module SqlCmd
2
+ module Security
3
+ module_function
4
+
5
+ # create a credential in SQL server
6
+ #
7
+ # options:
8
+ # verbose: Include an output message even if it's only updating an existing login
9
+ def create_credential(connection_string, name, identity, secret, options = {})
10
+ # TODO: don't update password if it hasn't changed
11
+ return if options['skip_credential_creation']
12
+ sql_script = ::File.read("#{SqlCmd.sql_script_dir}/Security/CreateOrUpdateCredential.sql")
13
+ raise 'name for credential must not be empty!' if name.nil? || name.empty?
14
+ raise "secret for credential [#{name}] must not be empty!" if secret.nil? || secret.empty?
15
+ identity ||= name
16
+ values = { 'credential_name' => name, 'identity' => identity, 'secret' => secret }
17
+ message = SqlCmd.execute_query(connection_string, sql_script, return_type: :scalar, values: values, readonly: true, retries: 3, retry_delay: 5)
18
+ EasyIO.logger.info message if message =~ /created/ || options['verbose']
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,89 @@
1
+ function Invoke-SQL {
2
+ param(
3
+ [string] $connectionString = $(throw "Please specify a connection string"),
4
+ [string] $sqlCommand = $(throw "Please specify a query."),
5
+ [string] $timeout = 172800
6
+ )
7
+
8
+ $sqlCommands = $sqlCommand -split "\n\s*GO\s*\n" # Split the query on each GO statement.
9
+ $connection = new-object system.data.SqlClient.SQLConnection($connectionString)
10
+ $handler = [System.Data.SqlClient.SqlInfoMessageEventHandler] { param($sender, $event) Write-host $event.Message }
11
+ $connection.add_InfoMessage($handler)
12
+ $connection.FireInfoMessageEventOnUserErrors = $true
13
+ $command = new-object system.data.sqlclient.sqlcommand($sqlCommand,$connection)
14
+ $command.CommandTimeout = $timeout
15
+ $adapter = New-Object System.Data.sqlclient.sqlDataAdapter $command
16
+ $tables = [System.Collections.ArrayList]@()
17
+ $tableCount = 0
18
+ $connection.Open()
19
+ $timeZoneCommand = 'BEGIN TRY
20
+ DECLARE @TimeZone VARCHAR(50)
21
+ EXEC MASTER.dbo.xp_regread ''HKEY_LOCAL_MACHINE'',''SYSTEM\CurrentControlSet\Control\TimeZoneInformation'',''TimeZoneKeyName'',@TimeZone OUT
22
+ SELECT * FROM (SELECT @TimeZone AS TimeZone) AS SQLServerTimeZone
23
+ END TRY
24
+ BEGIN CATCH
25
+ SELECT * FROM (SELECT ''FailedToReadTimeZone'' AS TimeZone) AS SQLServerTimeZone
26
+ END CATCH'
27
+
28
+ foreach ($sqlcmd in $sqlCommands) {
29
+ if ([string]::IsNullOrEmpty($sqlcmd)) { continue }
30
+ $command.CommandText = $sqlcmd
31
+ $dataset = New-Object System.Data.DataSet
32
+ $adapter.Fill($dataSet) | Out-Null
33
+ foreach ($table in $dataSet.Tables) {
34
+ $tableCount++
35
+ $table.TableName = "Table$tableCount"
36
+ $tables.Add($table) | Out-Null
37
+ }
38
+ }
39
+
40
+ # Get the server's time zone
41
+ $command.CommandText = $timeZoneCommand
42
+ $dataset = New-Object System.Data.DataSet
43
+ $adapter.Fill($dataSet) | Out-Null
44
+ foreach ($table in $dataSet.Tables) {
45
+ $tableCount++
46
+ $table.TableName = "Table$tableCount"
47
+ $tables.Add($table) | Out-Null
48
+ }
49
+
50
+ $connection.Close()
51
+ $tables
52
+ }
53
+
54
+ function ConvertSqlDatasetTo-Json {
55
+ param(
56
+ [object] $dataset = $(throw "Please specify a dataset")
57
+ )
58
+
59
+ $convertedTables = '#return_data#:{ '
60
+ foreach ($table in $dataset.DataSet.Tables) {
61
+ $convertedTable = ($table | select $table.Columns.ColumnName) | ConvertTo-Json -Compress
62
+ if (!$convertedTable) { $convertedTable = '[]' }
63
+ if (!$convertedTable.StartsWith('[')) { $convertedTable = "[ $convertedTable ]" } # Convert to Array if it's not
64
+ $convertedTables += '"' + $table.TableName + '": ' + $convertedTable + ','
65
+ }
66
+ $convertedTables.TrimEnd(',') + ' }'
67
+ }
68
+
69
+ function Build-ConnectionString{
70
+ param(
71
+ [string] $vip = $(throw "Please specify a server vip"),
72
+ [int] $port,
73
+ [string] $database = $(throw "Please specify a database"),
74
+ [string] $username,
75
+ [string] $password
76
+ )
77
+ $target = $vip
78
+ if($port -ne $null -and $port -ne 0){
79
+ $target = "$target,$port"
80
+ }
81
+ if(($username -ne "") -and ($password -ne "")){
82
+ $credentials = "Integrated Security=False;User ID=$username;Password=$password"
83
+ }
84
+ else{
85
+ Write-Warning "no credentials provided. falling back to integrated security"
86
+ $credentials = "Integrated Security=True;"
87
+ }
88
+ return "Data Source=$target; Initial Catalog=$database; $credentials"
89
+ }
data/lib/sql_cmd.rb ADDED
@@ -0,0 +1,44 @@
1
+ #
2
+ # Author:: Alex Munoz (<amunoz951@gmail.com>)
3
+ # Copyright:: Copyright (c) 2020 Alex Munoz
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ require 'time'
19
+ require 'easy_json_config'
20
+ require 'chef_bridge'
21
+ require 'easy_format'
22
+ require 'easy_io'
23
+ require 'zipr'
24
+ require 'json'
25
+ require 'open3'
26
+ require 'fileutils'
27
+ require 'easy_format'
28
+ require 'easy_time'
29
+ require 'hashly'
30
+
31
+ # SqlCmd Modules
32
+ require_relative 'sql_cmd/config'
33
+ require_relative 'sql_cmd/format'
34
+ require_relative 'sql_cmd/always_on'
35
+ require_relative 'sql_cmd/backups'
36
+ require_relative 'sql_cmd/database'
37
+ require_relative 'sql_cmd/agent'
38
+ require_relative 'sql_cmd/query'
39
+ require_relative 'sql_cmd/security'
40
+ require_relative 'sql_cmd/azure'
41
+ require_relative 'optional_dependencies'
42
+
43
+ # Assign globals
44
+ EasyTime.timezone = SqlCmd.config['environment']['timezone']