zlh-agent/internal/state/state.go

470 lines
11 KiB
Go
Executable File

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 &copyInfo
}
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 = &copyInfo
}
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
}