package state import ( "encoding/json" "fmt" "log" "os" "strconv" "sync" "time" ) /* -------------------------------------------------------------------------- CONFIG STRUCT ----------------------------------------------------------------------------*/ type Config struct { VMID int `json:"vmid"` // Container identity ContainerType string `json:"container_type,omitempty"` // Dev runtime (only for dev containers) Runtime string `json:"runtime,omitempty"` Version string `json:"version"` // OPTIONAL addons (role-agnostic) Addons []string `json:"addons,omitempty"` EnableCodeServer bool `json:"enable_code_server,omitempty"` Game string `json:"game"` Variant string `json:"variant"` World string `json:"world"` Ports []int `json:"ports"` ArtifactPath string `json:"artifact_path"` JavaPath string `json:"java_path"` MemoryMB int `json:"memory_mb"` // Steam + admin credentials SteamUser string `json:"steam_user,omitempty"` SteamPass string `json:"steam_pass,omitempty"` SteamAuth string `json:"steam_auth,omitempty"` AdminUser string `json:"admin_user,omitempty"` AdminPass string `json:"admin_pass,omitempty"` } /* -------------------------------------------------------------------------- AGENT STATE ENUM ----------------------------------------------------------------------------*/ type AgentState string const ( StateIdle AgentState = "idle" StateInstalling AgentState = "installing" StateStarting AgentState = "starting" StateRunning AgentState = "running" StateStopping AgentState = "stopping" StateCrashed AgentState = "crashed" StateError AgentState = "error" ) /* -------------------------------------------------------------------------- GLOBAL STATE STRUCT ----------------------------------------------------------------------------*/ type agentStatus struct { mu sync.Mutex state AgentState lastChange time.Time installStep string lastError error crashCount int lastCrash time.Time lastCrashInfo *CrashInfo intentionalStop bool ready bool readySource string readyError string lastReadyAt time.Time } type CrashInfo struct { Time time.Time `json:"time"` ExitCode int `json:"exitCode"` Signal int `json:"signal"` UptimeSeconds int64 `json:"uptimeSeconds"` Classification string `json:"classification"` LogTail []string `json:"logTail"` } var global = &agentStatus{ state: StateIdle, lastChange: time.Now(), } func stateLogf(format string, args ...any) { if cfg, err := LoadConfig(); err == nil && cfg != nil { log.Printf("[state] vmid=%d "+format, append([]any{cfg.VMID}, args...)...) return } log.Printf("[state] "+format, args...) } /* -------------------------------------------------------------------------- STATE GETTERS ----------------------------------------------------------------------------*/ func GetState() AgentState { global.mu.Lock() defer global.mu.Unlock() return global.state } func GetInstallStep() string { global.mu.Lock() defer global.mu.Unlock() return global.installStep } func GetError() error { global.mu.Lock() defer global.mu.Unlock() return global.lastError } func GetCrashCount() int { global.mu.Lock() defer global.mu.Unlock() return global.crashCount } func ResetCrashCount() { global.mu.Lock() defer global.mu.Unlock() global.crashCount = 0 } func GetLastChange() time.Time { global.mu.Lock() defer global.mu.Unlock() return global.lastChange } func GetReady() bool { global.mu.Lock() defer global.mu.Unlock() return global.ready } func GetReadySource() string { global.mu.Lock() defer global.mu.Unlock() return global.readySource } func GetReadyError() string { global.mu.Lock() defer global.mu.Unlock() return global.readyError } func GetLastReadyAt() time.Time { global.mu.Lock() defer global.mu.Unlock() return global.lastReadyAt } func GetLastCrash() *CrashInfo { global.mu.Lock() defer global.mu.Unlock() if global.lastCrashInfo == nil { return nil } copyInfo := *global.lastCrashInfo if global.lastCrashInfo.LogTail != nil { copyInfo.LogTail = append([]string(nil), global.lastCrashInfo.LogTail...) } return ©Info } func IsIntentionalStop() bool { global.mu.Lock() defer global.mu.Unlock() return global.intentionalStop } /* -------------------------------------------------------------------------- STATE SETTERS — unified with logging ----------------------------------------------------------------------------*/ func SetState(s AgentState) { global.mu.Lock() defer global.mu.Unlock() if global.state != s { stateLogf("%s -> %s", global.state, s) global.state = s global.lastChange = time.Now() } } func SetInstallStep(step string) { global.mu.Lock() defer global.mu.Unlock() global.installStep = step } func SetError(err error) { global.mu.Lock() defer global.mu.Unlock() global.lastError = err } func MarkIntentionalStop() { global.mu.Lock() defer global.mu.Unlock() global.intentionalStop = true } func ClearIntentionalStop() { global.mu.Lock() defer global.mu.Unlock() global.intentionalStop = false } func SetLastCrash(info *CrashInfo) { global.mu.Lock() defer global.mu.Unlock() if info == nil { global.lastCrashInfo = nil return } copyInfo := *info if info.LogTail != nil { copyInfo.LogTail = append([]string(nil), info.LogTail...) } global.lastCrashInfo = ©Info } func RecordCrash(err error) { global.mu.Lock() defer global.mu.Unlock() stateLogf("crash recorded err=%v", err) global.state = StateCrashed global.lastError = err global.crashCount++ global.lastCrash = time.Now() global.intentionalStop = false global.ready = false global.readySource = "" global.readyError = fmt.Sprintf("%v", err) global.lastReadyAt = time.Time{} } func SetReadyState(ready bool, source, errText string) { global.mu.Lock() defer global.mu.Unlock() global.ready = ready global.readySource = source global.readyError = errText if ready { global.lastReadyAt = time.Now() } else { global.lastReadyAt = time.Time{} } } /* -------------------------------------------------------------------------- CONFIG SAVE / LOAD ----------------------------------------------------------------------------*/ const configPath = "/opt/zlh-agent/config/payload.json" func SaveConfig(cfg *Config) error { if err := os.MkdirAll("/opt/zlh-agent/config", 0o755); err != nil { return err } b, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(configPath, b, 0o644) } func LoadConfig() (*Config, error) { b, err := os.ReadFile(configPath) if err != nil { return nil, err } var cfg Config if err := json.Unmarshal(b, &cfg); err != nil { return nil, err } if cfg.ContainerType == "" { vmidStr := strconv.Itoa(cfg.VMID) if len(vmidStr) > 0 { switch vmidStr[0] { case '6': cfg.ContainerType = "dev" case '5': cfg.ContainerType = "game" } } if cfg.ContainerType == "" && cfg.Runtime != "" { cfg.ContainerType = "dev" } if cfg.ContainerType == "" { cfg.ContainerType = "game" } } return &cfg, nil }