xetup/internal/state/state.go
X9 Dev d30767ef8b
Some checks failed
release / build-and-release (push) Failing after 32s
fix: comprehensive reliability and robustness improvements
Critical fixes:
- Fix resume mode: StepsByIDs returned Enabled=false, all resume steps
  would be SKIPPED (deployment could never resume after reboot)
- Add reboot loop protection: per-step retry counter (max 5) prevents
  infinite reboot cycles when a step always exits with code 9
- Block reboot when state.Save() fails in resumePhase (prevents state
  loss leading to full restart from scratch)
- Atomic state file write (write-to-tmp + rename) prevents JSON
  corruption on BSOD/power loss mid-write
- Script watchdog: kills scripts after 30 min of no output (resets on
  each line, so active long-running scripts are never killed)
- Fix copyFile: check Close() error explicitly instead of deferred
  close that silently drops flush errors (e.g. disk full)

High severity:
- Cleanup() now logs errors instead of silently ignoring them
- Email report: 3 retries with backoff + always saves C:\X9\report.html
- Winget parallel jobs: 10 min timeout, kill hung jobs
- UCPD stop verification: 2s wait + state check before PDF association
- Atera installer: /qn -> /qb so MFA window can appear
- GVLK activation: match by EditionID (registry, not localized) instead
  of fragile OS caption string matching

Medium severity:
- Default profile hive unload: retry loop (5 attempts, increasing delay)
- LayoutModification.xml: UTF-8 without BOM (PS 5.1 Set-Content adds BOM)
- Set-Reg SYSTEM task: try/finally ensures temp file + task cleanup
- Windows Update: @($available).Count for PS 5.1 single-result edge case
- config.json: add missing kmsServer field in activation section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 11:49:43 +02:00

82 lines
2.4 KiB
Go

// 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
RetryCounts map[string]int `json:"retryCounts,omitempty"` // per-step reboot retry counter
}
// 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 atomically (write-to-temp + rename), creating
// parent directories as needed. This prevents corruption if the system
// crashes mid-write (e.g. BSOD, power loss).
func Save(s *State) error {
p := statePath()
dir := filepath.Dir(p)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, p)
}
// 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
}