feat: state machine for reboot-resume across Windows Update cycles
All checks were successful
release / build-and-release (push) Successful in 24s
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:
parent
9feb7ba4e1
commit
5e01826a49
10 changed files with 600 additions and 150 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
20
internal/prereboot/prereboot_other.go
Normal file
20
internal/prereboot/prereboot_other.go
Normal 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() {}
|
||||
190
internal/prereboot/prereboot_windows.go
Normal file
190
internal/prereboot/prereboot_windows.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
12
internal/state/path_other.go
Normal file
12
internal/state/path_other.go
Normal 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"
|
||||
}
|
||||
7
internal/state/path_windows.go
Normal file
7
internal/state/path_windows.go
Normal 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
74
internal/state/state.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue