Skip to content
61 changes: 60 additions & 1 deletion .github/workflows/sql-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Install sqlcmd
- name: Install sqlcmd tools
run: |
# Ubuntu 24.04 runners have Microsoft repo pre-configured; avoid Signed-By conflicts
if ! grep -rql 'packages.microsoft.com' /etc/apt/sources.list.d/ 2>/dev/null; then
Expand All @@ -52,6 +52,29 @@ jobs:
sudo apt-get update
sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18

- name: Install go-sqlcmd (for the assertion harnesses)
env:
GH_TOKEN: ${{ github.token }}
run: |
# The Python assertion harnesses were written and validated against the
# go-based sqlcmd, not mssql-tools18, so install that to avoid
# output-parsing differences. It is not in the apt repo for these
# runners, so pull the latest linux-amd64 release binary.
set -euxo pipefail
assets=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/microsoft/go-sqlcmd/releases/latest \
| grep -oE 'https://[^"]+' | grep -iE 'linux[-_]amd64.*\.tar\.(bz2|gz)$')
echo "$assets"
url=$(echo "$assets" | head -1)
test -n "$url"
mkdir -p /tmp/gosqlcmd
curl -fsSL -o /tmp/gosqlcmd.tar "$url"
tar -xf /tmp/gosqlcmd.tar -C /tmp/gosqlcmd
bin=$(find /tmp/gosqlcmd -type f -name sqlcmd | head -1)
test -n "$bin"
sudo install "$bin" /usr/local/bin/sqlcmd
/usr/local/bin/sqlcmd --version

- name: Create test database
env:
SA_PASSWORD: CI_Test#2026!
Expand Down Expand Up @@ -105,3 +128,39 @@ jobs:
SA_PASSWORD: CI_Test#2026!
run: |
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -d DarlingData_CI_Test -i ".github/sql/test_basic_execution.sql"

# The harnesses connect to master and run EXECUTE dbo.sp_... there (as they
# do on a local box, where these procs live in master), so install the
# bundle into master too. The smoke tests above use DarlingData_CI_Test.
- name: Install procedures into master (for the assertion harnesses)
env:
SA_PASSWORD: CI_Test#2026!
run: |
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -d master -i "Install-All/DarlingData.sql"

# Assertion harnesses. These EXECUTE the output the procedures generate
# (findings, session DDL) rather than just running them, which is what
# catches the "reads correctly but is wrong when run" bugs the smoke tests
# above cannot. They are self-contained (build and drop their own scratch
# databases, sessions, and config); only PerfCheck and HumanEvents run
# here so far - IndexCleanup and ReproBuilder need a data source the CI
# containers do not have yet. They use the go-based sqlcmd via the
# SQLCMD_* environment overrides. Encryption is disabled (-N disable):
# the modern Go TLS stack rejects the SQL Server 2017 container's
# self-signed certificate outright, and -C (trust cert) does not bypass
# that; there is nothing to secure on a localhost throwaway container.
- name: sp_PerfCheck assertion harness
env:
SA_PASSWORD: CI_Test#2026!
SQLCMD_BIN: /usr/local/bin/sqlcmd
SQLCMD_CONN_ARGS: "-C -N disable"
run: |
python3 sp_PerfCheck/tests/run_tests.py --server localhost --password "$SA_PASSWORD"

- name: sp_HumanEvents assertion harness
env:
SA_PASSWORD: CI_Test#2026!
SQLCMD_BIN: /usr/local/bin/sqlcmd
SQLCMD_CONN_ARGS: "-C -N disable"
run: |
python3 sp_HumanEvents/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
17 changes: 15 additions & 2 deletions sp_HumanEvents/tests/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
"""

import argparse
import os
import re
import shlex
import subprocess
import sys

Expand All @@ -79,6 +81,17 @@ def find_sql_errors(text):

# ---------------------------------------------------------------- sqlcmd plumbing

def _sqlcmd_prefix():
"""The sqlcmd binary plus any connection args, overridable via environment so
one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the
go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN
at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
self-signed cert and disable encryption, since the modern Go TLS stack
rejects the SQL Server 2017 container's certificate outright."""
return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
os.environ.get("SQLCMD_CONN_ARGS", ""))


def _sqlcmd(server, password, sql, database="master",
query_timeout=None, subprocess_timeout=120):
"""Run a batch and return (stdout, stderr) decoded as UTF-8.
Expand All @@ -90,8 +103,8 @@ def _sqlcmd(server, password, sql, database="master",
cancels the batch server-side -- used to stop the never-ending collector
loop); subprocess_timeout is a hard backstop on the whole process.
"""
cmd = [
"sqlcmd", "-S", server, "-U", "sa", "-P", password,
cmd = _sqlcmd_prefix() + [
"-S", server, "-U", "sa", "-P", password,
"-d", database,
"-W", # trim trailing spaces
"-h", "-1", # no result-set headers
Expand Down
161 changes: 98 additions & 63 deletions sp_PerfCheck/sp_PerfCheck.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
SET ANSI_NULLS ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
Expand Down Expand Up @@ -1541,70 +1541,105 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(116, N'DBCC Event', N'Database'),
(137, N'Server Memory Change', N'Server');

/* Get relevant events from default trace */
INSERT INTO
#trace_events
(
event_time,
event_class,
event_subclass,
database_name,
database_id,
file_name,
object_name,
object_type,
duration_ms,
severity,
success,
error,
text_data,
file_growth,
is_auto,
spid
)
SELECT
event_time = t.StartTime,
event_class = t.EventClass,
event_subclass = t.EventSubClass,
database_name = DB_NAME(t.DatabaseID),
database_id = t.DatabaseID,
file_name = t.FileName,
object_name = t.ObjectName,
object_type = t.ObjectType,
duration_ms = t.Duration / 1000, /* Duration is in microseconds, convert to ms */
severity = t.Severity,
success = t.Success,
error = t.Error,
text_data = t.TextData,
file_growth = t.IntegerData, /* Size of growth in Data/Log Auto Grow event */
is_auto = t.IsSystem,
spid = t.SPID
FROM sys.fn_trace_gettable(@trace_path, DEFAULT) AS t
WHERE
(
/* Auto-grow and auto-shrink events */
t.EventClass IN (92, 93, 94, 95)
/* DBCC Events */
OR
/*
Get relevant events from the default trace. On Linux SQL Server
sys.traces still lists a default trace, but fn_trace_gettable cannot
read it there and raises Msg 19049; the same guard also covers a
missing or unreadable trace file. On failure, leave #trace_events
empty - the trace-based checks below then simply find nothing - and
record one informational note, rather than letting the error abort
the whole procedure before it returns any findings.
*/
BEGIN TRY
INSERT INTO
#trace_events
(
t.EventClass = 116
AND
(
t.TextData LIKE N'%FREEPROCCACHE%'
OR t.TextData LIKE N'%FREESYSTEMCACHE%'
OR t.TextData LIKE N'%DROPCLEANBUFFERS%'
OR t.TextData LIKE N'%SHRINKDATABASE%'
OR t.TextData LIKE N'%SHRINKFILE%'
OR t.TextData LIKE N'%WRITEPAGE%'
)
event_time,
event_class,
event_subclass,
database_name,
database_id,
file_name,
object_name,
object_type,
duration_ms,
severity,
success,
error,
text_data,
file_growth,
is_auto,
spid
)
/* Server memory change events */
OR t.EventClass = 137
/* Deadlock events - typically not in default trace but including for completeness */
OR t.EventClass = 148
)
/* Look back at the past 7 days of events at most */
AND t.StartTime > DATEADD(DAY, -7, SYSDATETIME());
SELECT
event_time = t.StartTime,
event_class = t.EventClass,
event_subclass = t.EventSubClass,
database_name = DB_NAME(t.DatabaseID),
database_id = t.DatabaseID,
file_name = t.FileName,
object_name = t.ObjectName,
object_type = t.ObjectType,
duration_ms = t.Duration / 1000, /* Duration is in microseconds, convert to ms */
severity = t.Severity,
success = t.Success,
error = t.Error,
text_data = t.TextData,
file_growth = t.IntegerData, /* Size of growth in Data/Log Auto Grow event */
is_auto = t.IsSystem,
spid = t.SPID
FROM sys.fn_trace_gettable(@trace_path, DEFAULT) AS t
WHERE
(
/* Auto-grow and auto-shrink events */
t.EventClass IN (92, 93, 94, 95)
/* DBCC Events */
OR
(
t.EventClass = 116
AND
(
t.TextData LIKE N'%FREEPROCCACHE%'
OR t.TextData LIKE N'%FREESYSTEMCACHE%'
OR t.TextData LIKE N'%DROPCLEANBUFFERS%'
OR t.TextData LIKE N'%SHRINKDATABASE%'
OR t.TextData LIKE N'%SHRINKFILE%'
OR t.TextData LIKE N'%WRITEPAGE%'
)
)
/* Server memory change events */
OR t.EventClass = 137
/* Deadlock events - typically not in default trace but including for completeness */
OR t.EventClass = 148
)
/* Look back at the past 7 days of events at most */
AND t.StartTime > DATEADD(DAY, -7, SYSDATETIME());
END TRY
BEGIN CATCH
INSERT INTO
#results
(
check_id,
priority,
category,
finding,
details,
url
)
VALUES
(
5004,
50, /* Informational: platform limitation, not a problem */
N'Default Trace',
N'Default Trace Not Readable',
N'The default trace is registered but could not be read (' +
ERROR_MESSAGE() +
N'). Its auto-grow, auto-shrink, and DBCC findings are ' +
N'unavailable. This is expected on Linux SQL Server, where ' +
N'the default trace cannot be read via fn_trace_gettable.',
N'https://erikdarling.com/sp_perfcheck/'
);
END CATCH;

/* Update event names from map */
UPDATE
Expand Down
25 changes: 23 additions & 2 deletions sp_PerfCheck/tests/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
"""

import argparse
import os
import re
import shlex
import subprocess
import sys

Expand Down Expand Up @@ -92,6 +94,17 @@ def find_sql_errors(text):
return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text)


def _sqlcmd_prefix():
"""The sqlcmd binary plus any connection args, overridable via environment so
one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the
go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN
at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
self-signed cert and disable encryption, since the modern Go TLS stack
rejects the SQL Server 2017 container's certificate outright."""
return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
os.environ.get("SQLCMD_CONN_ARGS", ""))


def _sqlcmd(server, password, sql, headers=True):
"""Run a batch and return (stdout, stderr) decoded as UTF-8.

Expand All @@ -100,8 +113,8 @@ def _sqlcmd(server, password, sql, headers=True):
that). Output is tab-delimited and trimmed (-W -s TAB) and the line width is
maxed (-w 65535) so wide rows are not wrapped mid-row.
"""
cmd = [
"sqlcmd", "-S", server, "-U", "sa", "-P", password,
cmd = _sqlcmd_prefix() + [
"-S", server, "-U", "sa", "-P", password,
"-d", "master",
"-W", # trim trailing spaces
"-w", "65535", # do not wrap wide rows
Expand Down Expand Up @@ -303,6 +316,14 @@ def structural_tests(server, password, R):
def forced_condition_tests(server, password, R):
"""Bidirectional forced-condition assertions on the check_id 1000
Non-Default Configuration check. Captures and restores exact originals."""
# Flush any pre-existing pending config change (value <> value_in_use) before
# baselining. Some images ship one: the SQL Server 2017 CI container has
# 'clr strict security' configured on but not yet in use. The harness's own
# RECONFIGURE calls would apply that staged change mid-run, and it would then
# surface as a spurious net change the harness never made. _sqlcmd does not
# raise on a SQL error, so a rejected RECONFIGURE is tolerated here.
_sqlcmd(server, password, "RECONFIGURE;", headers=False)

# Snapshot the entire config before touching anything.
before = snapshot_config(server, password)

Expand Down
Loading