Files
2026-05-27 22:12:14 -05:00

213 lines
8.2 KiB
Go

package bootstrap
import (
"context"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
type WSLState struct {
Installed bool
Version2Default bool
RestartRequired bool
FedoraAvailable string
FedoraInstalled string
FedoraReady bool
GuestConfigured bool
}
type WSLTransition string
const (
WSLInstallPrerequisites WSLTransition = "install-prerequisites"
WSLWaitForRestart WSLTransition = "wait-for-restart"
WSLSetDefaultVersion WSLTransition = "set-default-version"
WSLInstallFedora WSLTransition = "install-fedora"
WSLWaitForFedoraUser WSLTransition = "wait-for-fedora-user"
WSLRunGuestBootstrap WSLTransition = "run-guest-bootstrap"
WSLComplete WSLTransition = "complete"
)
func NextWSLTransition(state WSLState) WSLTransition {
switch {
case !state.Installed:
return WSLInstallPrerequisites
case state.RestartRequired:
return WSLWaitForRestart
case !state.Version2Default:
return WSLSetDefaultVersion
case state.FedoraInstalled == "":
return WSLInstallFedora
case !state.FedoraReady:
return WSLWaitForFedoraUser
case !state.GuestConfigured:
return WSLRunGuestBootstrap
default:
return WSLComplete
}
}
func DetectWSL(ctx context.Context, runner Runner) (WSLState, error) {
var state WSLState
status := runner.Run(ctx, "wsl.exe", "--status")
if status.Err != nil {
return state, nil
}
state.Installed = true
state.Version2Default = strings.Contains(strings.ToLower(status.CombinedOutput()), "default version: 2")
restart := runner.Run(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
`if ((Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')) { 'true' } else { 'false' }`)
state.RestartRequired = strings.Contains(strings.ToLower(restart.Stdout), "true")
online := runner.Run(ctx, "wsl.exe", "--list", "--online")
if online.Err != nil {
return state, fmt.Errorf("list online WSL distributions: %w: %s", online.Err, online.CombinedOutput())
}
state.FedoraAvailable = SelectOfficialFedora(online.Stdout)
if state.FedoraAvailable == "" {
return state, fmt.Errorf("no official FedoraLinux-* distribution was reported by wsl --list --online")
}
installed := runner.Run(ctx, "wsl.exe", "--list", "--quiet")
if installed.Err != nil {
return state, fmt.Errorf("list installed WSL distributions: %w: %s", installed.Err, installed.CombinedOutput())
}
for _, line := range strings.Split(strings.ReplaceAll(installed.Stdout, "\r", ""), "\n") {
distro := strings.TrimSpace(strings.ReplaceAll(line, "\x00", ""))
if strings.HasPrefix(distro, "FedoraLinux-") {
state.FedoraInstalled = distro
break
}
}
if state.FedoraInstalled == "" {
return state, nil
}
probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
ready := runner.Run(probeCtx, "wsl.exe", "-d", state.FedoraInstalled, "--exec", "sh", "-lc", "id -u >/dev/null")
state.FedoraReady = ready.Err == nil
if state.FedoraReady {
marker := runner.Run(ctx, "wsl.exe", "-d", state.FedoraInstalled, "--exec", "sh", "-lc", "test -f ~/.local/state/bootstrap_dev_env/wsl-complete")
state.GuestConfigured = marker.Err == nil
}
return state, nil
}
var fedoraPattern = regexp.MustCompile(`(?i)\bFedoraLinux-(\d+)\b`)
func SelectOfficialFedora(output string) string {
matches := fedoraPattern.FindAllStringSubmatch(output, -1)
type release struct {
name string
version int
}
var releases []release
for _, match := range matches {
version, _ := strconv.Atoi(match[1])
releases = append(releases, release{name: match[0], version: version})
}
sort.Slice(releases, func(i, j int) bool { return releases[i].version > releases[j].version })
if len(releases) == 0 {
return ""
}
return releases[0].name
}
type WSLResult struct {
Notice string
Issue error
Done bool
}
func RunWSLPhase(ctx context.Context, runner Runner, opts Options) WSLResult {
state, err := DetectWSL(ctx, runner)
if err != nil {
return WSLResult{Issue: err}
}
switch NextWSLTransition(state) {
case WSLInstallPrerequisites:
result := runner.Run(ctx, "wsl.exe", "--install", "--no-distribution")
if result.Err != nil {
return WSLResult{Issue: fmt.Errorf("install WSL prerequisites: %w: %s", result.Err, result.CombinedOutput())}
}
return WSLResult{Notice: "WSL prerequisites were installed. Restart Windows, then rerun this bootstrapper to install Fedora."}
case WSLWaitForRestart:
return WSLResult{Notice: "Windows has a pending restart. Restart Windows, then rerun this bootstrapper to resume Fedora WSL setup."}
case WSLSetDefaultVersion:
result := runner.Run(ctx, "wsl.exe", "--set-default-version", "2")
if result.Err != nil {
return WSLResult{Issue: fmt.Errorf("set WSL default version to 2: %w: %s", result.Err, result.CombinedOutput())}
}
return WSLResult{Notice: "WSL default version was set to 2. Rerun this bootstrapper to continue Fedora setup."}
case WSLInstallFedora:
result := runner.Run(ctx, "wsl.exe", "--install", "--distribution", state.FedoraAvailable, "--no-launch")
if result.Err != nil {
return WSLResult{Issue: fmt.Errorf("install %s: %w: %s", state.FedoraAvailable, result.Err, result.CombinedOutput())}
}
return WSLResult{Notice: fmt.Sprintf("%s was installed. Run `wsl -d %s` once to create its Linux username, then rerun this bootstrapper.", state.FedoraAvailable, state.FedoraAvailable)}
case WSLWaitForFedoraUser:
return WSLResult{Notice: fmt.Sprintf("Fedora requires first-launch setup. Run `wsl -d %s` and create its Linux username, then rerun this bootstrapper.", state.FedoraInstalled)}
case WSLRunGuestBootstrap:
invocation := GuestBootstrapInvocation(opts, state.FedoraInstalled)
result := runner.Run(ctx, invocation.Name, invocation.Args...)
if result.Err != nil {
return WSLResult{Issue: fmt.Errorf("run Fedora bootstrap: %w: %s", result.Err, result.CombinedOutput())}
}
return WSLResult{Notice: "Fedora WSL guest provisioning completed.", Done: true}
default:
return WSLResult{Notice: "Fedora WSL guest was already provisioned.", Done: true}
}
}
func GuestBootstrapInvocation(opts Options, distro string) CommandSpec {
arguments := []string{"--wsl", "--headless", "--yes"}
if opts.NoAI {
arguments = append(arguments, "--no-ai")
}
script := `set -eu
repo="$1"
shift
arch="$(uname -m)"
case "$arch" in
x86_64) asset_arch="amd64" ;;
aarch64|arm64) asset_arch="arm64" ;;
*) echo "Unsupported guest architecture: $arch" >&2; exit 1 ;;
esac
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" -o "$work/release.json"
python3 - "$work/release.json" "$asset_arch" > "$work/assets" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
arch = sys.argv[2]
assets = data.get("assets", [])
binaries = [a for a in assets if "linux" in a["name"].lower() and arch in a["name"].lower() and "sha256" not in a["name"].lower() and "checksum" not in a["name"].lower()]
if not binaries:
raise SystemExit("No Linux bootstrap release asset found for " + arch)
binary = binaries[0]
print(binary["browser_download_url"])
checksums = [a for a in assets if "sha256" in a["name"].lower() or "checksum" in a["name"].lower()]
print(checksums[0]["browser_download_url"] if checksums else "")
PY
binary_url="$(sed -n '1p' "$work/assets")"
checksum_url="$(sed -n '2p' "$work/assets")"
curl -fsSL "$binary_url" -o "$work/bootstrap_dev_env"
chmod +x "$work/bootstrap_dev_env"
if [ -n "$checksum_url" ]; then
curl -fsSL "$checksum_url" -o "$work/checksums"
expected="$(grep "$(basename "$binary_url")" "$work/checksums" | awk '{print $1}' | head -n 1)"
[ -n "$expected" ] || { echo "Published checksum did not contain the selected asset" >&2; exit 1; }
actual="$(sha256sum "$work/bootstrap_dev_env" | awk '{print $1}')"
[ "$expected" = "$actual" ] || { echo "Linux bootstrap checksum verification failed" >&2; exit 1; }
fi
"$work/bootstrap_dev_env" "$@"
mkdir -p "$HOME/.local/state/bootstrap_dev_env"
touch "$HOME/.local/state/bootstrap_dev_env/wsl-complete"`
args := []string{"-d", distro, "--exec", "sh", "-lc", script, "bootstrap-wsl", opts.LinuxReleaseRepo}
args = append(args, arguments...)
return CommandSpec{Name: "wsl.exe", Args: args}
}