Files
bootstrap_windows_env/test/integration/main.go

323 lines
12 KiB
Go

// Command integration drives the bootstrap_windows_env integration test by
// orchestrating a Hyper-V VM through the local `vagrant` CLI.
//
// Usage (run from any directory inside the repository):
//
// go run ./test/integration # build, vagrant up, run, destroy
// go run ./test/integration --keep # leave VM after run for debugging
// go run ./test/integration --skip-build # reuse dist/bootstrap_windows_env.exe
// go run ./test/integration --args="--yes --no-wsl --only os"
//
// The host must run Windows with Hyper-V enabled and the `vagrant` CLI in PATH.
// Hyper-V is the only Vagrant provider exercised here; the Vagrantfile in this
// directory documents the relevant environment variables for tuning.
//
// There is no official Vagrant SDK for Go (Vagrant is a Ruby project), so this
// driver shells out to the `vagrant` CLI through os/exec — the conventional
// "Go scripting" pattern for orchestrating external tools.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
)
const (
guestExe = `C:\Users\vagrant\bootstrap_windows_env.exe`
defaultArgs = "--yes --no-wsl"
exitUsage = 2
exitInternal = 1
)
type config struct {
args string
keep bool
skipBuild bool
provider string
timeout time.Duration
}
func main() {
cfg, err := parseFlags(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(exitUsage)
}
if runtime.GOOS != "windows" {
fmt.Fprintln(os.Stderr, "integration test must run on a Windows host with Hyper-V")
os.Exit(exitInternal)
}
if err := runIntegration(cfg); err != nil {
fmt.Fprintln(os.Stderr, "integration test failed:", err)
os.Exit(exitInternal)
}
fmt.Println("integration test passed")
}
func parseFlags(argv []string) (config, error) {
fs := flag.NewFlagSet("integration", flag.ContinueOnError)
cfg := config{
args: defaultArgs,
provider: "hyperv",
timeout: 90 * time.Minute,
}
fs.StringVar(&cfg.args, "args", cfg.args, "arguments passed to bootstrap_windows_env.exe inside the guest")
fs.BoolVar(&cfg.keep, "keep", false, "leave the VM running after the test (do not destroy)")
fs.BoolVar(&cfg.skipBuild, "skip-build", false, "reuse dist/bootstrap_windows_env.exe instead of rebuilding")
fs.StringVar(&cfg.provider, "provider", cfg.provider, "vagrant provider to use")
fs.DurationVar(&cfg.timeout, "timeout", cfg.timeout, "overall timeout for the integration run")
if err := fs.Parse(argv); err != nil {
return config{}, err
}
if fs.NArg() != 0 {
return config{}, fmt.Errorf("unexpected arguments: %v", fs.Args())
}
return cfg, nil
}
func runIntegration(cfg config) error {
root, err := repoRoot()
if err != nil {
return err
}
vagrantDir := filepath.Join(root, "test", "integration")
distDir := filepath.Join(root, "dist")
exePath := filepath.Join(distDir, "bootstrap_windows_env.exe")
if _, err := exec.LookPath("vagrant"); err != nil {
return fmt.Errorf("vagrant CLI not found in PATH: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(ctx, cfg.timeout)
defer cancel()
if !cfg.skipBuild {
if err := buildBootstrapper(ctx, root, exePath); err != nil {
return fmt.Errorf("build bootstrapper: %w", err)
}
}
if _, err := os.Stat(exePath); err != nil {
return fmt.Errorf("bootstrapper binary missing at %s: %w", exePath, err)
}
logFile, err := os.Create(filepath.Join(vagrantDir, "integration.log"))
if err != nil {
return fmt.Errorf("create log file: %w", err)
}
defer logFile.Close()
outWriter := io.MultiWriter(os.Stdout, logFile)
startTime := time.Now()
defer func() {
fmt.Fprintf(outWriter, "\n--- Integration Test Completed in %v ---\n", time.Since(startTime).Round(time.Second))
}()
if err := vagrant(ctx, vagrantDir, outWriter, "up", "--provider="+cfg.provider); err != nil {
return fmt.Errorf("vagrant up: %w", err)
}
defer func() {
// If the bootstrap or package installer is still in progress inside the guest,
// stop the processes to release log files and resources, allowing clean teardown.
killCtx, cancelKill := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelKill()
_ = vagrant(killCtx, vagrantDir, outWriter, "powershell", "-c", "Get-Process -Name bootstrap_windows_env,choco,nmap,msiexec,setup -ErrorAction SilentlyContinue | Stop-Process -Force")
// Always try to surface the in-guest bootstrapper log before destroying
// the VM. WinRM's stdout streaming is bursty; this gives a deterministic
// artifact even when the powershell call cut out mid-run.
logCtx, cancelLog := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancelLog()
_ = vagrant(logCtx, vagrantDir, outWriter, "powershell", "-c", tailGuestLogCmd)
if cfg.keep {
fmt.Println("--keep set; leaving Vagrant machine running. Destroy manually with `vagrant destroy -f` in", vagrantDir)
return
}
// Always attempt cleanup with a fresh context so an outer timeout does
// not leave a Hyper-V VM stranded.
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := vagrant(cleanupCtx, vagrantDir, outWriter, "destroy", "-f"); err != nil {
fmt.Fprintf(os.Stderr, "warning: vagrant destroy failed: %v\n", err)
}
}()
if err := vagrant(ctx, vagrantDir, outWriter, "upload", exePath, guestExe); err != nil {
return fmt.Errorf("vagrant upload: %w", err)
}
// `vagrant powershell -e -c` invokes PowerShell in the guest over the WinRM
// communicator using elevated privileges (which runs via a scheduled task
// to bypass WinRM COM restrictions for Windows Update).
guestCmd := fmt.Sprintf("$env:BOOTSTRAP_INTEGRATION_TEST='true'; & %q %s; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }", guestExe, cfg.args)
fmt.Fprintln(outWriter, "\n--- Starting Pass 1 (OS Phase) ---")
if err := vagrant(ctx, vagrantDir, outWriter, "powershell", "-e", "-c", guestCmd); err != nil {
fmt.Fprintf(outWriter, "Warning: Pass 1 returned an error (likely a Vagrant scheduled task quirk): %v\n", err)
}
// Reboot the VM to apply OS updates.
fmt.Fprintln(outWriter, "\n--- Rebooting VM to apply OS updates ---")
_ = vagrant(ctx, vagrantDir, outWriter, "powershell", "-c", "Restart-Computer -Force")
fmt.Fprintln(outWriter, "Waiting for VM to go offline...")
for i := 0; i < 60; i++ {
time.Sleep(5 * time.Second)
pollCtx, pollCancel := context.WithTimeout(ctx, 10*time.Second)
err := vagrant(pollCtx, vagrantDir, io.Discard, "powershell", "-c", "Write-Output 'still up'")
pollCancel()
if err != nil {
break // WinRM dropped, machine is rebooting
}
}
// Wait for the VM to come back online
fmt.Fprintln(outWriter, "Waiting for VM to come back online...")
deadline := time.Now().Add(15 * time.Minute)
vmReady := false
for time.Now().Before(deadline) {
time.Sleep(15 * time.Second)
// Use a short timeout for the poll so we don't hang if WinRM drops packets
pollCtx, pollCancel := context.WithTimeout(ctx, 30*time.Second)
err := vagrant(pollCtx, vagrantDir, outWriter, "powershell", "-c", "Write-Output 'VM is back'")
pollCancel()
if err == nil {
vmReady = true
break
}
}
if !vmReady {
return fmt.Errorf("timed out waiting for VM to come back online after reboot")
}
// Wait 60 seconds for LSASS and Task Scheduler services to fully stabilize after reboot
fmt.Fprintln(outWriter, "Waiting 60 seconds for system services to stabilize...")
time.Sleep(60 * time.Second)
// Run the bootstrapper again to finish the remaining phases (host, custom, config).
// We run these individually using --only to bypass the OS phase, which avoids
// infinite reboot loops and Vagrant scheduled task hangs.
fmt.Fprintln(outWriter, "\n--- Resuming bootstrap after reboot (Pass 2) ---")
chocoEnsureCmd := `
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host "Chocolatey not found. Installing Chocolatey..."
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Set-ExecutionPolicy Bypass -Scope Process -Force
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
} else {
Write-Host "Chocolatey is already installed."
}
`
_ = vagrant(ctx, vagrantDir, outWriter, "powershell", "-e", "-c", chocoEnsureCmd)
for _, phase := range []string{"host", "custom", "config"} {
fmt.Fprintf(outWriter, "\n--- Running Phase: %s ---\n", phase)
passArgs := strings.Replace(cfg.args, "--only os", "", 1) + " --only " + phase
guestCmdPhase := fmt.Sprintf("$env:BOOTSTRAP_INTEGRATION_TEST='true'; & %q %s; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }", guestExe, passArgs)
var phaseErr error
for attempt := 1; attempt <= 3; attempt++ {
if attempt > 1 {
fmt.Fprintf(outWriter, "\n[Attempt %d/3] Retrying phase %s after failure (likely WinRM network drop)...\n", attempt, phase)
time.Sleep(10 * time.Second) // Wait for network to stabilize before reconnecting
}
phaseErr = vagrant(ctx, vagrantDir, outWriter, "powershell", "-e", "-c", guestCmdPhase)
if phaseErr == nil || phase == "custom" {
break
}
}
if phaseErr != nil {
if phase == "custom" {
fmt.Fprintf(outWriter, "Warning: Phase custom failed (ignored because graphical installers are expected to fail or hang in non-interactive sessions): %v\n", phaseErr)
} else {
return fmt.Errorf("guest bootstrap run failed (phase %s) after 3 attempts: %w", phase, phaseErr)
}
}
}
return nil
}
func buildBootstrapper(ctx context.Context, repoRoot, exePath string) error {
if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil {
return err
}
fmt.Printf("building %s\n", exePath)
cmd := exec.CommandContext(ctx, "go", "build", "-o", exePath, "./cmd/bootstrap_windows_env")
cmd.Dir = repoRoot
cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH=amd64")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func vagrant(ctx context.Context, dir string, out io.Writer, args ...string) error {
fmt.Fprintf(out, "[vagrant] %s\n", strings.Join(args, " "))
cmd := exec.CommandContext(ctx, "vagrant", args...)
cmd.Dir = dir
cmd.Stdout = out
cmd.Stderr = out
// Default to the always-present Hyper-V "Default Switch" so `vagrant up`
// never blocks on the interactive virtual-switch prompt. Callers can
// override by exporting VAGRANT_HYPERV_VIRTUAL_SWITCH before running.
env := append(os.Environ(),
"VAGRANT_DEFAULT_PROVIDER=hyperv",
)
if os.Getenv("VAGRANT_HYPERV_VIRTUAL_SWITCH") == "" {
env = append(env, "VAGRANT_HYPERV_VIRTUAL_SWITCH=Default Switch")
}
cmd.Env = env
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("%w (deadline exceeded)", err)
}
return err
}
return nil
}
// tailGuestLogCmd locates the most recent bootstrap_windows_env log in the
// guest's Documents folder and prints its last 500 lines. Used after every
// run so the host has a deterministic artifact even when WinRM stops
// streaming midway through a long step.
const tailGuestLogCmd = `$ErrorActionPreference='SilentlyContinue'
$log = Get-ChildItem -Path "$env:USERPROFILE\Documents\bootstrap_windows_env-*.log" |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($log) {
Write-Host "=== guest log: $($log.FullName) ==="
Get-Content -Tail 500 -Path $log.FullName
} else {
Write-Host "no bootstrap_windows_env log found under $env:USERPROFILE\Documents"
}`
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", errors.New("could not locate go.mod ancestor of current directory")
}
dir = parent
}
}