Phase2
This commit is contained in:
269
apps/node-agent/internal/runtime/manager.go
Normal file
269
apps/node-agent/internal/runtime/manager.go
Normal file
@@ -0,0 +1,269 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user