package runtime import ( "context" "fmt" "log/slog" "sync" "github.com/hexahost/gamecloud/node-agent/internal/docker" "github.com/hexahost/gamecloud/node-agent/internal/protocol" ) const ( ServerStateProvisioning = "provisioning" ServerStateStopped = "stopped" ServerStateStarting = "starting" ServerStateRunning = "running" ServerStateStopping = "stopping" ServerStateDeleting = "deleting" ServerStateDeleted = "deleted" ServerStateError = "error" ResultCodeOK = "ok" ResultCodeError = "error" ) // Responder sends protocol responses back to the control plane. type Responder interface { SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error } type serverRecord struct { ServerID string ContainerID string HostPort int DataPath string RamMb int State string } // Manager tracks provisioned servers and executes lifecycle operations. type Manager struct { mu sync.RWMutex docker *docker.Client servers map[string]*serverRecord resp Responder log *slog.Logger } // NewManager creates a runtime manager backed by the given Docker client. func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager { return &Manager{ docker: dockerClient, servers: make(map[string]*serverRecord), resp: resp, log: log, } } // RunningCount returns the number of servers currently in the running state. func (m *Manager) RunningCount() int { m.mu.RLock() defer m.mu.RUnlock() count := 0 for _, rec := range m.servers { if rec.State == ServerStateRunning { count++ } } return count } // HandleProvision creates a Minecraft container for the server. func (m *Manager) HandleProvision(ctx context.Context, correlationID string, payload protocol.ServerProvisionPayload) { meta := payload.OperationMeta m.log.Info("provision server", "server_id", meta.ServerID, "host_port", payload.HostPort, "version", payload.MinecraftVersion, ) if !payload.EulaAccepted { m.fail(ctx, correlationID, meta, "EULA_NOT_ACCEPTED", "EULA must be accepted before provisioning") return } m.mu.Lock() if existing, ok := m.servers[meta.ServerID]; ok { m.mu.Unlock() m.log.Info("provision idempotent hit", "server_id", meta.ServerID, "container_id", existing.ContainerID) m.complete(ctx, correlationID, meta, existing.State) return } m.mu.Unlock() m.emitState(ctx, correlationID, meta, ServerStateProvisioning, "", "") containerID, err := m.docker.CreateMinecraftServer(ctx, docker.MinecraftServerSpec{ ServerID: meta.ServerID, MinecraftVersion: payload.MinecraftVersion, SoftwareFamily: payload.SoftwareFamily, HostPort: payload.HostPort, RamMb: payload.RamMb, DataPath: payload.DataPath, EulaAccepted: payload.EulaAccepted, }) if err != nil { m.fail(ctx, correlationID, meta, "PROVISION_FAILED", err.Error()) return } rec := &serverRecord{ ServerID: meta.ServerID, ContainerID: containerID, HostPort: payload.HostPort, DataPath: payload.DataPath, RamMb: payload.RamMb, State: ServerStateStopped, } m.mu.Lock() m.servers[meta.ServerID] = rec m.mu.Unlock() m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "") m.complete(ctx, correlationID, meta, ServerStateStopped) } // HandleStart starts a provisioned server container. func (m *Manager) HandleStart(ctx context.Context, correlationID string, payload protocol.ServerStartPayload) { meta := payload.OperationMeta rec, ok := m.getServer(meta.ServerID) if !ok { m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node") return } m.emitState(ctx, correlationID, meta, ServerStateStarting, "", "") if err := m.docker.StartContainer(ctx, rec.ContainerID); err != nil { m.setState(meta.ServerID, ServerStateError) m.emitState(ctx, correlationID, meta, ServerStateError, "START_FAILED", err.Error()) m.fail(ctx, correlationID, meta, "START_FAILED", err.Error()) return } m.setState(meta.ServerID, ServerStateRunning) m.emitState(ctx, correlationID, meta, ServerStateRunning, "", "") m.complete(ctx, correlationID, meta, ServerStateRunning) } // HandleStop stops a running server container. func (m *Manager) HandleStop(ctx context.Context, correlationID string, payload protocol.ServerStopPayload) { meta := payload.OperationMeta rec, ok := m.getServer(meta.ServerID) if !ok { m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node") return } m.emitState(ctx, correlationID, meta, ServerStateStopping, "", "") if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil { m.setState(meta.ServerID, ServerStateError) m.emitState(ctx, correlationID, meta, ServerStateError, "STOP_FAILED", err.Error()) m.fail(ctx, correlationID, meta, "STOP_FAILED", err.Error()) return } m.setState(meta.ServerID, ServerStateStopped) m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "") m.complete(ctx, correlationID, meta, ServerStateStopped) } // HandleDelete removes a server container and drops local state. func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta protocol.OperationMeta) { rec, ok := m.getServer(meta.ServerID) if !ok { m.complete(ctx, correlationID, meta, ServerStateDeleted) return } m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "") if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running { if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil { m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error()) return } } if err := m.docker.RemoveContainer(ctx, rec.ContainerID); err != nil { m.fail(ctx, correlationID, meta, "DELETE_FAILED", err.Error()) return } m.mu.Lock() delete(m.servers, meta.ServerID) m.mu.Unlock() m.emitState(ctx, correlationID, meta, ServerStateDeleted, "", "") m.complete(ctx, correlationID, meta, ServerStateDeleted) } func (m *Manager) getServer(serverID string) (*serverRecord, bool) { m.mu.RLock() defer m.mu.RUnlock() rec, ok := m.servers[serverID] if !ok { return nil, false } copy := *rec return ©, true } func (m *Manager) setState(serverID, state string) { m.mu.Lock() defer m.mu.Unlock() if rec, ok := m.servers[serverID]; ok { rec.State = state } } func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) { payload := protocol.ServerStatePayload{ OperationMeta: meta, State: state, ErrorCode: errorCode, ErrorMessage: errorMessage, } if err := m.resp.SendState(ctx, correlationID, payload); err != nil { m.log.Warn("send server.state failed", "server_id", meta.ServerID, "state", state, "error", err, ) } } func (m *Manager) complete(ctx context.Context, correlationID string, meta protocol.OperationMeta, _ string) { payload := protocol.ServerOperationResultPayload{ OperationMeta: meta, ResultCode: ResultCodeOK, } if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil { m.log.Warn("send operation complete failed", "server_id", meta.ServerID, "error", err, ) } } func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.OperationMeta, errorCode, errorMessage string) { m.emitState(ctx, correlationID, meta, ServerStateError, errorCode, errorMessage) payload := protocol.ServerOperationResultPayload{ OperationMeta: meta, ResultCode: ResultCodeError, ErrorCode: errorCode, ErrorMessage: errorMessage, } if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil { m.log.Warn("send operation failed failed", "server_id", meta.ServerID, "error", err, ) } }