Files
HexaHost-GameCloud/apps/node-agent/internal/runtime/manager.go
smueller 9b061c3ee7
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s
Phase3
2026-06-26 12:17:26 +02:00

483 lines
15 KiB
Go

package runtime
import (
"context"
"encoding/json"
"errors"
"log/slog"
"sync"
"github.com/hexahost/gamecloud/node-agent/internal/docker"
"github.com/hexahost/gamecloud/node-agent/internal/files"
"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
SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) 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
logStreams map[string]context.CancelFunc
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),
logStreams: make(map[string]context.CancelFunc),
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, "", "")
m.stopLogStream(meta.ServerID)
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)
}
// HandleLogsSubscribe starts streaming container logs for a server.
func (m *Manager) HandleLogsSubscribe(ctx context.Context, correlationID string, payload protocol.ServerLogsSubscribePayload) {
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
}
streamCtx, cancel := context.WithCancel(ctx)
m.mu.Lock()
if existing, ok := m.logStreams[meta.ServerID]; ok {
existing()
}
m.logStreams[meta.ServerID] = cancel
m.mu.Unlock()
go func() {
defer func() {
m.mu.Lock()
if current, ok := m.logStreams[meta.ServerID]; ok && current == cancel {
delete(m.logStreams, meta.ServerID)
}
m.mu.Unlock()
}()
err := m.docker.StreamLogs(streamCtx, rec.ContainerID, payload.TailLines, payload.Follow, func(stream, line string) {
logPayload := protocol.ServerLogPayload{
OperationMeta: meta,
Line: line,
Stream: stream,
}
if err := m.resp.SendLog(ctx, correlationID, logPayload); err != nil {
m.log.Warn("send server.log failed",
"server_id", meta.ServerID,
"error", err,
)
}
})
if err != nil && streamCtx.Err() == nil {
m.log.Warn("log stream ended with error",
"server_id", meta.ServerID,
"error", err,
)
}
}()
}
// HandleCommand executes a console command via RCON.
func (m *Manager) HandleCommand(ctx context.Context, correlationID string, payload protocol.ServerCommandPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "command", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
stdout, stderr, err := m.docker.ExecRcon(ctx, rec.ContainerID, payload.Command)
if err != nil {
m.failOperation(ctx, correlationID, meta, "command", "COMMAND_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "command", map[string]string{
"stdout": stdout,
"stderr": stderr,
})
}
// HandleFilesList lists files in the server data directory.
func (m *Manager) HandleFilesList(ctx context.Context, correlationID string, payload protocol.ServerFilesListPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.list", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
entries, err := files.ListDir(rec.DataPath, payload.Path)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.list", filesErrorCode(err), err.Error())
return
}
result := protocol.ServerFilesListResultPayload{
Entries: make([]protocol.FileEntry, 0, len(entries)),
}
for _, entry := range entries {
result.Entries = append(result.Entries, protocol.FileEntry{
Name: entry.Name,
Path: entry.Path,
IsDir: entry.IsDir,
Size: entry.Size,
ModifiedAt: entry.ModifiedAt.Format("2006-01-02T15:04:05Z"),
})
}
m.completeOperation(ctx, correlationID, meta, "files.list", result)
}
// HandleFilesRead reads a file from the server data directory.
func (m *Manager) HandleFilesRead(ctx context.Context, correlationID string, payload protocol.ServerFilesReadPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.read", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
content, truncated, err := files.ReadFile(rec.DataPath, payload.Path, payload.MaxBytes)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.read", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.read", protocol.ServerFilesReadResultPayload{
Path: payload.Path,
Content: content,
Truncated: truncated,
})
}
// HandleFilesWrite writes a file in the server data directory.
func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, payload protocol.ServerFilesWritePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.write", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if err := files.WriteFile(rec.DataPath, payload.Path, payload.Content, payload.Create); err != nil {
m.failOperation(ctx, correlationID, meta, "files.write", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.write", map[string]string{
"path": payload.Path,
})
}
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 &copy, 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) stopLogStream(serverID string) {
m.mu.Lock()
defer m.mu.Unlock()
if cancel, ok := m.logStreams[serverID]; ok {
cancel()
delete(m.logStreams, serverID)
}
}
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,
)
}
}
func (m *Manager) completeOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation string, result any) {
raw, err := json.Marshal(result)
if err != nil {
m.failOperation(ctx, correlationID, meta, operation, "RESULT_MARSHAL_FAILED", err.Error())
return
}
payload := protocol.ServerOperationResultPayload{
OperationMeta: meta,
ResultCode: ResultCodeOK,
Operation: operation,
Result: raw,
}
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
m.log.Warn("send operation complete failed",
"server_id", meta.ServerID,
"operation", operation,
"error", err,
)
}
}
func (m *Manager) failOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation, errorCode, errorMessage string) {
payload := protocol.ServerOperationResultPayload{
OperationMeta: meta,
ResultCode: ResultCodeError,
Operation: operation,
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,
"operation", operation,
"error", err,
)
}
}
func filesErrorCode(err error) string {
switch {
case errors.Is(err, files.ErrPathTraversal):
return "PATH_TRAVERSAL"
case errors.Is(err, files.ErrSymlink):
return "SYMLINK_NOT_ALLOWED"
case errors.Is(err, files.ErrNotFound):
return "FILE_NOT_FOUND"
case errors.Is(err, files.ErrNotDirectory):
return "NOT_A_DIRECTORY"
case errors.Is(err, files.ErrIsDirectory):
return "IS_DIRECTORY"
default:
return "FILES_ERROR"
}
}