diff --git a/pkg/standalone/standalone.go b/pkg/standalone/standalone.go index 2a1e81ca2..1de5bda44 100644 --- a/pkg/standalone/standalone.go +++ b/pkg/standalone/standalone.go @@ -706,7 +706,56 @@ func runSchedulerService(wg *sync.WaitGroup, errorChan chan<- error, info initIn args = append(args, "--etcd-client-listen-address=0.0.0.0") } + // On non-elevated Windows with WSL2 installed, verify the scheduler ports + // are free before attempting the container start, but only when the + // scheduler is publishing host ports. WSL2 commonly holds :2379 (etcd) + // and the only reliable fix requires an elevated terminal. + if info.dockerNetwork == "" && runtime.GOOS == daprWindowsOS && !isWindowsElevated() && isWSLAvailable() { + if portErr := checkSchedulerPorts(osPort); portErr != nil { + errorChan <- fmt.Errorf( + "failed to start scheduler service: %v\n\n"+ + "A required port is already in use (often due to WSL).\n"+ + "To resolve this, re-run 'dapr init' in an elevated (Administrator)\n"+ + "terminal (e.g. right-click → \"Run as administrator\"). When running\n"+ + "elevated, the CLI will automatically stop and restart WSL and\n"+ + "Windows networking services as part of the installation process", + portErr) + return + } + } + + // On elevated Windows with host-port publishing and WSL2 installed, shut + // down WSL2 and stop WinNAT so Docker can re-acquire the scheduler's port + // bindings (especially etcd :2379) that WSL2 may be holding. + // Skipped when using a Docker network (no host ports) or when WSL is not + // present, to avoid unnecessary service disruption. + winNATStopped := false + if info.dockerNetwork == "" && runtime.GOOS == daprWindowsOS && isWindowsElevated() && isWSLAvailable() { + print.InfoStatusEvent(os.Stdout, "Temporarily shutting down WSL to free ports for scheduler installation...") + if wslErr := shutdownWSL(); wslErr != nil { + print.WarningStatusEvent(os.Stdout, "Failed to shut down WSL: %v. Continuing...", wslErr) + } + print.InfoStatusEvent(os.Stdout, "Temporarily stopping Windows NAT service to free scheduler ports...") + if stopErr := stopWinNAT(); stopErr != nil { + print.WarningStatusEvent(os.Stdout, "Failed to stop Windows NAT service: %v. Continuing...", stopErr) + } else { + winNATStopped = true + } + } + _, err = utils.RunCmdAndWait(runtimeCmd, args...) + + // Restore WinNAT and restart WSL regardless of whether the scheduler container started successfully. + if info.dockerNetwork == "" && runtime.GOOS == daprWindowsOS && isWindowsElevated() && isWSLAvailable() { + if winNATStopped { + if startErr := startWinNAT(); startErr != nil { + print.WarningStatusEvent(os.Stdout, "Failed to restart Windows NAT service: %v", startErr) + } + } + print.InfoStatusEvent(os.Stdout, "Restarting WSL...") + startWSLBackground() + } + if err != nil { runError := isContainerRunError(err) if !runError { @@ -719,6 +768,24 @@ func runSchedulerService(wg *sync.WaitGroup, errorChan chan<- error, info initIn errorChan <- nil } +// checkSchedulerPorts verifies that all ports required by the scheduler +// service are available. grpcPort is the platform-specific gRPC port +// (50006 on Linux/Mac, 6060 on Windows). +func checkSchedulerPorts(grpcPort int) error { + return checkPorts(grpcPort, schedulerEtcdPort, schedulerHealthPort, schedulerMetricPort) +} + +// checkPorts returns an error for the first port in the list that is not +// available, including the port number in the message. +func checkPorts(ports ...int) error { + for _, p := range ports { + if err := utils.CheckIfPortAvailable(p); err != nil { + return fmt.Errorf("port %d is not available: %w", p, err) + } + } + return nil +} + func schedulerOverrideHostPort(info initInfo) bool { if info.runtimeVersion == "edge" || info.runtimeVersion == "dev" { return true diff --git a/pkg/standalone/wsl_nowindows.go b/pkg/standalone/wsl_nowindows.go new file mode 100644 index 000000000..2065e1d1e --- /dev/null +++ b/pkg/standalone/wsl_nowindows.go @@ -0,0 +1,34 @@ +//go:build !windows + +/* +Copyright 2021 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +// isWindowsElevated always returns false on non-Windows platforms. +func isWindowsElevated() bool { return false } + +// isWSLAvailable always returns false on non-Windows platforms. +func isWSLAvailable() bool { return false } + +// shutdownWSL is a no-op on non-Windows platforms. +func shutdownWSL() error { return nil } + +// stopWinNAT is a no-op on non-Windows platforms. +func stopWinNAT() error { return nil } + +// startWinNAT is a no-op on non-Windows platforms. +func startWinNAT() error { return nil } + +// startWSLBackground is a no-op on non-Windows platforms. +func startWSLBackground() {} diff --git a/pkg/standalone/wsl_nowindows_test.go b/pkg/standalone/wsl_nowindows_test.go new file mode 100644 index 000000000..86d5c6341 --- /dev/null +++ b/pkg/standalone/wsl_nowindows_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +/* +Copyright 2021 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestNoopStubs verifies that every non-Windows stub returns the correct +// zero/no-op value and does not panic. This guards against accidental breakage +// of the cross-platform build contract. +func TestNoopStubs(t *testing.T) { + assert.False(t, isWindowsElevated(), "isWindowsElevated must always be false on non-Windows") + assert.False(t, isWSLAvailable(), "isWSLAvailable must always be false on non-Windows") + assert.NoError(t, shutdownWSL()) + assert.NoError(t, stopWinNAT()) + assert.NoError(t, startWinNAT()) + startWSLBackground() // must not panic +} diff --git a/pkg/standalone/wsl_test.go b/pkg/standalone/wsl_test.go new file mode 100644 index 000000000..9c7be4018 --- /dev/null +++ b/pkg/standalone/wsl_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2021 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +import ( + "fmt" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCheckPorts exercises the core port-availability helper used by the +// Windows WSL2 port-conflict detection path. +func TestCheckPorts(t *testing.T) { + t.Run("returns nil when given no ports", func(t *testing.T) { + assert.NoError(t, checkPorts()) + }) + + t.Run("returns nil when all ports are free", func(t *testing.T) { + // Port 0 always passes CheckIfPortAvailable (the OS selects a free + // ephemeral port), so there is no bind/close race here. + assert.NoError(t, checkPorts(0, 0)) + }) + + t.Run("returns error containing port number when port is in use", func(t *testing.T) { + ln := holdPort(t) + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + err := checkPorts(port) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("port %d", port)) + }) + + t.Run("returns error for first occupied port in the list", func(t *testing.T) { + ln := holdPort(t) + defer ln.Close() + busy := ln.Addr().(*net.TCPAddr).Port + + // Port 0 is always free (OS picks an ephemeral port); busy comes second. + // We still expect failure once the busy port is reached. + err := checkPorts(0, busy) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("port %d", busy)) + }) + + t.Run("errors when the first port is occupied, ignoring a free port that follows", func(t *testing.T) { + ln := holdPort(t) + defer ln.Close() + busy := ln.Addr().(*net.TCPAddr).Port + + // busy comes first, port 0 (always free) follows — error must name busy only. + err := checkPorts(busy, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("port %d", busy)) + assert.NotContains(t, err.Error(), "port 0") + }) +} + +// TestCheckSchedulerPorts_PortInUse verifies that checkSchedulerPorts surfaces +// an error (with the port number) when the gRPC port it is given is already +// bound. This is the scenario triggered by WSL2 holding scheduler ports. +func TestCheckSchedulerPorts_PortInUse(t *testing.T) { + ln := holdPort(t) + defer ln.Close() + busyPort := ln.Addr().(*net.TCPAddr).Port + + err := checkSchedulerPorts(busyPort) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("port %d", busyPort)) +} + +// holdPort binds an OS-assigned port and returns the listener. The caller is +// responsible for closing it. Using ":0" matches the binding style of +// utils.CheckIfPortAvailable so the conflict is detected reliably. +func holdPort(t *testing.T) net.Listener { + t.Helper() + ln, err := net.Listen("tcp", ":0") + require.NoError(t, err) + return ln +} diff --git a/pkg/standalone/wsl_windows.go b/pkg/standalone/wsl_windows.go new file mode 100644 index 000000000..237f22fde --- /dev/null +++ b/pkg/standalone/wsl_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +/* +Copyright 2021 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +import ( + "os/exec" + + "golang.org/x/sys/windows" + + "github.com/dapr/cli/utils" +) + +// isWindowsElevated returns true if the current process is running with +// elevated (Administrator) privileges. +func isWindowsElevated() bool { + return windows.GetCurrentProcessToken().IsElevated() +} + +// isWSLAvailable returns true if the wsl executable is available in PATH. +func isWSLAvailable() bool { + _, err := exec.LookPath("wsl") + return err == nil +} + +// shutdownWSL runs `wsl --shutdown` to terminate the WSL2 instance and free any +// ports it holds. +func shutdownWSL() error { + _, err := utils.RunCmdAndWait("wsl", "--shutdown") + return err +} + +// stopWinNAT stops the Windows NAT driver service (WinNat) so that Docker +// can re-acquire port bindings that WinNAT was caching. +func stopWinNAT() error { + _, err := utils.RunCmdAndWait("net", "stop", "winnat") + return err +} + +// startWinNAT starts the Windows NAT driver service after the scheduler +// container has been created. +func startWinNAT() error { + _, err := utils.RunCmdAndWait("net", "start", "winnat") + return err +} + +// startWSLBackground starts WSL in the background to re-initialize WSL +// networking after a wsl --shutdown. We run a no-op command so the session +// exits immediately once WSL services are up, then wait in a goroutine to +// clean up the process handle. +func startWSLBackground() { + cmd := exec.Command("wsl", "--exec", "echo") + if err := cmd.Start(); err != nil { + return + } + go func() { _ = cmd.Wait() }() +} diff --git a/pkg/standalone/wsl_windows_test.go b/pkg/standalone/wsl_windows_test.go new file mode 100644 index 000000000..1c0de6cec --- /dev/null +++ b/pkg/standalone/wsl_windows_test.go @@ -0,0 +1,50 @@ +//go:build windows + +/* +Copyright 2021 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestIsWindowsElevated_Callable verifies the function completes without +// panicking. The actual return value depends on whether the test process is +// running as Administrator, so we only log it rather than assert a fixed value. +func TestIsWindowsElevated_Callable(t *testing.T) { + elevated := isWindowsElevated() + t.Logf("isWindowsElevated() = %v (test process running as Administrator: %v)", elevated, elevated) +} + +// TestIsWSLAvailable_MatchesLookPath verifies that isWSLAvailable reports the +// same result as exec.LookPath("wsl"), confirming it accurately reflects +// whether wsl.exe is on the PATH. +func TestIsWSLAvailable_MatchesLookPath(t *testing.T) { + _, err := exec.LookPath("wsl") + expected := err == nil + assert.Equal(t, expected, isWSLAvailable(), + "isWSLAvailable() should return true iff wsl.exe is on PATH") +} + +// TestStartWSLBackground_DoesNotBlock verifies that startWSLBackground returns +// promptly regardless of whether WSL is installed. When WSL is absent the +// internal cmd.Start() fails silently; when present, wsl --exec echo exits +// immediately and the cleanup goroutine reaps it. +func TestStartWSLBackground_DoesNotBlock(t *testing.T) { + // This must complete without hanging; no assertion on side-effects. + startWSLBackground() +}