Files
bootstrap_windows_env/internal/bootstrap/app.go
T

360 lines
10 KiB
Go

package bootstrap
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
type Bootstrapper struct {
Runner Runner
In io.Reader
Out io.Writer
Err io.Writer
Paths UserPaths
}
func NewBootstrapper() (*Bootstrapper, error) {
paths, err := DefaultUserPaths()
if err != nil {
return nil, err
}
return &Bootstrapper{
Runner: ExecRunner{Timeout: 45 * time.Minute},
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
Paths: paths,
}, nil
}
type Plan struct {
Phases []Phase
PackageStates map[Phase][]PackageState
ActionStates map[Phase][]ActionState
DeferredNativeWork []string
}
func BuildPlan(ctx context.Context, runner Runner, opts Options) Plan {
return BuildPlanForPhases(ctx, runner, opts, SelectedPhases(opts), true)
}
func BuildPlanForPhases(ctx context.Context, runner Runner, opts Options, phases []Phase, checkState bool) Plan {
plan := Plan{
Phases: append([]Phase(nil), phases...),
PackageStates: make(map[Phase][]PackageState),
ActionStates: make(map[Phase][]ActionState),
DeferredNativeWork: DeferredWindowsTools,
}
for _, phase := range plan.Phases {
packages := PackagesForPhase(opts, phase)
if len(packages) > 0 {
if checkState {
plan.PackageStates[phase] = CheckPackages(ctx, runner, packages, 8)
} else {
plan.PackageStates[phase] = UnknownPackageStates(packages)
}
}
switch phase {
case PhaseHost:
actions := HostActions(opts)
if checkState {
plan.ActionStates[phase] = CheckActions(ctx, runner, actions)
} else {
plan.ActionStates[phase] = UnknownActionStates(actions)
}
case PhaseCustom:
actions := CustomActions(opts)
if checkState {
plan.ActionStates[phase] = CheckActions(ctx, runner, actions)
} else {
plan.ActionStates[phase] = UnknownActionStates(actions)
}
}
}
return plan
}
func PrintPlan(out io.Writer, plan Plan, opts Options) {
fmt.Fprintln(out, "Windows bootstrap plan")
for _, phase := range plan.Phases {
fmt.Fprintf(out, "\n[%s]\n", phase)
states := plan.PackageStates[phase]
if len(states) > 0 {
for _, state := range states {
status := "pending"
if state.Installed {
status = "installed"
} else if state.CheckErr != nil {
status = "check failed; will retry install"
}
fmt.Fprintf(out, " choco: %s (%s) - %s\n", state.Package.Name, state.Package.ChocoID, status)
}
}
actions := plan.ActionStates[phase]
for _, state := range actions {
status := "pending"
if state.Installed {
status = "done"
}
fmt.Fprintf(out, " action: %s - %s\n", state.Action.Name, status)
}
if phase == PhaseOS {
fmt.Fprintln(out, " os: install Windows/Microsoft updates and stop before packages if reboot is required")
fmt.Fprintln(out, " os: create restore point, run WinUtil config, apply developer/power/update/UI/store/taskbar defaults")
fmt.Fprintln(out, " os: create second restore point before host package installation")
}
if phase == PhaseConfig {
fmt.Fprintln(out, " config: deploy managed WezTerm, PowerShell, oh-my-posh, and Neovim config")
}
if phase == PhaseWSL {
fmt.Fprintf(out, " wsl: install official Fedora WSL and invoke %s release with --wsl\n", opts.LinuxReleaseRepo)
}
}
if len(plan.DeferredNativeWork) > 0 && opts.Includes(PhaseHost) {
fmt.Fprintf(out, "\nDeferred native Windows workloads: %s\n", strings.Join(plan.DeferredNativeWork, ", "))
}
}
func (b *Bootstrapper) Run(ctx context.Context, opts Options) (runErr error) {
if b.In == nil {
b.In = os.Stdin
}
if b.Out == nil {
b.Out = os.Stdout
}
if b.Err == nil {
b.Err = os.Stderr
}
if b.Runner == nil {
b.Runner = ExecRunner{Timeout: 45 * time.Minute, Stream: b.Out}
}
phases := SelectedPhases(opts)
report := NewRunReport(opts, phases)
defer func() {
report.Finish(runErr)
logPath, err := WriteRunLog(b.Paths, report)
if err != nil {
fmt.Fprintf(b.Err, "ERROR: write run log: %v\n", err)
if runErr == nil {
runErr = fmt.Errorf("write run log: %w", err)
}
return
}
fmt.Fprintf(b.Out, "Run log: %s\n", logPath)
}()
checkState := opts.DryRun || !hasPhase(phases, PhaseOS)
plan := BuildPlanForPhases(ctx, b.Runner, opts, phases, checkState)
PrintPlan(b.Out, plan, opts)
if opts.DryRun {
return nil
}
if !opts.Yes {
if !confirm(b.In, b.Out, "Proceed with this plan? [y/N] ") {
fmt.Fprintln(b.Out, "Aborted.")
report.Event("aborted by user")
return nil
}
}
var issues Issues
if hasPhase(phases, PhaseOS) {
result := b.runOSPhase(ctx, report)
b.recordIssues(report, &issues, result.Issues)
if result.Stop {
report.Event("stopped after OS phase")
return issues.Err()
}
remaining := phasesWithout(phases, PhaseOS)
if len(remaining) > 0 {
report.Event("checking post-OS package and action state")
plan = BuildPlanForPhases(ctx, b.Runner, opts, remaining, true)
PrintPlan(b.Out, plan, opts)
issues = append(issues, b.executeCheckedPlan(ctx, plan, opts, report)...)
}
} else {
issues = append(issues, b.executeCheckedPlan(ctx, plan, opts, report)...)
}
if opts.Includes(PhaseConfig) {
authIssues := b.offerInteractiveAuth(ctx, opts)
b.recordIssues(report, &issues, authIssues)
}
return issues.Err()
}
func (b *Bootstrapper) runOSPhase(ctx context.Context, report *RunReport) OSPhaseResult {
report.Event("phase os started")
result := RunOSPhase(ctx, b.Runner)
for _, notice := range result.Notices {
b.recordNotice(report, notice)
}
if !result.Stop {
report.Event("phase os completed")
}
return result
}
func (b *Bootstrapper) executeCheckedPlan(ctx context.Context, plan Plan, opts Options, report *RunReport) Issues {
var issues Issues
for _, phase := range plan.Phases {
report.Event("phase %s started", phase)
switch phase {
case PhaseOS:
result := b.runOSPhase(ctx, report)
b.recordIssues(report, &issues, result.Issues)
if result.Stop {
return issues
}
case PhaseHost:
b.recordIssues(report, &issues, InstallPackages(ctx, b.Runner, PendingPackages(plan.PackageStates[phase])))
if notice, issue := b.configureHyperV(ctx); issue != nil {
b.recordIssues(report, &issues, Issues{*issue})
} else if notice != "" {
b.recordNotice(report, notice)
}
b.recordIssues(report, &issues, ExecuteActions(ctx, b.Runner, PendingActions(plan.ActionStates[phase])))
case PhaseCustom:
b.recordIssues(report, &issues, InstallPackages(ctx, b.Runner, PendingPackages(plan.PackageStates[phase])))
b.recordIssues(report, &issues, ExecuteActions(ctx, b.Runner, PendingActions(plan.ActionStates[phase])))
case PhaseConfig:
b.recordIssues(report, &issues, b.runConfigPhase(ctx, opts))
case PhaseWSL:
result := RunWSLPhase(ctx, b.Runner, opts)
if result.Issue != nil {
b.recordIssues(report, &issues, Issues{{Step: "wsl", Err: result.Issue}})
}
if result.Notice != "" {
b.recordNotice(report, result.Notice)
}
}
report.Event("phase %s completed", phase)
}
return issues
}
func (b *Bootstrapper) recordNotice(report *RunReport, notice string) {
fmt.Fprintln(b.Out, notice)
report.Notice(notice)
}
func (b *Bootstrapper) recordIssues(report *RunReport, all *Issues, issues Issues) {
if len(issues) == 0 {
return
}
*all = append(*all, issues...)
for _, issue := range issues {
fmt.Fprintf(b.Err, "ERROR: %s: %v\n", issue.Step, issue.Err)
report.Issue(issue)
}
}
func hasPhase(phases []Phase, target Phase) bool {
for _, phase := range phases {
if phase == target {
return true
}
}
return false
}
func phasesWithout(phases []Phase, target Phase) []Phase {
filtered := make([]Phase, 0, len(phases))
for _, phase := range phases {
if phase != target {
filtered = append(filtered, phase)
}
}
return filtered
}
func (b *Bootstrapper) configureHyperV(ctx context.Context) (string, *Issue) {
status, err := DetectHyperV(ctx, b.Runner)
if err != nil {
issue := Issue{Step: "detect Hyper-V", Err: err}
return "", &issue
}
notice, err := EnableHyperV(ctx, b.Runner, status)
if err != nil {
issue := Issue{Step: "configure Hyper-V", Err: err}
return "", &issue
}
return notice, nil
}
func (b *Bootstrapper) runConfigPhase(ctx context.Context, opts Options) Issues {
var issues Issues
deployments, err := DeployConfigAssets(b.Paths)
if err != nil {
issues = append(issues, Issue{Step: "deploy managed config", Err: err})
} else {
for _, line := range FileSummary(deployments) {
fmt.Fprintln(b.Out, line)
}
}
issue := ConfigureNeovim(ctx, b.Runner, b.Paths)
if !IsEmptyIssue(issue) {
issues = append(issues, issue)
}
return issues
}
func (b *Bootstrapper) offerInteractiveAuth(ctx context.Context, opts Options) Issues {
var issues Issues
if opts.Yes || IsSessionZero() {
return issues
}
if result := b.Runner.Run(ctx, "gh", "auth", "status"); result.Err != nil {
if confirm(b.In, b.Out, "Run `gh auth login` now? [y/N] ") {
login := b.Runner.Run(ctx, "gh", "auth", "login")
if login.Err != nil {
issues = append(issues, Issue{Step: "gh auth login", Err: fmt.Errorf("%w: %s", login.Err, login.CombinedOutput())})
}
}
}
key := filepath.Join(b.Paths.Home, ".ssh", "id_ed25519")
pub := key + ".pub"
if _, err := os.Stat(pub); os.IsNotExist(err) {
if confirm(b.In, b.Out, "Create and upload an ed25519 Git SSH key with gh? [y/N] ") {
if err := os.MkdirAll(filepath.Dir(key), 0o700); err != nil {
issues = append(issues, Issue{Step: "create SSH directory", Err: err})
return issues
}
host, _ := os.Hostname()
email := os.Getenv("USERNAME") + "@" + host
gen := b.Runner.Run(ctx, "ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", "")
if gen.Err != nil {
issues = append(issues, Issue{Step: "ssh-keygen", Err: fmt.Errorf("%w: %s", gen.Err, gen.CombinedOutput())})
return issues
}
add := b.Runner.Run(ctx, "gh", "ssh-key", "add", pub, "--title", "bootstrap_windows_env "+host)
if add.Err != nil {
issues = append(issues, Issue{Step: "gh ssh-key add", Err: fmt.Errorf("%w: %s", add.Err, add.CombinedOutput())})
}
}
}
return issues
}
func confirm(in io.Reader, out io.Writer, prompt string) bool {
if in == nil {
return false
}
fmt.Fprint(out, prompt)
scanner := bufio.NewScanner(in)
if !scanner.Scan() {
return false
}
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "y" || answer == "yes"
}