sql_cmd 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/lib/optional_dependencies.rb +30 -0
- data/lib/sql_cmd/agent.rb +32 -0
- data/lib/sql_cmd/always_on.rb +267 -0
- data/lib/sql_cmd/azure.rb +80 -0
- data/lib/sql_cmd/backups.rb +276 -0
- data/lib/sql_cmd/config.rb +62 -0
- data/lib/sql_cmd/database.rb +618 -0
- data/lib/sql_cmd/format.rb +124 -0
- data/lib/sql_cmd/query.rb +350 -0
- data/lib/sql_cmd/security.rb +21 -0
- data/lib/sql_cmd/sql_helper.ps1 +89 -0
- data/lib/sql_cmd.rb +44 -0
- data/sql_scripts/Agent/CreateSQLJob.sql +81 -0
- data/sql_scripts/Agent/JobLastRunInfo.sql +70 -0
- data/sql_scripts/Agent/JobRunStatus.sql +21 -0
- data/sql_scripts/Agent/SQLAgentStatus.sql +8 -0
- data/sql_scripts/AlwaysOn/AddDatabaseToAvailabilityGroupOnSecondary.sql +72 -0
- data/sql_scripts/AlwaysOn/AddDatabaseToPrimaryAvailabilityGroup.sql +16 -0
- data/sql_scripts/AlwaysOn/AutomaticSeedingProgress.sql +34 -0
- data/sql_scripts/AlwaysOn/ConfigurePrimaryForAutomaticSeeding.sql +2 -0
- data/sql_scripts/AlwaysOn/ConfigurePrimaryForManualSeeding.sql +2 -0
- data/sql_scripts/AlwaysOn/ConfigureSecondaryForAutomaticSeeding.sql +1 -0
- data/sql_scripts/AlwaysOn/DropSecondary.sql +58 -0
- data/sql_scripts/AlwaysOn/RemoveDatabaseFromGroup.sql +2 -0
- data/sql_scripts/AlwaysOn/SynchronizationState.sql +14 -0
- data/sql_scripts/Database/BackupDatabase.sql +95 -0
- data/sql_scripts/Database/CompressAllTables.sql +100 -0
- data/sql_scripts/Database/CreateLogin.sql +16 -0
- data/sql_scripts/Database/DropDatabase.sql +51 -0
- data/sql_scripts/Database/GetBackupFiles.sql +31 -0
- data/sql_scripts/Database/GetBackupHeaders.sql +94 -0
- data/sql_scripts/Database/GetFileInfoFromBackup.sql +9 -0
- data/sql_scripts/Database/RestoreDatabase.sql +185 -0
- data/sql_scripts/Database/SetFullRecovery.sql +19 -0
- data/sql_scripts/Database/SetSQLCompatibility.sql +33 -0
- data/sql_scripts/Security/AssignDatabaseRoles.sql +44 -0
- data/sql_scripts/Security/CreateOrUpdateCredential.sql +11 -0
- data/sql_scripts/Security/CreateSqlLogin.sql +20 -0
- data/sql_scripts/Security/ExportDatabasePermissions.sql +757 -0
- data/sql_scripts/Security/GenerateCreateLoginsScript.sql +144 -0
- data/sql_scripts/Security/GenerateValidateLoginsScript.sql +83 -0
- data/sql_scripts/Security/GetUserSID.sql +3 -0
- data/sql_scripts/Security/UpdateSqlPassword.sql +24 -0
- data/sql_scripts/Security/ValidateDatabaseRoles.sql +12 -0
- data/sql_scripts/Status/ANSINullsOffTableCount.sql +13 -0
- data/sql_scripts/Status/ANSINullsOffTables.sql +9 -0
- data/sql_scripts/Status/BackupProgress.sql +17 -0
- data/sql_scripts/Status/DatabaseInfo.sql +199 -0
- data/sql_scripts/Status/DatabaseSize.sql +26 -0
- data/sql_scripts/Status/DiskSpace.sql +14 -0
- data/sql_scripts/Status/RestoreProgress.sql +17 -0
- data/sql_scripts/Status/SQLSettings.sql +182 -0
- data/sql_scripts/Status/UncompressedTableCount.sql +27 -0
- metadata +224 -0
@@ -0,0 +1,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']
|