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 }} 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 diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index cc7643bf..3a8f28be 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) { @@ -563,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 = @{ @@ -596,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 @@ -646,9 +795,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 +818,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 +1118,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 +1141,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 +1216,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 +1240,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 +1279,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 +1315,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 +1332,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 +1693,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 +2306,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 +2454,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 @@ -2773,13 +3040,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 } @@ -2806,9 +3147,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 @@ -2846,21 +3186,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)." } @@ -2941,14 +3273,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 } # ============================================================================= @@ -3103,7 +3433,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 +3491,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/lib/setup-linux.sh b/scripts/lib/setup-linux.sh index 8ff766f8..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 @@ -1111,7 +1157,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 6d37348e..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 -b242727eb3d0c8a3eb721349fb540408386c3ab6ffa1628247e6556211c77258 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 @@ -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 +fbb21a1380b525fdb344a5d79aa255a10b46f53bf9b49efecaa2a5ca20fa4bfc scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index c974f921..a82e04b6 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 } @@ -886,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 @@ -947,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 @@ -985,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' } @@ -1003,15 +1116,171 @@ 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' + } +} + +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) ------------------------------- 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 } +