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/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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -547,9 +700,9 @@ func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
|
func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
|
||||||
items := runner.AllSelectableItems()
|
items := runner.AllSelectableItems()
|
||||||
features := make(config.Features)
|
features := make(config.Features)
|
||||||
stepOn := make(map[string]bool)
|
stepOn := make(map[string]bool)
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.FeatureID == "" {
|
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"
|
"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"
|
||||||
|
|
@ -127,9 +133,10 @@ 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")
|
||||||
|
|
|
||||||
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
|
# 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
$result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log " $_" -Level INFO }
|
||||||
if ($installed.Count -gt 0) {
|
Write-Log " Update pass complete - reboot required for next round" -Level OK
|
||||||
$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
|
|
||||||
}
|
|
||||||
} 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
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue