From 0f744ab6fc6924d7b17011040a8dd74281634cc8 Mon Sep 17 00:00:00 2001 From: shujaat_tracebloc <153823837+shujaatTracebloc@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:33:35 +0200 Subject: [PATCH 1/7] chore(installer): honest step labels + per-tool progress in the Windows installer (#422) (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(#422): honest step labels + per-tool heartbeat/progress in the PS installer Step 1/5 "Checking system requirements" actually installed ~700 MB of tools, nearly all console-silent (downloads with the progress overlay off since #471, plus silent winget/Add-AppxPackage/installer invocations), which reads as a hang. The k3d start path also streamed raw INFO[...] lines past the style system. - Split Step 1 into "Checking system requirements" (preflight/GPU/virtualisation) and a dedicated "Installing system tools" step; renumber to /6. - Invoke-WithHeartbeat: run a blocking op in a background job with a live spinner (built on the existing Wait-JobWithProgress) so no op sits silent >10s. Wired into every tool download (kubectl/k3d/helm/winget/Docker Desktop), the winget installs, Add-AppxPackage, and the Docker Desktop installer. - Get-ToolSummaryLine: one honest line per tool (name, version, size, elapsed), printed as each tool becomes ready. - Route `k3d cluster start` through Invoke-WithHeartbeat: capture its raw output to the log + show a styled heartbeat instead of streaming INFO[...] lines (and fail loudly if start fails, instead of always reporting "started"). - Tests: Pester for Get-ToolSummaryLine, Invoke-WithHeartbeat, and source guards for the 6-step split + no-raw-k3d-output. The copy catalog is bash-driven and the bash installer already splits check (step a) from install (step b) with real progress, so its golden is unaffected. Closes #422 Co-Authored-By: Claude Opus 4.8 * fix(#422): job-runspace TLS 1.2 floor + k3d start exit-code check (Bugbot) Two High-severity findings from moving work into Start-Job via Invoke-WithHeartbeat: - TLS 1.2 doesn't carry into job runspaces (PS 5.1 defaults to TLS 1.0/1.1), so in-job HTTPS downloads (kubectl/k3d/helm/winget/Docker Desktop) could fail SSL/TLS on hosts that need the explicit floor. Re-apply Tls12 in $script:JobInit (OR-in, don't clobber), which every job runs before its scriptblock. - A native `k3d cluster start` non-zero exit leaves the job state 'Completed', so Invoke-WithHeartbeat never threw and the installer reported "Compute environment started." on a stopped cluster. The start scriptblock now checks $LASTEXITCODE and throws its captured output, so the existing catch surfaces a real Err. Adds a functional in-job-TLS test and a source guard for the exit-code throw. Co-Authored-By: Claude Opus 4.8 * fix(#422): surface heartbeat failure detail + fail loudly on Docker install (Bugbot) Two follow-on findings from the Start-Job/heartbeat design: - Invoke-WithHeartbeat threw a generic 'Failed while: ...' and swallowed the job's real error (Receive-Job -ErrorAction SilentlyContinue), so the k3d-start detail never reached the log/Err. Now capture output+error (2>&1) and the job's terminating reason, and include it in the throw; the k3d-start catch passes it as Err detail too. - The Docker Desktop installer Start-Process had no -ErrorAction Stop and no exit check, so a spawn/install failure completed the job as success and Step 2 continued. Now -ErrorAction Stop + PassThru + exit-code throw, wrapped so it Errs cleanly with the real detail. Adds a heartbeat failure-detail test + a Docker-installer source guard. Co-Authored-By: Claude Opus 4.8 * fix(#422): print k3d/helm summary only after the execute-gate (Bugbot) k3d and helm printed their green Get-ToolSummaryLine 'ready' line inside the download branch, before Assert-ToolRuns — so a corrupt/wrong-arch binary showed as ready and then failed the gate (kubectl already gates first). Compute the summary at download time (correct elapsed) but defer the Ok until after the execute-gate passes. Adds a source guard. Co-Authored-By: Claude Opus 4.8 * fix(#422): winget Docker install falls back + fails loudly (Bugbot) The winget Docker path soft-logged failures, never checked $LASTEXITCODE, and had no direct-download fallback when winget was present — so a failed winget install let Step 2 continue and only surfaced as the 10-minute Docker-wait timeout later. Now: the winget scriptblock throws on a non-zero exit; if winget is absent OR didn't land the exe, fall through to the direct download (parity with k3d/helm); and a final Test-Path guard Errs immediately if neither path installed Docker. Adds a source guard. Co-Authored-By: Claude Opus 4.8 * fix(#422): run installers as killable processes, not orphan-prone jobs (Bugbot) Start-Process -Wait / winget install inside Invoke-WithHeartbeat (a background job) leaks the child process on timeout: Stop-Job ends the job runspace but the installer keeps running, and the winget path could time out then fall through to a second concurrent install. Switch the Docker Desktop installer + all winget installs (Docker, k3d, helm) to Start-Process -PassThru + Wait-ProcessWithDeadline, which shows the spinner AND kills the actual process on timeout, then checks the exit code. Downloads (Invoke-WebRequest) stay on Invoke-WithHeartbeat — no child process to orphan. Updates the Docker source guards accordingly. Co-Authored-By: Claude Opus 4.8 * fix(#422): run k3d cluster start as a killable process too (Bugbot) Same orphan hazard as the installers: k3d cluster start ran inside Invoke-WithHeartbeat (a job), so Stop-Job on timeout left the native k3d child running. Switch it to Start-Process -PassThru + Wait-ProcessWithDeadline (kills on timeout), redirecting its raw INFO[...] to temp files for the log; check both the deadline and the exit code so a failed/stuck start Errs with the real reason instead of a false 'started'. Now every process-spawning op is killable; only in-runspace downloads + Add-AppxPackage remain on the job-based heartbeat. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 251 ++++++++++++++++++++++++---- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 88 ++++++++++ 3 files changed, 305 insertions(+), 36 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index cc7643bf..bd0a9002 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -171,7 +171,66 @@ function Wait-JobWithProgress { # install (#409). Every Start-Job below passes this as -InitializationScript to # pin the job to a local working directory before it runs anything. (SystemRoot # is always local; the guard makes it a no-op on non-Windows Pester runs.) -$script:JobInit = { if ($env:SystemRoot) { Set-Location $env:SystemRoot } } +$script:JobInit = { + if ($env:SystemRoot) { Set-Location $env:SystemRoot } + # Job runspaces don't inherit the parent's TLS floor (set once at script top). + # Windows PowerShell 5.1 still defaults to TLS 1.0/1.1, which many corporate + # proxies and CDNs reject — so in-job HTTPS downloads (kubectl/k3d/helm/winget/ + # Docker Desktop via Invoke-WithHeartbeat) would fail SSL/TLS without this + # (#422 Bugbot). Re-apply TLS 1.2 (OR-in, don't clobber a higher floor). + try { + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch {} +} + +# One honest line per system tool once it's ready (#422): name, version, and +# whatever of {size, elapsed} is known — so "Installing system tools" shows +# concrete per-tool progress instead of a silent ~700 MB. Pure/formatting-only +# so it is unit-testable. e.g. "kubectl v1.31.0 (~60 MB, 12s)". +function Get-ToolSummaryLine { + param([string]$Name, [string]$Version = "", [string]$Size = "", [int]$ElapsedSec = -1) + $head = if ($Version) { "$Name $Version" } else { "$Name" } + $meta = @() + if ($Size) { $meta += $Size } + if ($ElapsedSec -ge 0) { $meta += ("{0}s" -f $ElapsedSec) } + if ($meta.Count) { return "$head (" + ($meta -join ", ") + ")" } + return $head +} + +# Run a blocking operation with a live spinner heartbeat so Steps 1-2 never sit +# console-silent for more than a couple of seconds (#422): downloads (progress +# overlay is off for speed, #471), winget installs, and the Docker Desktop +# installer are otherwise dead air. The scriptblock runs in a background job +# (jobs don't inherit functions/vars — pass inputs via -ArgumentList) driven by +# Wait-JobWithProgress. Returns the job's output; throws on timeout or job +# failure so callers keep their existing Invoke-WithRetry / try-catch flow. +function Invoke-WithHeartbeat { + param( + [Parameter(Mandatory)][scriptblock]$Script, + [object[]]$ArgumentList = @(), + [string]$Message = "Working", + [int]$TimeoutSec = 1800, + [int]$PollSeconds = 2 + ) + $job = Start-Job -ScriptBlock $Script -ArgumentList $ArgumentList -InitializationScript $script:JobInit + $finished = Wait-JobWithProgress -Job $job -TimeoutSec $TimeoutSec -Message $Message -PollSeconds $PollSeconds + # Capture BOTH output and error records (2>&1) so a failure's real detail + # (e.g. the k3d/installer error the scriptblock threw) can be surfaced, not + # swallowed (#422 Bugbot). The job's terminating exception is the most reliable + # source of the reason. + $out = @(Receive-Job $job -ErrorAction SilentlyContinue 2>&1) + $state = $job.State + $reason = $null + try { $reason = $job.ChildJobs[0].JobStateInfo.Reason.Message } catch {} + Remove-Job $job -Force -ErrorAction SilentlyContinue + if (-not $finished) { throw "Timed out after ${TimeoutSec}s while: ${Message}" } + if ($state -eq 'Failed') { + $detail = if ($reason) { "$reason" } else { ("$($out -join "`n")").Trim() } + throw ("Failed while: ${Message}" + $(if ($detail) { " -- $detail" } else { "" })) + } + return $out +} function Get-WindowsArch { switch ($env:PROCESSOR_ARCHITECTURE) { @@ -646,9 +705,16 @@ function Install-Winget { $url = "https://github.com/microsoft/winget-cli/releases/latest/download/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" $dest = "$env:TEMP\winget-installer.msixbundle" Invoke-WithRetry -Label "winget download" -ScriptBlock { - Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading winget (~200 MB)" ` + -ArgumentList @($url, $dest) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $u -OutFile $d -UseBasicParsing + } } - Add-AppxPackage -Path $dest + # Add-AppxPackage on a ~200 MB bundle is console-silent for a while (#422). + Invoke-WithHeartbeat -Message "Installing winget" -ArgumentList @($dest) -Script { + param($d); Add-AppxPackage -Path $d + } | Out-Null Remove-Item $dest -Force -ErrorAction SilentlyContinue RefreshPath Log "winget installed." @@ -662,23 +728,71 @@ function Install-DockerDesktop { $dockerExe = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe" if (-not (Test-Path $dockerExe)) { + # Try winget first (if present), then fall back to the direct download when + # winget is absent OR its install didn't land the exe — parity with k3d/helm, + # so a swallowed winget failure doesn't leave Step 2 to die in the long + # Docker-wait later (#422 Bugbot). if (Has "winget") { - winget install -e --id Docker.DockerDesktop ` - --accept-package-agreements --accept-source-agreements --silent - } else { + # winget install is console-silent for minutes on a 600 MB package (#422). + # Run it as a tracked PROCESS (not a background job): Wait-ProcessWithDeadline + # shows a spinner AND kills the process on timeout, so a stuck install can't + # orphan past the step and fall through to a second concurrent install — + # Stop-Job would leave the job's child process running (#422 Bugbot). + Info "Installing Docker Desktop (~600 MB via winget) -- several minutes is normal." + try { + $wp = Start-Process -FilePath "winget" -PassThru -ErrorAction Stop -ArgumentList @( + "install","-e","--id","Docker.DockerDesktop", + "--accept-package-agreements","--accept-source-agreements","--silent") + if (-not (Wait-ProcessWithDeadline -Process $wp -Deadline (Get-Date).AddMinutes(40) -Message "Installing Docker Desktop (winget)")) { + throw "winget Docker install timed out (process killed)" + } + if ($wp.ExitCode -ne 0) { throw "winget exited $($wp.ExitCode)" } + } catch { Log "Docker Desktop winget install failed (will try direct download): $_" } + RefreshPath + } + + if (-not (Test-Path $dockerExe)) { $ddArch = Get-WindowsArch # Honest progress (#468): the single biggest download of the install. # Size measured 2026-07-29 (613 MB). Info "Downloading Docker Desktop (~600 MB) -- the biggest download of this install; several minutes is normal." $installer = "$env:TEMP\DockerDesktopInstaller.exe" + $ddUrl = "https://desktop.docker.com/win/main/$ddArch/Docker%20Desktop%20Installer.exe" Invoke-WithRetry -Label "Docker download" -ScriptBlock { - Invoke-WebRequest -Uri "https://desktop.docker.com/win/main/$ddArch/Docker%20Desktop%20Installer.exe" ` - -OutFile $installer -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading Docker Desktop (~600 MB)" -TimeoutSec 2400 ` + -ArgumentList @($ddUrl, $installer) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $u -OutFile $d -UseBasicParsing + } + } + # Run the installer as a tracked PROCESS with a deadline that KILLS it on + # timeout (a background job would orphan the installer, #422 Bugbot). + # -ErrorAction Stop catches a spawn failure; the exit code catches a failed + # install — either way fail loudly, never continue as if Docker installed. + try { + $ip = Start-Process -FilePath $installer -ArgumentList "install --quiet --accept-license" ` + -PassThru -ErrorAction Stop + } catch { + Remove-Item $installer -Force -ErrorAction SilentlyContinue + Err "Docker Desktop installer wouldn't start. Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." "$_" + } + if (-not (Wait-ProcessWithDeadline -Process $ip -Deadline (Get-Date).AddMinutes(40) -Message "Installing Docker Desktop")) { + Remove-Item $installer -Force -ErrorAction SilentlyContinue + Err "Docker Desktop installation timed out (installer stopped). Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." + } + if ($ip.ExitCode -ne 0) { + Remove-Item $installer -Force -ErrorAction SilentlyContinue + Err "Docker Desktop installation failed (installer exited $($ip.ExitCode)). Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." } - Start-Process -FilePath $installer -ArgumentList "install --quiet --accept-license" -Wait Remove-Item $installer -Force -ErrorAction SilentlyContinue + RefreshPath + } + + # Neither winget nor the direct installer produced the exe — fail loudly now + # rather than in the 10-minute Docker-wait below (#422 Bugbot). + if (-not (Test-Path $dockerExe)) { + Err "Docker Desktop installation didn't complete. Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." } - RefreshPath } $dockerRunning = $false @@ -914,11 +1028,16 @@ function Install-Kubectl { (Invoke-WebRequest "https://dl.k8s.io/release/stable.txt" -UseBasicParsing).Content.Trim() } Log "Downloading kubectl $kVer ($arch)..." - Info "Downloading kubectl $kVer (~60 MB)..." $kubectlDest = "$TOOL_DIR\kubectl.exe" + $kUrl = "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe" + $t0 = Get-Date + # Heartbeat during the otherwise-silent transfer (#422); retry wraps it. Invoke-WithRetry -Label "download" -ScriptBlock { - Invoke-WebRequest "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe" ` - -OutFile $kubectlDest -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading kubectl $kVer (~60 MB)" ` + -ArgumentList @($kUrl, $kubectlDest) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest $u -OutFile $d -UseBasicParsing + } } $expectedHash = Invoke-WithRetry -Label "checksum" -ScriptBlock { (Invoke-WebRequest "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe.sha256" ` @@ -932,6 +1051,7 @@ function Install-Kubectl { RefreshPath Log "kubectl $kVer installed." Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client") -BinPath $kubectlDest + Ok (Get-ToolSummaryLine -Name "kubectl" -Version $kVer -Size "~60 MB" -ElapsedSec ([int]((Get-Date) - $t0).TotalSeconds)) } # ── Pinned tool versions (#382 / #410) ────────────────────────────────────── @@ -1006,13 +1126,22 @@ function Install-K3dAndHelm { if (-not (Has "k3d")) { if (Has "winget") { Log "Installing k3d via winget..." - $null = (winget install -e --id Rancher.k3d ` - --accept-package-agreements --accept-source-agreements --silent 2>&1) + # winget install is console-silent; run it as a killable tracked process + # (not a job — Stop-Job would orphan the child on timeout) with a spinner + + # deadline. Best-effort: on failure the direct download below takes over (#422). + try { + $kp = Start-Process -FilePath "winget" -PassThru -ErrorAction Stop -ArgumentList @( + "install","-e","--id","Rancher.k3d","--accept-package-agreements","--accept-source-agreements","--silent") + if (-not (Wait-ProcessWithDeadline -Process $kp -Deadline (Get-Date).AddMinutes(10) -Message "Installing k3d (winget)")) { + throw "k3d winget install timed out (process killed)" + } + } catch { Log "k3d winget install: $_" } } RefreshPath if (-not (Has "k3d")) { $arch = Get-WindowsArch + $t0k3d = Get-Date Log "Downloading k3d binary directly ($arch)..." # Pinned by default (#382 / #410) — no api.github.com on the default path. $k3dVer = Resolve-ToolVersion -Name "k3d" -Value $K3dVersion ` @@ -1021,11 +1150,14 @@ function Install-K3dAndHelm { if (-not $tag) { throw "no Location header on the /releases/latest redirect" } $tag } - Info "Downloading k3d $k3dVer (~25 MB)..." $k3dDest = "$TOOL_DIR\k3d.exe" + $k3dUrl = "https://github.com/k3d-io/k3d/releases/download/$k3dVer/k3d-windows-$arch.exe" Invoke-WithRetry -Label "k3d download" -ScriptBlock { - Invoke-WebRequest "https://github.com/k3d-io/k3d/releases/download/$k3dVer/k3d-windows-$arch.exe" ` - -OutFile $k3dDest -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading k3d $k3dVer (~25 MB)" ` + -ArgumentList @($k3dUrl, $k3dDest) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest $u -OutFile $d -UseBasicParsing + } } # Fail-closed verification, matching the Linux path and the kubectl # precedent: an unfetchable checksums.txt, a missing asset line, or a @@ -1057,16 +1189,29 @@ function Install-K3dAndHelm { } Log "k3d checksum verified." RefreshPath + # Compute the summary now (correct elapsed) but print it only AFTER the + # execute-gate passes — a corrupt/wrong-arch binary must not show a green + # "ready" line before Assert-ToolRuns (#422 Bugbot; kubectl gates first too). + $k3dSummary = Get-ToolSummaryLine -Name "k3d" -Version $k3dVer -Size "~25 MB" -ElapsedSec ([int]((Get-Date) - $t0k3d).TotalSeconds) } } Assert-ToolRuns -Name "k3d" -VersionArgs @("version") -BinPath "$TOOL_DIR\k3d.exe" + if ($k3dSummary) { Ok $k3dSummary } # -- Helm -- if (-not (Has "helm")) { if (Has "winget") { Log "Installing Helm via winget..." - $null = (winget install -e --id Helm.Helm ` - --accept-package-agreements --accept-source-agreements --silent 2>&1) + # winget install is console-silent; killable tracked process + spinner/deadline + # (a job would orphan the child on timeout). Best-effort: the direct download + # below takes over on failure (#422). + try { + $hp = Start-Process -FilePath "winget" -PassThru -ErrorAction Stop -ArgumentList @( + "install","-e","--id","Helm.Helm","--accept-package-agreements","--accept-source-agreements","--silent") + if (-not (Wait-ProcessWithDeadline -Process $hp -Deadline (Get-Date).AddMinutes(10) -Message "Installing Helm (winget)")) { + throw "helm winget install timed out (process killed)" + } + } catch { Log "helm winget install: $_" } RefreshPath } @@ -1080,11 +1225,15 @@ function Install-K3dAndHelm { if (-not $c) { throw "empty helm-latest-version response" } $c } - Info "Downloading Helm $helmVer (~20 MB)..." + $t0helm = Get-Date $helmZip = "$env:TEMP\helm-$helmVer-windows-$arch.zip" + $helmUrl = "https://get.helm.sh/helm-$helmVer-windows-$arch.zip" Invoke-WithRetry -Label "helm download" -ScriptBlock { - Invoke-WebRequest "https://get.helm.sh/helm-$helmVer-windows-$arch.zip" ` - -OutFile $helmZip -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading Helm $helmVer (~20 MB)" ` + -ArgumentList @($helmUrl, $helmZip) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest $u -OutFile $d -UseBasicParsing + } } $helmExtract = "$env:TEMP\helm-extract" if (Test-Path $helmExtract) { Remove-Item $helmExtract -Recurse -Force } @@ -1093,11 +1242,14 @@ function Install-K3dAndHelm { Remove-Item $helmZip -Force -ErrorAction SilentlyContinue Remove-Item $helmExtract -Recurse -Force -ErrorAction SilentlyContinue RefreshPath + # Summary printed only after the execute-gate below (#422 Bugbot). + $helmSummary = Get-ToolSummaryLine -Name "helm" -Version $helmVer -Size "~20 MB" -ElapsedSec ([int]((Get-Date) - $t0helm).TotalSeconds) } if (-not (Has "helm")) { Err "Helm could not be installed. Install manually from https://helm.sh/docs/intro/install/ and re-run." } } Assert-ToolRuns -Name "helm" -VersionArgs @("version") -BinPath "$TOOL_DIR\helm.exe" + if ($helmSummary) { Ok $helmSummary } Ok "System tools" } @@ -1451,7 +1603,32 @@ function New-K3dCluster { Ok "Compute environment already running." } else { Log "Cluster '$CLUSTER_NAME' exists but stopped -- starting..." - k3d cluster start $CLUSTER_NAME + # Run k3d start as a killable tracked PROCESS with a deadline (a background + # job would orphan the native k3d child on timeout, #422 Bugbot), capturing + # its raw INFO[...] to temp files so it goes to the log, not streamed to the + # console. Exit code + timeout are both checked so a failed start Errs with + # the real reason instead of falsely reporting "started". + $startOutFile = Join-Path $env:TEMP "k3d-start-$(Get-Random).log" + $startErrFile = Join-Path $env:TEMP "k3d-start-err-$(Get-Random).log" + $sp = $null + try { + $sp = Start-Process -FilePath "k3d" -ArgumentList @("cluster","start",$CLUSTER_NAME) ` + -NoNewWindow -PassThru -ErrorAction Stop ` + -RedirectStandardOutput $startOutFile -RedirectStandardError $startErrFile + } catch { + Remove-Item $startOutFile, $startErrFile -Force -ErrorAction SilentlyContinue + Err "Couldn't start the existing '$CLUSTER_NAME' environment (k3d wouldn't start). Check Docker is running, then re-run." "$_" + } + $startTimedOut = -not (Wait-ProcessWithDeadline -Process $sp -Deadline (Get-Date).AddMinutes(5) -Message "Starting your secure environment") + $startLog = (("$(Get-Content $startErrFile -Raw -ErrorAction SilentlyContinue)`n$(Get-Content $startOutFile -Raw -ErrorAction SilentlyContinue)")).Trim() + Remove-Item $startOutFile, $startErrFile -Force -ErrorAction SilentlyContinue + if ($startLog) { Log "k3d cluster start: $startLog" } + if ($startTimedOut) { + Err "Starting the existing '$CLUSTER_NAME' environment timed out (k3d stopped). Check Docker is running, then re-run." $startLog + } + if ($sp.ExitCode -ne 0) { + Err "Couldn't start the existing '$CLUSTER_NAME' environment. Check Docker is running, then re-run." $startLog + } Ok "Compute environment started." } @@ -2039,7 +2216,7 @@ function Get-InstalledClientInfo { # adopted - cluster already registered: TB_PROV_ID/TB_PROV_NS, no password # fallback - CLI missing/too old -> the legacy manual prompts in the Helm step function Invoke-ProvisionClient { - Step 4 5 "Registering this machine" + Step 5 6 "Registering this machine" $script:TB_PROV_MODE = "fallback" if (Get-ProvisioningPreset) { @@ -2187,7 +2364,7 @@ function Invoke-ProvisionClient { function Install-ClientHelm { # -- Step 5/5: Install tracebloc client -- - Step 5 5 "Installing tracebloc client" + Step 6 6 "Installing tracebloc client" if (-not (Test-Path $HOST_DATA_DIR)) { New-Item -ItemType Directory -Path $HOST_DATA_DIR -Force | Out-Null @@ -3103,7 +3280,7 @@ function Install-TraceblocCli { # credential in Step 4 (browser sign-in + `client create`). A failed CLI # install is still non-fatal: Step 4 falls back to the legacy manual- # credential flow, so the machine can always be connected. - Step 3 5 "Install the tracebloc CLI" + Step 4 6 "Install the tracebloc CLI" Info "Installing the tracebloc CLI..." @@ -3161,33 +3338,37 @@ Start-InstallLog Print-Banner Print-Roadmap -# -- Step 1/5: Check system requirements -- -Step 1 5 "Checking system requirements" +# -- Step 1/6: Check system requirements (honest split from tool install, #422) -- +Step 1 6 "Checking system requirements" Test-Preflight Find-Gpu Enable-VirtualisationFeatures + +# -- Step 2/6: Install system tools (~700 MB — Docker Desktop, kubectl, k3d, helm; +# each names its wait + shows a heartbeat + prints a summary line, #422) -- +Step 2 6 "Installing system tools" Install-Winget Install-DockerDesktop Install-NvidiaContainerToolkit Install-Kubectl Install-K3dAndHelm -# -- Step 2/5: Set up secure compute environment -- -Step 2 5 "Setting up secure compute environment" +# -- Step 3/6: Set up secure compute environment -- +Step 3 6 "Setting up secure compute environment" New-K3dCluster Install-GpuDevicePlugin Confirm-GpuNode -# -- Step 3/5: install the tracebloc CLI FIRST (#388) — it mints the machine -# credential in Step 4; a CLI-install hiccup degrades Step 4 to the legacy +# -- Step 4/6: install the tracebloc CLI FIRST (#388) — it mints the machine +# credential in Step 5; a CLI-install hiccup degrades Step 5 to the legacy # manual-credential fallback instead of aborting. Install-TraceblocCli -# -- Step 4/5: register this machine (browser sign-in + `client create`; +# -- Step 5/6: register this machine (browser sign-in + `client create`; # env-var credentials skip it; missing/old CLI falls back to manual prompts) -- Invoke-ProvisionClient -# -- Step 5/5 handled inside Install-ClientHelm -- +# -- Step 6/6 handled inside Install-ClientHelm -- Install-ClientHelm # Verify the client actually came up before reporting anything diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index 6d37348e..e14bbcdd 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -0ef884056dadf1232188dccebdae7c497188e67d6e23333c7ee6c0b9984252e9 scripts/install-k8s.ps1 +867236f9a76b93ec9fd48d6f5d649389c6f159ee8d314f70938cb566089c0d1c scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index c974f921..39f7c492 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -28,6 +28,94 @@ Describe "Get-BackendUrl" { It "unknown -> prod" { $env:CLIENT_ENV = "whatever"; Get-BackendUrl | Should -Be "https://api.tracebloc.io/" } } +Describe "Get-ToolSummaryLine (#422 honest per-tool progress)" { + It "name + version + size + elapsed" { + Get-ToolSummaryLine -Name "kubectl" -Version "v1.31.0" -Size "~60 MB" -ElapsedSec 12 | + Should -Be "kubectl v1.31.0 (~60 MB, 12s)" + } + It "name + version only (no meta parens)" { + Get-ToolSummaryLine -Name "helm" -Version "v4.2.3" | Should -Be "helm v4.2.3" + } + It "name only" { Get-ToolSummaryLine -Name "k3d" | Should -Be "k3d" } + It "size without elapsed" { + Get-ToolSummaryLine -Name "k3d" -Version "v5.9.0" -Size "~25 MB" | + Should -Be "k3d v5.9.0 (~25 MB)" + } + It "elapsed 0 is shown (not treated as absent)" { + Get-ToolSummaryLine -Name "helm" -Version "v4.2.3" -ElapsedSec 0 | + Should -Be "helm v4.2.3 (0s)" + } +} + +Describe "Invoke-WithHeartbeat (#422 no silent window)" { + It "returns the operation output" { + (Invoke-WithHeartbeat -Message "adding" -PollSeconds 1 -Script { 40 + 2 }) | Should -Be 42 + } + It "throws when the operation fails (so callers keep retry/abort flow)" { + { Invoke-WithHeartbeat -Message "boom" -PollSeconds 1 -Script { throw "kaboom" } } | Should -Throw + } + It "passes ArgumentList into the job scriptblock" { + (Invoke-WithHeartbeat -Message "args" -PollSeconds 1 -ArgumentList @("a","b") -Script { param($x,$y) "$x$y" }) | + Should -Be "ab" + } + It "job runspaces get the TLS 1.2 floor (Bugbot #422)" { + # Jobs don't inherit the parent's SecurityProtocol; JobInit must re-apply it, + # else in-job HTTPS downloads fail on TLS-1.2-only hosts. + (Invoke-WithHeartbeat -Message "tls" -PollSeconds 1 -Script { [Net.ServicePointManager]::SecurityProtocol.ToString() }) | + Should -Match 'Tls12' + } + It "surfaces the real failure detail, not just a generic message (Bugbot #422)" { + # A failed job's real error must reach the caller (log + Err), not be swallowed. + { Invoke-WithHeartbeat -Message "op" -PollSeconds 1 -Script { throw "REAL_REASON_XYZ" } } | + Should -Throw -ExpectedMessage "*REAL_REASON_XYZ*" + } +} + +Describe "Step honesty (#422 split check vs install)" { + BeforeAll { $script:SRC = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + It "runs six steps, not five" { + $script:SRC | Should -Match 'Step 6 6 "' + $script:SRC | Should -Not -Match 'Step [0-9] 5 "' + } + It "has a dedicated 'Installing system tools' step" { + $script:SRC | Should -Match 'Step 2 6 "Installing system tools"' + } + It "the k3d start path runs as a killable process with output to the log, not streamed (Bugbot #422)" { + # No bare streaming form; k3d start is a tracked process with its raw INFO[...] + # redirected to temp files (logged), so nothing streams to the console. + $script:SRC | Should -Not -Match '(?m)^\s*k3d cluster start \$CLUSTER_NAME\s*$' + $script:SRC | Should -Match 'Start-Process -FilePath "k3d" -ArgumentList @\("cluster","start"' + $script:SRC | Should -Match 'RedirectStandardError \$startErrFile' + } + It "k3d start Errs on timeout or non-zero exit, never a false 'started' (Bugbot #422)" { + # A deadline that KILLS the process (no orphan) plus an exit-code check both + # gate the "started" line. + $script:SRC | Should -Match 'Wait-ProcessWithDeadline -Process \$sp' + $script:SRC | Should -Match '\$sp\.ExitCode -ne 0' + } + It "the Docker installer runs as a killable process, not an orphan-prone job (Bugbot #422)" { + # Start-Process -PassThru + Wait-ProcessWithDeadline (kills on timeout) + an + # exit-code check — a background job would orphan the installer on timeout. + $script:SRC | Should -Match 'Start-Process -FilePath \$installer[\s\S]{0,80}-PassThru -ErrorAction Stop' + $script:SRC | Should -Match 'Wait-ProcessWithDeadline -Process \$ip' + $script:SRC | Should -Match '\$ip\.ExitCode -ne 0' + } + It "k3d/helm print their green summary only after the execute-gate (Bugbot #422)" { + # A corrupt/wrong-arch binary must fail Assert-ToolRuns before any green Ok; + # the summary is deferred to after the gate (kubectl already does this). + $script:SRC | Should -Match 'Assert-ToolRuns -Name "k3d"[\s\S]{0,80}if \(\$k3dSummary\) \{ Ok' + $script:SRC | Should -Match 'Assert-ToolRuns -Name "helm"[\s\S]{0,80}if \(\$helmSummary\) \{ Ok' + } + It "the winget Docker path is killable, checks exit, falls back, and fails loudly (Bugbot #422)" { + # winget runs as a tracked process (killable on timeout), its exit is checked + # (throw -> fallback), and a final Test-Path guard Errs if nothing landed. + $script:SRC | Should -Match 'Start-Process -FilePath "winget"[\s\S]{0,240}Docker\.DockerDesktop' + $script:SRC | Should -Match 'Wait-ProcessWithDeadline -Process \$wp' + $script:SRC | Should -Match '\$wp\.ExitCode -ne 0' + $script:SRC | Should -Match "Docker Desktop installation didn't complete" + } +} + Describe "Get-ErrDetailLines (#423 honest failure output)" { BeforeEach { $script:LOG_FILE = "C:\Users\x\.tracebloc\install-20260729-000000.log" } AfterEach { $script:LOG_FILE = $null } From 51278d52ee7f1b71febc6d24c054fc24407ea56b Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:43:07 +0200 Subject: [PATCH 2/7] fix(installer): neutral Tier-1 rootless header - no early no-admin promise (#481) Bugbot on the staging promotion (#480): the Tier-1 branch printed 'no administrator rights needed' BEFORE _ensure_subid_ranges / _ensure_cgroup_delegation ran - on hosts where either fires, the operator saw a no-admin promise immediately contradicted by an announced sudo touch or a prepare-host handoff. The header now stays neutral ('user-space install'); the two prerequisite helpers already announce themselves or hand off when they actually apply. Tier 0's claim is unconditionally true and stays. Manifest regenerated. --- scripts/lib/setup-linux.sh | 7 ++++++- scripts/manifest.sha256 | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/lib/setup-linux.sh b/scripts/lib/setup-linux.sh index 8ff766f8..ba3fce92 100644 --- a/scripts/lib/setup-linux.sh +++ b/scripts/lib/setup-linux.sh @@ -1111,7 +1111,12 @@ install_linux() { # two announced prerequisite touches (_ensure_subid_ranges + _ensure_cgroup_ # delegation), each of which routes to prepare-host when unprivileged. if _rootless_active; then - info "Setting up a rootless container runtime — no administrator rights needed." + # Header stays neutral on privileges: unlike Tier 0 above, this branch has + # two conditional privileged prerequisites (subuid ranges, cgroup + # delegation). Each announces itself or hands off to prepare-host when it + # actually applies — promising "no admin" here first read as a + # contradiction on hosts where one fires (Bugbot, #480). + info "Setting up a rootless container runtime (user-space install)." _ensure_subid_ranges # RFC 0001 #1220: the one narrow privileged residue — gated + announced, or handed off _ensure_cgroup_delegation || true # RFC 0001 #1221: delegate cpu/cpuset/io so pod CPU/mem limits enforce (best-effort; routes to prepare-host if unprivileged) install_rootless_docker diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index e14bbcdd..5939ae4d 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -5,7 +5,7 @@ c29c4fafe6691bfbfb6f90d761aa0650d79d0a5962a599926284d9e3e6a10006 scripts/instal 7977ef12a16fc6aee1c2824cae13bd1450f7fe1c1345323292e4a14decbfe83f scripts/lib/gpu-nvidia.sh b569eec2d8ffb9673da287a2a59d249a7dbc7236c98ab6a5062136bcc69a942c scripts/lib/gpu-amd.sh 76f0d4230d4aff510114968a477ef60e28860d1827c8ff36853a3f59d30be617 scripts/lib/setup-macos.sh -b242727eb3d0c8a3eb721349fb540408386c3ab6ffa1628247e6556211c77258 scripts/lib/setup-linux.sh +b8e88eb2b134f3dd5466026ca14464b6b6af42f077f57a6b0bbaaae9dfab17c3 scripts/lib/setup-linux.sh 1bdf2bf09c07f096551a9405af232f19f11649064f9e5123b4eca0f9f55ab20d scripts/lib/cluster.sh 045caf6efeb583e5005d881d9281edae6a6aedf8f318b0a4021964b3b4b29cc5 scripts/lib/gpu-plugins.sh e673941dd9d63b2fcbb2051a1c3a86ae9a7ef6b780b9f52b34f37bfdefd66948 scripts/lib/install-client-helm.sh From 40af07f47fac550cb9e99f68df9cf85d78841e11 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:48:56 +0200 Subject: [PATCH 3/7] chore(release): bump chart to 1.9.8 for next cycle (#482) --- client/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/Chart.yaml b/client/Chart.yaml index 97d44b3a..5d3d77d6 100644 --- a/client/Chart.yaml +++ b/client/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: client description: A unified Helm chart for tracebloc on AKS, EKS, bare-metal, and OpenShift type: application -version: 1.9.7 -appVersion: "1.9.7" +version: 1.9.8 +appVersion: "1.9.8" keywords: - tracebloc - kubernetes From 40bed86f80460683b800628b0ecf9418a5b0b087 Mon Sep 17 00:00:00 2001 From: shujaat_tracebloc <153823837+shujaatTracebloc@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:27:07 +0200 Subject: [PATCH 4/7] fix(installer): report host RAM consistently + achievable memory advice (#417) (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#417): report host RAM consistently + achievable memory advice The preflight memory check preferred Docker's WSL2 VM budget over physical RAM, so the same 15 GB laptop reported "7 GB" with Docker up and "15 GB" with it down -- flip-flopping across re-runs -- and recommended "give Docker >= 16 GB" on a 15 GB host (impossible). - Get-PfMemGb now returns HOST RAM only (physical, via CIM) -- identical whether Docker is up or down. The runtime VM budget is read separately (Get-PfRuntimeMemGb) and shown as its own labeled line ("Docker's current share: N GB"). - Get-PfMemRecommendation caps every suggestion at (host - 2 GB), so we never advise more memory than the machine physically has; floors at 1 GB. - Step-1 (Test-Preflight) and Step-2 (Test-PreflightRuntimeMem) now give one consistent, host-aware message; Step-2's recommendation is capped too. Tests: Get-PfMemRecommendation (cap/floor/16-on-15 cases), Get-PfMemGb reports host RAM regardless of the Docker budget (Windows + cross-platform decoupling), and Test-PreflightRuntimeMem caps its recommendation at host RAM. Updated the former "Get-PfMemGb prefers docker" test (it asserted the flip-flop bug). Closes #417 Co-Authored-By: Claude Opus 4.8 * fix(#417): don't dangle unachievable memory advice on too-small hosts (Bugbot) Two follow-ups to the capped-recommendation logic: - Step-1's middle branch (host below the training threshold) told 5-7 GB hosts to give Docker host-2 GB (3-5 GB) 'to train locally' — which can't train (~8 GB/job). It now states the truth: runs fine, but local training needs a bigger machine (~warnMemGb+2 GB+), with no impossible target. - Test-PreflightRuntimeMem said 'Raise Docker to N' even when N <= the current budget (a no-op on a host already at its achievable cap). It now only recommends raising when that's actually possible; otherwise it names the real fix (more RAM). Tests updated: the capped-rec test uses a 9 GB host (cap 7, not the 8 target), and a new test asserts no no-op 'raise to' when already at the cap. Co-Authored-By: Claude Opus 4.8 * fix(#417): training-warn threshold accounts for the OS reserve (Bugbot) Step-1 marked memory Ok at host >= warnMemGb (8), but sparing an 8 GB Docker budget also needs ~2 GB for the OS (the cap in Get-PfMemRecommendation), so an 8-9 GB host got a green check that Step-2 then contradicted with 'can't spare more'. Extend the too-small-for-training branch to host < warnMemGb + 2 so Step-1 agrees with Step-2. Adds a test that a 9 GB host is flagged, not Ok. Co-Authored-By: Claude Opus 4.8 * test(#417): make the host-RAM decoupling test host-independent (Bugbot) The cross-platform decoupling test asserted Get-PfMemGb -Not -Be 8 while mocking docker to 8 GiB but not CIM, so on a real 8 GB Windows host (where host RAM is genuinely 8) it would flakily fail even though the fix is correct. Assert instead that Get-PfMemGb never invokes docker (Should -Invoke docker -Times 0) - the true decoupling guarantee, host-independent - and add a separate positive test that Get-PfRuntimeMemGb still follows the docker budget. The exact host figure stays locked by the Windows-gated CIM-mocked sibling test. Co-Authored-By: Claude Opus 4.8 * fix(#417): grade the effective memory figure, keep host RAM as the label (Asad) Reworked per review: Step-1 graded host RAM and demoted the Docker budget to a decorative string, so a throttled budget (e.g. 32 GB host / 2 GB Docker) showed a green Ok and the 15/7 machine from #417 lost its warning. New Show-MemoryStatus (shared by Step-1 and the post-Docker re-check): - Grades the EFFECTIVE figure the client actually gets (Docker's VM budget when known, else host RAM), so a throttled budget is never green-OK'd; both the min 'will OOM' and warn 'training may OOM' floors apply to the budget. - Always REPORTS host RAM as the label (no flip-flop); when host RAM is unreadable (CIM blocked) but the budget is, reports the budget labelled as Docker's share instead of skipping. - Threads recMemGb back into the training target (was dead on Windows), capped at host - OS reserve, so the number is achievable (13 on a 15 GB host, not 10/16). - Single $script:PfOsReserveGb constant (was the literal 2 in three places); the warnMemGb+2 rung is gone, so PF_WARN_MEM_GB no longer means two things by OS. Tests: comprehensive Show-MemoryStatus grading (reviewer's 32/2, 16/4, 15/7, 10/5, host-down, CIM-blocked, healthy) + Step-1/Step-2 delegation. Co-Authored-By: Claude Opus 4.8 * fix(#417): don't cap memory advice at the throttled budget when host RAM is unknown (Bugbot) When CIM was blocked (host RAM unreadable), $capHost fell back to the Docker budget, so recommendations were capped at (budget - reserve) -- producing backwards, contradictory hints like 'Give Docker at least 5 GB (up to 2 GB)' on a 4 GB budget. The budget is the current throttled value, not a ceiling. Now only cap at the host when host RAM is known; when it isn't, advise the raw targets (at least minMemGb, up to warnMemGb). Adds a regression test. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 119 +++++++++++++++++++++------- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 100 ++++++++++++++++++++++- 3 files changed, 189 insertions(+), 32 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index bd0a9002..062a59c2 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -2950,13 +2950,87 @@ function Get-PfRuntimeCpu { return $null } -# Prefer the runtime view, fall back to the host (CIM). +# Total physical HOST RAM in GB — the consistent memory figure we report, whether +# or not Docker is up (#417). The container runtime's smaller VM budget is read +# separately via Get-PfRuntimeMemGb and shown as its own labeled line, so the +# reported host RAM never flip-flops across re-runs. $null if undeterminable. function Get-PfMemGb { - $r = Get-PfRuntimeMemGb; if ($null -ne $r) { return $r } try { return [math]::Floor((Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).TotalPhysicalMemory / 1GB) } catch { return $null } } +# Single source for the RAM we assume the host OS needs, so Docker is never +# advised to take all of it. Used to cap recommendations AND to reason about the +# achievable budget in one place, so the two can't drift (#417 reviewer). +$script:PfOsReserveGb = 2 + +# Cap a desired Docker-memory recommendation at what the host can actually give +# (physical RAM minus the OS reserve), so we never advise more than the machine +# physically has — e.g. "give Docker 16 GB" on a 15 GB laptop (#417). Floors at +# 1 GB so a tiny host still yields a positive number. +function Get-PfMemRecommendation([int]$DesiredGb, [int]$HostGb) { + $cap = $HostGb - $script:PfOsReserveGb + if ($cap -lt 1) { $cap = 1 } + if ($DesiredGb -lt $cap) { return $DesiredGb } + return $cap +} + +# Assess memory and print the consistent warn/ok line(s) (#417). Grades the +# EFFECTIVE figure the client actually gets — Docker's VM budget when known, else +# host RAM — so a throttled budget is never green-OK'd (reviewer). ALWAYS reports +# host RAM as the label so the number doesn't flip-flop across re-runs; if host RAM +# is unreadable (locked-down machine) but the budget is, reports the budget, +# labelled as Docker's share. Warn-only. Shared by Step-1 preflight and the +# post-Docker re-check so their wording never diverges. +function Show-MemoryStatus { + param($HostGb, $BudgetGb) # either may be $null + $minMemGb = if ($env:PF_MIN_MEM_GB) { [int]$env:PF_MIN_MEM_GB } else { 5 } + $warnMemGb = if ($env:PF_WARN_MEM_GB) { [int]$env:PF_WARN_MEM_GB } else { 8 } + $recMemGb = if ($env:PF_REC_MEM_GB) { [int]$env:PF_REC_MEM_GB } else { 16 } + + # Effective = what the client actually gets; grade on this. + $effective = if ($null -ne $BudgetGb) { $BudgetGb } elseif ($null -ne $HostGb) { $HostGb } else { $null } + if ($null -eq $effective) { Warn "Memory: couldn't determine total RAM (skipping)."; return } + + # Label = host RAM (consistent). Host unreadable but budget known -> report the budget. + if ($null -ne $HostGb) { + $label = "$HostGb GB" + $budgetNote = if ($null -ne $BudgetGb) { " (Docker's current share: $BudgetGb GB)" } else { "" } + } else { + $label = "$BudgetGb GB" + $budgetNote = " (Docker's share; host RAM unreadable)" + } + # Cap recommendations at the host ceiling ONLY when host RAM is known. When it's + # unreadable we have no ceiling (the budget is the current throttled value, not + # the max), so advise the raw targets rather than capping at the budget -- which + # produced backwards hints like "at least 5 GB (up to 2 GB)" (#483 Bugbot). + if ($null -ne $HostGb) { + $recTrain = Get-PfMemRecommendation -DesiredGb $recMemGb -HostGb $HostGb + $recRun = Get-PfMemRecommendation -DesiredGb $warnMemGb -HostGb $HostGb + } else { + $recTrain = $recMemGb + $recRun = $warnMemGb + } + # A throttled Docker budget is fixed at the daemon; a small host needs more RAM. + $budgetIsBottleneck = ($null -ne $BudgetGb) -and ($null -eq $HostGb -or $BudgetGb -lt $HostGb) + + if ($effective -lt $minMemGb) { + Warn "Memory: $label$budgetNote - below the $minMemGb GB the client needs; it will OOM." + if ($budgetIsBottleneck) { + Hint "Give Docker at least $minMemGb GB (up to $recRun GB): WSL2 backend - [wsl2] memory=${recRun}GB in %UserProfile%\.wslconfig + 'wsl --shutdown'; Hyper-V - Docker Desktop -> Settings -> Resources -> Advanced." + } else { + Hint "This machine has $label of RAM total; the client needs at least $minMemGb GB. Free up memory or use a larger machine." + } + } + elseif ($effective -lt $warnMemGb) { + Warn "Memory: $label$budgetNote - enough to run the client, but training (~8 GB/job) may OOM; $recTrain GB recommended to train locally." + Hint "For local training, give Docker up to $recTrain GB: WSL2 backend - [wsl2] memory=${recTrain}GB in %UserProfile%\.wslconfig + 'wsl --shutdown'; Hyper-V - Docker Desktop -> Settings -> Resources -> Advanced." + } + else { + Ok "Memory: $label$budgetNote" + } +} + function Get-PfCpu { $r = Get-PfRuntimeCpu; if ($null -ne $r) { return $r } try { return [int](Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).NumberOfLogicalProcessors } @@ -2983,9 +3057,8 @@ function Test-Preflight { $minDiskGb = if ($env:PF_MIN_DISK_GB) { [int]$env:PF_MIN_DISK_GB } else { 10 } $warnDiskGb = if ($env:PF_WARN_DISK_GB) { [int]$env:PF_WARN_DISK_GB } else { 20 } - $minMemGb = if ($env:PF_MIN_MEM_GB) { [int]$env:PF_MIN_MEM_GB } else { 5 } - $warnMemGb = if ($env:PF_WARN_MEM_GB) { [int]$env:PF_WARN_MEM_GB } else { 8 } - $recMemGb = if ($env:PF_REC_MEM_GB) { [int]$env:PF_REC_MEM_GB } else { 16 } + # Memory thresholds live in Show-MemoryStatus (it reads the PF_*_MEM_GB env vars + # itself), so they aren't declared here anymore (#417 reviewer). $minCpu = if ($env:PF_MIN_CPU) { [int]$env:PF_MIN_CPU } else { 2 } $recCpu = if ($env:PF_REC_CPU) { [int]$env:PF_REC_CPU } else { 4 } $hardFail = 0 @@ -3023,21 +3096,13 @@ function Test-Preflight { elseif ($cpu -lt $recCpu) { Warn "CPU: $cpu cores - fine to run; $recCpu+ recommended to train locally." } else { Ok "CPU: $cpu cores" } - # Memory is warn-only on Windows: at preflight the Docker Desktop / WSL2 daemon may - # be down (so this is host RAM); the post-Docker re-check sees the real VM budget. - $mem = Get-PfMemGb - if ($null -eq $mem) { Warn "Memory: couldn't determine total RAM (skipping)." } - elseif ($mem -lt $minMemGb) { - Warn "Memory: $mem GB - below the $minMemGb GB the client needs; it will OOM." - Hint "Give Docker more memory (>= $warnMemGb GB; $recMemGb GB to train), then re-run:" - Hint " WSL2 backend (the default): set [wsl2] memory=${warnMemGb}GB in %UserProfile%\.wslconfig, run 'wsl --shutdown', restart Docker Desktop." - Hint " Hyper-V backend: Docker Desktop -> Settings -> Resources -> Advanced." - } - elseif ($mem -lt $warnMemGb) { - Warn "Memory: $mem GB - enough to run, but training (~8 GB/job) may OOM; $recMemGb GB recommended to train locally." - Hint "To train locally give Docker >= $recMemGb GB: WSL2 backend - [wsl2] memory=${recMemGb}GB in %UserProfile%\.wslconfig + 'wsl --shutdown'; Hyper-V backend - Docker Desktop -> Settings -> Resources -> Advanced." - } - else { Ok "Memory: $mem GB" } + # Memory (warn-only on Windows). Report HOST RAM as the label so the number is + # identical whether Docker is up or down (#417), but GRADE the effective figure + # the client actually gets — Docker's VM budget when the daemon is already up at + # preflight, else host RAM — so a throttled budget is never green-OK'd (reviewer). + # At preflight Docker is usually down, so this grades host; Test-PreflightRuntimeMem + # re-runs the same assessment once Docker is up and its budget is known. + Show-MemoryStatus -HostGb (Get-PfMemGb) -BudgetGb (Get-PfRuntimeMemGb) $disk = Get-PfFreeGb if ($null -eq $disk) { Warn "Disk: couldn't determine free space (skipping)." } @@ -3118,14 +3183,12 @@ function Test-Preflight { # Docker, so aborting here would be jarring. function Test-PreflightRuntimeMem { if ($env:TRACEBLOC_SKIP_PREFLIGHT) { return } - $mem = Get-PfRuntimeMemGb - if ($null -eq $mem) { return } - $warnMemGb = if ($env:PF_WARN_MEM_GB) { [int]$env:PF_WARN_MEM_GB } else { 8 } - $recMemGb = if ($env:PF_REC_MEM_GB) { [int]$env:PF_REC_MEM_GB } else { 16 } - if ($mem -lt $warnMemGb) { - Warn "Docker is running with $mem GB - recommended >= $warnMemGb GB ($recMemGb GB to train); the client may OOM under load." - Hint "Give Docker >= $warnMemGb GB, then re-install: WSL2 backend - [wsl2] memory=${warnMemGb}GB in %UserProfile%\.wslconfig + 'wsl --shutdown'; Hyper-V backend - Docker Desktop -> Settings -> Resources -> Advanced." - } + $budget = Get-PfRuntimeMemGb + if ($null -eq $budget) { return } + # Re-run the SAME assessment now that Docker's budget is known, so both floors + # (min "will OOM" + warn "training may OOM") apply to the budget and the wording + # matches Step-1 (#417 reviewer). Host RAM stays the reported label. + Show-MemoryStatus -HostGb (Get-PfMemGb) -BudgetGb $budget } # ============================================================================= diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index 5939ae4d..50444f1d 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -867236f9a76b93ec9fd48d6f5d649389c6f159ee8d314f70938cb566089c0d1c scripts/install-k8s.ps1 +c2f6cbbab2e0172b2aa42c4f93f02e803150f0d85e4ea83b5fc0a5cc302ca5ee scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index 39f7c492..5c36ae17 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -974,6 +974,13 @@ Describe "Get-Pf* resource readers" -Skip:(-not $IsWindows) { Mock Get-CimInstance { [pscustomobject]@{ TotalPhysicalMemory = 8GB } } Get-PfMemGb | Should -Be 8 } + It "Get-PfMemGb reports host RAM even when Docker reports a smaller budget (#417)" { + # The flip-flop bug: same 16 GB host read as ~8 GB while Docker was up. Now the + # host figure wins regardless of the Docker VM budget. + Mock Get-CimInstance { [pscustomobject]@{ TotalPhysicalMemory = 16GB } } + Mock docker { '8589934592' } # Docker would report 8 GiB; must be IGNORED + Get-PfMemGb | Should -Be 16 + } It "Get-PfFreeGb reads free disk in GB" { Mock Get-CimInstance { [pscustomobject]@{ FreeSpace = 50GB } } Get-PfFreeGb | Should -Be 50 @@ -1035,6 +1042,15 @@ Describe "Test-Preflight" { Mock Test-PfUrl { "ok" }; Mock Get-PfMemGb { 3 } { Test-Preflight } | Should -Not -Throw } + It "a throttled Docker budget warns even on a large host (not a green Ok) (#417 reviewer)" { + # The reviewer's key case: 32 GB host but Docker throttled to 2 GB. Grading the + # EFFECTIVE figure must OOM-warn (budget < the 5 GB floor), not green-OK the host. + Mock Test-PfUrl { "ok" }; Mock Get-PfMemGb { 32 }; Mock Get-PfRuntimeMemGb { 2 } + $out = (Test-Preflight 6>&1 | Out-String) + $out | Should -Match 'it will OOM' + $out | Should -Match "Docker's current share: 2 GB" # budget named + $out | Should -Match '32 GB' # host RAM still the label + } It "PF_MIN_MEM_GB override relaxes the floor" { Mock Test-PfUrl { "ok" }; Mock Get-PfMemGb { 3 }; $env:PF_MIN_MEM_GB = "2" { Test-Preflight } | Should -Not -Throw @@ -1073,9 +1089,18 @@ Describe "Get-PfFsType" -Skip:(-not $IsWindows) { } Describe "Get-Pf* runtime (Docker VM) view preference" { - It "Get-PfMemGb prefers docker MemTotal over the host" { + It "Get-PfRuntimeMemGb follows the docker MemTotal (#417)" { Mock docker { '8589934592' } # 8 GiB, in bytes - Get-PfMemGb | Should -Be 8 + Get-PfRuntimeMemGb | Should -Be 8 + } + It "Get-PfMemGb never consults the Docker VM budget (#417 no flip-flop)" { + # Host-independent + cross-platform: the flip-flop bug was Get-PfMemGb reading + # the docker budget. Prove it's decoupled by asserting Get-PfMemGb never calls + # docker at all. Avoids the flaky "Should -Not -Be 8" on a real 8 GB host; the + # exact host figure is locked by the Windows-gated CIM-mocked sibling test. + Mock docker { '8589934592' } + $null = Get-PfMemGb + Should -Invoke docker -Times 0 } It "Get-PfCpu prefers docker NCPU over the host" { Mock docker { '2' } @@ -1091,15 +1116,84 @@ Describe "Get-Pf* runtime (Docker VM) view preference" { } } +Describe "Get-PfMemRecommendation (#417 achievable memory advice)" { + It "caps the recommendation at host RAM - 2 GB" { + Get-PfMemRecommendation -DesiredGb 16 -HostGb 15 | Should -Be 13 + } + It "16 GB target on a 15 GB host -> 13, never the impossible 16 (the reported bug)" { + Get-PfMemRecommendation -DesiredGb 16 -HostGb 15 | Should -Not -Be 16 + } + It "returns the desired value untouched when it fits" { + Get-PfMemRecommendation -DesiredGb 8 -HostGb 32 | Should -Be 8 + } + It "floors at 1 GB on a tiny host (never zero/negative)" { + Get-PfMemRecommendation -DesiredGb 8 -HostGb 2 | Should -Be 1 + } +} + +Describe "Show-MemoryStatus (#417 grade effective, label host)" { + It "throttled budget on a big host -> OOM-gated, host labeled, budget named (reviewer 32/2)" { + $out = (Show-MemoryStatus -HostGb 32 -BudgetGb 2 6>&1 | Out-String) + $out | Should -Match '32 GB' # host RAM = the label + $out | Should -Match "Docker's current share: 2 GB" # budget shown + $out | Should -Match 'it will OOM' # graded on the 2 GB budget + } + It "the #417 machine (15 GB host / 7 GB budget) warns with a capped 13 GB target" { + $out = (Show-MemoryStatus -HostGb 15 -BudgetGb 7 6>&1 | Out-String) + $out | Should -Match 'training .* may OOM' + $out | Should -Match '13 GB recommended' # min(recMemGb 16, host-2 13) + $out | Should -Not -Match '16 GB recommended' # never more than the host has + } + It "Docker down (budget null) -> grades + reports host RAM, no flip-flop" { + $out = (Show-MemoryStatus -HostGb 15 -BudgetGb $null 6>&1 | Out-String) + $out | Should -Match 'Memory: 15 GB' + $out | Should -Not -Match "Docker's current share" + } + It "host unreadable (CIM blocked) but budget known -> reports the budget, still gated" { + $out = (Show-MemoryStatus -HostGb $null -BudgetGb 4 6>&1 | Out-String) + $out | Should -Match 'Memory: 4 GB' + $out | Should -Match 'host RAM unreadable' + $out | Should -Match 'it will OOM' # 4 < 5 floor still applies + } + It "host unknown -> advice isn't capped at the (throttled) budget (#483 Bugbot)" { + # No host ceiling is known, so recommend the raw targets, never a backwards + # "at least 5 GB (up to 2 GB)" derived from the current 4 GB budget. + $out = (Show-MemoryStatus -HostGb $null -BudgetGb 4 6>&1 | Out-String) + $out | Should -Match 'at least 5 GB \(up to 8 GB\)' + $out | Should -Not -Match 'up to 2 GB' + } + It "both unreadable -> skips (couldn't determine)" { + $out = (Show-MemoryStatus -HostGb $null -BudgetGb $null 6>&1 | Out-String) + $out | Should -Match "couldn't determine total RAM" + } + It "healthy host + healthy budget -> green Ok" { + (Show-MemoryStatus -HostGb 32 -BudgetGb 24 6>&1 | Out-String) | Should -Match 'Memory: 32 GB' + (Show-MemoryStatus -HostGb 32 -BudgetGb 24 6>&1 | Out-String) | Should -Not -Match 'OOM' + } +} + Describe "Test-PreflightRuntimeMem (post-Docker, warn-only)" { It "small Docker VM -> warns, does not throw" { - Mock Get-PfRuntimeMemGb { 4 } + Mock Get-PfRuntimeMemGb { 4 }; Mock Get-PfMemGb { 16 } { Test-PreflightRuntimeMem } | Should -Not -Throw } It "daemon not reporting (null) -> no-op, does not throw" { Mock Get-PfRuntimeMemGb { $null } { Test-PreflightRuntimeMem } | Should -Not -Throw } + It "grades the budget with both floors and caps the rec at host RAM (#417 reviewer)" { + Mock Get-PfRuntimeMemGb { 7 } # budget in the training-warn band + Mock Get-PfMemGb { 15 } # host -> cap the rec at 13 GB + $out = (Test-PreflightRuntimeMem 6>&1 | Out-String) + $out | Should -Match '13 GB recommended' + $out | Should -Not -Match '16 GB recommended' + $out | Should -Match "Docker's current share: 7 GB" + } + It "a below-floor budget OOM-warns (min floor applies to the budget, not just warn) (#417 reviewer)" { + Mock Get-PfRuntimeMemGb { 2 }; Mock Get-PfMemGb { 32 } + $out = (Test-PreflightRuntimeMem 6>&1 | Out-String) + $out | Should -Match 'it will OOM' + } } # --- reboot persistence (Set-ClusterAutostart) ------------------------------- From 28737fd916024256011553b3de1ee0bd9d6c6cfb Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:32:36 +0200 Subject: [PATCH 5/7] =?UTF-8?q?ci:=20arm=20the=20code-quality=20gate=20?= =?UTF-8?q?=E2=80=94=20soft-fail=20off,=20findings=20block=20(backend#1303?= =?UTF-8?q?)=20(#486)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog at zero fleet-wide; the quality contexts are already required on develop. Also adds a workflow_dispatch(all-files) trigger for whole-tree scans (gitleaks baseline). Co-authored-by: Claude Opus 4.8 --- .github/workflows/code-quality-caller.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-quality-caller.yml b/.github/workflows/code-quality-caller.yml index ba1b1618..cec0a446 100644 --- a/.github/workflows/code-quality-caller.yml +++ b/.github/workflows/code-quality-caller.yml @@ -3,6 +3,14 @@ name: Code quality on: pull_request: types: [opened, reopened, synchronize, ready_for_review] + # Manual whole-tree scan (gitleaks baseline etc.) -- runs every enabled + # job in all-files mode instead of a PR diff. + workflow_dispatch: + inputs: + all-files: + description: "Scan the whole repo, not a diff" + type: boolean + default: true # Supersede the previous run when a branch is pushed again. Measured: # workflows missing this stack ~10-minute duplicate runs per push. @@ -18,4 +26,7 @@ jobs: uses: tracebloc/.github/.github/workflows/code-quality.yml@main with: shell: true # repos with shell scripts - # soft-fail: false # flip once the backlog is clear + # The gate is armed: findings fail the job. Backlog cleared to zero + # fleet-wide + advisory soak done (backend#1303). + soft-fail: false + all-files: ${{ inputs.all-files || false }} From 793daae4f677a5f11d8766b9581b5b3db3df3c7e Mon Sep 17 00:00:00 2001 From: shujaat_tracebloc <153823837+shujaatTracebloc@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:27:47 +0200 Subject: [PATCH 6/7] fix(installer): WSL update survives Store-blocked networks + skips when current (#414) (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#414): WSL update survives Store-blocked networks + skips when current The installer ran `wsl --update` through the Microsoft Store with a 90s silent- timeout job: on Store-blocked corporate networks it silently skipped (Docker Desktop then confronted the user with its own install-WSL prompt + reboot), it re-ran up to 90s on every re-run even when the kernel was current, and its output went only to the log. New Update-Wsl: - Skips when WSL is already current (Test-WslCurrent parses `wsl --version`), so the block finishes in <2s on a re-run. - Uses `wsl --update --web-download`, which fetches from Microsoft's servers instead of the Store, so a Store-blocked machine still updates the kernel with no Docker Desktop WSL prompt. Runs as a killable tracked process with a deadline. - On failure, surfaces the exact manual MSI step on screen (github.com/microsoft/ WSL/releases), not swallowed to the log. Scope note: the issue also suggested auto-falling-back to the GitHub-releases MSI. That isn't implemented automatically because it would require api.github.com (the WSL asset name carries a 4th version component the API-free /releases/latest redirect can't resolve), and #410 -- enforced by a test -- forbids the rate-limited GitHub API in this installer. The manual step is surfaced clearly instead; a test guards against a regression that re-adds the API. Tests: Test-WslCurrent parsing; source guards for --web-download, skip-when-current, no bare Store-path job, the manual step, and the #410 no-API invariant. Closes #414 Co-Authored-By: Claude Opus 4.8 * fix(#414): decode wsl --version as UTF-16 so skip-when-current fires (Bugbot) wsl.exe writes UTF-16LE; capturing it via 'cmd /c ... | Out-String' left the output null-interleaved, so Test-WslCurrent never matched -- skip-when-current never fired and every re-run attempted a full (up to 5 min) web update and could show a false MSI warning. Capture wsl --version with [Console]::OutputEncoding set to Unicode (the same pattern the wsl --list reader already uses), restored in a finally. Adds a source guard. Co-Authored-By: Claude Opus 4.8 * fix(#414): name the arch-matched WSL MSI in the manual hint (Bugbot) The manual fallback hint hardcoded wsl..x64.msi, but Get-WindowsArch returns arm64 on ARM hosts and GitHub ships wsl..arm64.msi. An ARM operator following the x64 step installs the wrong package and still hits the Docker Desktop WSL prompt this path avoids. Compute the MSI arch from the host. Co-Authored-By: Claude Opus 4.8 * fix(#414): detect WSL via the version number, not the localized label (Bugbot) Test-WslCurrent matched the English 'WSL version:' label, but wsl --version localizes it (e.g. Japanese 'WSL バージョン:'), so skip-when-current never fired on non-English Windows and every re-run attempted the full web update. Match the dotted version number instead, which modern WSL always prints regardless of locale. Adds a non-English test case. Co-Authored-By: Claude Opus 4.8 * fix(#414): harden WSL update per review — floor, bounded probe, retry, real errors Reworked Update-Wsl to address Asad's review: - Test-WslCurrent now grades a version FLOOR, not mere presence: it pulls the first dotted version (the WSL version line, locale-independent) and requires >= TB_WSL_MIN_VERSION (default 2.1.0), so a stale modern WSL (2.0.x) still updates instead of being green-OK'd forever. - The wsl --version probe is BOUNDED: Get-WslVersionOutput runs it in a job with Wait-JobWithProgress -TimeoutSec 20 (like the wsl --list reader) and returns "" on timeout, so a wedged LxssManager can't freeze Step 1. The encoding restore is wrapped (finally { try {...} catch {} }) so it can't kill the installer on a console-less host. - Invoke-WslUpdate runs wsl --update as a tracked process with a deadline, redirects stdout/stderr to temp files (logged), and classifies the outcome (ok / not-found / timeout / failed) — so failures leave real WSL evidence in the log + -Diagnose, and wsl's \r progress no longer fights the spinner. - Two-rung ladder: on a non-zero web-download exit (unpatched wsl.exe rejects the flag), retry plain `wsl --update` before giving up. - Differentiated failure messages: not-found / timed out / exited N — no longer the single "the Store may be blocked" line that --web-download rules out. Tests: Update-Wsl is now EXECUTED (mocked deps) across skip / web-download / retry / timeout / not-found branches; Test-WslCurrent covers the stale-floor + custom- floor cases; source guards anchored on the real invocations; dropped the duplicate #410 guard (the #410 Describe owns that invariant). Co-Authored-By: Claude Opus 4.8 * fix(#414): raise WSL currency floor to Docker Desktop's 2.1.5 minimum (Bugbot) The 2.1.0 floor let 2.1.0-2.1.4 boxes skip the update yet still hit Docker Desktop's update-WSL prompt (it requires >= 2.1.5). Default the floor to 2.1.5. Adds a 2.1.4 boundary test. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 134 +++++++++++++++++++++++----- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 87 ++++++++++++++++++ 3 files changed, 200 insertions(+), 23 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index 062a59c2..3a8f28be 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -622,6 +622,117 @@ function Enable-OneVirtFeature { } } +# Modern (Store or standalone) WSL prints a version block from `wsl --version`; +# legacy/absent WSL errors or prints nothing. Returns $true when WSL is already +# installed and current enough that no update is needed (#414). Takes the command +# output as a parameter so it's unit-testable without WSL present. +# +# True only when WSL is present AND at least $MinVersion -- not merely present. +# `wsl --version` localizes its labels (Japanese "WSL バージョン:"), so match the +# version NUMBER, not the "WSL version:" label. The FIRST dotted version in the +# block is the WSL version (kernel/WSLg follow); require it to meet a floor so a +# STALE modern WSL (e.g. 2.0.x) still updates instead of being green-OK'd forever +# (#414 reviewer -- matching any dotted number was effectively Test-WslPresent). +# The floor is Docker Desktop's documented WSL minimum (2.1.5): below it, Docker +# Desktop prompts to update WSL, the exact symptom this avoids (#414 Bugbot). +# TB_WSL_MIN_VERSION overrides it. +function Test-WslCurrent { + param( + [string]$VersionOutput, + [string]$MinVersion = $(if ($env:TB_WSL_MIN_VERSION) { $env:TB_WSL_MIN_VERSION } else { "2.1.5" }) + ) + $m = [regex]::Match($VersionOutput, '\d+\.\d+\.\d+(\.\d+)?') + if (-not $m.Success) { return $false } + try { return ([version]$m.Value -ge [version]$MinVersion) } catch { return $false } +} + +# `wsl --version` can hang on a wedged LxssManager (plausible on the same corporate +# boxes this targets), so run it BOUNDED as a job (like the wsl --list reader) and +# return "" on timeout so skip-when-current treats WSL as not-current and the update +# still runs (#414 reviewer). Encoding is set to Unicode inside the job (wsl.exe +# writes UTF-16LE); the restore is wrapped so a throw there can't kill the install. +function Get-WslVersionOutput { + $job = Start-Job -InitializationScript $JobInit -ScriptBlock { + $prev = [Console]::OutputEncoding + try { [Console]::OutputEncoding = [System.Text.Encoding]::Unicode; (wsl --version 2>$null | Out-String) } + finally { try { [Console]::OutputEncoding = $prev } catch {} } + } + $out = "" + if (Wait-JobWithProgress -Job $job -TimeoutSec 20 -Message "Checking WSL") { + $out = (Receive-Job $job -ErrorAction SilentlyContinue | Out-String) + } else { + Log "wsl --version timed out; treating WSL as not current." + } + Remove-Job $job -Force -ErrorAction SilentlyContinue + return $out +} + +# Run `wsl --update [ExtraArgs]` as a tracked process with a deadline, redirecting +# its output to temp files (logged -- so a failure leaves real WSL evidence in the +# log and the -Diagnose bundle, and wsl's \r progress doesn't fight the spinner), +# and classify the outcome: ok / not-found (spawn failed) / timeout / failed (#414 +# reviewer). Returns @{ State; ExitCode }. +function Invoke-WslUpdate { + param([string[]]$ExtraArgs = @()) + $wslArgs = @("--update") + $ExtraArgs + $label = if ($ExtraArgs -contains "--web-download") { "Updating WSL (web download, bypassing the Store)" } else { "Updating WSL" } + $outF = Join-Path $env:TEMP "wsl-update-$(Get-Random).out.log" + $errF = Join-Path $env:TEMP "wsl-update-$(Get-Random).err.log" + Info "$label..." + $p = $null + try { + $p = Start-Process -FilePath "wsl" -ArgumentList $wslArgs -NoNewWindow -PassThru -ErrorAction Stop ` + -RedirectStandardOutput $outF -RedirectStandardError $errF + } catch { + Remove-Item $outF, $errF -Force -ErrorAction SilentlyContinue + Log "wsl $($wslArgs -join ' ') wouldn't start: $_" + return @{ State = 'not-found'; ExitCode = $null } + } + $timedOut = -not (Wait-ProcessWithDeadline -Process $p -Deadline (Get-Date).AddMinutes(5) -Message $label) + $log = ("$(Get-Content $errF -Raw -ErrorAction SilentlyContinue)`n$(Get-Content $outF -Raw -ErrorAction SilentlyContinue)").Trim() + Remove-Item $outF, $errF -Force -ErrorAction SilentlyContinue + if ($log) { Log "wsl $($wslArgs -join ' '): $log" } + if ($timedOut) { return @{ State = 'timeout'; ExitCode = $null } } + if ($p.ExitCode -eq 0) { return @{ State = 'ok'; ExitCode = 0 } } + return @{ State = 'failed'; ExitCode = $p.ExitCode } +} + +# Update WSL in a way that survives Store-blocked corporate networks (#414): +# 1. Skip when already current (bounded probe + version floor; fast <2s re-runs). +# 2. Prefer `wsl --update --web-download` (Microsoft's servers, not the Store). +# 3. If that exits non-zero (e.g. an unpatched wsl.exe that rejects --web-download), +# retry plain `wsl --update` (Store path) once before giving up. +# 4. On failure, name the specific cause + the exact manual MSI step ON SCREEN. +# We deliberately do NOT auto-download the GitHub-releases MSI: that needs +# api.github.com (asset name unresolvable API-free), which #410 forbids. +function Update-Wsl { + if (Test-WslCurrent -VersionOutput (Get-WslVersionOutput)) { + Ok "WSL is current" + return + } + + $r = Invoke-WslUpdate -ExtraArgs @("--web-download") + if ($r.State -eq 'ok') { Ok "WSL updated"; return } + # Two-rung ladder: --web-download is only understood by a serviced wsl.exe; an + # unpatched box rejects it and exits non-zero fast, where plain --update works + # (#414 reviewer). Only retry on a real exit (not timeout / missing wsl.exe). + if ($r.State -eq 'failed') { + $r2 = Invoke-WslUpdate -ExtraArgs @() + if ($r2.State -eq 'ok') { Ok "WSL updated"; return } + $r = $r2 + } + + # Differentiated failure -- "the Store may be blocked" is the one cause + # --web-download rules out, so don't say it (#414 reviewer). + switch ($r.State) { + 'not-found' { Warn "Couldn't update WSL: wsl.exe wasn't found." } + 'timeout' { Warn "Updating WSL timed out and was stopped." } + default { Warn "Couldn't update WSL automatically (wsl exited $($r.ExitCode))." } + } + $msiArch = if ((Get-WindowsArch) -eq 'arm64') { 'arm64' } else { 'x64' } + Hint "Download the latest WSL MSI (wsl..$msiArch.msi) from https://github.com/microsoft/WSL/releases, run it, then re-run this installer -- otherwise Docker Desktop will prompt you to install WSL." +} + function Enable-VirtualisationFeatures { $rebootNeeded = $false $features = @{ @@ -655,28 +766,7 @@ function Enable-VirtualisationFeatures { Ok "System features" - Log "Updating WSL..." - $wslJob = Start-Job -InitializationScript $JobInit -ScriptBlock { cmd /c "wsl --update 2>&1" } - Write-Host -NoNewline " " - $wslTimeoutSec = 90 - $wslElapsed = 0 - while ($wslJob.State -eq "Running" -and $wslElapsed -lt $wslTimeoutSec) { - Write-Host -NoNewline "." -ForegroundColor DarkGray - Start-Sleep -Seconds 2 - $wslElapsed += 2 - } - Write-Host "" - if ($wslJob.State -eq "Running") { - Stop-Job $wslJob - Log "WSL update timed out after ${wslTimeoutSec}s -- skipping." - Warn "WSL update is taking too long. Skipping for now." - Hint "Run 'wsl --update' manually after installation." - } else { - $wslUpdate = Receive-Job -Job $wslJob - $wslExitOk = $wslJob.State -eq "Completed" - if (-not $wslExitOk) { Log "WSL update may not have completed cleanly." } - } - Remove-Job -Job $wslJob -Force + Update-Wsl $wslSetJob = Start-Job -InitializationScript $JobInit -ScriptBlock { cmd /c "wsl --set-default-version 2 2>&1" } $wslSetDone = $wslSetJob | Wait-Job -Timeout 20 diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index 50444f1d..db03c8ae 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -c2f6cbbab2e0172b2aa42c4f93f02e803150f0d85e4ea83b5fc0a5cc302ca5ee scripts/install-k8s.ps1 +fbb21a1380b525fdb344a5d79aa255a10b46f53bf9b49efecaa2a5ca20fa4bfc scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index 5c36ae17..a82e04b6 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -1196,6 +1196,93 @@ Describe "Test-PreflightRuntimeMem (post-Docker, warn-only)" { } } +Describe "Test-WslCurrent (#414 skip-when-current, version floor)" { + It "modern WSL at/above the floor -> current" { + Test-WslCurrent -VersionOutput "WSL version: 2.3.26.0`nKernel version: 5.15.167.4-1" | Should -BeTrue + } + It "a STALE modern WSL below the floor -> not current, so it still updates (reviewer)" { + Test-WslCurrent -VersionOutput "WSL version: 2.0.0.0`nKernel version: 5.15.90.1" | Should -BeFalse + } + It "below Docker Desktop's 2.1.5 minimum (e.g. 2.1.4) -> not current (Bugbot #414)" { + Test-WslCurrent -VersionOutput "WSL version: 2.1.4.0`nKernel version: 5.15.150.1" | Should -BeFalse + } + It "empty output (WSL absent) -> not current" { + Test-WslCurrent -VersionOutput "" | Should -BeFalse + } + It "legacy error text (no version block) -> not current" { + Test-WslCurrent -VersionOutput "Windows Subsystem for Linux has no installed distributions." | Should -BeFalse + } + It "non-English (localized) label still graded via the version number (Bugbot #414)" { + Test-WslCurrent -VersionOutput "WSL バージョン: 2.3.26.0`nカーネル バージョン: 5.15.167.4-1" | Should -BeTrue + } + It "honors a custom floor via TB_WSL_MIN_VERSION / -MinVersion" { + Test-WslCurrent -VersionOutput "WSL version: 2.3.26.0" -MinVersion "3.0.0" | Should -BeFalse + } +} + +Describe "Update-Wsl branching (#414 reviewer — executed, not just grepped)" { + BeforeEach { Mock Ok {}; Mock Warn {}; Mock Hint {}; Mock Info {}; Mock Log {}; Mock Get-WindowsArch { "amd64" } } + It "already current -> skips the update entirely" { + Mock Get-WslVersionOutput { "WSL version: 2.3.26.0" }; Mock Test-WslCurrent { $true } + Mock Invoke-WslUpdate { throw "must not run when current" } + { Update-Wsl } | Should -Not -Throw + Should -Invoke Invoke-WslUpdate -Times 0 + Should -Invoke Ok -ParameterFilter { $m -match 'current' } + } + It "web-download succeeds -> no Store-path retry" { + Mock Get-WslVersionOutput { "" }; Mock Test-WslCurrent { $false } + Mock Invoke-WslUpdate { @{ State = 'ok'; ExitCode = 0 } } + Update-Wsl + Should -Invoke Invoke-WslUpdate -Times 1 + Should -Invoke Ok -ParameterFilter { $m -match 'updated' } + } + It "web-download exits non-zero -> retries the plain Store path (two-rung ladder)" { + Mock Get-WslVersionOutput { "" }; Mock Test-WslCurrent { $false } + Mock Invoke-WslUpdate { if ($ExtraArgs -contains '--web-download') { @{ State='failed'; ExitCode=1 } } else { @{ State='ok'; ExitCode=0 } } } + Update-Wsl + Should -Invoke Invoke-WslUpdate -Times 2 + Should -Invoke Ok -ParameterFilter { $m -match 'updated' } + } + It "timeout is NOT retried and is reported as a timeout, not 'Store blocked'" { + Mock Get-WslVersionOutput { "" }; Mock Test-WslCurrent { $false } + Mock Invoke-WslUpdate { @{ State = 'timeout'; ExitCode = $null } } + Update-Wsl + Should -Invoke Invoke-WslUpdate -Times 1 + Should -Invoke Warn -ParameterFilter { $m -match 'timed out' } + } + It "wsl.exe missing -> reports not-found, no retry" { + Mock Get-WslVersionOutput { "" }; Mock Test-WslCurrent { $false } + Mock Invoke-WslUpdate { @{ State = 'not-found'; ExitCode = $null } } + Update-Wsl + Should -Invoke Invoke-WslUpdate -Times 1 + Should -Invoke Warn -ParameterFilter { $m -match "wasn't found" } + } +} + +Describe "WSL update wiring (#414 source guards)" { + BeforeAll { $script:WSRC = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + It "prefers the Store-free web download (anchored on the invocation, reviewer)" { + $script:WSRC | Should -Match 'Invoke-WslUpdate -ExtraArgs @\("--web-download"\)' + } + It "the wsl --version probe is BOUNDED (job + deadline), not a synchronous hang (reviewer)" { + $script:WSRC | Should -Match 'Get-WslVersionOutput' + $script:WSRC | Should -Match 'Wait-JobWithProgress -Job \$job -TimeoutSec 20' + } + It "Invoke-WslUpdate redirects output so failures leave real WSL evidence (reviewer)" { + $script:WSRC | Should -Match '-RedirectStandardOutput \$outF -RedirectStandardError \$errF' + } + It "the OutputEncoding restore is wrapped so a throw can't kill the installer (reviewer)" { + $script:WSRC | Should -Match 'finally \{ try \{ \[Console\]::OutputEncoding = \$prev \} catch \{\} \}' + } + It "no longer uses the bare Store-path 'wsl --update' 90s job" { + $script:WSRC | Should -Not -Match 'cmd /c "wsl --update 2>&1"' + } + It "the manual MSI hint names the arch-matched package, not hardcoded x64 (Bugbot #414)" { + $script:WSRC | Should -Match "Get-WindowsArch\) -eq 'arm64'" + $script:WSRC | Should -Match 'wsl\.\.\$msiArch\.msi' + } +} + # --- reboot persistence (Set-ClusterAutostart) ------------------------------- Describe "Set-ClusterAutostart" { AfterEach { $env:TRACEBLOC_NO_AUTOSTART = $null } From e201ad03431826543adff7b8021dd6e79d7e0ecb Mon Sep 17 00:00:00 2001 From: Arturo Peroni Date: Thu, 30 Jul 2026 12:49:23 +0200 Subject: [PATCH 7/7] feat(install): Tier-1 clean Tier-2 fall-through incl. no-systemd (#1222) (#485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(install): Tier-1 no-systemd fallback + Tier-2 fall-through (#1222) Last slice of #1177 (LPI Tier 1) — the code hardening that completes the rootless path. Everything stays behind the opt-in TB_TIER1_ROOTLESS flag; the §5 host-matrix validation (fuse-overlayfs perf) and the flag flip to default-on are host-gated and NOT in this PR (deferred, tracked on #1222). - _user_systemd_available: detect a usable per-user systemd manager via `systemctl --user is-system-running` (a state word => present, even on non-zero exit; empty => no manager/bus) plus XDG_RUNTIME_DIR. - _start_rootless_nohup: on hardened/HPC nodes with no user-systemd, start dockerd-rootless.sh via nohup under an owned XDG_RUNTIME_DIR, poll the socket to Ready, skip linger. Still user-space, no root. Sets TB_ROOTLESS_NO_LINGER. - install_rootless_docker branches systemd-vs-nohup; the daemon-verify failure now routes via _tier2_fallthrough (prepare-host remedy) instead of a bare error — no proceeding on a broken socket, no false Tier-1. - summary.sh::_reboot_note: honest "will NOT restart automatically" note on the no-linger path (takes precedence over the autostart flag). Tests: no-systemd nohup branch; daemon-never-Ready -> Tier-2 fall-through; the 5 existing install_rootless_docker tests updated to model is-system-running; the reboot-note no-linger case. shellcheck clean; full bats suite green; manifest regen. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(install): address Bugbot #485 — persist the exact rootless runtime dir (no-systemd path) On the nohup fallback, /run/user/ may be unwritable so the socket lands under $HOME/.tracebloc-rootless-run. Before, the persisted DOCKER_HOST used the generic ${XDG_RUNTIME_DIR:-/run/user/$(id -u)} template (→ wrong socket in a fresh no-systemd shell) and the restart guidance omitted XDG_RUNTIME_DIR (dockerd-rootless.sh refuses without it), so the operator couldn't bring the daemon back. Now: - _start_rootless_nohup records TB_ROOTLESS_RUNTIME_DIR and shows the full 'XDG_RUNTIME_DIR= nohup dockerd-rootless.sh &' restart command. - _persist_docker_host persists 'export XDG_RUNTIME_DIR=' before DOCKER_HOST, so a new shell resolves the SAME socket the install used AND can restart the daemon. - summary.sh::_reboot_note carries the exact dir in the restart hint. - Tests: rc sourced with XDG unset resolves DOCKER_HOST to the $HOME socket; the runtime dir is recorded; the reboot-note hint carries the dir. manifest regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(install): address Bugbot #485 r2 — honest 'Started' claim + setuptool Tier-2 fall-through - _start_rootless_nohup: only claim "Started rootless Docker…" once the poll confirms the daemon answered (_up). A bare "Started…" before a failed poll contradicted the shared verify's "daemon never answered" fall-through moments later (Bugbot medium). - install_rootless_docker: guard both install paths (dockerd-rootless-setuptool.sh / get.docker.com/rootless) with '|| _tier2_fallthrough', so a setuptool/installer failure routes to the prepare-host remedy instead of a bare set -e abort with the spinner log tail — _tier2_fallthrough's documented setuptool coverage was not actually wired (Bugbot medium). - Tests: nohup daemon-never-answers => no false "Started" + Tier-2; setuptool install failure => Tier-2 fall-through naming the setuptool. manifest regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(install): address Bugbot #485 r3 — don't clobber a session XDG_RUNTIME_DIR The r1 persist wrote 'export XDG_RUNTIME_DIR=' unconditionally into the shell rc. ~/.bashrc is sourced on every host sharing the home (HPC NFS), so that clobbered a legitimate pam/systemd /run/user/ on a systemd node and broke user-systemd there — a regression from the r1 fix. Guard it: 'export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}"', supplying our dir only when the session hasn't set one. The test now also asserts a pre-set XDG is preserved (not clobbered) alongside the no-systemd resolve case. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(install): address Bugbot #485 r4 — holistic rewrite of the no-systemd persist/launch path - _launch_dockerd_rootless: add . - Self-review hardening: same-dir temp + `cat` (not `mv`) so a symlinked/stow'd rc + perms survive and a full disk bails before touching the rc; strip only a WELL-FORMED block (both markers) so a malformed rc isn't eaten past a missing END marker. - Tests: systemd→nohup transition; * refactor(install): descope the no-systemd nohup fallback from #1222 -> Tier-2 (#1354) Six consecutive Bugbot rounds landed on the no-systemd nohup fallback (async daemon + set -e + curl|bash stdin + shared-home rc persistence), none validatable without a real HPC host. Descope it: a host with no per-user systemd now routes to the Tier-2 prepare-host remedy (honest + testable) instead of a blind nohup bring-up. - Delete _start_rootless_nohup + _launch_dockerd_rootless; install_rootless_docker's no-systemd branch now calls _tier2_fallthrough. - Revert _persist_docker_host to the simple systemd-path form (pam sets XDG_RUNTIME_DIR; no $HOME-fallback / atomic-block / XDG-persist complexity). - Drop the now-dead TB_ROOTLESS_NO_LINGER branch in summary.sh::_reboot_note. - Tests: no-systemd => Tier-2 fall-through; removed the nohup / persist-XDG / launch tests. full setup-linux + summary suites green; shellcheck clean; manifest regenerated. The nohup fallback is tracked for a host-available slice in #1354. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(install): gate on user-systemd BEFORE installing (Bugbot #485) install_rootless_docker checked _user_systemd_available only AFTER the setuptool install + the user proxy drop-in. The setuptool sets up a `systemctl --user` unit and fails first on a no-systemd host, so the operator got a vague setuptool reason plus a partial ~/bin install + drop-ins before the Tier-2 remedy. Move the gate to the TOP -> fail fast to _tier2_fallthrough with the accurate "no per-user systemd" reason and no artifacts. The later systemd branch is now unconditional (the redundant re-check is removed). Test now also asserts the setuptool never runs on the no-systemd path. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(install): name the researcher in _tier2_fallthrough's prepare-host remedy (Bugbot #485) _tier2_fallthrough printed a bare `prepare-host` hint with no TB_PREPARE_USER / username. run_prepare_host only grants docker-group access + provisions subuid ranges when the user is named, so an admin who followed the bare hint prepared the host but NOT the researcher — looping them back into the same fall-through. Name the researcher (id -un), matching _ensure_subid_ranges' hand-off verbatim (export TB_PREPARE_USER=, `prepare-host `). Test asserts the remedy names them. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/lib/setup-linux.sh | 76 +++++++++++++++++++++++++++------- scripts/manifest.sha256 | 2 +- scripts/tests/setup-linux.bats | 66 +++++++++++++++++++++++++---- 3 files changed, 121 insertions(+), 23 deletions(-) diff --git a/scripts/lib/setup-linux.sh b/scripts/lib/setup-linux.sh index ba3fce92..ea9d2859 100644 --- a/scripts/lib/setup-linux.sh +++ b/scripts/lib/setup-linux.sh @@ -777,17 +777,20 @@ _persist_docker_host() { fi local rc; rc="$(_tools_rc_for_shell)" local marker='# Added by tracebloc installer (RFC 0001 #1221): rootless Docker socket' - # Our own prior line → idempotent, nothing to do. Key this off OUR marker, not a - # bare 'DOCKER_HOST=' probe: that also matched a user's own DOCKER_HOST (e.g. a - # remote/TCP daemon) and made us silently skip — leaving new shells pointed at the - # wrong daemon while the install assumes rootless (Asad + Bugbot on #478). + # Our own prior line → idempotent, nothing to do. Key off OUR marker, not a bare + # 'DOCKER_HOST=' probe (that also matched a user's own DOCKER_HOST and silently skipped + # — Asad + Bugbot #478). if [ -f "$rc" ] && grep -qF "$marker" "$rc" 2>/dev/null; then return 0; fi - # A DOCKER_HOST the user set themselves → don't clobber it, but don't silently - # pretend we persisted the rootless socket either: warn so they know to repoint it. + # A DOCKER_HOST the user set themselves → don't clobber it, but warn so they know to + # repoint it at the rootless socket. if [ -f "$rc" ] && grep -qE '^[[:space:]]*(export[[:space:]]+)?DOCKER_HOST=' "$rc" 2>/dev/null; then warn "Your ${rc} already sets DOCKER_HOST — left it untouched. If it isn't the rootless socket, new terminals and the tracebloc CLI won't reach the rootless daemon; point it at: export DOCKER_HOST=\"unix://\$XDG_RUNTIME_DIR/docker.sock\"" return 0 fi + # Rootless Tier 1 runs under user-systemd, so pam sets XDG_RUNTIME_DIR=/run/user/ + # and the socket always lives there — persist the standard template. (The no-systemd + # nohup fallback, where the socket could live under $HOME, is descoped from this slice + # and deferred until it can be validated on a real HPC host — see the PR/issue.) { printf '\n%s\n' "$marker" printf '%s\n' 'export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock"' @@ -840,12 +843,52 @@ _tier0_gpu_flags() { # and the no-systemd/HPC fallback + fuse-overlayfs perf (slice 4, #1222). The whole # path is gated behind TB_TIER1_ROOTLESS=1 at the call site until the spike's §5 # host-validation matrix runs (#1176 / #1177). +# _user_systemd_available — is there a per-user systemd manager to run the rootless +# daemon under? RFC 0001 #1222. `systemctl --user is-system-running` prints a state +# word (running/degraded/starting/…) whenever a user manager exists — even when it +# exits non-zero — and prints NOTHING when there's no user D-Bus/manager (hardened / +# HPC login nodes) or no systemctl at all. Also require XDG_RUNTIME_DIR, where the +# user socket must live. A non-empty state ⇒ usable; otherwise take the nohup fallback. +_user_systemd_available() { + [ -n "${XDG_RUNTIME_DIR:-}" ] || return 1 + [ -n "$(systemctl --user is-system-running 2>/dev/null)" ] +} + +# _tier2_fallthrough REASON — a rootless Tier-1 bring-up failed mid-flight (setuptool +# error, daemon never Ready) or the host has no per-user systemd. Rather than proceed on +# a broken/absent socket or die opaquely, route to the Tier-2 prepare-host remedy — the +# honest "this host needs a one-time admin step" outcome (RFC 0001 #1222). Exits. +_tier2_fallthrough() { + local reason="${1:-rootless setup failed}" + # NAME the researcher, matching _ensure_subid_ranges' hand-off (Bugbot on #485): a bare + # `prepare-host` provisions nothing for the user — run_prepare_host only grants + # docker-group + subuid ranges when TB_PREPARE_USER is set — so an admin who followed a + # bare hint would leave the researcher unable to install, looping back to this fall-through. + local _user; _user="$(id -un 2>/dev/null || printf '%s' "${USER:-}")" + warn "Couldn't complete a rootless install (${reason}) — falling back to the administrator-prepared path." + hint "Have an administrator prepare this host once (naming you as the researcher), then re-run as yourself:" + hint " export TB_PREPARE_USER=${_user}" + hint " curl -fsSL https://tracebloc.io/i.sh | bash -s -- prepare-host" + hint " (or, with the CLI: tracebloc prepare-host ${_user})" + error "This host couldn't complete a rootless install (${reason}); an administrator must prepare it for '${_user}' (see above), then re-run. Details: docs/rfcs/0001-least-privilege-install.md" +} + + install_rootless_docker() { # Resolve the current user robustly: $USER can be empty in headless / su / cron # contexts (Saqlain review, #452), and the linger call + success line below need a # real name. `id -un` is authoritative; fall back to $USER only if it somehow fails. local _user; _user="$(id -un 2>/dev/null || printf '%s' "${USER:-}")" + # Gate on per-user systemd BEFORE installing anything (Bugbot on #485): the setuptool + # sets up a `systemctl --user` unit and fails on a host with no user manager, so + # checking first yields the accurate "no per-user systemd" reason and avoids a partial + # ~/bin install + user drop-ins before the Tier-2 remedy. The nohup fallback for such + # hosts is deferred (#1354); until then, route to prepare-host. + if ! _user_systemd_available; then + _tier2_fallthrough "this host has no per-user systemd (systemctl --user has no manager); rootless without it needs a one-time admin step" + fi + # Preconditions — the subuid/subgid range and the setuid newuidmap/newgidmap # helpers — are ensured by _ensure_subid_ranges, called just before this in # install_linux's Tier-1 branch (RFC 0001 #1220). So by the time we get here the @@ -855,14 +898,19 @@ install_rootless_docker() { # by docker-ce-rootless-extras when it's already present; otherwise fetch Docker's # official rootless installer (same retry + mktemp pattern as install_docker_engine's # get.docker.com path), run as the current user — never under sudo. + # Guard both install paths: under `set -e` an unguarded spin_cmd failure would + # abort with the spinner log tail, NOT the Tier-2 remedy this slice promises for a + # setuptool/installer failure — route it through _tier2_fallthrough instead (Bugbot #485 r2). if has dockerd-rootless-setuptool.sh; then - spin_cmd "Installing rootless Docker…" dockerd-rootless-setuptool.sh install + spin_cmd "Installing rootless Docker…" dockerd-rootless-setuptool.sh install \ + || _tier2_fallthrough "the rootless setup tool (dockerd-rootless-setuptool.sh install) failed" else local rootless_script rootless_script="$(mktemp)" retry 3 5 curl_secure -fsSL https://get.docker.com/rootless -o "$rootless_script" # No chmod +x — we run it via `sh "$rootless_script"`, which ignores the exec bit (Asad review, #452). - spin_cmd "Installing rootless Docker…" sh "$rootless_script" + spin_cmd "Installing rootless Docker…" sh "$rootless_script" \ + || _tier2_fallthrough "the rootless installer (get.docker.com/rootless) failed" rm -f "$rootless_script" fi @@ -882,12 +930,10 @@ install_rootless_docker() { # `systemctl --user enable --now docker` below picks it up. _configure_docker_proxy user - # Start the user daemon and make it survive logout / return after reboot without - # an active login session — both user-scoped, no root. Neither is fatal: the - # bounded `docker info` verify below is the real gate, so a systemctl hiccup - # falls through to actionable guidance instead of a bare set -e abort (mirrors - # install_docker_engine's start-then-verify). Linger is optional and can fail on - # polkit-locked hosts even when the daemon is up, so it only warns (Bugbot). + # Start the user daemon under per-user systemd (survives logout via linger). We already + # gated on _user_systemd_available at the TOP of this function, so this is unconditional + # here; neither call is fatal — the bounded `docker info` verify below is the real gate, + # and linger can fail on polkit-locked hosts even when the daemon is up (Bugbot). systemctl --user enable --now docker || true loginctl enable-linger "$_user" \ || warn "Couldn't enable linger (optional) — the rootless daemon may not survive logout. Enable it later with: loginctl enable-linger ${_user}" @@ -907,7 +953,7 @@ install_rootless_docker() { # a race). if ! _bounded 15 docker info >/dev/null 2>&1; then _bounded 15 docker info || true - error "Rootless Docker didn't come up on ${DOCKER_HOST} (daemon output above). See https://docs.docker.com/engine/security/rootless/ for kernel/uidmap prerequisites." + _tier2_fallthrough "the rootless daemon never answered on ${DOCKER_HOST}" fi # Be honest about privilege: _ensure_subid_ranges may have used one announced sudo # touch (subuid range / uidmap install) on the root/sudo_nopw path. Only claim diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index db03c8ae..d8f6ff0d 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -5,7 +5,7 @@ c29c4fafe6691bfbfb6f90d761aa0650d79d0a5962a599926284d9e3e6a10006 scripts/instal 7977ef12a16fc6aee1c2824cae13bd1450f7fe1c1345323292e4a14decbfe83f scripts/lib/gpu-nvidia.sh b569eec2d8ffb9673da287a2a59d249a7dbc7236c98ab6a5062136bcc69a942c scripts/lib/gpu-amd.sh 76f0d4230d4aff510114968a477ef60e28860d1827c8ff36853a3f59d30be617 scripts/lib/setup-macos.sh -b8e88eb2b134f3dd5466026ca14464b6b6af42f077f57a6b0bbaaae9dfab17c3 scripts/lib/setup-linux.sh +816814b69fac55dd23708c090dbed6e7dfee500764ef61a2bf14434be35ddc4a scripts/lib/setup-linux.sh 1bdf2bf09c07f096551a9405af232f19f11649064f9e5123b4eca0f9f55ab20d scripts/lib/cluster.sh 045caf6efeb583e5005d881d9281edae6a6aedf8f318b0a4021964b3b4b29cc5 scripts/lib/gpu-plugins.sh e673941dd9d63b2fcbb2051a1c3a86ae9a7ef6b780b9f52b34f37bfdefd66948 scripts/lib/install-client-helm.sh diff --git a/scripts/tests/setup-linux.bats b/scripts/tests/setup-linux.bats index 15e9f58d..1d79cd9b 100644 --- a/scripts/tests/setup-linux.bats +++ b/scripts/tests/setup-linux.bats @@ -896,7 +896,8 @@ _stub_install_steps() { MOCK_CALLS="$(mktemp)" PRESENT_CMDS="curl newuidmap newgidmap dockerd-rootless-setuptool.sh docker" XDG_RUNTIME_DIR=/run/user/1000 - systemctl() { record "systemctl $*"; } + # is-system-running echoes a state word ⇒ user-systemd present ⇒ systemd path (#1222) + systemctl() { record "systemctl $*"; case "$*" in *is-system-running*) echo running ;; esac; } loginctl() { record "loginctl $*"; } id() { [ "${1:-}" = "-un" ] && echo testuser || echo "testuser docker"; } # id -un → clean username (#452) install_rootless_docker # called directly to observe the exported DOCKER_HOST @@ -907,12 +908,12 @@ _stub_install_steps() { ! mock_calls | grep -q sudo # no blanket sudo anywhere on the rootless path } -@test "install_rootless_docker: XDG_RUNTIME_DIR unset falls back to /run/user/" { +@test "install_rootless_docker: DOCKER_HOST targets the XDG runtime-dir socket (systemd path)" { MOCK_CALLS="$(mktemp)" PRESENT_CMDS="curl newuidmap newgidmap dockerd-rootless-setuptool.sh docker" - unset XDG_RUNTIME_DIR + XDG_RUNTIME_DIR=/run/user/1000 id() { [ "${1:-}" = "-u" ] && echo 1000 || echo "testuser docker"; } - systemctl() { :; } + systemctl() { case "$*" in *is-system-running*) echo running ;; esac; } loginctl() { :; } install_rootless_docker [ "$DOCKER_HOST" = "unix:///run/user/1000/docker.sock" ] @@ -924,7 +925,7 @@ _stub_install_steps() { XDG_RUNTIME_DIR=/run/user/1000 HOME="$BATS_TEST_TMPDIR" curl_secure() { record "curl_secure $*"; return 0; } # no network - systemctl() { :; } + systemctl() { case "$*" in *is-system-running*) echo running ;; esac; } loginctl() { :; } install_rootless_docker case ":$PATH:" in *":$HOME/bin:"*) : ;; *) return 1 ;; esac # ~/bin now on PATH for the run @@ -935,7 +936,7 @@ _stub_install_steps() { PRESENT_CMDS="newuidmap newgidmap dockerd-rootless-setuptool.sh docker" XDG_RUNTIME_DIR=/run/user/1000 HOME="$BATS_TEST_TMPDIR" - systemctl() { return 1; } # user-systemd refuses + systemctl() { case "$*" in *is-system-running*) echo degraded ;; *) return 1 ;; esac; } # present, but enable refuses loginctl() { return 1; } # linger blocked (polkit-locked) docker() { return 0; } # ...but the daemon is actually up install_rootless_docker # must reach the export + verify, not set -e abort @@ -945,7 +946,7 @@ _stub_install_steps() { @test "install_rootless_docker: success line is honest about the admin touch (#458)" { PRESENT_CMDS="newuidmap newgidmap dockerd-rootless-setuptool.sh docker" XDG_RUNTIME_DIR=/run/user/1000; HOME="$BATS_TEST_TMPDIR" - systemctl() { :; }; loginctl() { :; } + systemctl() { case "$*" in *is-system-running*) echo running ;; esac; }; loginctl() { :; } # Zero-root path (gate didn't touch sudo): claims no admin rights. MOCK_CALLS="$(mktemp)"; unset TB_ROOTLESS_ADMIN_TOUCH run install_rootless_docker @@ -957,6 +958,56 @@ _stub_install_steps() { [[ "$output" == *"one-time admin step"* ]] } +# ── no-systemd fallback + Tier-2 fall-through (RFC 0001 #1222) ──────────────── + +@test "install_rootless_docker: no user-systemd -> Tier-2 prepare-host fall-through (nohup fallback descoped, #1222)" { + MOCK_CALLS="$(mktemp)" + PRESENT_CMDS="newuidmap newgidmap dockerd-rootless-setuptool.sh docker" + XDG_RUNTIME_DIR=/run/user/1000; HOME="$BATS_TEST_TMPDIR" + systemctl() { record "systemctl $*"; } # is-system-running → empty ⇒ no user manager + loginctl() { record "loginctl $*"; } + run install_rootless_docker + [ "$status" -ne 0 ] # routes to Tier-2 and exits (no blind nohup bring-up) + [[ "$output" == *"prepare-host"* ]] # the Tier-2 remedy + [[ "$output" == *"no per-user systemd"* ]] # accurate reason (not a vague setuptool failure) + ! mock_calls | grep -q "systemctl --user enable" # never attempted the user-systemd bring-up + ! mock_calls | grep -q "dockerd-rootless-setuptool.sh install" # gate is UPFRONT → no partial ~/bin install (Bugbot #485) +} + +@test "install_rootless_docker: daemon never Ready -> Tier-2 prepare-host fall-through, not a silent proceed (#1222)" { + MOCK_CALLS="$(mktemp)" + PRESENT_CMDS="newuidmap newgidmap dockerd-rootless-setuptool.sh docker" + XDG_RUNTIME_DIR=/run/user/1000; HOME="$BATS_TEST_TMPDIR" + systemctl() { case "$*" in *is-system-running*) echo running ;; esac; } # systemd present + loginctl() { :; } + docker() { return 1; } # daemon never answers on the socket + run install_rootless_docker + [ "$status" -ne 0 ] # exits via fall-through, not onward + [[ "$output" == *"prepare-host"* ]] # routes to the Tier-2 remedy +} + +@test "install_rootless_docker: setuptool install failure -> Tier-2 fall-through, not a bare set -e abort (#485 r2)" { + MOCK_CALLS="$(mktemp)" + PRESENT_CMDS="newuidmap newgidmap dockerd-rootless-setuptool.sh docker" + XDG_RUNTIME_DIR=/run/user/1000; HOME="$BATS_TEST_TMPDIR" + systemctl() { case "$*" in *is-system-running*) echo running ;; esac; } + loginctl() { :; } + spin_cmd() { record "$*"; case "$*" in *"dockerd-rootless-setuptool.sh install"*) return 1 ;; *) return 0 ;; esac; } + run install_rootless_docker + [ "$status" -ne 0 ] + [[ "$output" == *"prepare-host"* ]] # routed to the Tier-2 remedy… + [[ "$output" == *"setup tool"* ]] # …naming the setuptool failure, not a spinner tail +} + +@test "_tier2_fallthrough: names the researcher in the prepare-host remedy so prepare-host actually provisions them (Bugbot #485)" { + id() { [ "${1:-}" = "-un" ] && echo researcher || echo "researcher"; } + run _tier2_fallthrough "some reason" + [ "$status" -ne 0 ] # exits + [[ "$output" == *"export TB_PREPARE_USER=researcher"* ]] # names the researcher (run_prepare_host keys off this) + [[ "$output" == *"tracebloc prepare-host researcher"* ]] # CLI form names them too + [[ "$output" == *"prepare it for 'researcher'"* ]] # final error names them +} + # ── _ensure_subid_ranges: the Tier-1 subuid/subgid gate (RFC 0001 #1220) ───── @test "_ensure_subid_ranges: present => proceeds with zero privileged calls" { MOCK_CALLS="$(mktemp)" @@ -1429,3 +1480,4 @@ _stub_install_steps() { grep -q 'tcp://10.0.0.5:2375' "$HOME/.bashrc" # their line left untouched [ "$(grep -c 'DOCKER_HOST=' "$HOME/.bashrc")" -eq 1 ] # we did NOT append the rootless line } +