feat: state machine for reboot-resume across Windows Update cycles
All checks were successful
release / build-and-release (push) Successful in 24s

xetup.exe now acts as an orchestrator across system reboots:
- PS scripts exit 9 to signal "reboot needed, re-run me" (WU) or
  "done but reboot needed to finalize" (Dell BIOS)
- On exit 9: xetup saves state.json, ensures adminx9 account,
  copies itself to stable path, enables autologon, registers
  X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
- On resume: loads pending steps from state, continues seamlessly
  with "Pokracuji po restartu..." label in the run window
- On completion: disables autologon, removes X9-Resume task,
  deletes state file, shows summary with accumulated results
  across all reboot rounds

New packages: internal/state, internal/prereboot
Script 12: simplified to exit 0 (done) or exit 9 (reboot needed)
Script 11: exit 9 when DCU exit code 1 (BIOS staged, reboot needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
X9 Dev 2026-04-16 17:09:53 +02:00
parent 9feb7ba4e1
commit 5e01826a49
10 changed files with 600 additions and 150 deletions

View file

@ -21,16 +21,10 @@ import (
"git.xetup.x9.cz/x9/xetup/internal/config" "git.xetup.x9.cz/x9/xetup/internal/config"
"git.xetup.x9.cz/x9/xetup/internal/gui" "git.xetup.x9.cz/x9/xetup/internal/gui"
"git.xetup.x9.cz/x9/xetup/internal/runner" "git.xetup.x9.cz/x9/xetup/internal/runner"
"git.xetup.x9.cz/x9/xetup/internal/state"
) )
func main() { func main() {
// Load config (falls back to defaults when config.json is missing)
cfgPath := config.ConfigPath()
cfg, err := config.Load(cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
}
// Temp working directory cleaned up on exit // Temp working directory cleaned up on exit
tmpDir, err := os.MkdirTemp("", "xetup-*") tmpDir, err := os.MkdirTemp("", "xetup-*")
if err != nil { if err != nil {
@ -38,7 +32,7 @@ func main() {
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
// Extract embedded scripts and assets // Extract embedded scripts and assets (required in both initial and resume mode)
if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil { if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
log.Fatalf("Failed to extract scripts: %v", err) log.Fatalf("Failed to extract scripts: %v", err)
} }
@ -46,6 +40,24 @@ func main() {
log.Fatalf("Failed to extract assets: %v", err) log.Fatalf("Failed to extract assets: %v", err)
} }
// Determine config: use stored state in resume mode, load from file otherwise
cfgPath := config.ConfigPath()
var cfg config.Config
if st, err := state.Load(); err == nil {
// Resume mode: use the config that was active when the run started
cfg = st.Config
if st.ConfigPath != "" {
cfgPath = st.ConfigPath
}
} else {
// Normal mode: load config from disk (falls back to defaults when absent)
cfg, err = config.Load(cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
}
}
// Write runtime config JSON so PowerShell scripts can read it // Write runtime config JSON so PowerShell scripts can read it
cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir) cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
if err != nil { if err != nil {

View file

@ -7,6 +7,10 @@
// load/save config buttons for per-client presets // load/save config buttons for per-client presets
// 2. Live run real-time log streamed from PowerShell scripts // 2. Live run real-time log streamed from PowerShell scripts
// 3. Summary per-step OK / ERROR / SKIPPED with elapsed time // 3. Summary per-step OK / ERROR / SKIPPED with elapsed time
//
// Resume mode (state file present): skips the config form and runs only
// the pending steps recorded in the state file. On completion, disables
// autologon and removes the X9-Resume scheduled task.
package gui package gui
import ( import (
@ -23,11 +27,20 @@ import (
. "github.com/lxn/walk/declarative" . "github.com/lxn/walk/declarative"
"git.xetup.x9.cz/x9/xetup/internal/config" "git.xetup.x9.cz/x9/xetup/internal/config"
"git.xetup.x9.cz/x9/xetup/internal/prereboot"
"git.xetup.x9.cz/x9/xetup/internal/runner" "git.xetup.x9.cz/x9/xetup/internal/runner"
"git.xetup.x9.cz/x9/xetup/internal/state"
) )
// Run opens the xetup window and blocks until the user closes it. // Run opens the xetup window and blocks until the user closes it.
func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) { func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
// Resume mode: state file present from a previous interrupted run
if st, err := state.Load(); err == nil {
resumePhase(st, runCfg)
return
}
// Normal flow: config form → run → summary
for { for {
res := formPhase(cfg, runCfg, cfgPath) res := formPhase(cfg, runCfg, cfgPath)
switch res.action { switch res.action {
@ -38,13 +51,53 @@ func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
cfgPath = res.cfgPath cfgPath = res.cfgPath
case "run": case "run":
runCfg.ProfileType = res.cfg.Deployment.ProfileType runCfg.ProfileType = res.cfg.Deployment.ProfileType
results := runPhase(runCfg, res.steps) results, needsReboot := runPhase(runCfg, res.steps, false)
donePhase(results) if needsReboot {
prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg)
return
}
donePhase(results, nil)
return return
} }
} }
} }
// --------------------------------------------------------------------------
// Resume mode
// --------------------------------------------------------------------------
func resumePhase(st *state.State, runCfg runner.RunConfig) {
runCfg.LogFile = st.LogFile
steps := runner.StepsByIDs(st.PendingSteps)
results, needsReboot := runPhase(runCfg, steps, true)
// Accumulate completed results (NeedsReboot step excluded runs again)
newResults := append(st.Results, toStateResults(results)...) //nolint:gocritic
if needsReboot {
// Update state and reboot infrastructure already in place (running as adminx9)
pending := pendingStepIDs(steps, results)
newSt := &state.State{
Config: st.Config,
ConfigPath: st.ConfigPath,
LogFile: st.LogFile,
PendingSteps: pending,
Results: newResults,
}
if err := state.Save(newSt); err != nil {
walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
}
reboot()
return
}
// All steps complete
prereboot.Cleanup()
_ = state.Delete()
donePhase(results, st.Results)
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Phase 1 Config form // Phase 1 Config form
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -232,14 +285,17 @@ func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formR
// Phase 2 Live run view // Phase 2 Live run view
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// stepIndicator tracks a step's label and blink state. // stepIndicator tracks a step's label and current status.
type stepIndicator struct { type stepIndicator struct {
label *walk.Label label *walk.Label
stepID string stepID string
status string // "pending", "running", "ok", "error", "skipped", "cancelled" status string // "pending", "running", "ok", "error", "skipped", "cancelled"
} }
func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result { // runPhase runs steps and streams live log output.
// resuming=true changes the title and initial status message.
// Returns (results, needsReboot).
func runPhase(runCfg runner.RunConfig, steps []runner.Step, resuming bool) ([]runner.Result, bool) {
var ( var (
mw *walk.MainWindow mw *walk.MainWindow
statusLbl *walk.Label statusLbl *walk.Label
@ -249,7 +305,7 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
// Build step indicator labels (one per step, in order) // Build step indicator labels (one per step, in order)
indicators := make([]stepIndicator, len(steps)) indicators := make([]stepIndicator, len(steps))
stepIndex := make(map[string]int, len(steps)) // stepID → index stepIndex := make(map[string]int, len(steps))
indWidgets := make([]Widget, len(steps)) indWidgets := make([]Widget, len(steps))
for i, s := range steps { for i, s := range steps {
indicators[i] = stepIndicator{stepID: s.ID, status: "pending"} indicators[i] = stepIndicator{stepID: s.ID, status: "pending"}
@ -262,13 +318,20 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
} }
} }
title := "xetup \u2014 probiha instalace"
initialStatus := "Spoustim..."
if resuming {
title = "xetup \u2014 pokracuji po restartu"
initialStatus = "Pokracuji po restartu..."
}
if err := (MainWindow{ if err := (MainWindow{
AssignTo: &mw, AssignTo: &mw,
Title: "xetup \u2014 probiha instalace", Title: title,
Size: Size{Width: 760, Height: 740}, Size: Size{Width: 760, Height: 740},
Layout: VBox{}, Layout: VBox{},
Children: []Widget{ Children: []Widget{
Label{AssignTo: &statusLbl, Text: "Spoustim..."}, Label{AssignTo: &statusLbl, Text: initialStatus},
// Step progress strip // Step progress strip
Composite{ Composite{
Layout: HBox{MarginsZero: true}, Layout: HBox{MarginsZero: true},
@ -299,17 +362,17 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
}, },
}, },
}.Create()); err != nil { }.Create()); err != nil {
return nil return nil, false
} }
// updateIndicator refreshes label text and color — call inside Synchronize. // updateIndicator refreshes a step label's text and color — call inside Synchronize.
updateIndicator := func(idx int) { updateIndicator := func(idx int) {
ind := &indicators[idx] ind := &indicators[idx]
s := steps[idx] s := steps[idx]
switch ind.status { switch ind.status {
case "running": case "running":
ind.label.SetText(s.Num + " \u25ba") // ► ind.label.SetText(s.Num + " \u25ba") // ►
ind.label.SetTextColor(walk.RGB(30, 144, 255)) // dodger blue ind.label.SetTextColor(walk.RGB(30, 144, 255))
case "ok": case "ok":
ind.label.SetText(s.Num + " \u2713") // ✓ ind.label.SetText(s.Num + " \u2713") // ✓
ind.label.SetTextColor(walk.RGB(0, 180, 0)) ind.label.SetTextColor(walk.RGB(0, 180, 0))
@ -386,7 +449,78 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
return results
needsReboot := len(results) > 0 && results[len(results)-1].NeedsReboot
return results, needsReboot
}
// --------------------------------------------------------------------------
// Pre-reboot helper
// --------------------------------------------------------------------------
// prepareRebootAndRestart saves state, sets up resume infrastructure, and reboots.
func prepareRebootAndRestart(
cfg config.Config,
allSteps []runner.Step,
completedResults []runner.Result,
cfgPath string,
runCfg runner.RunConfig,
) {
pending := pendingStepIDs(allSteps, completedResults)
st := &state.State{
Config: cfg,
ConfigPath: cfgPath,
LogFile: runCfg.LogFile,
PendingSteps: pending,
Results: toStateResults(completedResults),
}
if err := state.Save(st); err != nil {
walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
return
}
if err := prereboot.Prepare(); err != nil {
walk.MsgBox(nil, "Chyba", "Prereboot setup selhal: "+err.Error(), walk.MsgBoxIconError)
return
}
reboot()
}
// pendingStepIDs returns IDs of steps that still need to run.
// Steps with NeedsReboot=true (re-run after reboot) and steps not yet
// reached (absent from completedResults) are both included.
func pendingStepIDs(allSteps []runner.Step, completedResults []runner.Result) []string {
done := make(map[string]bool, len(completedResults))
for _, r := range completedResults {
if !r.NeedsReboot {
done[r.Step.ID] = true
}
}
var pending []string
for _, s := range allSteps {
if !done[s.ID] {
pending = append(pending, s.ID)
}
}
return pending
}
// toStateResults converts runner results to state.StepResult for persistence.
// Steps with NeedsReboot=true are excluded (they will run again next round).
func toStateResults(results []runner.Result) []state.StepResult {
var sr []state.StepResult
for _, r := range results {
if r.NeedsReboot {
continue
}
sr = append(sr, state.StepResult{
StepID: r.Step.ID,
Num: r.Step.Num,
Name: r.Step.Name,
Status: r.Status,
Elapsed: r.Elapsed,
})
}
return sr
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -395,18 +529,38 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
const rebootCountdown = 60 // seconds before automatic reboot const rebootCountdown = 60 // seconds before automatic reboot
func donePhase(results []runner.Result) { // donePhase shows the final summary screen.
// currentResults are from the last run; prevResults accumulate earlier rounds.
func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
var ( var (
mw *walk.MainWindow mw *walk.MainWindow
countdownLbl *walk.Label countdownLbl *walk.Label
) )
ok, errs, skipped := 0, 0, 0 // Combined ordered list: previous rounds first, then current round
rows := make([]Widget, 0, len(results)) type displayRow struct {
Num string
Name string
Status string
Elapsed time.Duration
}
for _, res := range results { var rows []displayRow
for _, r := range prevResults {
rows = append(rows, displayRow{r.Num, r.Name, r.Status, r.Elapsed})
}
for _, r := range currentResults {
if r.NeedsReboot {
continue
}
rows = append(rows, displayRow{r.Step.Num, r.Step.Name, r.Status, r.Elapsed})
}
ok, errs, skipped := 0, 0, 0
widgets := make([]Widget, 0, len(rows))
for _, row := range rows {
var icon string var icon string
switch res.Status { switch row.Status {
case "OK": case "OK":
ok++ ok++
icon = "OK " icon = "OK "
@ -417,11 +571,11 @@ func donePhase(results []runner.Result) {
skipped++ skipped++
icon = "\u2013 " icon = "\u2013 "
} }
text := icon + res.Step.Num + " \u2013 " + res.Step.Name text := icon + row.Num + " \u2013 " + row.Name
if res.Elapsed > 0 { if row.Elapsed > 0 {
text += fmt.Sprintf(" (%s)", res.Elapsed.Round(time.Second)) text += fmt.Sprintf(" (%s)", row.Elapsed.Round(time.Second))
} }
rows = append(rows, Label{ widgets = append(widgets, Label{
Text: text, Text: text,
Font: Font{Family: "Consolas", PointSize: 9}, Font: Font{Family: "Consolas", PointSize: 9},
}) })
@ -429,7 +583,6 @@ func donePhase(results []runner.Result) {
summaryText := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped) summaryText := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped)
// cancelled by "Zrusit restart" button
cancelReboot := make(chan struct{}) cancelReboot := make(chan struct{})
if err := (MainWindow{ if err := (MainWindow{
@ -447,7 +600,7 @@ func donePhase(results []runner.Result) {
ScrollView{ ScrollView{
MinSize: Size{Height: 510}, MinSize: Size{Height: 510},
Layout: VBox{MarginsZero: true}, Layout: VBox{MarginsZero: true},
Children: rows, Children: widgets,
}, },
HSeparator{}, HSeparator{},
Label{ Label{
@ -467,7 +620,7 @@ func donePhase(results []runner.Result) {
PushButton{ PushButton{
Text: " Restartovat ted ", Text: " Restartovat ted ",
OnClicked: func() { OnClicked: func() {
close(cancelReboot) // signal goroutine to stop ticker close(cancelReboot)
reboot() reboot()
mw.Close() mw.Close()
}, },
@ -476,7 +629,7 @@ func donePhase(results []runner.Result) {
Text: " Zrusit restart ", Text: " Zrusit restart ",
OnClicked: func() { OnClicked: func() {
select { select {
case <-cancelReboot: // already closed case <-cancelReboot:
default: default:
close(cancelReboot) close(cancelReboot)
} }

View file

@ -0,0 +1,20 @@
//go:build !windows
// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot
// StablePath is the platform stub for the stable binary path.
const StablePath = "/tmp/xetup-resume"
// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"
// IsAdminx9 always returns false on non-Windows platforms.
func IsAdminx9() bool { return false }
// Prepare is a no-op on non-Windows platforms.
func Prepare() error { return nil }
// Cleanup is a no-op on non-Windows platforms.
func Cleanup() {}

View file

@ -0,0 +1,190 @@
//go:build windows
// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"golang.org/x/sys/windows/registry"
)
// StablePath is where xetup.exe is copied so it survives across reboots.
const StablePath = `C:\Windows\Setup\Scripts\xetup.exe`
// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"
const adminUser = "adminx9"
const winlogonKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`
// IsAdminx9 reports whether the current process runs as the adminx9 account.
func IsAdminx9() bool {
return strings.EqualFold(os.Getenv("USERNAME"), adminUser)
}
// Prepare ensures the reboot-resume infrastructure is in place.
// When not running as adminx9 (first reboot trigger):
// - creates the adminx9 local account if it does not exist
// - copies the running binary to StablePath
// - enables autologon for adminx9 (AutoLogonCount=20 safety cap)
// - registers the X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
//
// When already running as adminx9 (subsequent rounds) all of the above is
// already in place; the function returns immediately without changes.
// Safe to call repeatedly (idempotent).
func Prepare() error {
if IsAdminx9() {
return nil
}
if err := ensureAdminx9User(); err != nil {
return fmt.Errorf("adminx9 account: %w", err)
}
if err := copySelfTo(StablePath); err != nil {
return fmt.Errorf("copy binary: %w", err)
}
if err := setAutologon(); err != nil {
return fmt.Errorf("autologon: %w", err)
}
if err := registerResumeTask(); err != nil {
return fmt.Errorf("resume task: %w", err)
}
return nil
}
// Cleanup disables autologon and removes the X9-Resume scheduled task.
// Called when all deployment steps have completed successfully.
func Cleanup() {
_ = disableAutologon()
_ = unregisterResumeTask()
}
// ensureAdminx9User creates the adminx9 local account if absent.
func ensureAdminx9User() error {
// "net user adminx9" exits 0 when user exists
if err := newHiddenCmd("net", "user", adminUser).Run(); err == nil {
return nil // already exists
}
// Create with empty password
if b, err := newHiddenCmd("net", "user", adminUser, "", "/add").CombinedOutput(); err != nil {
return fmt.Errorf("net user /add: %s: %w", strings.TrimSpace(string(b)), err)
}
// Add to local Administrators group
if b, err := newHiddenCmd("net", "localgroup", "Administrators", adminUser, "/add").CombinedOutput(); err != nil {
return fmt.Errorf("net localgroup: %s: %w", strings.TrimSpace(string(b)), err)
}
// Hide from the Windows login screen
k, _, err := registry.CreateKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList`,
registry.SET_VALUE)
if err != nil {
return fmt.Errorf("registry hide: %w", err)
}
defer k.Close()
return k.SetDWordValue(adminUser, 0)
}
// copySelfTo copies the running executable to dst.
// Skips the copy when src and dst resolve to the same path.
func copySelfTo(dst string) error {
src, err := os.Executable()
if err != nil {
return err
}
if resolved, err := filepath.EvalSymlinks(src); err == nil {
src = resolved
}
if strings.EqualFold(src, dst) {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, r)
return err
}
func setAutologon() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
for _, kv := range []struct{ name, val string }{
{"AutoAdminLogon", "1"},
{"DefaultUserName", adminUser},
{"DefaultPassword", ""},
{"DefaultDomainName", "."},
} {
if err := k.SetStringValue(kv.name, kv.val); err != nil {
return fmt.Errorf("set %s: %w", kv.name, err)
}
}
// Safety cap: self-limits even if cleanup task fails to run
return k.SetDWordValue("AutoLogonCount", 20)
}
func disableAutologon() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
_ = k.SetStringValue("AutoAdminLogon", "0")
_ = k.DeleteValue("DefaultPassword")
_ = k.DeleteValue("AutoLogonCount")
return nil
}
func registerResumeTask() error {
ps := fmt.Sprintf(`
$action = New-ScheduledTaskAction -Execute '%s' -Argument '--resume'
$trigger = New-ScheduledTaskTrigger -AtLogOn -User '%s'
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 4) -MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId '%s' -LogonType Interactive -RunLevel Highest
Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName 'X9-Resume' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
`, StablePath, adminUser, adminUser)
return runPS(ps)
}
func unregisterResumeTask() error {
return runPS(`Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue`)
}
func runPS(script string) error {
cmd := newHiddenCmd("powershell.exe",
"-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
b, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(b)), err)
}
return nil
}
func newHiddenCmd(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd
}

View file

@ -5,6 +5,7 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@ -14,6 +15,11 @@ import (
"time" "time"
) )
// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
// code 9, signalling that it completed successfully but requires a system
// reboot before deployment can continue.
var ErrNeedsReboot = errors.New("reboot required")
// Step describes a single deployment step. // Step describes a single deployment step.
type Step struct { type Step struct {
ID string // e.g. "adminAccount" ID string // e.g. "adminAccount"
@ -128,8 +134,9 @@ type RunConfig struct {
// Result is the outcome of a single step. // Result is the outcome of a single step.
type Result struct { type Result struct {
Step Step Step Step
Status string // "OK", "ERROR", "SKIPPED" Status string // "OK", "ERROR", "SKIPPED", "CANCELLED"
Elapsed time.Duration Elapsed time.Duration
NeedsReboot bool // true when the script exited with code 9
} }
// LogLine is a single output line from a running script. // LogLine is a single output line from a running script.
@ -180,19 +187,23 @@ func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
elapsed := time.Since(start) elapsed := time.Since(start)
status := "OK" status := "OK"
needsReboot := false
if err != nil { if err != nil {
if ctx.Err() != nil { if errors.Is(err, ErrNeedsReboot) {
needsReboot = true
// status remains "OK": step completed, reboot is just required to continue
} else if ctx.Err() != nil {
status = "CANCELLED" status = "CANCELLED"
} else { } else {
status = "ERROR" status = "ERROR"
} }
} }
res := Result{Step: step, Status: status, Elapsed: elapsed} res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
r.onResult(res) r.onResult(res)
results = append(results, res) results = append(results, res)
if ctx.Err() != nil { if ctx.Err() != nil || needsReboot {
break break
} }
} }
@ -253,7 +264,14 @@ func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error
}) })
} }
return cmd.Wait() waitErr := cmd.Wait()
if waitErr != nil {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
return ErrNeedsReboot
}
}
return waitErr
} }
// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log: // skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:
@ -362,6 +380,32 @@ func extractDir(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(s
return nil return nil
} }
// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
var result []Step
for _, s := range AllSteps() {
if want[s.ID] {
result = append(result, s)
}
}
return result
}
// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
for _, s := range AllSteps() {
if s.ID == id {
return s, true
}
}
return Step{}, false
}
// WriteConfig serialises cfg to a temp JSON file and returns its path. // WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) { func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
path := filepath.Join(tmpDir, "config-runtime.json") path := filepath.Join(tmpDir, "config-runtime.json")

View file

@ -0,0 +1,12 @@
//go:build !windows
package state
import "os"
func statePath() string {
if h, err := os.UserHomeDir(); err == nil {
return h + "/xetup-state.json"
}
return "/tmp/xetup-state.json"
}

View file

@ -0,0 +1,7 @@
//go:build windows
package state
func statePath() string {
return `C:\Windows\Setup\Scripts\xetup-state.json`
}

74
internal/state/state.go Normal file
View file

@ -0,0 +1,74 @@
// Package state manages the persistent xetup deployment state used to resume
// after a system reboot. The state file is written before each reboot and
// deleted once all deployment steps have completed.
package state
import (
"encoding/json"
"os"
"path/filepath"
"time"
"git.xetup.x9.cz/x9/xetup/internal/config"
)
// StepResult records the outcome of a single deployment step.
// Results are accumulated across reboot rounds so the final summary
// can show every step regardless of which round it ran in.
type StepResult struct {
StepID string `json:"stepID"`
Num string `json:"num"`
Name string `json:"name"`
Status string `json:"status"` // "OK", "ERROR", "SKIPPED"
Elapsed time.Duration `json:"elapsed"`
}
// State holds everything xetup needs to resume after a reboot.
type State struct {
Config config.Config `json:"config"`
ConfigPath string `json:"configPath"` // path to original config.json
LogFile string `json:"logFile"`
PendingSteps []string `json:"pendingSteps"` // step IDs to run next, in canonical order
Results []StepResult `json:"results"` // accumulated across all rounds
}
// Load reads the state file. Returns a non-nil error when the file is absent.
func Load() (*State, error) {
data, err := os.ReadFile(statePath())
if err != nil {
return nil, err
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
}
// Save writes the state file, creating parent directories as needed.
func Save(s *State) error {
p := statePath()
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0644)
}
// Delete removes the state file. Silently ignores not-found.
func Delete() error {
err := os.Remove(statePath())
if os.IsNotExist(err) {
return nil
}
return err
}
// Exists reports whether a state file is present on disk.
func Exists() bool {
_, err := os.Stat(statePath())
return err == nil
}

View file

@ -143,10 +143,20 @@ if (-not $runDrivers -and -not $runBios) {
# 5 = no applicable updates found for this system # 5 = no applicable updates found for this system
# others = error or partial failure # others = error or partial failure
switch ($exitCode) { switch ($exitCode) {
0 { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK } 0 {
1 { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK } Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK
5 { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK } }
default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN } 1 {
Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK
Write-Log "Step 11 complete - signalling reboot needed (exit 9)" -Level OK
exit 9
}
5 {
Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK
}
default {
Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN
}
} }
} }

View file

@ -3,21 +3,19 @@
Installs all available Windows Updates via PSWindowsUpdate module. Installs all available Windows Updates via PSWindowsUpdate module.
.DESCRIPTION .DESCRIPTION
First pass: installs all currently available updates without rebooting. Installs all currently available updates without rebooting.
Then registers a scheduled task "X9-WindowsUpdate" that runs on every Exits with code 9 when updates were installed (reboot required for
logon (as SYSTEM) until no more updates are found - handles the typical further rounds) or code 0 when the system is already fully up to date.
2-3 reboot cycles required on a fresh Windows installation.
Operator workflow: The xetup.exe state machine handles the reboot cycle:
1. xetup completes all steps - exit 9 -> xetup saves state, sets autologon + X9-Resume task, reboots
2. Operator reboots manually - on each subsequent logon X9-Resume launches xetup --resume
3. On each subsequent logon the scheduled task runs another update pass - xetup re-runs this step until it exits 0 (no more updates)
4. Task removes itself automatically when system is fully up to date - then disables autologon, removes X9-Resume, shows the summary screen
.ITEMS .ITEMS
nainstalovat-pswindowsupdate-modul: Installs NuGet provider and PSWindowsUpdate module from PSGallery. nainstalovat-pswindowsupdate-modul: Installs NuGet provider and PSWindowsUpdate module from PSGallery.
spustit-prvni-kolo-windows-update: First update pass without reboot - installs all currently available updates. spustit-kolo-windows-update: One update pass without reboot. Exits 9 when updates were applied (more rounds needed). Exits 0 when system is fully up to date.
registrovat-scheduled-task-pro-dalsi-kola: Registers X9-WindowsUpdate scheduled task that runs on logon, handles post-reboot update rounds, and self-deletes when no more updates are found.
#> #>
param( param(
[object]$Config, [object]$Config,
@ -56,102 +54,32 @@ try {
} }
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# 2. First update pass (no reboot) # 2. Check and install available updates
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
Write-Log "Running first Windows Update pass..." -Level INFO Write-Log "Checking for available Windows Updates..." -Level INFO
try {
$available = Get-WindowsUpdate -AcceptAll -IgnoreReboot -ErrorAction Stop
} catch {
Write-Log " Failed to check for updates: $_" -Level ERROR
exit 1
}
if (-not $available -or $available.Count -eq 0) {
Write-Log " System is fully up to date" -Level OK
Write-Log "Step 12 complete" -Level OK
exit 0
}
Write-Log " Found $($available.Count) update(s) - installing..." -Level INFO
try { try {
$result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1 $result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1
$installed = @($result | Where-Object { $_ -match 'KB\d+|Downloaded|Installed' })
if ($installed.Count -gt 0) {
$result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log " $_" -Level INFO } $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log " $_" -Level INFO }
Write-Log " First pass complete - reboot required for remaining rounds" -Level OK Write-Log " Update pass complete - reboot required for next round" -Level OK
} else {
Write-Log " System already up to date" -Level OK
}
} catch { } catch {
Write-Log " First pass failed: $_" -Level ERROR Write-Log " Update install failed: $_" -Level ERROR
exit 1
} }
# ----------------------------------------------------------------------- # Signal xetup that a reboot is needed before running this step again
# 3. Enable autologon for adminx9 (temporary - disabled when updates complete) Write-Log "Step 12 - reboot required (exit 9)" -Level OK
# ----------------------------------------------------------------------- exit 9
Write-Log "Enabling temporary autologon for adminx9..." -Level INFO
$winlogonPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
try {
Set-ItemProperty -Path $winlogonPath -Name "AutoAdminLogon" -Value "1" -Type String -Force
Set-ItemProperty -Path $winlogonPath -Name "DefaultUserName" -Value "adminx9" -Type String -Force
Set-ItemProperty -Path $winlogonPath -Name "DefaultPassword" -Value "" -Type String -Force
Set-ItemProperty -Path $winlogonPath -Name "DefaultDomainName" -Value "." -Type String -Force
# Safety cap: max 10 automatic logons in case the task fails to clean up
Set-ItemProperty -Path $winlogonPath -Name "AutoLogonCount" -Value 10 -Type DWord -Force
Write-Log " Autologon enabled (adminx9, max 10 rounds)" -Level OK
} catch {
Write-Log " Failed to enable autologon: $_" -Level WARN
Write-Log " Windows Update rounds will require manual login after each reboot" -Level WARN
}
# -----------------------------------------------------------------------
# 4. Scheduled task for post-reboot update rounds (self-deleting)
# -----------------------------------------------------------------------
Write-Log "Registering post-reboot update task..." -Level INFO
$taskName = "X9-WindowsUpdate"
# PowerShell block that runs on each logon until no more updates found.
# When done: disables autologon and removes itself.
$updateScript = @'
Import-Module PSWindowsUpdate -Force -ErrorAction Stop
$updates = Get-WindowsUpdate -AcceptAll -IgnoreReboot
if ($updates) {
Install-WindowsUpdate -AcceptAll -IgnoreReboot | Out-File "C:\Windows\Setup\Scripts\wu-pass-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8
} else {
# No more updates - disable autologon and clean up
$wl = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty -Path $wl -Name "AutoAdminLogon" -Value "0" -Type String -Force
Remove-ItemProperty -Path $wl -Name "DefaultPassword" -ErrorAction SilentlyContinue
Remove-ItemProperty -Path $wl -Name "AutoLogonCount" -ErrorAction SilentlyContinue
# Leave a visible marker on the shared Desktop so the operator knows it's done
$ts = Get-Date -Format "yyyy-MM-dd HH:mm"
$doneMsg = "Windows Update dokoncen: $ts`r`nStroj je plne aktualizovan a pripraven k predani klientovi."
[System.IO.File]::WriteAllText(
"C:\Users\Public\Desktop\! WU HOTOVO $ts.txt",
$doneMsg,
[System.Text.Encoding]::UTF8
)
# Lock the workstation - login screen = clear visual signal for the operator.
# Runs as adminx9 (interactive session) via a one-shot task, self-deletes after 5 min.
$lockAction = New-ScheduledTaskAction -Execute "rundll32.exe" -Argument "user32.dll,LockWorkStation"
$lockTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(5)
$lockPrincipal = New-ScheduledTaskPrincipal -UserId "adminx9" -LogonType Interactive -RunLevel Limited
$lockSettings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName "X9-WUDoneLock" -Action $lockAction -Trigger $lockTrigger `
-Principal $lockPrincipal -Settings $lockSettings -Force -ErrorAction SilentlyContinue | Out-Null
Unregister-ScheduledTask -TaskName "X9-WindowsUpdate" -Confirm:$false
}
'@
try {
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$updateScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
# Remove existing task first (idempotent)
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Settings $settings -Principal $principal -Force | Out-Null
Write-Log " Task '$taskName' registered - runs on each logon until fully updated" -Level OK
} catch {
Write-Log " Failed to register scheduled task: $_" -Level WARN
Write-Log " Manual Windows Update rounds will be needed after reboot" -Level WARN
}
Write-Log "Step 12 - Windows Update complete" -Level OK