323 lines
12 KiB
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
|
|
}
|
|
} |