package state import ( "encoding/json" "fmt" "log" "os" "strconv" "strings" "sync" "time" ) /* -------------------------------------------------------------------------- CONFIG STRUCT ----------------------------------------------------------------------------*/ type Config struct { VMID int `json:"vmid"` // Container identity ContainerIP string `json:"container_ip,omitempty"` ContainerType string `json:"container_type,omitempty"` // Dev runtime (only for dev containers) Runtime string `json:"runtime,omitempty"` InternalProfile string `json:"internal_profile,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 operation OperationInfo } 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"` } type OperationInfo struct { InProgress bool `json:"inProgress"` Type string `json:"type,omitempty"` Maintenance bool `json:"maintenance"` StartedAt time.Time `json:"startedAt,omitempty"` Message string `json:"message,omitempty"` } 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 } func GetOperation() OperationInfo { global.mu.Lock() defer global.mu.Unlock() return global.operation } /* -------------------------------------------------------------------------- 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{} } } func TryStartOperation(opType string, maintenance bool, message string) (func(), bool, OperationInfo) { global.mu.Lock() defer global.mu.Unlock() if global.operation.InProgress { return nil, false, global.operation } global.operation = OperationInfo{ InProgress: true, Type: opType, Maintenance: maintenance, StartedAt: time.Now().UTC(), Message: message, } end := func() { global.mu.Lock() defer global.mu.Unlock() if global.operation.Type == opType { global.operation = OperationInfo{} } } return end, true, global.operation } func SetOperationMessage(message string) { global.mu.Lock() defer global.mu.Unlock() if global.operation.InProgress { global.operation.Message = message } } /* -------------------------------------------------------------------------- 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, 0o600) } func ValidateConfig(cfg *Config) error { if cfg == nil { return fmt.Errorf("config required") } if cfg.VMID <= 0 { return fmt.Errorf("vmid must be positive") } normalizeContainerType(cfg) switch strings.ToLower(strings.TrimSpace(cfg.ContainerType)) { case "dev": return validateDevConfig(cfg) case "game": return validateGameConfig(cfg) default: return fmt.Errorf("unsupported container_type: %s", cfg.ContainerType) } } func normalizeContainerType(cfg *Config) { if strings.TrimSpace(cfg.ContainerType) != "" { cfg.ContainerType = strings.ToLower(strings.TrimSpace(cfg.ContainerType)) return } 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 == "" && strings.TrimSpace(cfg.Runtime) != "" { cfg.ContainerType = "dev" } if cfg.ContainerType == "" { cfg.ContainerType = "game" } } func validateDevConfig(cfg *Config) error { if strings.TrimSpace(cfg.Game) != "" || strings.TrimSpace(cfg.Variant) != "" { return fmt.Errorf("game and variant are not valid for dev containers") } runtime := strings.ToLower(strings.TrimSpace(cfg.Runtime)) switch runtime { case "node", "python", "go", "java", "dotnet": cfg.Runtime = runtime default: return fmt.Errorf("unsupported dev runtime: %s", cfg.Runtime) } if strings.TrimSpace(cfg.Version) == "" { return fmt.Errorf("version required for dev containers") } return validateMemory(cfg.MemoryMB) } func validateGameConfig(cfg *Config) error { if cfg.EnableCodeServer { return fmt.Errorf("code-server is only valid for dev containers") } game := strings.ToLower(strings.TrimSpace(cfg.Game)) if game == "" { return fmt.Errorf("game required for game containers") } cfg.Game = game switch game { case "minecraft": variant := strings.ToLower(strings.TrimSpace(cfg.Variant)) switch variant { case "vanilla", "fabric", "paper", "purpur", "quilt", "forge", "neoforge": cfg.Variant = variant default: return fmt.Errorf("unsupported minecraft variant: %s", cfg.Variant) } if strings.TrimSpace(cfg.Version) == "" { return fmt.Errorf("version required for minecraft containers") } case "valheim", "rust", "terraria", "projectzomboid": default: return fmt.Errorf("unsupported game: %s", cfg.Game) } if err := validateMemory(cfg.MemoryMB); err != nil { return err } for _, port := range cfg.Ports { if port < 1 || port > 65535 { return fmt.Errorf("invalid port: %d", port) } } return nil } func validateMemory(memoryMB int) error { if memoryMB == 0 { return nil } if memoryMB < 256 || memoryMB > 262144 { return fmt.Errorf("memory_mb out of range: %d", memoryMB) } return nil } 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 } normalizeContainerType(&cfg) return &cfg, nil }