From 2eb603cf8c233d26483711a88d56e08ad818c832 Mon Sep 17 00:00:00 2001 From: Jan Dubois Date: Fri, 2 Oct 2026 00:28:28 -0700 Subject: [PATCH 1/2] copytool: expect converted paths in TestRsyncCommandPaths on Windows On Windows, Command converts each local path for the rsync it runs, because a Cygwin or MSYS rsync reads "C:" as a host. The test still expected the paths as given, but no Windows CI job had rsync on PATH when the unit tests ran, so it always skipped there. Convert the expected paths the same way. Signed-off-by: Jan Dubois --- pkg/copytool/copytool_test.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/copytool/copytool_test.go b/pkg/copytool/copytool_test.go index e94d3194..707015a4 100644 --- a/pkg/copytool/copytool_test.go +++ b/pkg/copytool/copytool_test.go @@ -128,7 +128,20 @@ func TestRsyncCommandPaths(t *testing.T) { // The paths are everything after the "--" separator. sep := slices.Index(cmd.Args, "--") assert.Assert(t, sep != -1, "rsync args must contain the %#q separator: %v", "--", cmd.Args) - assert.DeepEqual(t, cmd.Args[sep+1:], tt.expected) + // Command converts each local path for the rsync it runs. Convert + // without the trailing slash so a conversion that drops it still + // fails here. + expected := make([]string, len(tt.expected)) + for i, p := range tt.expected { + trimmed := strings.TrimSuffix(p, "/") + expected[i], err = tool.pathForRsync(t.Context(), trimmed) + assert.NilError(t, err) + expected[i] += strings.TrimPrefix(p, trimmed) + } + assert.DeepEqual(t, cmd.Args[sep+1:], expected) + for _, arg := range cmd.Args[sep+1:] { + assert.Assert(t, !looksRemoteToRsync(arg), "rsync reads %q as host:path", arg) + } }) } } From fee349adabfe893370d83065621bbe554da746fa Mon Sep 17 00:00:00 2001 From: Jan Dubois Date: Sun, 26 Jul 2026 23:45:30 -0700 Subject: [PATCH 2/2] CI: factor Windows setup into composite actions; add plain-Windows jobs Lima should run on a Windows host whose only ssh is the native OpenSSH client, but the existing Windows jobs run with Git for Windows and MSYS2 installed, so a new dependency on their tools would go unnoticed. The plain-Windows jobs remove both toolchains before testing. The MSYS2 jobs got a Cygwin ssh from Git for Windows through _LIMA_WINDOWS_EXTRA_PATH and now install MSYS2's openssh instead. That was the experimental variable's last use in the repo, so remove it. The MSYS2 jobs also install rsync, so the integration tests cover the rsync copy backend on Windows. Because MSYS2 setup now runs before the unit tests and make, the unit tests that skip without rsync run on Windows too. The WSL2 job's id changes from windows to windows-wsl2. Signed-off-by: Jan Dubois --- .github/actions/windows_msys2_prep/action.yml | 27 +++ .../actions/windows_plain_build/action.yml | 24 +++ .github/actions/windows_plain_host/action.yml | 172 ++++++++++++++++++ .../windows_plain_smoke_test/action.yml | 78 ++++++++ .../windows_plain_templates/action.yml | 74 ++++++++ .../actions/windows_qemu_install/action.yml | 11 ++ .github/actions/windows_wsl2_setup/action.yml | 55 ++++++ .github/workflows/test.yml | 117 +++++++----- cmd/limactl/main.go | 8 - pkg/sshutil/sshutil_windows_test.go | 34 ++++ .../en/docs/config/environment-variables.md | 14 -- website/content/en/docs/releases/breaking.md | 5 + 12 files changed, 554 insertions(+), 65 deletions(-) create mode 100644 .github/actions/windows_msys2_prep/action.yml create mode 100644 .github/actions/windows_plain_build/action.yml create mode 100644 .github/actions/windows_plain_host/action.yml create mode 100644 .github/actions/windows_plain_smoke_test/action.yml create mode 100644 .github/actions/windows_plain_templates/action.yml create mode 100644 .github/actions/windows_qemu_install/action.yml create mode 100644 .github/actions/windows_wsl2_setup/action.yml diff --git a/.github/actions/windows_msys2_prep/action.yml b/.github/actions/windows_msys2_prep/action.yml new file mode 100644 index 00000000..40973e4c --- /dev/null +++ b/.github/actions/windows_msys2_prep/action.yml @@ -0,0 +1,27 @@ +name: 'windows msys2 prep' +description: > + Adds C:\msys64\usr\bin to PATH for the rest of the job, unit tests and + make included, and installs the MSYS2 packages the Windows integration + jobs need. +runs: + using: composite + steps: + - name: Prepend MSYS2 to PATH and install test dependencies + shell: pwsh + # Writes only the static literal C:\msys64\usr\bin; no PR-controlled data + # reaches the env file. + run: | # zizmor: ignore[github-env] + $ErrorActionPreference = 'Stop' + Add-Content -Path $env:GITHUB_PATH -Value 'C:\msys64\usr\bin' + # rsync: without it the copy test skips its rsync backend, so the Windows + # path conversion that backend performs goes unexercised. + # openssh: keeps a Cygwin ssh ahead of %SystemRoot%\System32\OpenSSH, so + # these jobs cover the Cygwin toolchain and the plain jobs the native one. + & 'C:\msys64\usr\bin\pacman.exe' -Sy --noconfirm diffutils jq openssh rsync socat w3m + if ($LASTEXITCODE -ne 0) { throw "pacman failed: $LASTEXITCODE" } + # Lima's ssh selection skips an ssh.exe without scp.exe and ssh-keygen.exe + # beside it, and reverse-sshfs takes the sftp-server from the same install. + foreach ($exe in 'bin\ssh.exe', 'bin\scp.exe', 'bin\ssh-keygen.exe', 'lib\ssh\sftp-server.exe') { + $exePath = Join-Path 'C:\msys64\usr' $exe + if (-not (Test-Path $exePath)) { throw "missing: $exePath" } + } diff --git a/.github/actions/windows_plain_build/action.yml b/.github/actions/windows_plain_build/action.yml new file mode 100644 index 00000000..2b034604 --- /dev/null +++ b/.github/actions/windows_plain_build/action.yml @@ -0,0 +1,24 @@ +name: 'windows plain build' +description: > + Builds limactl.exe and the Linux/amd64 guest agent with `go build`, without + make. limactl needs the guest agent to start an instance, and + `go build ./cmd/limactl` does not produce it. + + Without make's -X ldflag the version stays "". Lima treats an + unparsable version as newer than any other, so default.yaml's + minimumLimaVersion accepts these builds. +runs: + using: composite + steps: + - name: Build limactl and Linux/amd64 guest agent + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + go build -o _output\bin\limactl.exe .\cmd\limactl + if ($LASTEXITCODE -ne 0) { throw "limactl build failed: $LASTEXITCODE" } + New-Item -ItemType Directory -Force -Path _output\share\lima | Out-Null + $env:CGO_ENABLED = '0' + $env:GOOS = 'linux' + $env:GOARCH = 'amd64' + go build -o _output\share\lima\lima-guestagent.Linux-x86_64 .\cmd\lima-guestagent + if ($LASTEXITCODE -ne 0) { throw "lima-guestagent build failed: $LASTEXITCODE" } diff --git a/.github/actions/windows_plain_host/action.yml b/.github/actions/windows_plain_host/action.yml new file mode 100644 index 00000000..9ae7bd67 --- /dev/null +++ b/.github/actions/windows_plain_host/action.yml @@ -0,0 +1,172 @@ +name: 'windows plain host' +description: > + Uninstalls MSYS2 and Git for Windows, then verifies they are gone and the + native OpenSSH client is present, so a runner-image change in either + direction fails the job. + + Destructive and irreversible: it also rewrites the Machine and User PATH in + the registry, so run it only on an ephemeral runner. + + Run AFTER steps that need git CLI (checkout, windows_plain_templates) and + BEFORE the build / smoke test steps. +runs: + using: composite + steps: + - name: Uninstall Git for Windows + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $uninstaller = 'C:\Program Files\Git\unins000.exe' + if (Test-Path $uninstaller) { + Write-Host "Running Git for Windows Inno Setup uninstaller..." + & $uninstaller /VERYSILENT /NORESTART /SUPPRESSMSGBOXES + # A copy of unins000 in %TEMP% (_unins.tmp, or _iu*.tmp in older Inno + # Setup) does the uninstall. unins000 waits for it, but the copy + # outlives unins000 to delete unins000.exe and the Git directory. + $deadline = (Get-Date).AddSeconds(120) + while ((Get-Date) -lt $deadline -and + (Get-Process -Name 'unins000', '_unins*', '_iu*' -ErrorAction SilentlyContinue)) { + Start-Sleep -Seconds 2 + } + } else { + Write-Host "No Git for Windows uninstaller at $uninstaller; skipping." + } + foreach ($d in 'C:\Program Files\Git', 'C:\Program Files (x86)\Git') { + if (Test-Path $d) { + Write-Host "Removing leftover $d" + Remove-Item $d -Recurse -Force -ErrorAction SilentlyContinue + } + } + # pwsh does not wait for the GUI uninstaller or record its exit status, + # so the verify step below is the gate. + exit 0 + + - name: Uninstall MSYS2 and standalone mingw + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # MSYS2 and the mingw dirs on this image are extracted trees with no + # uninstaller. + $procs = Get-Process | Where-Object { + try { $_.Modules.ModuleName -contains 'msys-2.0.dll' } catch { $false } + } + if ($procs) { + Write-Host "Stopping $($procs.Count) process(es) still mapping msys-2.0.dll" + $procs | Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + } + foreach ($d in 'C:\msys64', 'C:\tools\msys64', 'C:\msys32', + 'C:\mingw64', 'C:\mingw32') { + if (Test-Path $d) { + Write-Host "Removing $d" + Remove-Item $d -Recurse -Force -ErrorAction SilentlyContinue + } + } + + - name: Scrub forbidden entries from Machine + User PATH + shell: pwsh + # Publishes the runner's own Machine + User PATH minus a static deny-list; + # no PR-controlled data reaches the env file. + run: | # zizmor: ignore[github-env] + $ErrorActionPreference = 'Stop' + # The uninstall leaves stale entries in the Machine and User PATH. + # Registry changes do not reach the running runner, so also publish the + # result via $GITHUB_ENV. $GITHUB_PATH entries from earlier steps are + # still prepended on top of it, out of the scrub's reach. + $forbidden = '(?i)(msys|mingw|cygwin|\\Git\\(cmd|bin|usr))' + foreach ($scope in @('Machine','User')) { + $orig = [Environment]::GetEnvironmentVariable('Path', $scope) + if (-not $orig) { continue } + $cleaned = ($orig -split ';' | Where-Object { $_ -and ($_ -notmatch $forbidden) }) -join ';' + if ($cleaned -ne $orig) { + Write-Host "Scrubbing $scope PATH" + [Environment]::SetEnvironmentVariable('Path', $cleaned, $scope) + } + } + $machine = [Environment]::GetEnvironmentVariable('Path','Machine') + $user = [Environment]::GetEnvironmentVariable('Path','User') + $combined = (@($machine, $user) | Where-Object { $_ }) -join ';' + Add-Content -Path $env:GITHUB_ENV -Value "PATH=$combined" + + - name: Verify host is vanilla Windows (no MSYS2 / Git for Windows / Cygwin) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # Check the inherited PATH, which is what later steps see. Rebuilding it + # from the registry would only repeat the scrub step's own read. + $fail = @() + + # --- Layer 1: filesystem --- + $fsForbidden = @( + 'C:\msys64', 'C:\tools\msys64', 'C:\msys32', + 'C:\Program Files\Git', 'C:\Program Files (x86)\Git', + 'C:\mingw64', 'C:\mingw32', + 'C:\cygwin', 'C:\cygwin64', 'C:\tools\cygwin' + ) + foreach ($d in $fsForbidden) { + if (Test-Path $d) { $fail += "filesystem: $d still exists" } + } + + # --- Layer 2: PATH (process + machine + user) --- + $pathRegex = '(?i)(msys|mingw|cygwin|\\Git\\(cmd|bin|usr))' + foreach ($scope in @('Process','Machine','User')) { + $p = [Environment]::GetEnvironmentVariable('Path', $scope) + if (-not $p) { continue } + foreach ($entry in $p -split ';') { + if ($entry -and ($entry -match $pathRegex)) { + $fail += ("PATH ({0}): {1}" -f $scope, $entry) + } + } + } + + # --- Layer 3: smoking-gun binaries --- + # WSL installs %SystemRoot%\System32\bash.exe; allow that bash only. + $forbiddenCmds = @('cygpath','pacman','mintty','git','git-bash','git-cmd') + foreach ($cmd in $forbiddenCmds) { + $g = Get-Command $cmd -ErrorAction SilentlyContinue + if ($g) { $fail += ("binary: {0} -> {1}" -f $cmd, $g.Source) } + } + $allowedBash = Join-Path $env:SystemRoot 'System32\bash.exe' + $bash = Get-Command bash -ErrorAction SilentlyContinue + if ($bash -and ($bash.Source -ne $allowedBash)) { + $fail += "binary: bash -> $($bash.Source) (only $allowedBash is allowed)" + } + $sh = Get-Command sh -ErrorAction SilentlyContinue + if ($sh) { $fail += "binary: sh -> $($sh.Source)" } + + # --- Layer 4: registry uninstall keys --- + $uninstallRoots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($root in $uninstallRoots) { + if (-not (Test-Path $root)) { continue } + $keys = Get-ChildItem $root -ErrorAction SilentlyContinue + foreach ($k in $keys) { + $props = Get-ItemProperty $k.PSPath -ErrorAction SilentlyContinue + if ($props -and $props.DisplayName -and + ($props.DisplayName -match '(?i)(^git$|msys|git for windows|cygwin|mingw)')) { + $fail += ("registry: {0} -> DisplayName='{1}'" -f $k.PSPath, $props.DisplayName) + } + } + } + + # --- Layer 5: the toolchain these jobs do need --- + # Check up front. A missing ssh.exe fails later with an opaque exec + # error, a missing scp.exe or ssh-keygen.exe makes Lima fall back to a + # bare ssh lookup, and a missing sftp-server.exe drops reverse-sshfs to + # the builtin driver. + $opensshDir = Join-Path $env:SystemRoot 'System32\OpenSSH' + foreach ($exe in 'ssh.exe', 'scp.exe', 'ssh-keygen.exe', 'sftp-server.exe') { + $exePath = Join-Path $opensshDir $exe + if (-not (Test-Path $exePath)) { $fail += "missing: $exePath" } + } + + if ($fail.Count -gt 0) { + Write-Host "Plain-Windows verification FAILED:" -ForegroundColor Red + $fail | ForEach-Object { Write-Host " $_" } + Write-Error "Runner is not in the expected plain-Windows state; see the list above." + exit 1 + } + Write-Host "Plain-Windows verification PASSED." diff --git a/.github/actions/windows_plain_smoke_test/action.yml b/.github/actions/windows_plain_smoke_test/action.yml new file mode 100644 index 00000000..2ca4b7dc --- /dev/null +++ b/.github/actions/windows_plain_smoke_test/action.yml @@ -0,0 +1,78 @@ +name: 'windows plain smoke test' +description: > + Runs Lima's create / start / shell / copy / stop / delete cycle against a + given template on a plain Windows host, and dumps the instance logs on + failure. +inputs: + template: + description: Path to the Lima template (e.g. .\templates\experimental\wsl2.yaml). + required: true + instance-name: + description: Name to use for the test instance. + required: false + default: plain + vm-type: + description: > + Lima vmType to force via `--vm-type` (e.g. "qemu" or "wsl2"). When empty, + the template or Lima's default decides. + required: false + default: '' +runs: + using: composite + steps: + - name: Smoke test (create / start / shell / copy / stop / delete) + shell: pwsh + env: + LIMA_HOME: ${{ runner.temp }}\lima + INSTANCE: ${{ inputs.instance-name }} + TEMPLATE: ${{ inputs.template }} + VM_TYPE: ${{ inputs.vm-type }} + run: | + $ErrorActionPreference = 'Stop' + # $ErrorActionPreference does not stop on a native command's exit code. + function Invoke-Limactl { + & .\_output\bin\limactl.exe --debug @args + if ($LASTEXITCODE -ne 0) { throw "limactl $($args -join ' ') exited $LASTEXITCODE" } + } + if (Test-Path $env:LIMA_HOME) { Remove-Item $env:LIMA_HOME -Recurse -Force } + New-Item -ItemType Directory -Path $env:LIMA_HOME | Out-Null + $createArgs = @('--tty=false', "--name=$env:INSTANCE") + if ($env:VM_TYPE) { $createArgs += "--vm-type=$env:VM_TYPE" } + $createArgs += $env:TEMPLATE + Invoke-Limactl create @createArgs + Invoke-Limactl start $env:INSTANCE + Invoke-Limactl shell --tty=false $env:INSTANCE -- uname -srm + 'roundtrip' | Out-File -Encoding ascii "$env:LIMA_HOME\rt.txt" + Invoke-Limactl copy "$env:LIMA_HOME\rt.txt" "${env:INSTANCE}:/tmp/rt.txt" + Invoke-Limactl shell --tty=false $env:INSTANCE -- cat /tmp/rt.txt + # Cover the guest -> host direction too. + Invoke-Limactl copy "${env:INSTANCE}:/tmp/rt.txt" "$env:LIMA_HOME\rt-back.txt" + # -Raw yields $null for an empty file, and $null.Trim() would throw + # before the mismatch message a zero-byte copy is meant to produce. + $back = "$(Get-Content "$env:LIMA_HOME\rt-back.txt" -Raw)".Trim() + if ($back -ne 'roundtrip') { + throw "round-trip mismatch: expected 'roundtrip', got '$back'" + } + Invoke-Limactl stop $env:INSTANCE + Invoke-Limactl delete --force $env:INSTANCE + + - name: Dump Lima logs on failure + if: failure() + shell: pwsh + env: + LIMA_HOME: ${{ runner.temp }}\lima + INSTANCE: ${{ inputs.instance-name }} + run: | + $instDir = Join-Path $env:LIMA_HOME $env:INSTANCE + if (-not (Test-Path $instDir)) { + Write-Host "No instance directory at $instDir, nothing to dump." + exit 0 + } + Get-ChildItem $instDir | Format-Table Name, Length, LastWriteTime + foreach ($name in 'ha.stdout.log','ha.stderr.log','serial.log','lima.yaml','ssh.config') { + $p = Join-Path $instDir $name + if (Test-Path $p) { + Write-Host ("===== {0} =====" -f $p) + Get-Content $p + } + } diff --git a/.github/actions/windows_plain_templates/action.yml b/.github/actions/windows_plain_templates/action.yml new file mode 100644 index 00000000..7c6c5b24 --- /dev/null +++ b/.github/actions/windows_plain_templates/action.yml @@ -0,0 +1,74 @@ +name: 'windows plain templates' +description: > + Resolves Lima's template symlinks and copies templates/ to + _output/share/lima/templates/, replicating `make`'s TEMPLATES target without + make. + + A checkout writes the git symlinks under templates/ as NTFS symlinks or as + plaintext stubs, depending on core.symlinks, so the targets are read from + git's object store instead. + + This action MUST run before windows_plain_host (which uninstalls Git for + Windows), because it shells out to `git ls-tree` and `git cat-file`. +runs: + using: composite + steps: + - name: Install templates (replicating make's TEMPLATES target) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + throw "git not found; this action must run before windows_plain_host" + } + $srcRoot = 'templates' + $dstRoot = '_output\share\lima\templates' + if (Test-Path $dstRoot) { Remove-Item $dstRoot -Recurse -Force } + New-Item -ItemType Directory -Force -Path $dstRoot | Out-Null + Copy-Item -Path "$srcRoot\*" -Destination $dstRoot -Recurse -Force + + $linkTargets = @{} + # Check ls-tree's exit code before the loop, whose `git cat-file` calls + # overwrite $LASTEXITCODE. + $lsTreeOutput = git ls-tree -r HEAD $srcRoot + if ($LASTEXITCODE -ne 0) { throw "git ls-tree failed: $LASTEXITCODE" } + foreach ($line in $lsTreeOutput) { + if ($line -notmatch '^120000\s') { continue } + $parts = $line -split "`t", 2 + $sha = ($parts[0] -split '\s+')[2] + $posixPath = $parts[1] + # A failed cat-file writes only to stderr. Keep the value a string, + # since $null.Trim() would throw ahead of the check below. + $target = "$(git cat-file blob $sha)".Trim() + if ($LASTEXITCODE -ne 0) { throw "git cat-file $sha failed: $LASTEXITCODE" } + $linkTargets[$posixPath] = $target + } + + foreach ($posixPath in $linkTargets.Keys) { + $resolved = $posixPath + $depth = 0 + while ($linkTargets.ContainsKey($resolved)) { + if ($depth++ -gt 16) { throw "Symlink chain too deep starting from $posixPath" } + $target = $linkTargets[$resolved] + $slash = $resolved.LastIndexOf('/') + $resolved = if ($slash -ge 0) { "$($resolved.Substring(0, $slash))/$target" } else { $target } + $segs = New-Object System.Collections.ArrayList + foreach ($s in ($resolved -split '/')) { + if ($s -eq '' -or $s -eq '.') { continue } + if ($s -eq '..') { + if ($segs.Count -gt 0) { $segs.RemoveAt($segs.Count - 1) | Out-Null } + continue + } + $segs.Add($s) | Out-Null + } + $resolved = $segs -join '/' + } + # Defensive: the '..' handling above silently underflows. Guard against + # a future symlink edit whose chain resolves outside templates/. + if (-not $resolved.StartsWith("$srcRoot/")) { + throw "Symlink $posixPath resolved to $resolved, which escapes $srcRoot/" + } + $srcFile = $resolved -replace '/', '\' + $dstFile = Join-Path '_output\share\lima' ($posixPath -replace '/', '\') + Write-Host "Resolving symlink: $posixPath -> $resolved" + Copy-Item -Force -Path $srcFile -Destination $dstFile + } diff --git a/.github/actions/windows_qemu_install/action.yml b/.github/actions/windows_qemu_install/action.yml new file mode 100644 index 00000000..c20ba165 --- /dev/null +++ b/.github/actions/windows_qemu_install/action.yml @@ -0,0 +1,11 @@ +name: 'windows qemu install' +description: Installs QEMU on the Windows runner via winget. +runs: + using: composite + steps: + - name: Install QEMU + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + winget install --silent --accept-source-agreements --accept-package-agreements --disable-interactivity SoftwareFreedomConservancy.QEMU + if ($LASTEXITCODE -ne 0) { throw "winget install QEMU failed: $LASTEXITCODE" } diff --git a/.github/actions/windows_wsl2_setup/action.yml b/.github/actions/windows_wsl2_setup/action.yml new file mode 100644 index 00000000..a0dd9c9a --- /dev/null +++ b/.github/actions/windows_wsl2_setup/action.yml @@ -0,0 +1,55 @@ +name: 'windows wsl2 setup' +description: > + Enables WSL2 and imports a dummy distro, because `wsl --list --verbose` + (called from Lima) fails when no distro is installed (lima-vm/lima#1826). + WSL2 2.5.7.0 and later reject a distro tarball without /bin/sh and /etc. +runs: + using: composite + steps: + - name: Enable WSL2 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # $ErrorActionPreference does not stop on a native command's exit code. + wsl --set-default-version 2 + if ($LASTEXITCODE -ne 0) { throw "wsl --set-default-version failed: $LASTEXITCODE" } + wsl --shutdown + if ($LASTEXITCODE -ne 0) { throw "wsl --shutdown failed: $LASTEXITCODE" } + # --update and --list --online reach a Microsoft update service that + # returns transient 403s, so these four calls are informational. + wsl --update + wsl --status + wsl --version + wsl --list --online + # pwsh would otherwise exit with --list --online's status. + exit 0 + - name: Install dummy WSL2 distro + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # Keep the scratch tarball and the distro disk under $RUNNER_TEMP so no + # untracked files land in the checked-out workspace. + $scratch = Join-Path $env:RUNNER_TEMP 'wsl2-dummy' + New-Item -ItemType Directory -Force -Path "$scratch\bin","$scratch\etc" | Out-Null + Set-Content -Path "$scratch\bin\sh" -Value '' + $tarball = Join-Path $env:RUNNER_TEMP 'wsl2-dummy.tar' + tar -cf $tarball --format ustar -C $scratch . + if ($LASTEXITCODE -ne 0) { throw "tar -cf $tarball failed: $LASTEXITCODE" } + # Unchecked, because a fresh runner has nothing to unregister. A second + # run on a developer machine needs the unregister before the import. + wsl --unregister dummy + $distroDir = Join-Path $env:RUNNER_TEMP 'wsl2-dummy-distro' + New-Item -ItemType Directory -Force -Path $distroDir | Out-Null + # A composite action step cannot set timeout-minutes, so bound the + # import here. -ArgumentList joins its elements unquoted, so quote the + # two paths. + $p = Start-Process -FilePath wsl.exe -PassThru -NoNewWindow ` + -ArgumentList '--import', 'dummy', "`"$distroDir`"", "`"$tarball`"" + if (-not $p.WaitForExit(60000)) { + $p.Kill() + throw "wsl --import dummy timed out after 60s" + } + if ($p.ExitCode -ne 0) { throw "wsl --import dummy failed: $($p.ExitCode)" } + wsl --list --verbose + # Informational, like the listings in the step above. + exit 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index adcee756..53f805d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -147,36 +147,11 @@ jobs: - name: Uninstall run: sudo make uninstall - windows: + windows-wsl2: name: "Windows tests (WSL2)" runs-on: windows-2025 timeout-minutes: 45 steps: - - name: Enable WSL2 - run: | - wsl --set-default-version 2 - wsl --shutdown - wsl --update - wsl --status - wsl --version - wsl --list --online - - name: Install WSL2 distro - timeout-minutes: 1 - run: | - # FIXME: At least one distro has to be installed here, - # otherwise `wsl --list --verbose` (called from Lima) fails: - # https://github.com/lima-vm/lima/pull/1826#issuecomment-1729993334 - # The distro image itself is not consumed by Lima. - # Starting with WSL2 version 2.5.7.0 the distro will be rejected - # if it doesn't contain /bin/sh and /etc. - # ------------------------------------------------------------------ - mkdir dummy - mkdir dummy\bin - mkdir dummy\etc - echo "" >dummy\bin\sh - tar -cf dummy.tar --format ustar -C dummy . - wsl --import dummy $env:TEMP dummy.tar - wsl --list --verbose - name: Set gitconfig run: | git config --global core.autocrlf false @@ -190,27 +165,26 @@ jobs: with: go-version: stable cache: false + - uses: $/.github/actions/windows_wsl2_setup + - uses: $/.github/actions/windows_msys2_prep - name: Unit tests run: go test -v ./... - name: Make run: make - name: Integration tests (WSL2, Windows host) run: | - $env:PATH = "$pwd\_output\bin;" + 'C:\msys64\usr\bin;' + $env:PATH - pacman -Sy --noconfirm diffutils jq socat w3m - $env:MSYS2_ENV_CONV_EXCL = 'HOME_HOST;HOME_GUEST;_LIMA_WINDOWS_EXTRA_PATH' + $env:PATH = "$pwd\_output\bin;" + $env:PATH + $env:MSYS2_ENV_CONV_EXCL = 'HOME_HOST;HOME_GUEST' $env:HOME_HOST = $(cygpath.exe "$env:USERPROFILE") $env:HOME_GUEST = "/mnt$env:HOME_HOST" $env:LIMACTL_CREATE_ARGS = '--vm-type=wsl2 --mount-type=wsl2 --containerd=system' - $env:_LIMA_WINDOWS_EXTRA_PATH = 'C:\Program Files\Git\usr\bin' bash.exe -c "./hack/test-templates.sh templates/experimental/wsl2.yaml" - name: Run BATS port-forwarding test (WSL2, Windows host) run: | - $env:PATH = "$pwd\_output\bin;" + 'C:\msys64\usr\bin;' + $env:PATH - $env:MSYS2_ENV_CONV_EXCL = '_LIMA_WINDOWS_EXTRA_PATH;LIMA_BATS_PORT_FORWARDING_BASE_TEMPLATE' + $env:PATH = "$pwd\_output\bin;" + $env:PATH + $env:MSYS2_ENV_CONV_EXCL = 'LIMA_BATS_PORT_FORWARDING_BASE_TEMPLATE' $env:LIMACTL_CREATE_ARGS = '--vm-type=wsl2 --mount-type=wsl2 --containerd=system' $env:LIMA_BATS_PORT_FORWARDING_BASE_TEMPLATE = 'template:experimental/wsl2' - $env:_LIMA_WINDOWS_EXTRA_PATH = 'C:\Program Files\Git\usr\bin' bash.exe -c "./hack/bats/lib/bats-core/bin/bats --timing ./hack/bats/tests/port-forwarding.bats" windows-qemu: @@ -231,29 +205,86 @@ jobs: with: go-version: stable cache: false + - uses: $/.github/actions/windows_qemu_install + - uses: $/.github/actions/windows_msys2_prep - name: Make run: make - - name: Install QEMU - run: | - winget install --silent --accept-source-agreements --accept-package-agreements --disable-interactivity SoftwareFreedomConservancy.QEMU - name: Integration tests (QEMU, Windows host) run: | - $env:PATH = "$pwd\_output\bin;" + 'C:\msys64\usr\bin;' + 'C:\Program Files\QEMU;' + $env:PATH - pacman -Sy --noconfirm diffutils jq socat w3m - $env:MSYS2_ENV_CONV_EXCL = 'HOME_HOST;HOME_GUEST;_LIMA_WINDOWS_EXTRA_PATH' + $env:PATH = "$pwd\_output\bin;" + 'C:\Program Files\QEMU;' + $env:PATH + $env:MSYS2_ENV_CONV_EXCL = 'HOME_HOST;HOME_GUEST' $env:HOME_HOST = $(cygpath.exe "$env:USERPROFILE") $env:HOME_GUEST = "$env:HOME_HOST" $env:LIMACTL_CREATE_ARGS = '--vm-type=qemu' - $env:_LIMA_WINDOWS_EXTRA_PATH = 'C:\Program Files\Git\usr\bin' bash.exe -c "./hack/test-templates.sh templates/default.yaml" - name: Run BATS port-forwarding test (QEMU, Windows host) run: | - $env:PATH = "$pwd\_output\bin;" + 'C:\msys64\usr\bin;' + 'C:\Program Files\QEMU;' + $env:PATH - $env:MSYS2_ENV_CONV_EXCL = '_LIMA_WINDOWS_EXTRA_PATH' + $env:PATH = "$pwd\_output\bin;" + 'C:\Program Files\QEMU;' + $env:PATH $env:LIMACTL_CREATE_ARGS = '--vm-type=qemu' - $env:_LIMA_WINDOWS_EXTRA_PATH = 'C:\Program Files\Git\usr\bin' bash.exe -c "./hack/bats/lib/bats-core/bin/bats --timing ./hack/bats/tests/port-forwarding.bats" + windows-plain-wsl2: + # Smoke test of Lima's WSL2 driver on a host with only what a default + # Windows 10/11 install ships (native OpenSSH, wsl.exe, tar). If a step + # ever needs MSYS2 or Git for Windows back to pass, Lima has gained a new + # external-binary dependency, and that needs review first. + name: "Windows tests (WSL2, plain Windows host)" + runs-on: windows-2025 + timeout-minutes: 30 + steps: + - name: Set gitconfig + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - uses: $/.github/actions/windows_wsl2_setup + # No windows_plain_templates: wsl2.yaml has no `base:`. + - uses: $/.github/actions/windows_plain_host + - uses: $/.github/actions/windows_plain_build + - uses: $/.github/actions/windows_plain_smoke_test + with: + template: .\templates\experimental\wsl2.yaml + + windows-plain-qemu: + # Like windows-plain-wsl2, for the QEMU driver. The reverse-sshfs mount + # starts through the native sftp-server, but sshocker does not fail when + # the mount check fails, so the mount itself is not covered. + name: "Windows tests (QEMU, plain Windows host)" + runs-on: windows-2025 + timeout-minutes: 30 + steps: + - name: Set gitconfig + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - uses: $/.github/actions/windows_qemu_install + - uses: $/.github/actions/windows_plain_templates + - uses: $/.github/actions/windows_plain_host + # No QEMU on PATH here: limactl appends the default QEMU install dir to + # its own PATH, and this job tests that. + - name: Check that QEMU is not on PATH + run: | + $qemu = Get-Command qemu-system-x86_64 -ErrorAction SilentlyContinue + if ($qemu) { throw "QEMU is on PATH: $($qemu.Source)" } + - uses: $/.github/actions/windows_plain_build + - uses: $/.github/actions/windows_plain_smoke_test + with: + template: .\templates\default.yaml + vm-type: qemu + qemu: name: "Integration tests (QEMU, macOS host)" runs-on: macos-15-large # Intel diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 378f5feb..880fb4ee 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -40,14 +40,6 @@ func main() { yq.MaybeRunYQ() if runtime.GOOS == "windows" { - extras, hasExtra := os.LookupEnv("_LIMA_WINDOWS_EXTRA_PATH") - if hasExtra && strings.TrimSpace(extras) != "" { - p := os.Getenv("PATH") - err := os.Setenv("PATH", strings.TrimSpace(extras)+string(filepath.ListSeparator)+p) - if err != nil { - logrus.Warning("Can't add extras to PATH, relying entirely on system PATH") - } - } p := os.Getenv("PATH") newPath := envutil.AppendDirsToPath(p, envutil.PlatformCommonToolDirs) if newPath != p { diff --git a/pkg/sshutil/sshutil_windows_test.go b/pkg/sshutil/sshutil_windows_test.go index 50e8d762..0c276c94 100644 --- a/pkg/sshutil/sshutil_windows_test.go +++ b/pkg/sshutil/sshutil_windows_test.go @@ -83,6 +83,40 @@ func TestPickCompleteSSHOnWindows(t *testing.T) { t.Setenv("PATH", mingit) assert.Equal(t, pickCompleteSSHOnWindows(), nativeSSH) }) + + t.Run("incomplete native install disqualifies it too", func(t *testing.T) { + fakeRoot := resolvedTempDir(t) + nativeDir := filepath.Join(fakeRoot, "System32", "OpenSSH") + assert.NilError(t, os.MkdirAll(nativeDir, 0o755)) + assert.NilError(t, os.WriteFile(filepath.Join(nativeDir, "ssh.exe"), nil, 0o644)) + t.Setenv("SystemRoot", fakeRoot) + t.Setenv("PATH", "") + + assert.Equal(t, pickCompleteSSHOnWindows(), "", + "an ssh.exe without its companions never qualifies, wherever it lives") + }) +} + +// TestNewSSHExeFallsBackToPATH: when no directory holds a complete install, +// NewSSHExe takes whatever `ssh` PATH resolves. +func TestNewSSHExeFallsBackToPATH(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only PATH walk") + } + + partial := resolvedTempDir(t) + sshExe := filepath.Join(partial, "ssh.exe") + assert.NilError(t, os.WriteFile(sshExe, nil, 0o644)) + + t.Setenv("SystemRoot", resolvedTempDir(t)) + t.Setenv("PATH", partial) + t.Setenv(EnvShellSSH, "") + + assert.Equal(t, pickCompleteSSHOnWindows(), "", "the partial install must not qualify") + + got, err := NewSSHExe() + assert.NilError(t, err) + assert.Equal(t, got.Exe, sshExe) } // TestCygpathForSSH: an ssh.exe next to cygpath.exe is Cygwin-based and diff --git a/website/content/en/docs/config/environment-variables.md b/website/content/en/docs/config/environment-variables.md index 2a213265..61964f9a 100644 --- a/website/content/en/docs/config/environment-variables.md +++ b/website/content/en/docs/config/environment-variables.md @@ -147,20 +147,6 @@ This page documents the environment variables used in Lima. export LIMA_USERNET_RESOLVE_IP_ADDRESS_TIMEOUT=5 ``` -### `_LIMA_WINDOWS_EXTRA_PATH` - -- **Description**: Additional directories which will be added to PATH by `limactl.exe` process to search for tools. - It is useful, when there is a need to prevent collisions between binaries available in active shell and ones - used by `limactl.exe` - injecting them only for the running process w/o altering PATH observed by user shell. - Is is Windows specific and does nothing for other platforms. -- **Default**: unset -- **Usage**: - ```bat - set _LIMA_WINDOWS_EXTRA_PATH=C:\Program Files\Git\usr\bin - ``` -- **Note**: It is an experimental setting and has no guarantees being ever promoted to stable. It may be removed - or changed at any stage of project development. - ### `QEMU_SYSTEM_AARCH64` - **Description**: Path to the `qemu-system-aarch64` binary. diff --git a/website/content/en/docs/releases/breaking.md b/website/content/en/docs/releases/breaking.md index 858a7876..5c56bb8c 100644 --- a/website/content/en/docs/releases/breaking.md +++ b/website/content/en/docs/releases/breaking.md @@ -3,6 +3,11 @@ title: Breaking changes weight: 20 --- +## v2.3.0 +- The experimental `_LIMA_WINDOWS_EXTRA_PATH` environment variable was removed. It prepended directories to + `limactl.exe`'s own PATH on Windows hosts, leaving the calling shell's PATH untouched. Put them on `PATH` + instead. + ## v2.2.0 - The default [`socket_vmnet` group](../config/network/vmnet.md) in `networks.yaml` was changed from `everyone` to `admin`. A non-admin user must set `group` to a group they belong to (e.g. `staff`).