Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ jobs:
- buildlint
- test
strategy:
# let every job finish so a failure on one runner doesn't hide the outcome (and hardware) of the others
fail-fast: false
matrix:
os: [ 'windows-2022', 'windows-2025' ]
sqlserver: [ 'sql-latest', 'sql-2025', 'sql-2022', 'sql-2019', 'sql-2017', 'sql-2016' ]
Expand All @@ -133,13 +135,43 @@ jobs:
uses: actions/checkout@v7
with:
persist-credentials: false
# SQL Server can't start on a volume reporting a sector size over 4 KB, so record the runner's hardware to
# compare against any setup failures
- name: Log runner hardware
continue-on-error: true
shell: pwsh
# write everything straight to the host so it isn't reordered around the output of fsutil
run: |
try {
$vmSize = Invoke-RestMethod -Headers @{ Metadata = 'true' } -TimeoutSec 5 -Uri 'http://169.254.169.254/metadata/instance/compute/vmSize?api-version=2021-02-01&format=text'
} catch {
$vmSize = "unknown ($_)"
}
Write-Host "VM size: $vmSize"
Write-Host "CPU: $((Get-CimInstance Win32_Processor).Name)"
Get-PhysicalDisk | Format-Table -AutoSize FriendlyName, BusType, MediaType, LogicalSectorSize, PhysicalSectorSize | Out-Host
$forced = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\stornvme\Parameters\Device' -ErrorAction SilentlyContinue).ForcedPhysicalSectorSizeInBytes
Write-Host "ForcedPhysicalSectorSizeInBytes: $(if ($forced) { $forced } else { 'not set' })"
foreach ($volume in Get-Volume | Where-Object { $_.DriveLetter -and $_.DriveType -eq 'Fixed' } | Sort-Object DriveLetter) {
Write-Host "$($volume.DriveLetter):"
fsutil fsinfo sectorinfo "$($volume.DriveLetter):"
}
- name: Install sql server
uses: ./
with:
sqlserver-version: ${{ matrix.sqlserver }}
native-client-version: 11
odbc-version: 17
install-updates: true
- name: Upload SQL Server logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: sqlserver-logs-${{ matrix.os }}-${{ matrix.sqlserver }}-${{ github.run_attempt }}
path: |
C:/Program Files/Microsoft SQL Server/*/Setup Bootstrap/Log/
C:/Program Files/Microsoft SQL Server/MSSQL*/MSSQL/Log/
if-no-files-found: ignore
release:
name: Release
concurrency: release
Expand Down
6 changes: 3 additions & 3 deletions lib/main/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/main/index.js.map

Large diffs are not rendered by default.

50 changes: 44 additions & 6 deletions src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
downloadExeInstaller,
downloadSseiInstaller,
downloadUpdateInstaller,
gatherErrorLogs,
gatherInputs,
gatherSummaryFiles,
getOsVersion,
Expand Down Expand Up @@ -45,6 +46,35 @@ function findOrDownloadUpdates(config: VersionConfig): Promise<string> {
return downloadUpdateInstaller(config);
}

/**
* Decode the contents of a log file. Unlike the setup logs, the error logs are written as UTF-16LE, so use the
* byte order mark to pick the encoding.
*
* @param {Buffer} content
* @returns {string}
*/
function decodeLogFile(content: Buffer): string {
const encoding = content[0] === 0xff && content[1] === 0xfe ? 'utf-16le' : 'utf-8';
return new TextDecoder(encoding).decode(content);
}

/**
* Find log files to output. This runs after the installation, so failing to find them must not hide its outcome.
*
* @param {string} description
* @param {() => Promise<string[]>} find
* @returns {Promise<string[]>} The file paths, empty if they couldn't be found
*/
async function findLogFiles(description: string, find: () => Promise<string[]>): Promise<string[]> {
try {
return await find();
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
core.warning(`Unable to find ${description}: ${reason}`);
return [];
}
}

export default async function install() {
let threw = false;
const {
Expand Down Expand Up @@ -185,16 +215,24 @@ export default async function install() {
throw e;
} finally {
// For information purposes, output the summary.txt file
// if there was an error, then also fetch the detail.txt file and output that too
const files = await gatherSummaryFiles(threw || core.isDebug());
// read the files in parallel
const contents: [string, Buffer][] = await Promise.all(files.map((path) => readFile(path).then((content): [string, Buffer] => {
// if there was an error, then also fetch the detail.txt file and the instance's error logs (which
// record why the database engine failed to start) and output those too
const withDetail = threw || core.isDebug();
const files = await findLogFiles('SQL Server setup logs', () => gatherSummaryFiles(withDetail));
if (withDetail) {
files.push(...await findLogFiles('SQL Server error logs', () => gatherErrorLogs(instanceName)));
}
// read the files in parallel, a file that can't be read must not hide the outcome of the install
const contents = await Promise.all(files.map((path) => readFile(path).then((content): [string, Buffer] => {
return [path, content];
}, (error: unknown) => {
const reason = error instanceof Error ? error.message : String(error);
core.warning(`Unable to read ${path}: ${reason}`);
})));
// output the files sequentially
contents.forEach(([file, content]) => {
contents.filter((read) => read !== undefined).forEach(([file, content]) => {
core.startGroup(basename(file));
core.info(content.toString());
core.info(decodeLogFile(content));
core.endGroup();
});
}
Expand Down
85 changes: 84 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdtemp, readdir } from 'node:fs/promises';
import { basename, extname, dirname, join as joinPaths } from 'node:path';
import { basename, extname, dirname, join as joinPaths, win32 } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
Expand Down Expand Up @@ -406,3 +406,86 @@ export async function gatherSummaryFiles(withDetail: boolean = false): Promise<s
}) : undefined;
return summaryFiles.concat(detailFile ? [detailFile] : []);
}

/**
* Gather the error logs of an instance. Used after a failed installation to output the reason the Database
* Engine did not start, which is recorded here rather than in the setup logs.
*
* See: https://learn.microsoft.com/en-us/sql/tools/configuration-manager/viewing-the-sql-server-error-log
*
* @param {string} instanceName
* @returns {Promise<string[]>} The file paths, oldest log first
*/
export async function gatherErrorLogs(instanceName: string): Promise<string[]> {
// Each start of the engine moves the previous logs along to ERRORLOG.1, ERRORLOG.2, etc. Setup records where the
// current log is in the instance's startup parameters, which accounts for custom instance or data directories.
// Otherwise, glob the default instance directory, which includes the major version (eg: MSSQL16.MSSQLSERVER).
const errorLogPath = await findErrorLogPath(instanceName);
let errorLogs: string[];
if (errorLogPath) {
const logDirectory = win32.dirname(errorLogPath);
const logName = win32.basename(errorLogPath);
errorLogs = (await readdir(logDirectory))
.filter((file) => file.startsWith(logName) && /^(\.\d+)?$/.test(file.slice(logName.length)))
.map((file) => win32.join(logDirectory, file));
} else {
errorLogs = await glob.create(`C:/Program Files/Microsoft SQL Server/MSSQL[0-9]*.${instanceName}/MSSQL/Log/ERRORLOG*`)
.then((globber) => globber.glob());
}
if (errorLogs.length) {
core.debug(`Found error logs: ${errorLogs.join(', ')}`);
} else {
core.notice('No error logs found');
}
// order the logs so that the one from the most recent start of the engine comes last
const generation = (path: string) => Number(win32.extname(path).slice(1));
return errorLogs.sort((a, b) => generation(b) - generation(a));
}

/**
* Find the path of an instance's current error log from the startup parameters that setup records for it.
*
* See: https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/database-engine-service-startup-options
*
* @param {string} instanceName
* @returns {Promise<string | undefined>} The path, if the instance has been registered
*/
async function findErrorLogPath(instanceName: string): Promise<string | undefined> {
const instanceIds = await queryRegistry('HKLM\\SOFTWARE\\Microsoft\\Microsoft SQL Server\\Instance Names\\SQL');
const instanceId = instanceIds.get(instanceName);
if (!instanceId) {
return undefined;
}
const parameters = await queryRegistry(`HKLM\\SOFTWARE\\Microsoft\\Microsoft SQL Server\\${instanceId}\\MSSQLServer\\Parameters`);
// the parameters are stored as SQLArg0, SQLArg1, etc. and the error log is the one starting -e
for (const parameter of parameters.values()) {
const [, errorLogPath] = parameter.match(/^-e\s*(.+)$/) ?? [];
if (errorLogPath) {
return errorLogPath;
}
}
}

/**
* Read the values of a registry key.
*
* @param {string} key
* @returns {Promise<Map<string, string>>} The value names and their data, empty if the key doesn't exist
*/
async function queryRegistry(key: string): Promise<Map<string, string>> {
const { exitCode, stdout } = await exec.getExecOutput('reg', ['query', key], {
ignoreReturnCode: true,
silent: true,
});
const values = new Map<string, string>();
if (exitCode === 0) {
// values are listed beneath the key as "<name> <type> <data>"
for (const line of stdout.split(/\r?\n/)) {
const [, name, data] = line.match(/^\s+(\S+)\s+REG_\w+\s+(.*?)\s*$/) ?? [];
if (name) {
values.set(name, data);
}
}
}
return values;
}
96 changes: 95 additions & 1 deletion test/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const utils = {
gatherInputs: mock.fn(),
getOsVersion: mock.fn(async () => 2022 as number | null),
gatherSummaryFiles: mock.fn(async () => [] as string[]),
gatherErrorLogs: mock.fn(async () => [] as string[]),
downloadExeInstaller: mock.fn(async () => 'C:/tmp/exe/setup.exe'),
downloadBoxInstaller: mock.fn(async () => 'C:/tmp/box/setup.exe'),
downloadSseiInstaller: mock.fn(async () => 'C:/tmp/ssei/setup.exe'),
Expand Down Expand Up @@ -67,7 +68,7 @@ function resetAllMocks() {
core.startGroup, core.endGroup, core.group, core.isDebug,
exec.exec, tc.find, readFile,
installNativeClient, installOdbc,
utils.gatherInputs, utils.getOsVersion, utils.gatherSummaryFiles,
utils.gatherInputs, utils.getOsVersion, utils.gatherSummaryFiles, utils.gatherErrorLogs,
utils.downloadExeInstaller, utils.downloadBoxInstaller,
utils.downloadSseiInstaller, utils.downloadUpdateInstaller,
utils.waitForDatabase, utils.downloadTool,
Expand All @@ -85,9 +86,11 @@ describe('install', () => {
core.platform.platform = 'win32';
exec.exec.mock.mockImplementation(async () => 0);
tc.find.mock.mockImplementation(() => '');
readFile.mock.mockImplementation(async () => Buffer.from(''));
utils.gatherInputs.mock.mockImplementation(() => defaultInputs());
utils.getOsVersion.mock.mockImplementation(async () => 2022);
utils.gatherSummaryFiles.mock.mockImplementation(async () => []);
utils.gatherErrorLogs.mock.mockImplementation(async () => []);
utils.downloadExeInstaller.mock.mockImplementation(async () => 'C:/tmp/exe/setup.exe');
utils.downloadBoxInstaller.mock.mockImplementation(async () => 'C:/tmp/box/setup.exe');
utils.downloadSseiInstaller.mock.mockImplementation(async () => 'C:/tmp/ssei/setup.exe');
Expand Down Expand Up @@ -344,6 +347,97 @@ describe('install', () => {
assert.equal(utils.gatherSummaryFiles.mock.calls[0].arguments[0], true);
assert.ok(core.info.mock.calls.some((c) => c.arguments[0] === 'test data'));
});
it('does not fetch error logs after a successful install', async () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ wait: false }));
await install();
assert.equal(utils.gatherErrorLogs.mock.callCount(), 0);
});
it('fetches error logs after the summary files on errors', async () => {
readFile.mock.mockImplementation(async (path: string) => Buffer.from(`contents of ${path}`));
utils.gatherSummaryFiles.mock.mockImplementation(async () => ['C:/tmp/summary.txt', 'C:/tmp/detail.txt']);
utils.gatherErrorLogs.mock.mockImplementation(async () => ['C:/mssql/ERRORLOG.1', 'C:/mssql/ERRORLOG']);
exec.exec.mock.mockImplementation(async () => 1);
await assert.rejects(() => install(), { message: 'SQL Server setup failed with exit code 1' });
assert.equal(utils.gatherErrorLogs.mock.callCount(), 1);
assert.equal(utils.gatherErrorLogs.mock.calls[0].arguments[0], 'MSSQLSERVER');
assert.deepEqual(core.startGroup.mock.calls.map((c) => c.arguments[0]), [
'summary.txt',
'detail.txt',
'ERRORLOG.1',
'ERRORLOG',
]);
assert.ok(core.info.mock.calls.some((c) => c.arguments[0] === 'contents of C:/mssql/ERRORLOG'));
assert.equal(core.endGroup.mock.callCount(), 4);
});
it('fetches error logs during debug', async () => {
readFile.mock.mockImplementation(async () => Buffer.from('test data'));
utils.gatherErrorLogs.mock.mockImplementation(async () => ['C:/mssql/ERRORLOG']);
core.isDebug.mock.mockImplementation(() => true);
await install();
assert.equal(utils.gatherErrorLogs.mock.callCount(), 1);
assert.equal(core.startGroup.mock.calls.at(-1)?.arguments[0], 'ERRORLOG');
});
it('decodes UTF-16LE log files', async () => {
readFile.mock.mockImplementation(async () => Buffer.concat([
Buffer.from([0xff, 0xfe]),
Buffer.from('Server process ID is 1234.', 'utf16le'),
]));
utils.gatherErrorLogs.mock.mockImplementation(async () => ['C:/mssql/ERRORLOG']);
exec.exec.mock.mockImplementation(async () => { throw new Error('synthetic error'); });
await assert.rejects(() => install(), { message: 'synthetic error' });
assert.ok(core.info.mock.calls.some((c) => c.arguments[0] === 'Server process ID is 1234.'));
});
for (const error of [new Error('synthetic read error'), 'synthetic read error']) {
it(`warns about log files it cannot read with ${typeof error} and outputs the rest`, async () => {
readFile.mock.mockImplementation(async (path: string) => {
if (path === 'C:/mssql/ERRORLOG') {
throw error;
}
return Buffer.from('test data');
});
utils.gatherSummaryFiles.mock.mockImplementation(async () => ['C:/tmp/summary.txt']);
utils.gatherErrorLogs.mock.mockImplementation(async () => ['C:/mssql/ERRORLOG']);
exec.exec.mock.mockImplementation(async () => { throw new Error('synthetic error'); });
await assert.rejects(() => install(), { message: 'synthetic error' });
assert.equal(core.warning.mock.callCount(), 1);
assert.equal(core.warning.mock.calls[0].arguments[0], 'Unable to read C:/mssql/ERRORLOG: synthetic read error');
assert.deepEqual(core.startGroup.mock.calls.map((c) => c.arguments[0]), ['summary.txt']);
assert.ok(core.info.mock.calls.some((c) => c.arguments[0] === 'test data'));
});
}
for (const error of [new Error('synthetic glob error'), 'synthetic glob error']) {
it(`warns about setup logs it cannot find with ${typeof error} and outputs the error logs`, async () => {
readFile.mock.mockImplementation(async () => Buffer.from('test data'));
utils.gatherSummaryFiles.mock.mockImplementation(async () => { throw error; });
utils.gatherErrorLogs.mock.mockImplementation(async () => ['C:/mssql/ERRORLOG']);
exec.exec.mock.mockImplementation(async () => { throw new Error('synthetic error'); });
await assert.rejects(() => install(), { message: 'synthetic error' });
assert.deepEqual(core.warning.mock.calls.map((c) => c.arguments[0]), ['Unable to find SQL Server setup logs: synthetic glob error']);
assert.deepEqual(core.startGroup.mock.calls.map((c) => c.arguments[0]), ['ERRORLOG']);
});
}
it('warns about error logs it cannot find and outputs the setup logs', async () => {
readFile.mock.mockImplementation(async () => Buffer.from('test data'));
utils.gatherSummaryFiles.mock.mockImplementation(async () => ['C:/tmp/summary.txt']);
utils.gatherErrorLogs.mock.mockImplementation(async () => { throw new Error('synthetic registry error'); });
exec.exec.mock.mockImplementation(async () => { throw new Error('synthetic error'); });
await assert.rejects(() => install(), { message: 'synthetic error' });
assert.deepEqual(core.warning.mock.calls.map((c) => c.arguments[0]), ['Unable to find SQL Server error logs: synthetic registry error']);
assert.deepEqual(core.startGroup.mock.calls.map((c) => c.arguments[0]), ['summary.txt']);
});
it('still succeeds if it cannot find the setup logs', async () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ wait: false }));
utils.gatherSummaryFiles.mock.mockImplementation(async () => { throw new Error('synthetic glob error'); });
await install();
assert.deepEqual(core.warning.mock.calls.map((c) => c.arguments[0]), ['Unable to find SQL Server setup logs: synthetic glob error']);
});
it('still succeeds if it cannot find the error logs during debug', async () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ wait: false }));
core.isDebug.mock.mockImplementation(() => true);
utils.gatherErrorLogs.mock.mockImplementation(async () => { throw new Error('synthetic registry error'); });
await install();
assert.deepEqual(core.warning.mock.calls.map((c) => c.arguments[0]), ['Unable to find SQL Server error logs: synthetic registry error']);
});
it('installs native client if needed', async () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ wait: false, nativeClientVersion: '11' }));
await install();
Expand Down
Loading
Loading