diff --git a/cmd/xetup/main.go b/cmd/xetup/main.go index fb81787..a4c9679 100644 --- a/cmd/xetup/main.go +++ b/cmd/xetup/main.go @@ -21,16 +21,10 @@ import ( "git.xetup.x9.cz/x9/xetup/internal/config" "git.xetup.x9.cz/x9/xetup/internal/gui" "git.xetup.x9.cz/x9/xetup/internal/runner" + "git.xetup.x9.cz/x9/xetup/internal/state" ) 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 tmpDir, err := os.MkdirTemp("", "xetup-*") if err != nil { @@ -38,7 +32,7 @@ func main() { } 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 { log.Fatalf("Failed to extract scripts: %v", err) } @@ -46,6 +40,24 @@ func main() { 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 cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir) if err != nil { diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 86763d2..9964ff2 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -7,6 +7,10 @@ // load/save config buttons for per-client presets // 2. Live run – real-time log streamed from PowerShell scripts // 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 import ( @@ -23,11 +27,20 @@ import ( . "github.com/lxn/walk/declarative" "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/state" ) // Run opens the xetup window and blocks until the user closes it. 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 { res := formPhase(cfg, runCfg, cfgPath) switch res.action { @@ -38,13 +51,53 @@ func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) { cfgPath = res.cfgPath case "run": runCfg.ProfileType = res.cfg.Deployment.ProfileType - results := runPhase(runCfg, res.steps) - donePhase(results) + results, needsReboot := runPhase(runCfg, res.steps, false) + if needsReboot { + prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg) + return + } + donePhase(results, nil) 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 // -------------------------------------------------------------------------- @@ -232,14 +285,17 @@ func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formR // 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 { label *walk.Label stepID string 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 ( mw *walk.MainWindow 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) 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)) for i, s := range steps { 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{ AssignTo: &mw, - Title: "xetup \u2014 probiha instalace", + Title: title, Size: Size{Width: 760, Height: 740}, Layout: VBox{}, Children: []Widget{ - Label{AssignTo: &statusLbl, Text: "Spoustim..."}, + Label{AssignTo: &statusLbl, Text: initialStatus}, // Step progress strip Composite{ Layout: HBox{MarginsZero: true}, @@ -299,17 +362,17 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result { }, }, }.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) { ind := &indicators[idx] s := steps[idx] switch ind.status { case "running": 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": ind.label.SetText(s.Num + " \u2713") // ✓ ind.label.SetTextColor(walk.RGB(0, 180, 0)) @@ -386,7 +449,78 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result { mu.Lock() 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 -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 ( - mw *walk.MainWindow + mw *walk.MainWindow countdownLbl *walk.Label ) - ok, errs, skipped := 0, 0, 0 - rows := make([]Widget, 0, len(results)) + // Combined ordered list: previous rounds first, then current round + 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 - switch res.Status { + switch row.Status { case "OK": ok++ icon = "OK " @@ -417,11 +571,11 @@ func donePhase(results []runner.Result) { skipped++ icon = "\u2013 " } - text := icon + res.Step.Num + " \u2013 " + res.Step.Name - if res.Elapsed > 0 { - text += fmt.Sprintf(" (%s)", res.Elapsed.Round(time.Second)) + text := icon + row.Num + " \u2013 " + row.Name + if row.Elapsed > 0 { + text += fmt.Sprintf(" (%s)", row.Elapsed.Round(time.Second)) } - rows = append(rows, Label{ + widgets = append(widgets, Label{ Text: text, 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) - // cancelled by "Zrusit restart" button cancelReboot := make(chan struct{}) if err := (MainWindow{ @@ -447,7 +600,7 @@ func donePhase(results []runner.Result) { ScrollView{ MinSize: Size{Height: 510}, Layout: VBox{MarginsZero: true}, - Children: rows, + Children: widgets, }, HSeparator{}, Label{ @@ -467,7 +620,7 @@ func donePhase(results []runner.Result) { PushButton{ Text: " Restartovat ted ", OnClicked: func() { - close(cancelReboot) // signal goroutine to stop ticker + close(cancelReboot) reboot() mw.Close() }, @@ -476,7 +629,7 @@ func donePhase(results []runner.Result) { Text: " Zrusit restart ", OnClicked: func() { select { - case <-cancelReboot: // already closed + case <-cancelReboot: default: close(cancelReboot) } @@ -547,9 +700,9 @@ func itemEnabled(cfg config.Config, item runner.SelectableItem) bool { } func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) { - items := runner.AllSelectableItems() + items := runner.AllSelectableItems() features := make(config.Features) - stepOn := make(map[string]bool) + stepOn := make(map[string]bool) for _, item := range items { if item.FeatureID == "" { diff --git a/internal/prereboot/prereboot_other.go b/internal/prereboot/prereboot_other.go new file mode 100644 index 0000000..35b4748 --- /dev/null +++ b/internal/prereboot/prereboot_other.go @@ -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() {} diff --git a/internal/prereboot/prereboot_windows.go b/internal/prereboot/prereboot_windows.go new file mode 100644 index 0000000..b84570e --- /dev/null +++ b/internal/prereboot/prereboot_windows.go @@ -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 +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 4bcacd5..ed88355 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -5,6 +5,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -14,6 +15,11 @@ import ( "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. type Step struct { ID string // e.g. "adminAccount" @@ -127,9 +133,10 @@ type RunConfig struct { // Result is the outcome of a single step. type Result struct { - Step Step - Status string // "OK", "ERROR", "SKIPPED" - Elapsed time.Duration + Step Step + Status string // "OK", "ERROR", "SKIPPED", "CANCELLED" + Elapsed time.Duration + NeedsReboot bool // true when the script exited with code 9 } // 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) status := "OK" + needsReboot := false 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" } else { status = "ERROR" } } - res := Result{Step: step, Status: status, Elapsed: elapsed} + res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot} r.onResult(res) results = append(results, res) - if ctx.Err() != nil { + if ctx.Err() != nil || needsReboot { 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: @@ -362,6 +380,32 @@ func extractDir(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(s 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. func WriteConfig(cfg interface{}, tmpDir string) (string, error) { path := filepath.Join(tmpDir, "config-runtime.json") diff --git a/internal/state/path_other.go b/internal/state/path_other.go new file mode 100644 index 0000000..262870d --- /dev/null +++ b/internal/state/path_other.go @@ -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" +} diff --git a/internal/state/path_windows.go b/internal/state/path_windows.go new file mode 100644 index 0000000..3ed06f7 --- /dev/null +++ b/internal/state/path_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package state + +func statePath() string { + return `C:\Windows\Setup\Scripts\xetup-state.json` +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..f714d76 --- /dev/null +++ b/internal/state/state.go @@ -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 +} diff --git a/scripts/11-dell-update.ps1 b/scripts/11-dell-update.ps1 index b4928fd..f955a25 100644 --- a/scripts/11-dell-update.ps1 +++ b/scripts/11-dell-update.ps1 @@ -143,10 +143,20 @@ if (-not $runDrivers -and -not $runBios) { # 5 = no applicable updates found for this system # others = error or partial failure switch ($exitCode) { - 0 { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK } - 1 { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -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 } + 0 { + Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK + } + 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 + } } } diff --git a/scripts/12-windows-update.ps1 b/scripts/12-windows-update.ps1 index 0a076d0..a84a0e6 100644 --- a/scripts/12-windows-update.ps1 +++ b/scripts/12-windows-update.ps1 @@ -3,21 +3,19 @@ Installs all available Windows Updates via PSWindowsUpdate module. .DESCRIPTION - First pass: installs all currently available updates without rebooting. - Then registers a scheduled task "X9-WindowsUpdate" that runs on every - logon (as SYSTEM) until no more updates are found - handles the typical - 2-3 reboot cycles required on a fresh Windows installation. + Installs all currently available updates without rebooting. + Exits with code 9 when updates were installed (reboot required for + further rounds) or code 0 when the system is already fully up to date. - Operator workflow: - 1. xetup completes all steps - 2. Operator reboots manually - 3. On each subsequent logon the scheduled task runs another update pass - 4. Task removes itself automatically when system is fully up to date + The xetup.exe state machine handles the reboot cycle: + - exit 9 -> xetup saves state, sets autologon + X9-Resume task, reboots + - on each subsequent logon X9-Resume launches xetup --resume + - xetup re-runs this step until it exits 0 (no more updates) + - then disables autologon, removes X9-Resume, shows the summary screen .ITEMS 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. - 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. + 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. #> param( [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 { $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 } - Write-Log " First pass complete - reboot required for remaining rounds" -Level OK - } else { - Write-Log " System already up to date" -Level OK - } + $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log " $_" -Level INFO } + Write-Log " Update pass complete - reboot required for next round" -Level OK } catch { - Write-Log " First pass failed: $_" -Level ERROR + Write-Log " Update install failed: $_" -Level ERROR + exit 1 } -# ----------------------------------------------------------------------- -# 3. Enable autologon for adminx9 (temporary - disabled when updates complete) -# ----------------------------------------------------------------------- -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 +# Signal xetup that a reboot is needed before running this step again +Write-Log "Step 12 - reboot required (exit 9)" -Level OK +exit 9