diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 9a561dea..be8f5992 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -4,7 +4,7 @@ The tooling that makes a native Windows host satisfy the contract in [`docs/host ## What Is Here -- [`install-tools.ps1`][install-tools] installs and upgrades the host tools, and reports what each one is installed at, where it came from, and which scope it sits in. +- [`install-tools.ps1`][install-tools] installs and upgrades the host tools, and reports what each one is installed at, where it came from, and which scope it sits in. Installing, upgrading or reinstalling `docker` also brings the WSL platform up to Docker Desktop's own floor where it is behind. - [`upgrade-host.ps1`][upgrade-host] upgrades the packages `winget` manages and updates the WSL platform. - [`setup-github.ps1`][setup-github] configures the SSH key, git, and commit signing. - [`setup-wsl.ps1`][setup-wsl] installs a WSL distribution and reports how Docker Desktop is integrated with the ones this host runs. @@ -80,7 +80,13 @@ pwsh -NoProfile -File ..\bootstrap.ps1 -Help Docker's own `docker-desktop` distribution is excluded from every distribution listing, since it is Docker's rather than one an operator installed. -`install-tools.ps1` checks, before installing or upgrading `docker`, that `wsl.exe` is present and reports a WSL version at or above `2.1.5`, Docker Desktop's own documented floor for the platform it depends on. Where either is not the case it skips `docker` and names the exact remedy (`wsl --install --no-distribution`, or `upgrade-host.ps1 -Wsl`) rather than installing against a platform Docker Desktop cannot use, or Windows-feature-installing on the caller's behalf. It never runs `wsl --install` or `wsl --update` itself: those stay `upgrade-host.ps1 -Wsl` and a person's own choice, since an update restarts every distribution and neither belongs as a side effect of installing a different tool. The same check surfaces as a note under `-Report`, read-only, before a caller ever runs `-Install`. +`install-tools.ps1` checks, before installing or upgrading `docker`, that `wsl.exe` is present and reports a WSL version at or above `2.1.5`, Docker Desktop's own documented floor for the platform it depends on. Where `wsl.exe` is missing entirely it skips `docker` and names the remedy (`wsl --install --no-distribution`), the one case this still leaves to a person: standing up WSL from nothing is a different action from bringing an existing platform current, the same reasoning that keeps a distribution *install* in `setup-wsl.ps1` rather than folded in here. The same check surfaces as a note under `-Report`, read-only, before a caller ever runs `-Install`. + +Where WSL is present but behind the floor, or `docker` itself is about to change version, `install-tools.ps1` drives the fix itself rather than only naming it, asking first unless `-Yes` was given. Where Docker Desktop is running, it stops it through its own CLI (`docker desktop stop`, not the tray icon) rather than leave it up through the change, runs `wsl --update` where the floor is not met, and once the `docker` package itself is also settled, shuts every WSL distribution down with `wsl --shutdown` and starts Docker Desktop again (`docker desktop start`). A run that finds Docker Desktop already stopped has nothing to restore, so it stops there too: neither the shutdown nor the restart runs. + +This moved a boundary the tool used to hold: WSL used to be read-only here, and `upgrade-host.ps1 -Wsl` was the only thing that ever ran `wsl --update`, on the reasoning that an update restarts every distribution and that does not belong as a side effect of installing a different tool. Two things moved it. Docker Desktop's own per-distro WSL integration goes stale across an engine bump often enough to have a name on Docker's own tracker (`WSL integration with distro '' unexpectedly stopped`), and the dialog's own "Restart the WSL integration" button does not clear it, since it retries the proxy inside the distro that is already running against the same stale state. A full stop, `wsl --shutdown`, start cycle does clear it. And once a `docker` version bump already needed that exact stop-then-restart window for its own integration to recover, updating WSL inside that same window costs nothing extra, and skips the `upgrade-host.ps1 -Wsl` refusal a Docker Desktop already running would otherwise walk straight into. + +`wsl --update` installs through its own MSI and raises a UAC prompt when this `pwsh` is not itself elevated, the state the rest of this tooling deliberately stays in. Nothing can answer that prompt unattended, so where the process is neither elevated nor at an interactive console, `install-tools.ps1` refuses to start the update rather than hang, and restores Docker Desktop to how it found it first. ## Differences From the Linux Tooling diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 62615f14..cf7dab8d 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -109,6 +109,12 @@ it finds and an installer that needs administrator asks for it itself. Naming a disagrees with the installed copy would add a second copy beside the first rather than replacing it, so an upgrade refuses that case and names -Reinstall, which removes the old copy first. +An install, upgrade or reinstall of docker also brings the WSL platform itself up to Docker +Desktop's own floor where it is behind, and stops Docker Desktop first either way, since Docker +Desktop holds the WSL service open and both a platform update and its own WSL integration +otherwise need a restart to recover from an engine bump. wsl --update raises its own administrator +prompt, which this refuses to start unattended where nothing could answer it, rather than hang. + Examples: install-tools.ps1 Report on every tool install-tools.ps1 -Install Install what is missing @@ -484,7 +490,8 @@ function Add-ToolNote { # Docker Desktop's own documented floor for the WSL platform, per docs.docker.com/desktop/features/wsl. $DOCKER_WSL_FLOOR = '2.1.5' -# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# Every wsl.exe read goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# A wsl.exe call that changes the host instead of reading it goes through Invoke-WslRun below, which applies the same guard to a live streamed run rather than a captured one. # Mirrored from upgrade-host.ps1 rather than shared with it, on the same rule as the rest of this directory: a script here has to stay independently fetchable. function Invoke-Wsl { param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) @@ -500,6 +507,19 @@ function Invoke-Wsl { } } +# The mutation-side counterpart to Invoke-Wsl: runs a wsl.exe command that changes the host through the same run wrapper every other mutating command in this script uses, with the same WSL_UTF8 guard applied around it, so its live streamed output does not read to a person as NUL separated characters either. +function Invoke-WslRun { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + return (run 'wsl.exe' @Arguments) + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + # Just the "WSL version" line, as a value Compare-HostVersion can read. # Get-WslVersion in upgrade-host.ps1 concatenates WSL, kernel and WSLg into one display string instead, which serves a report rather than a comparison. function Get-WslPlatformVersion { @@ -511,9 +531,8 @@ function Get-WslPlatformVersion { } # What stands between this host and installing or upgrading docker, or $null where nothing does. -# Read-only: this never runs wsl --install or wsl --update itself. -# Those stay a person's own action through upgrade-host.ps1 -Wsl and setup-wsl.ps1, confirmed with the maintainer as the boundary. -# A WSL platform update restarts every distribution, and neither action belongs as a silent side effect of installing a different tool. +# A pure report either way, used as-is for -Report's note and as Enter-DockerMaintenance's own pre-check below, which is what actually drives the WSL platform update this used to only name. +# Standing up WSL from nothing with wsl --install stays a person's own step regardless, since that is a different action on a different subject from bringing an existing platform current, the same reasoning that keeps distribution installs in setup-wsl.ps1 rather than folded in here. function Test-WslReadyForDocker { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return "wsl.exe was not found, and Docker Desktop needs WSL2. Install it with: wsl --install --no-distribution" @@ -528,6 +547,127 @@ function Test-WslReadyForDocker { return $null } +# --- Docker --- + +# The processes that mean Docker Desktop is up when its own CLI cannot answer, mirrored from upgrade-host.ps1's Get-DockerProcess rather than shared with it, on the same rule as the rest of this directory: a script here has to stay independently fetchable. +$DOCKER_PROCESSES = @('Docker Desktop', 'com.docker.backend', 'com.docker.build') + +# Whether Docker Desktop answers as running right now, preferring its own CLI over a process probe, since the CLI is what agrees with what the tray icon shows and a vmmem process surviving a crash is not the same as the backend actually being up. +# Falls back to the process probe where the CLI itself cannot answer (missing from PATH, or the status call failing), rather than reading either as "not running" outright: that reading is what let Enter-DockerMaintenance run wsl --update against a Docker Desktop this function wrongly reported as already down. +function Test-DockerDesktopRunning { + if (Get-Command docker -ErrorAction SilentlyContinue) { + $text = (& docker desktop status --format json 2>&1 | Out-String) + if ($LASTEXITCODE -eq 0) { + # Parsed rather than matched, the same way setup-wsl.ps1 reads Docker's own settings file, with the regex kept as a fallback for a CLI version that ever answers non-JSON rather than reading that as "not running". + try { + return ((ConvertFrom-Json $text -ErrorAction Stop).Status -eq 'running') + } catch { + return ($text -match '"Status"\s*:\s*"running"') + } + } + } + return [bool](Get-Process -Name $script:DOCKER_PROCESSES -ErrorAction SilentlyContinue) +} + +function Stop-DockerDesktop { + # Test-DockerDesktopRunning can answer true through the process probe alone, with the docker CLI missing or off PATH, and & docker there is a command-not-found PowerShell throws rather than a native exit code this can warn on, so this checks first rather than let that surface as a crash. + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + warn 'docker desktop stop needs the docker CLI, which is not on PATH; stop Docker Desktop by hand from its tray icon' + return 1 + } + info 'Stopping Docker Desktop' + $code = run 'docker' @('desktop', 'stop', '--timeout', '90') + if ($code -ne 0) { warn "docker desktop stop exited $code" } + return $code +} + +function Start-DockerDesktop { + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + warn 'docker desktop start needs the docker CLI, which is not on PATH; start Docker Desktop by hand and check WSL integration per distro' + return 1 + } + info 'Starting Docker Desktop' + $code = run 'docker' @('desktop', 'start', '--timeout', '180') + if ($code -ne 0) { warn 'docker desktop start did not exit cleanly, start Docker Desktop by hand and check WSL integration per distro' } + return $code +} + +# Everything a docker install, upgrade or reinstall needs from Docker Desktop and WSL before winget touches the package is folded into one window rather than run twice: Docker Desktop holds the WSL service open, so a platform update fails part way while it is running, the same reason upgrade-host.ps1 -Wsl refuses outright. +# Docker Desktop's own per distro WSL integration also goes stale across an engine bump often enough that it has a name on Docker's own tracker, surfacing as "WSL integration with distro '' unexpectedly stopped" the next time anything in that distro touches docker, and the in app "Restart the WSL integration" button on that dialog does not clear it, since it retries the proxy inside the distro that is already running against the same stale state. +# Both are fixed the same way where Docker Desktop is running to begin with: stopped, WSL brought current, every distro shut down with it so each one's integration remounts fresh rather than being patched in place, and Docker Desktop started again once the docker package itself is also settled, by Exit-DockerMaintenance below; a run that finds Docker Desktop already stopped has nothing to restore and none of that runs. +# Boundary note: this script used to be read-only on WSL by design, naming upgrade-host.ps1 -Wsl as a person's own step rather than running it. +# The maintainer moved that boundary once a docker version bump on its own already needed this same stop, then restart, window for its own integration to recover, since a platform update asks for nothing more than that same window with Docker Desktop already stopped inside it. +# Returns whether docker is clear to proceed, and whether this stopped Docker Desktop, which is what Exit-DockerMaintenance needs to know whether it owes a restart. +function Enter-DockerMaintenance { + param([Parameter(Mandatory)][bool]$WasRunning, [string]$WslProblem) + + $result = @{ Proceed = $true; Stopped = $false } + if (-not $WasRunning -and -not $WslProblem) { return $result } + + # Test-WslReadyForDocker's own string carries a remedy clause for a caller that will not fix WSL itself, "Update it with: host-setup\windows\upgrade-host.ps1 -Wsl", which reads as misleading and duplicative embedded in a prompt for a run about to do exactly that. + $wslReason = if ($WslProblem) { $WslProblem -replace '\s*Update it with:.*$', '' } else { $WslProblem } + $question = if ($WslProblem -and $WasRunning) { + "docker needs WSL updated first ($wslReason). Stop Docker Desktop, update WSL, and restart Docker Desktop to continue?" + } elseif ($WslProblem) { + "docker needs WSL updated first ($wslReason). Update WSL to continue?" + } else { + "docker is about to be upgraded or reinstalled, and Docker Desktop's own WSL integration commonly goes stale across a change like that. Stop Docker Desktop first, and restart it after, to avoid that?" + } + if (-not (confirm $question)) { + if ($WslProblem) { $result.Proceed = $false; return $result } + note 'docker' 'WSL integration may report a stale per-distro error until Docker Desktop and WSL are both restarted (docker desktop stop; wsl --shutdown; docker desktop start); declined the automatic cycle' + return $result + } + + # Gated on whether Docker Desktop is still observed running after the attempt, not on Stop-DockerDesktop's own exit code: a non-zero exit that still left Docker Desktop stopped is not a reason to refuse the run and, just as importantly, not a reason to leave Stopped unset, which would tell Exit-DockerMaintenance below there is nothing to restart and strand the host stopped when it started running. + # Skipped under -DryRun, where nothing actually stopped and Docker Desktop is still genuinely running, which would otherwise read every dry run on a live host as this exact failure rather than as a preview. + if ($WasRunning) { + Stop-DockerDesktop | Out-Null + if (-not $script:DRY_RUN -and (Test-DockerDesktopRunning)) { + warn 'docker skipped, Docker Desktop did not stop, and a WSL platform update fails part way while it is running' + $result.Proceed = $false + return $result + } + $result.Stopped = $true + } + if ($WslProblem) { + # Installing through wsl --update's own MSI raises a UAC prompt when this pwsh is not itself elevated, the state the rest of this script deliberately stays in (running elevated trades this one prompt for winget installers elsewhere that fail pre-elevated instead). + # Nothing can answer that prompt without a person at the console, so a run with neither would hang rather than fail, which -Yes is supposed to rule out, not walk into unattended. + if (-not $script:ELEVATED -and (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected)) { + # Built rather than a fixed string, because whether this refusal itself restarts Docker Desktop depends on whether $result.Stopped is set, and a run that found it already stopped restarts nothing for the remedy below to run into. + $restartNote = if ($result.Stopped) { ' This refusal also restarts Docker Desktop, to leave the host as it found it, so it may already be running again by the time this is read.' } else { '' } + warn "wsl --update needs administrator and raises its own prompt, which nothing can answer unattended; it would hang rather than fail, so this refuses to start it. Run interactively once so the prompt has someone to answer, or quit Docker Desktop yourself and run host-setup\windows\upgrade-host.ps1 -Wsl, which refuses on its own while Docker Desktop is running.$restartNote" + if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } + $result.Proceed = $false + return $result + } + info 'Updating the WSL platform' + info 'This restarts every distribution, so anything running inside one is stopped' + $updateCode = Invoke-WslRun '--update' + if ($updateCode -ne 0) { warn "wsl --update exited $updateCode" } + # Skipped under -DryRun on the same rule as the stop check above: wsl --update never ran, so WSL is still genuinely behind, and re-checking it here would read every dry run as this same failure rather than as a preview. + $stillBroken = if ($script:DRY_RUN) { $null } else { Test-WslReadyForDocker } + if ($stillBroken) { + warn "docker skipped, still $stillBroken after the update" + if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } + $result.Proceed = $false + return $result + } + } + return $result +} + +# The other half of Enter-DockerMaintenance, run once the docker package itself is settled: shuts every WSL distro down so each one's integration remounts fresh, then starts Docker Desktop again. +# A no-op where Enter-DockerMaintenance never stopped anything, so a run that changed nothing about Docker Desktop does not restart it for no reason, and a run that was declined does not either. +function Exit-DockerMaintenance { + param([Parameter(Mandatory)][bool]$Stopped) + if (-not $Stopped) { return } + info 'Shutting down WSL' + $shutdownCode = Invoke-WslRun '--shutdown' + if ($shutdownCode -ne 0) { warn "wsl --shutdown exited $shutdownCode" } + Start-DockerDesktop | Out-Null +} + # --- Actions --- function Show-List { @@ -588,17 +728,6 @@ function Invoke-ToolApply { return } - # Docker Desktop needs WSL2 already present and current, and does not install or update it itself. - # A WSL gap has nothing to do with any other tool in this run, so it is collected here on the same rule as a failed install rather than ending the whole run. - if ($ToolName -eq 'docker') { - $wslProblem = Test-WslReadyForDocker - if ($wslProblem) { - warn "docker skipped, $wslProblem" - $script:FAILED += $ToolName - return - } - } - # Naming a scope the installed copy does not sit in would add a second copy beside it, so the removal is asked for rather than done on the way past. if ($script:WANT_SCOPE -and $state.Rows.Count -gt 0 -and $state.Scope.Count -gt 0 -and $state.Scope -notcontains $script:WANT_SCOPE -and $script:MODE -ne 'reinstall') { @@ -643,6 +772,26 @@ function Invoke-ToolApply { return } + # Reached only once real work is about to happen: every "leave it alone" status above already returned. + # That is deliberate here, not incidental: an already current docker with a stale WSL platform has nothing this run needs to fix, and gating on WSL readiness any earlier would skip a tool that was never going to change anyway. + $dockerMaintenance = $null + if ($ToolName -eq 'docker') { + $wasRunning = Test-DockerDesktopRunning + $wslProblem = Test-WslReadyForDocker + if ($wslProblem -and -not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + warn "docker skipped, $wslProblem" + $script:FAILED += $ToolName + return + } + $dockerMaintenance = Enter-DockerMaintenance -WasRunning $wasRunning -WslProblem $wslProblem + if (-not $dockerMaintenance.Proceed) { + # $wslProblem is empty on the path where Enter-DockerMaintenance refused over a failed Docker Desktop stop rather than a WSL gap, and that reason was already warned there, but a bare "docker skipped, " with nothing after the comma still reads as a broken log line rather than a completed one. + warn "docker skipped$(if ($wslProblem) { ", $wslProblem" } else { ', Docker Desktop maintenance failed, see the warning above' })" + $script:FAILED += $ToolName + return + } + } + if ($script:MODE -ne 'reinstall') { log "${ToolName}: $($state.Status)$(if ($state.Available) { ", the source carries $($state.Available)" })" } @@ -666,6 +815,8 @@ function Invoke-ToolApply { info 'Close whatever is running it, then run this again' } $script:FAILED += $ToolName + # Owed regardless of how this returns: Enter-DockerMaintenance stopped Docker Desktop for this attempt, and a failed install is not a reason to leave it down. + if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } return } } @@ -676,6 +827,7 @@ function Invoke-ToolApply { $after = if ($now) { $now } else { '-' } $script:CHANGED += "$ToolName $before -> $after" } + if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } } function Invoke-Apply {