diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index e7956ec..adffaba 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -18,4 +18,4 @@ jobs: New-Item -ItemType Directory -Force dist | Out-Null go build -o dist/bootstrap_windows_env.exe ./cmd/bootstrap_windows_env - name: Run - run: ./dist/bootstrap_windows_env.exe --yes --headless --no-wsl + run: ./dist/bootstrap_windows_env.exe --yes --headless --no-wsl --no-restore-points diff --git a/README.md b/README.md index 357c6c6..584f013 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,11 @@ Tagged GitHub releases build and upload a precompiled `bootstrap_windows_env.exe .\bootstrap_windows_env.exe --only wsl .\bootstrap_windows_env.exe --no-ai .\bootstrap_windows_env.exe --no-wsl +.\bootstrap_windows_env.exe --no-restore-points ``` +`--no-restore-points` skips the two System Restore checkpoints in the `os` phase. Use this on Windows Server SKUs (including GitHub Actions `windows-latest` runners), where `Checkpoint-Computer` is not supported. + ## Phases `os` runs before any application installation. It invokes Windows Update/Microsoft Update, stops for reboot when required, creates a restore point, runs Chris Titus Tech WinUtil with the managed sane-default config, enables Developer Mode and Windows sudo, applies power/update/UI/store/taskbar defaults, then creates a second restore point before package installation. diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index ce8d9d2..6edf4d1 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -106,8 +106,13 @@ func PrintPlan(out io.Writer, plan Plan, opts Options) { } 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 opts.NoRestorePoints { + fmt.Fprintln(out, " os: skip System Restore checkpoints (--no-restore-points)") + fmt.Fprintln(out, " os: run WinUtil config, apply developer/power/update/UI/store/taskbar defaults") + } else { + 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") @@ -189,7 +194,7 @@ func (b *Bootstrapper) Run(ctx context.Context, opts Options) (runErr error) { func (b *Bootstrapper) runOSPhase(ctx context.Context, report *RunReport) OSPhaseResult { report.Event("phase os started") - result := RunOSPhase(ctx, b.Runner) + result := RunOSPhase(ctx, b.Runner, report.Options.NoRestorePoints) for _, notice := range result.Notices { b.recordNotice(report, notice) } diff --git a/internal/bootstrap/options.go b/internal/bootstrap/options.go index 4c7e173..eba1663 100644 --- a/internal/bootstrap/options.go +++ b/internal/bootstrap/options.go @@ -26,6 +26,7 @@ type Options struct { Yes bool DryRun bool Headless bool + NoRestorePoints bool LinuxReleaseRepo string } @@ -40,6 +41,7 @@ func ParseOptions(args []string, stderr io.Writer) (Options, error) { fs.BoolVar(&opts.Yes, "yes", false, "execute the displayed plan without confirmation") fs.BoolVar(&opts.DryRun, "dry-run", false, "detect state and display work without making changes") fs.BoolVar(&opts.Headless, "headless", false, "disable interactive login offers and require --yes to execute") + fs.BoolVar(&opts.NoRestorePoints, "no-restore-points", false, "skip System Restore checkpoints (required on Windows Server/CI where Checkpoint-Computer is unsupported)") fs.StringVar(&opts.LinuxReleaseRepo, "linux-release-repo", "JMR-dev/bootstrap_dev_env", "GitHub repository containing Linux bootstrap releases") if err := fs.Parse(args); err != nil { return Options{}, err diff --git a/internal/bootstrap/options_test.go b/internal/bootstrap/options_test.go index 02695c0..c3ec17a 100644 --- a/internal/bootstrap/options_test.go +++ b/internal/bootstrap/options_test.go @@ -61,3 +61,20 @@ func TestParseOptionsAcceptsHeadlessWithDryRunAndCustomRepo(t *testing.T) { t.Fatalf("opts = %#v", opts) } } + +func TestParseOptionsAcceptsNoRestorePoints(t *testing.T) { + opts, err := ParseOptions([]string{"--yes", "--no-restore-points"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if !opts.NoRestorePoints { + t.Fatalf("expected NoRestorePoints to be set: %#v", opts) + } + defaults, err := ParseOptions(nil, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if defaults.NoRestorePoints { + t.Fatal("NoRestorePoints should default to false") + } +} diff --git a/internal/bootstrap/osconfig.go b/internal/bootstrap/osconfig.go index 7ffeddd..3278a03 100644 --- a/internal/bootstrap/osconfig.go +++ b/internal/bootstrap/osconfig.go @@ -12,7 +12,7 @@ type OSPhaseResult struct { Stop bool } -func RunOSPhase(ctx context.Context, runner Runner) OSPhaseResult { +func RunOSPhase(ctx context.Context, runner Runner, noRestorePoints bool) OSPhaseResult { var result OSPhaseResult if issue := runOSCommand(ctx, runner, "install Windows updates", WindowsUpdateCommand()); issue.Err != nil { result.Issues = append(result.Issues, issue) @@ -25,15 +25,19 @@ func RunOSPhase(ctx context.Context, runner Runner) OSPhaseResult { result.Stop = true return result } - if issue := runOSCommand(ctx, runner, "create restore point before OS configuration", RestorePointCommand("bootstrap_windows_env: before OS configuration")); issue.Err != nil { - result.Issues = append(result.Issues, issue) - result.Stop = true - return result + if noRestorePoints { + result.Notices = append(result.Notices, "Skipped System Restore checkpoints (--no-restore-points). Checkpoint-Computer is unsupported on Windows Server SKUs.") + } else { + if issue := runOSCommand(ctx, runner, "create restore point before OS configuration", RestorePointCommand("bootstrap_windows_env: before OS configuration")); issue.Err != nil { + result.Issues = append(result.Issues, issue) + result.Stop = true + return result + } } if issue := runOSCommand(ctx, runner, "run Chris Titus Tech WinUtil", WinUtilCommand()); issue.Err != nil { result.Issues = append(result.Issues, issue) } - for _, step := range []struct { + steps := []struct { name string cmd CommandSpec fatal bool @@ -45,8 +49,15 @@ func RunOSPhase(ctx context.Context, runner Runner) OSPhaseResult { {"set Vivaldi default browser associations", VivaldiDefaultBrowserCommand(), false}, {"update Microsoft Store apps", StoreUpdateCommand(), false}, {"apply taskbar pins", TaskbarCommand(), false}, - {"create restore point before package installation", RestorePointCommand("bootstrap_windows_env: before package installation"), true}, - } { + } + if !noRestorePoints { + steps = append(steps, struct { + name string + cmd CommandSpec + fatal bool + }{"create restore point before package installation", RestorePointCommand("bootstrap_windows_env: before package installation"), true}) + } + for _, step := range steps { if issue := runOSCommand(ctx, runner, step.name, step.cmd); issue.Err != nil { result.Issues = append(result.Issues, issue) if step.fatal { diff --git a/internal/bootstrap/osconfig_test.go b/internal/bootstrap/osconfig_test.go index 8dca4ee..18a521b 100644 --- a/internal/bootstrap/osconfig_test.go +++ b/internal/bootstrap/osconfig_test.go @@ -51,7 +51,7 @@ func TestRunOSPhaseStopsBeforeRestorePointWhenRebootIsRequired(t *testing.T) { commandKey(update.Name, update.Args...): {}, commandKey(reboot.Name, reboot.Args...): {Stdout: "true\n"}, }, defaultErr: true} - result := RunOSPhase(context.Background(), runner) + result := RunOSPhase(context.Background(), runner, false) if !result.Stop { t.Fatal("expected OS phase to stop for pending reboot") } @@ -65,7 +65,7 @@ func TestRunOSPhaseStopsBeforeRestorePointWhenRebootIsRequired(t *testing.T) { func TestRunOSPhaseRunsSecondRestorePointAfterConfiguration(t *testing.T) { runner := &fakeRunner{} - result := RunOSPhase(context.Background(), runner) + result := RunOSPhase(context.Background(), runner, false) if result.Stop { t.Fatalf("OS phase unexpectedly stopped: %#v", result) } @@ -84,7 +84,7 @@ func TestRunOSPhaseContinuesAfterNonRestoreFailure(t *testing.T) { runner := &fakeRunner{responses: map[string]CommandResult{ commandKey(update.Name, update.Args...): {Err: context.Canceled, Stderr: "update failed"}, }} - result := RunOSPhase(context.Background(), runner) + result := RunOSPhase(context.Background(), runner, false) if result.Stop { t.Fatalf("non-restore failure should be reported but should not stop the OS phase: %#v", result) } @@ -102,7 +102,7 @@ func TestRunOSPhaseStopsWhenSecondRestorePointFails(t *testing.T) { runner := &fakeRunner{responses: map[string]CommandResult{ commandKey(second.Name, second.Args...): {Err: context.Canceled, Stderr: "restore failed"}, }} - result := RunOSPhase(context.Background(), runner) + result := RunOSPhase(context.Background(), runner, false) if !result.Stop { t.Fatal("expected OS phase to stop when final restore point fails") } @@ -117,7 +117,7 @@ func TestRunOSPhaseStopsWhenFirstRestorePointFails(t *testing.T) { runner := &fakeRunner{responses: map[string]CommandResult{ commandKey(first.Name, first.Args...): {Err: errors.New("restore failed"), Stderr: "blocked"}, }} - result := RunOSPhase(context.Background(), runner) + result := RunOSPhase(context.Background(), runner, false) if !result.Stop { t.Fatal("expected first restore point failure to stop OS phase") } @@ -135,7 +135,7 @@ func TestRunOSPhaseRecordsWinUtilFailureAndContinuesToFinalRestorePoint(t *testi runner := &fakeRunner{responses: map[string]CommandResult{ commandKey(winutil.Name, winutil.Args...): {Err: errors.New("winutil failed"), Stderr: "script failed"}, }} - result := RunOSPhase(context.Background(), runner) + result := RunOSPhase(context.Background(), runner, false) if result.Stop { t.Fatalf("WinUtil failure should not stop OS phase: %#v", result) } @@ -160,7 +160,7 @@ func TestRunOSPhaseRecordsRebootProbeFailureAndContinues(t *testing.T) { runner := &fakeRunner{responses: map[string]CommandResult{ commandKey(reboot.Name, reboot.Args...): {Err: errors.New("probe failed"), Stderr: "bad registry"}, }} - result := RunOSPhase(context.Background(), runner) + result := RunOSPhase(context.Background(), runner, false) if result.Stop { t.Fatalf("reboot probe failure should be recorded but should not stop OS phase: %#v", result) } @@ -172,6 +172,40 @@ func TestRunOSPhaseRecordsRebootProbeFailureAndContinues(t *testing.T) { } } +func TestRunOSPhaseSkipsRestorePointsWhenDisabled(t *testing.T) { + runner := &fakeRunner{} + result := RunOSPhase(context.Background(), runner, true) + if result.Stop { + t.Fatalf("OS phase unexpectedly stopped: %#v", result) + } + if err := result.Issues.Err(); err != nil { + t.Fatal(err) + } + first := RestorePointCommand("bootstrap_windows_env: before OS configuration") + second := RestorePointCommand("bootstrap_windows_env: before package installation") + if runner.called(first.Name, first.Args...) || runner.called(second.Name, second.Args...) { + t.Fatal("restore point commands should not run when --no-restore-points is set") + } + winutil := WinUtilCommand() + if !runner.called(winutil.Name, winutil.Args...) { + t.Fatal("expected WinUtil to still run when restore points are skipped") + } + power := PowerPolicyCommand() + if !runner.called(power.Name, power.Args...) { + t.Fatal("expected later configuration steps to still run when restore points are skipped") + } + var found bool + for _, n := range result.Notices { + if strings.Contains(n, "Skipped System Restore checkpoints") { + found = true + break + } + } + if !found { + t.Fatalf("expected skip notice in result.Notices, got %#v", result.Notices) + } +} + func TestRestorePointCommandDisablesFrequencyThrottle(t *testing.T) { cmd := RestorePointCommand("bootstrap_windows_env: test") joined := strings.Join(cmd.Args, " ") diff --git a/internal/bootstrap/runlog.go b/internal/bootstrap/runlog.go index aea4e98..cb6cbf0 100644 --- a/internal/bootstrap/runlog.go +++ b/internal/bootstrap/runlog.go @@ -66,13 +66,14 @@ func WriteRunLog(paths UserPaths, report *RunReport) (string, error) { fmt.Fprintf(&b, "duration: %s\n", report.Finished.Sub(report.Started).Round(time.Second)) } fmt.Fprintf(&b, "phases: %s\n", phasesForLog(report.Phases)) - fmt.Fprintf(&b, "options: only=%s no-ai=%t no-wsl=%t yes=%t dry-run=%t headless=%t linux-release-repo=%s\n", + fmt.Fprintf(&b, "options: only=%s no-ai=%t no-wsl=%t yes=%t dry-run=%t headless=%t no-restore-points=%t linux-release-repo=%s\n", report.Options.Only, report.Options.NoAI, report.Options.NoWSL, report.Options.Yes, report.Options.DryRun, report.Options.Headless, + report.Options.NoRestorePoints, report.Options.LinuxReleaseRepo, ) if report.FinalError != "" {