diff --git a/README.md b/README.md index 4a4e436c..a7f6d6c3 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,37 @@ switches are most important to you to have implemented next in the new sqlcmd. - `:Connect` now has an optional `-G` parameter to select one of the authentication methods for Azure SQL Database - `SqlAuthentication`, `ActiveDirectoryDefault`, `ActiveDirectoryIntegrated`, `ActiveDirectoryServicePrincipal`, `ActiveDirectoryManagedIdentity`, `ActiveDirectoryPassword`. If `-G` is not provided, either Integrated security or SQL Authentication will be used, dependent on the presence of a `-U` username parameter. - The new `--driver-logging-level` command line parameter allows you to see traces from the `go-mssqldb` client driver. Use `64` to see all traces. - Sqlcmd can now print results using a vertical format. Use the new `--vertical` command line option to set it. It's also controlled by the `SQLCMDFORMAT` scripting variable. +- `:help` displays a list of available sqlcmd commands. +- `:serverlist` lists local SQL Server instances discovered via the SQL Server Browser service (UDP port 1434). The command queries the SQL Browser service and displays the server name and instance name for each discovered instance. If no instances are found or the Browser service cannot be reached, no output is produced. Other post-connect errors are printed to stderr. - Sqlcmd defaults to a horizontal output format (space separated, no borders). To use the new ASCII table format, use the new `--ascii` command line option or set `SQLCMDFORMAT` to `ascii` (`-v SQLCMDFORMAT=ascii`). Note that when using the ASCII table format, individual column widths are determined by the content, but the `SQLCMDCOLWIDTH` variable and the `-w` parameter are still used to control the maximum screen width, determining when columns wrap into separate table segments. The following variables are ignored: `SQLCMDMAXFIXEDTYPEWIDTH`, `SQLCMDMAXVARTYPEWIDTH`, and `SQLCMDHEADERS`. +``` +1> :serverlist + MYSERVER\SQL2019 + MYSERVER\SQL2022 +``` + +#### Using :serverlist in batch scripts + +When automating server discovery, capture output and errors separately: + +```batch +@echo off +REM Discover local SQL Server instances and connect to the first one +sqlcmd -Q ":serverlist" 2> errors.log > servers.txt +if exist errors.log for %%I in (errors.log) do if %%~zI gtr 0 ( + type errors.log + exit /b 1 +) +for /f "tokens=1" %%s in (servers.txt) do ( + echo Connecting to %%s... + sqlcmd -S %%s -Q "SELECT @@SERVERNAME" + goto :done +) +echo No SQL Server instances found +:done +``` + ``` 1> select session_id, client_interface_name, program_name from sys.dm_exec_sessions where session_id=@@spid 2> go diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index e0664955..a7769b58 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -5,20 +5,16 @@ package sqlcmd import ( - "context" "errors" "fmt" - "net" "os" "regexp" "runtime/trace" "strconv" "strings" - "time" mssql "github.com/microsoft/go-mssqldb" "github.com/microsoft/go-mssqldb/azuread" - "github.com/microsoft/go-mssqldb/msdsn" "github.com/microsoft/go-sqlcmd/internal/localizer" "github.com/microsoft/go-sqlcmd/pkg/console" "github.com/microsoft/go-sqlcmd/pkg/sqlcmd" @@ -239,10 +235,12 @@ func Execute(version string) { // emulate -L returning no servers if args.ListServers != "" { if args.ListServers != "c" { - fmt.Println() - fmt.Println(localizer.Sprintf("Servers:")) + _, _ = fmt.Fprint(os.Stdout, sqlcmd.SqlcmdEol) + _, _ = fmt.Fprintf(os.Stdout, "%s%s", localizer.Sprintf("Servers:"), sqlcmd.SqlcmdEol) + } + if err := sqlcmd.ListLocalServers(os.Stdout); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%v%s", err, sqlcmd.SqlcmdEol) } - listLocalServers() os.Exit(0) } if len(argss) > 0 { @@ -957,76 +955,3 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) { s.SetError(nil) return s.Exitcode, err } - -func listLocalServers() { - bmsg := []byte{byte(msdsn.BrowserAllInstances)} - resp := make([]byte, 16*1024-1) - dialer := &net.Dialer{} - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - conn, err := dialer.DialContext(ctx, "udp", ":1434") - // silently ignore failures to connect, same as ODBC - if err != nil { - return - } - defer conn.Close() - dl, _ := ctx.Deadline() - _ = conn.SetDeadline(dl) - _, err = conn.Write(bmsg) - if err != nil { - if !errors.Is(err, os.ErrDeadlineExceeded) { - fmt.Println(err) - } - return - } - read, err := conn.Read(resp) - if err != nil { - if !errors.Is(err, os.ErrDeadlineExceeded) { - fmt.Println(err) - } - return - } - - data := parseInstances(resp[:read]) - instances := make([]string, 0, len(data)) - for s := range data { - if s == "MSSQLSERVER" { - - instances = append(instances, "(local)", data[s]["ServerName"]) - } else { - instances = append(instances, fmt.Sprintf(`%s\%s`, data[s]["ServerName"], s)) - } - } - for _, s := range instances { - fmt.Println(" ", s) - } -} - -func parseInstances(msg []byte) msdsn.BrowserData { - results := msdsn.BrowserData{} - if len(msg) > 3 && msg[0] == 5 { - out_s := string(msg[3:]) - tokens := strings.Split(out_s, ";") - instdict := map[string]string{} - got_name := false - var name string - for _, token := range tokens { - if got_name { - instdict[name] = token - got_name = false - } else { - name = token - if len(name) == 0 { - if len(instdict) == 0 { - break - } - results[strings.ToUpper(instdict["InstanceName"])] = instdict - instdict = map[string]string{} - continue - } - got_name = true - } - } - } - return results -} diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 66dd1dba..3e87dcb9 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -113,6 +113,16 @@ func newCommands() Commands { action: xmlCommand, name: "XML", }, + "HELP": { + regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`), + action: helpCommand, + name: "HELP", + }, + "SERVERLIST": { + regex: regexp.MustCompile(`(?im)^[ \t]*:SERVERLIST(?:[ \t]+(.*$)|$)`), + action: serverlistCommand, + name: "SERVERLIST", + }, } } @@ -596,6 +606,71 @@ func xmlCommand(s *Sqlcmd, args []string, line uint) error { return nil } +// helpCommand displays the list of available sqlcmd commands +func helpCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("HELP", line) + } + helpText := `:!! [] + - Executes a command in the operating system shell. +:connect server[\instance] [-l timeout] [-U user [-P password]] [-D database] [-G authentication-method] + - Connects to a SQL Server instance. +:ed + - Edits the current or last executed statement cache. +:error + - Redirects error output to a file, stderr, or stdout. +:exit + - Quits sqlcmd immediately. +:exit() + - Execute statement cache; quit with no return value. +:exit() + - Execute the specified query; returns numeric result. +go [] + - Executes the statement cache (n times). +:help + - Shows this list of commands. +:list + - Prints the content of the statement cache. +:listvar + - Lists the set sqlcmd scripting variables. +:on error [exit|ignore] + - Action for batch or sqlcmd command errors. +:out |stderr|stdout + - Redirects query output to a file, stderr, or stdout. +:quit + - Quits sqlcmd immediately. +:r + - Append file contents to the statement cache. +:reset + - Discards the statement cache. +:serverlist + - Lists local SQL Server instances. +:setvar + - Removes a sqlcmd scripting variable. +:setvar + - Sets a sqlcmd scripting variable. +:xml [on|off] + - Sets XML output mode. +` + _, err := s.GetOutput().Write([]byte(helpText)) + return err +} + +// serverlistCommand lists locally available SQL Server instances +func serverlistCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("SERVERLIST", line) + } + if err := ListLocalServers(s.GetOutput()); err != nil { + errorOutput := s.err + if errorOutput == nil { + errorOutput = os.Stderr + } + s.WriteError(errorOutput, err) + } + return nil +} + func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) { var b *strings.Builder end := len(arg) diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 76c509a8..5f498534 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -54,6 +54,10 @@ func TestCommandParsing(t *testing.T) { {`:XML ON `, "XML", []string{`ON `}}, {`:RESET`, "RESET", []string{""}}, {`RESET`, "RESET", []string{""}}, + {`:HELP`, "HELP", []string{""}}, + {`:help`, "HELP", []string{""}}, + {`:SERVERLIST`, "SERVERLIST", []string{""}}, + {`:serverlist`, "SERVERLIST", []string{""}}, } for _, test := range commands { @@ -464,3 +468,30 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) { } } + +func TestHelpCommand(t *testing.T) { + v := InitializeVariables(false) + s := New(nil, "", v) + buf := &memoryBuffer{buf: new(bytes.Buffer)} + s.SetOutput(buf) + defer func() { _ = buf.Close() }() + + err := helpCommand(s, []string{""}, 1) + assert.NoError(t, err, "helpCommand should not error") + + output := buf.buf.String() + // Verify key commands are listed + assert.Contains(t, output, ":connect", "help should list :connect") + assert.Contains(t, output, ":exit", "help should list :exit") + assert.Contains(t, output, ":help", "help should list :help") + assert.Contains(t, output, ":setvar", "help should list :setvar") + assert.Contains(t, output, ":listvar", "help should list :listvar") + assert.Contains(t, output, ":out", "help should list :out") + assert.Contains(t, output, ":error", "help should list :error") + assert.Contains(t, output, ":r", "help should list :r") + assert.Contains(t, output, ":serverlist", "help should list :serverlist") + assert.Contains(t, output, "go []", "help should list go") + assert.Contains(t, output, `:connect server[\instance] [-l timeout] [-U user [-P password]] [-D database] [-G authentication-method]`) + assert.Contains(t, output, ":setvar ") + assert.NotContains(t, output, ":setvar {variable}") +} diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go new file mode 100644 index 00000000..435ae419 --- /dev/null +++ b/pkg/sqlcmd/serverlist.go @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package sqlcmd + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "sort" + "strings" + "syscall" + "time" + + "github.com/microsoft/go-mssqldb/msdsn" +) + +const serverListTimeout = 5 * time.Second + +var getLocalServerInstances = GetLocalServerInstances + +// ListLocalServers queries the SQL Browser service for available SQL Server instances +// and writes the results to the provided writer. +func ListLocalServers(w io.Writer) error { + instances, err := getLocalServerInstances() + if err != nil { + return err + } + for _, s := range instances { + if _, err := fmt.Fprintf(w, " %s%s", s, SqlcmdEol); err != nil { + return err + } + } + return nil +} + +// GetLocalServerInstances queries the SQL Browser service and returns a list of +// available SQL Server instances on the local machine. An unavailable Browser +// service returns no instances; other post-connect network failures are returned. +func GetLocalServerInstances() ([]string, error) { + bmsg := []byte{byte(msdsn.BrowserAllInstances)} + resp := make([]byte, 16*1024-1) + dialer := &net.Dialer{} + ctx, cancel := context.WithTimeout(context.Background(), serverListTimeout) + defer cancel() + conn, err := dialer.DialContext(ctx, "udp", ":1434") + // silently ignore failures to connect, same as ODBC + if err != nil { + return nil, nil + } + defer func() { _ = conn.Close() }() + dl, _ := ctx.Deadline() + _ = conn.SetDeadline(dl) + _, err = conn.Write(bmsg) + if err != nil { + if isBrowserUnavailableError(err) { + return nil, nil + } + return nil, err + } + read, err := conn.Read(resp) + if err != nil { + if isBrowserUnavailableError(err) { + return nil, nil + } + return nil, err + } + + data := parseInstances(resp[:read]) + return localServerInstanceNames(data), nil +} + +func isBrowserUnavailableError(err error) bool { + return errors.Is(err, os.ErrDeadlineExceeded) || + errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, syscall.ECONNRESET) +} + +func localServerInstanceNames(data msdsn.BrowserData) []string { + instances := make([]string, 0, len(data)) + + // Sort instance names for deterministic output + instanceNames := make([]string, 0, len(data)) + for s := range data { + instanceNames = append(instanceNames, s) + } + sort.Strings(instanceNames) + + for _, s := range instanceNames { + serverName := data[s]["ServerName"] + if serverName == "" { + // Skip instances without a ServerName + continue + } + if s == "MSSQLSERVER" { + instances = append(instances, serverName) + } else { + instances = append(instances, fmt.Sprintf(`%s\%s`, serverName, s)) + } + } + return instances +} + +func parseInstances(msg []byte) msdsn.BrowserData { + results := msdsn.BrowserData{} + if len(msg) > 3 && msg[0] == 5 { + outStr := string(msg[3:]) + tokens := strings.Split(outStr, ";") + instanceDict := map[string]string{} + gotName := false + var name string + addInstance := func() { + if instName, ok := instanceDict["InstanceName"]; ok && instName != "" { + results[strings.ToUpper(instName)] = instanceDict + } + } + for _, token := range tokens { + if gotName { + instanceDict[name] = token + gotName = false + } else { + name = token + if len(name) == 0 { + if len(instanceDict) == 0 { + break + } + // Skip malformed responses without a valid instance name. + addInstance() + instanceDict = map[string]string{} + continue + } + gotName = true + } + } + if !gotName && len(instanceDict) > 0 { + addInstance() + } + } + return results +} diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go new file mode 100644 index 00000000..276fe595 --- /dev/null +++ b/pkg/sqlcmd/serverlist_test.go @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package sqlcmd + +import ( + "bytes" + "errors" + "fmt" + "syscall" + "testing" + + "github.com/microsoft/go-mssqldb/msdsn" + "github.com/stretchr/testify/assert" +) + +type failingWriter struct { + err error +} + +func (w failingWriter) Write([]byte) (int, error) { + return 0, w.err +} + +func TestListLocalServers(t *testing.T) { + original := getLocalServerInstances + getLocalServerInstances = func() ([]string, error) { + return []string{`MYSERVER\SQL2019`, `MYSERVER\SQL2022`}, nil + } + defer func() { getLocalServerInstances = original }() + + var buf bytes.Buffer + + assert.NoError(t, ListLocalServers(&buf)) + assert.Equal(t, " MYSERVER\\SQL2019"+SqlcmdEol+" MYSERVER\\SQL2022"+SqlcmdEol, buf.String()) + + writeErr := errors.New("write failed") + assert.ErrorIs(t, ListLocalServers(failingWriter{err: writeErr}), writeErr) +} + +func TestParseInstances(t *testing.T) { + // Test parsing of SQL Browser response + // Format: 0x05 (response type), 2 bytes length, then alternating key;value tokens + // Each instance ends with two semicolons + + t.Run("empty response", func(t *testing.T) { + result := parseInstances([]byte{}) + assert.Empty(t, result) + }) + + t.Run("invalid header", func(t *testing.T) { + result := parseInstances([]byte{1, 0, 0}) + assert.Empty(t, result) + }) + + t.Run("valid single instance", func(t *testing.T) { + // Simulating SQL Browser response format + // Header: 0x05 followed by 2 length bytes, then the instance data + data := []byte{5, 0, 0} + instanceData := "ServerName;MYSERVER;InstanceName;MSSQLSERVER;IsClustered;No;Version;15.0.2000.5;tcp;1433;;" + data = append(data, []byte(instanceData)...) + + result := parseInstances(data) + assert.Len(t, result, 1) + assert.Contains(t, result, "MSSQLSERVER") + assert.Equal(t, "MYSERVER", result["MSSQLSERVER"]["ServerName"]) + assert.Equal(t, "1433", result["MSSQLSERVER"]["tcp"]) + }) + + t.Run("valid multiple instances", func(t *testing.T) { + data := []byte{5, 0, 0} + instanceData := "ServerName;MYSERVER;InstanceName;MSSQLSERVER;tcp;1433;;ServerName;MYSERVER;InstanceName;SQLEXPRESS;tcp;1434;;" + data = append(data, []byte(instanceData)...) + + result := parseInstances(data) + assert.Len(t, result, 2) + assert.Contains(t, result, "MSSQLSERVER") + assert.Contains(t, result, "SQLEXPRESS") + }) + + t.Run("missing final terminator", func(t *testing.T) { + data := append([]byte{5, 0, 0}, []byte("ServerName;MYSERVER;InstanceName;SQLEXPRESS;tcp;1434")...) + + result := parseInstances(data) + + assert.Equal(t, "MYSERVER", result["SQLEXPRESS"]["ServerName"]) + assert.Equal(t, "1434", result["SQLEXPRESS"]["tcp"]) + }) +} + +func TestLocalServerInstanceNamesSkipsMissingServerNames(t *testing.T) { + data := msdsn.BrowserData{ + "MISSING": {"InstanceName": "MISSING"}, + "EMPTY": {"ServerName": "", "InstanceName": "EMPTY"}, + "VALID": {"ServerName": "MYSERVER", "InstanceName": "VALID"}, + } + + assert.Equal(t, []string{`MYSERVER\VALID`}, localServerInstanceNames(data)) +} + +func TestIsBrowserUnavailableError(t *testing.T) { + assert.True(t, isBrowserUnavailableError(fmt.Errorf("browser unavailable: %w", syscall.ECONNREFUSED))) + assert.True(t, isBrowserUnavailableError(fmt.Errorf("browser unavailable: %w", syscall.ECONNRESET))) + assert.False(t, isBrowserUnavailableError(errors.New("network failure"))) +} + +func TestServerlistCommand(t *testing.T) { + original := getLocalServerInstances + getLocalServerInstances = func() ([]string, error) { + return []string{`MYSERVER\SQL2019`}, nil + } + defer func() { getLocalServerInstances = original }() + + v := InitializeVariables(false) + s := New(nil, "", v) + buf := &memoryBuffer{buf: new(bytes.Buffer)} + s.SetOutput(buf) + defer func() { _ = buf.Close() }() + + err := serverlistCommand(s, []string{""}, 1) + + assert.NoError(t, err) + assert.Equal(t, " MYSERVER\\SQL2019"+SqlcmdEol, buf.buf.String()) +} + +func TestServerlistCommandWritesErrors(t *testing.T) { + discoveryErr := errors.New("network failure") + original := getLocalServerInstances + getLocalServerInstances = func() ([]string, error) { + return nil, discoveryErr + } + defer func() { getLocalServerInstances = original }() + + s := New(nil, "", InitializeVariables(false)) + errBuf := &memoryBuffer{buf: new(bytes.Buffer)} + s.SetError(errBuf) + defer func() { _ = errBuf.Close() }() + + assert.NoError(t, serverlistCommand(s, []string{""}, 1)) + assert.Equal(t, discoveryErr.Error()+SqlcmdEol, errBuf.buf.String()) +}