diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml index 420f3118..11399427 100644 --- a/.github/workflows/sql-tests.yml +++ b/.github/workflows/sql-tests.yml @@ -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 @@ -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! @@ -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" diff --git a/sp_HumanEvents/tests/run_tests.py b/sp_HumanEvents/tests/run_tests.py index 69df0b59..b2e72ca2 100644 --- a/sp_HumanEvents/tests/run_tests.py +++ b/sp_HumanEvents/tests/run_tests.py @@ -53,7 +53,9 @@ """ import argparse +import os import re +import shlex import subprocess import sys @@ -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. @@ -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 diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql index b37244df..86c10332 100644 --- a/sp_PerfCheck/sp_PerfCheck.sql +++ b/sp_PerfCheck/sp_PerfCheck.sql @@ -1,4 +1,4 @@ -SET ANSI_NULLS ON; +SET ANSI_NULLS ON; SET ANSI_PADDING ON; SET ANSI_WARNINGS ON; SET ARITHABORT ON; @@ -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 diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py index 01c741ff..97db6da9 100644 --- a/sp_PerfCheck/tests/run_tests.py +++ b/sp_PerfCheck/tests/run_tests.py @@ -47,7 +47,9 @@ """ import argparse +import os import re +import shlex import subprocess import sys @@ -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. @@ -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 @@ -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)