Phase3
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

This commit is contained in:
smueller
2026-06-26 12:17:26 +02:00
parent c4077d4673
commit 9b061c3ee7
92 changed files with 4128 additions and 146 deletions

View File

@@ -14,7 +14,7 @@ import (
"github.com/hexahost/gamecloud/node-agent/internal/ws"
)
const Version = "0.2.0-phase2"
const Version = "0.3.0-phase3"
// Agent coordinates the node agent lifecycle until shutdown.
type Agent struct {

View File

@@ -1,8 +1,11 @@
package docker
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"strconv"
"strings"
@@ -10,6 +13,7 @@ import (
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
)
@@ -189,3 +193,134 @@ func mapSoftwareFamily(family string) string {
return "VANILLA"
}
}
// StreamLogs streams container logs and invokes callback for each line.
func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error {
opts := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: follow,
Timestamps: false,
}
if tail > 0 {
opts.Tail = strconv.Itoa(tail)
} else {
opts.Tail = "all"
}
reader, err := c.cli.ContainerLogs(ctx, containerID, opts)
if err != nil {
return fmt.Errorf("container logs: %w", err)
}
defer reader.Close()
return readMultiplexedLogLines(ctx, reader, callback)
}
// ExecCommand runs a command inside a container and returns stdout/stderr.
func (c *Client) ExecCommand(ctx context.Context, containerID string, cmd []string) (stdout, stderr string, err error) {
execResp, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return "", "", fmt.Errorf("exec create: %w", err)
}
attachResp, err := c.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
if err != nil {
return "", "", fmt.Errorf("exec attach: %w", err)
}
defer attachResp.Close()
var outBuf, errBuf bytes.Buffer
if _, err := stdcopy.StdCopy(&outBuf, &errBuf, attachResp.Reader); err != nil {
return outBuf.String(), errBuf.String(), fmt.Errorf("exec copy: %w", err)
}
inspect, err := c.cli.ContainerExecInspect(ctx, execResp.ID)
if err != nil {
return outBuf.String(), errBuf.String(), fmt.Errorf("exec inspect: %w", err)
}
if inspect.ExitCode != 0 {
msg := strings.TrimSpace(errBuf.String())
if msg == "" {
msg = strings.TrimSpace(outBuf.String())
}
if msg == "" {
msg = fmt.Sprintf("exit code %d", inspect.ExitCode)
}
return outBuf.String(), errBuf.String(), fmt.Errorf("exec failed: %s", msg)
}
return outBuf.String(), errBuf.String(), nil
}
// ExecRcon runs a Minecraft console command via rcon-cli inside the container.
func (c *Client) ExecRcon(ctx context.Context, containerID, command string) (stdout, stderr string, err error) {
return c.ExecCommand(ctx, containerID, []string{"rcon-cli", command})
}
func readMultiplexedLogLines(ctx context.Context, r io.Reader, callback func(stream, line string)) error {
header := make([]byte, 8)
var partial []byte
var partialStream string
for {
if err := ctx.Err(); err != nil {
return err
}
_, err := io.ReadFull(r, header)
if err == io.EOF {
break
}
if err != nil {
return err
}
stream := "stdout"
if header[0] == 2 {
stream = "stderr"
}
size := binary.BigEndian.Uint32(header[4:8])
payload := make([]byte, size)
if _, err := io.ReadFull(r, payload); err != nil {
return err
}
if partialStream != "" && partialStream != stream {
if len(partial) > 0 {
callback(partialStream, string(partial))
partial = nil
}
}
partialStream = stream
partial = append(partial, payload...)
for {
idx := bytes.IndexByte(partial, '\n')
if idx < 0 {
break
}
line := string(partial[:idx])
partial = partial[idx+1:]
line = strings.TrimSuffix(line, "\r")
if line != "" {
callback(stream, line)
}
}
}
if len(partial) > 0 {
stream := partialStream
if stream == "" {
stream = "stdout"
}
callback(stream, string(partial))
}
return nil
}

View File

@@ -0,0 +1,280 @@
package files
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const DefaultMaxReadBytes = 1 << 20 // 1MB
var (
ErrPathTraversal = errors.New("path escapes data root")
ErrSymlink = errors.New("symlinks are not allowed")
ErrNotFound = errors.New("file not found")
ErrNotDirectory = errors.New("path is not a directory")
ErrIsDirectory = errors.New("path is a directory")
)
// DirEntry describes a file or directory entry returned by ListDir.
type DirEntry struct {
Name string
Path string
IsDir bool
Size int64
ModifiedAt time.Time
}
// ResolvePath resolves relativePath under dataRoot and rejects path traversal.
func ResolvePath(dataRoot, relativePath string) (string, error) {
root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil {
return "", fmt.Errorf("resolve data root: %w", err)
}
rel := strings.TrimPrefix(relativePath, "/")
rel = strings.TrimPrefix(rel, `\`)
rel = filepath.Clean(rel)
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", ErrPathTraversal
}
resolved := filepath.Join(root, rel)
resolved, err = filepath.Abs(resolved)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) {
return "", ErrPathTraversal
}
return resolved, nil
}
// ListDir lists entries in a directory relative to dataRoot.
func ListDir(dataRoot, relativePath string) ([]DirEntry, error) {
dirPath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return nil, err
}
if err := rejectSymlinks(dataRoot, dirPath); err != nil {
return nil, err
}
info, err := os.Lstat(dirPath)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, err
}
if !info.IsDir() {
return nil, ErrNotDirectory
}
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, err
}
root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil {
return nil, err
}
result := make([]DirEntry, 0, len(entries))
for _, entry := range entries {
entryPath := filepath.Join(dirPath, entry.Name())
if err := rejectSymlinks(root, entryPath); err != nil {
continue
}
entryInfo, err := entry.Info()
if err != nil {
return nil, err
}
rel, err := filepath.Rel(root, entryPath)
if err != nil {
return nil, err
}
rel = filepath.ToSlash(rel)
result = append(result, DirEntry{
Name: entry.Name(),
Path: rel,
IsDir: entry.IsDir(),
Size: entryInfo.Size(),
ModifiedAt: entryInfo.ModTime().UTC(),
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].IsDir != result[j].IsDir {
return result[i].IsDir
}
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
})
return result, nil
}
// ReadFile reads file content relative to dataRoot up to maxBytes.
func ReadFile(dataRoot, relativePath string, maxBytes int) (content string, truncated bool, err error) {
if maxBytes <= 0 {
maxBytes = DefaultMaxReadBytes
}
filePath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return "", false, err
}
if err := rejectSymlinks(dataRoot, filePath); err != nil {
return "", false, err
}
info, err := os.Lstat(filePath)
if err != nil {
if os.IsNotExist(err) {
return "", false, ErrNotFound
}
return "", false, err
}
if info.IsDir() {
return "", false, ErrIsDirectory
}
if info.Mode()&os.ModeSymlink != 0 {
return "", false, ErrSymlink
}
f, err := os.Open(filePath)
if err != nil {
return "", false, err
}
defer f.Close()
limited := io.LimitReader(f, int64(maxBytes)+1)
data, err := io.ReadAll(limited)
if err != nil {
return "", false, err
}
if len(data) > maxBytes {
truncated = true
data = data[:maxBytes]
}
return string(data), truncated, nil
}
// WriteFile atomically writes content to a path relative to dataRoot.
func WriteFile(dataRoot, relativePath, content string, create bool) error {
filePath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return err
}
if _, err := os.Lstat(filePath); err != nil {
if os.IsNotExist(err) {
if !create {
return ErrNotFound
}
} else {
return err
}
} else if !create {
if err := rejectSymlinks(dataRoot, filePath); err != nil {
return err
}
}
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".hexahost-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
if _, err := tmp.WriteString(content); err != nil {
cleanup()
return err
}
if err := tmp.Sync(); err != nil {
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpPath, filePath); err != nil {
cleanup()
return err
}
return nil
}
func rejectSymlinks(dataRoot, target string) error {
root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil {
return err
}
target, err = filepath.Abs(target)
if err != nil {
return err
}
if target == root {
info, err := os.Lstat(target)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: %s", ErrSymlink, target)
}
return nil
}
rel, err := filepath.Rel(root, target)
if err != nil {
return err
}
current := root
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if part == "" || part == "." {
continue
}
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: %s", ErrSymlink, current)
}
}
return nil
}

View File

@@ -0,0 +1,94 @@
package files
import (
"os"
"path/filepath"
"testing"
)
func TestResolvePath_BlocksTraversal(t *testing.T) {
root := t.TempDir()
tests := []string{
"../outside",
"../../etc/passwd",
"foo/../../outside",
}
for _, rel := range tests {
t.Run(rel, func(t *testing.T) {
_, err := ResolvePath(root, rel)
if err == nil {
t.Fatalf("expected path traversal error for %q", rel)
}
if err != ErrPathTraversal {
t.Fatalf("expected ErrPathTraversal, got %v", err)
}
})
}
}
func TestResolvePath_AllowsNestedPaths(t *testing.T) {
root := t.TempDir()
nested := filepath.Join(root, "world", "region")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
resolved, err := ResolvePath(root, "world/region")
if err != nil {
t.Fatalf("resolve nested path: %v", err)
}
if resolved != nested {
t.Fatalf("resolved %q want %q", resolved, nested)
}
}
func TestReadFile_BlocksTraversal(t *testing.T) {
root := t.TempDir()
secret := filepath.Join(filepath.Dir(root), "secret.txt")
if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(secret) })
_, _, err := ReadFile(root, "../"+filepath.Base(secret))
if err == nil {
t.Fatal("expected path traversal error")
}
if err != ErrPathTraversal {
t.Fatalf("expected ErrPathTraversal, got %v", err)
}
}
func TestWriteFile_AtomicWrite(t *testing.T) {
root := t.TempDir()
rel := "server.properties"
if err := WriteFile(root, rel, "motd=Hello", true); err != nil {
t.Fatalf("write file: %v", err)
}
content, truncated, err := ReadFile(root, rel, 0)
if err != nil {
t.Fatalf("read file: %v", err)
}
if truncated {
t.Fatal("did not expect truncated read")
}
if content != "motd=Hello" {
t.Fatalf("content %q want %q", content, "motd=Hello")
}
}
func TestListDir_RejectsTraversal(t *testing.T) {
root := t.TempDir()
_, err := ListDir(root, "..")
if err == nil {
t.Fatal("expected path traversal error")
}
if err != ErrPathTraversal {
t.Fatalf("expected ErrPathTraversal, got %v", err)
}
}

View File

@@ -137,12 +137,76 @@ type ServerStopPayload struct {
OperationMeta
}
// ServerCommandPayload instructs the agent to run a console command via RCON.
type ServerCommandPayload struct {
OperationMeta
Command string `json:"command"`
}
// ServerLogsSubscribePayload instructs the agent to stream container logs.
type ServerLogsSubscribePayload struct {
OperationMeta
TailLines int `json:"tailLines"`
Follow bool `json:"follow"`
}
// ServerLogPayload carries a single log line from a game server.
type ServerLogPayload struct {
OperationMeta
Line string `json:"line"`
Stream string `json:"stream"`
}
// ServerFilesListPayload lists files in a server data directory.
type ServerFilesListPayload struct {
OperationMeta
Path string `json:"path"`
}
// ServerFilesReadPayload reads a file from the server data directory.
type ServerFilesReadPayload struct {
OperationMeta
Path string `json:"path"`
MaxBytes int `json:"maxBytes"`
}
// ServerFilesWritePayload writes content to a file in the server data directory.
type ServerFilesWritePayload struct {
OperationMeta
Path string `json:"path"`
Content string `json:"content"`
Create bool `json:"create"`
}
// FileEntry describes a file or directory in a list response.
type FileEntry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
// ServerFilesListResultPayload is the result of a files.list operation.
type ServerFilesListResultPayload struct {
Entries []FileEntry `json:"entries"`
}
// ServerFilesReadResultPayload is the result of a files.read operation.
type ServerFilesReadResultPayload struct {
Path string `json:"path"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
}
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
type ServerOperationResultPayload struct {
OperationMeta
ResultCode string `json:"resultCode"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
ResultCode string `json:"resultCode"`
Operation string `json:"operation,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// ServerStatePayload reports the observed server state.

View File

@@ -212,3 +212,168 @@ func TestNewEnvelope(t *testing.T) {
t.Fatalf("protocol version %d", env.ProtocolVersion)
}
}
func TestPhase3Payloads_JSONRoundtrip(t *testing.T) {
meta := OperationMeta{
ServerID: "srv-01",
Generation: 3,
}
command := ServerCommandPayload{
OperationMeta: meta,
Command: "say hello",
}
commandRaw, err := json.Marshal(command)
if err != nil {
t.Fatal(err)
}
var decodedCommand ServerCommandPayload
if err := json.Unmarshal(commandRaw, &decodedCommand); err != nil {
t.Fatal(err)
}
if decodedCommand.Command != command.Command {
t.Fatalf("command: got %q want %q", decodedCommand.Command, command.Command)
}
logsSub := ServerLogsSubscribePayload{
OperationMeta: meta,
TailLines: 100,
Follow: true,
}
logsSubRaw, err := json.Marshal(logsSub)
if err != nil {
t.Fatal(err)
}
var decodedLogsSub ServerLogsSubscribePayload
if err := json.Unmarshal(logsSubRaw, &decodedLogsSub); err != nil {
t.Fatal(err)
}
if decodedLogsSub.TailLines != 100 || !decodedLogsSub.Follow {
t.Fatalf("logs subscribe mismatch: %+v", decodedLogsSub)
}
filesList := ServerFilesListPayload{
OperationMeta: meta,
Path: "world",
}
filesListRaw, err := json.Marshal(filesList)
if err != nil {
t.Fatal(err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-files-list",
Type: TypeServerFilesList,
Timestamp: time.Now().UTC(),
Payload: filesListRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("files list envelope: %v", err)
}
filesRead := ServerFilesReadPayload{
OperationMeta: meta,
Path: "server.properties",
MaxBytes: 4096,
}
filesReadRaw, err := json.Marshal(filesRead)
if err != nil {
t.Fatal(err)
}
env = &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-files-read",
Type: TypeServerFilesRead,
Timestamp: time.Now().UTC(),
Payload: filesReadRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("files read envelope: %v", err)
}
filesWrite := ServerFilesWritePayload{
OperationMeta: meta,
Path: "ops.txt",
Content: "test",
Create: true,
}
filesWriteRaw, err := json.Marshal(filesWrite)
if err != nil {
t.Fatal(err)
}
env = &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-files-write",
Type: TypeServerFilesWrite,
Timestamp: time.Now().UTC(),
Payload: filesWriteRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("files write envelope: %v", err)
}
logLine := ServerLogPayload{
OperationMeta: meta,
Line: "[Server] Done",
Stream: "stdout",
}
logLineRaw, err := json.Marshal(logLine)
if err != nil {
t.Fatal(err)
}
env = &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-log",
Type: TypeServerLog,
Timestamp: time.Now().UTC(),
Payload: logLineRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("server log envelope: %v", err)
}
}
func TestServerOperationResultPayload_WithResult(t *testing.T) {
listResult, err := json.Marshal(ServerFilesListResultPayload{
Entries: []FileEntry{{
Name: "server.properties",
Path: "server.properties",
IsDir: false,
Size: 128,
ModifiedAt: "2026-06-26T12:00:00Z",
}},
})
if err != nil {
t.Fatal(err)
}
payload := ServerOperationResultPayload{
OperationMeta: OperationMeta{ServerID: "srv-01", Generation: 1},
ResultCode: "ok",
Operation: "files.list",
Result: listResult,
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded ServerOperationResultPayload
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Operation != "files.list" {
t.Fatalf("operation: got %q", decoded.Operation)
}
if len(decoded.Result) == 0 {
t.Fatal("expected result payload")
}
var result ServerFilesListResultPayload
if err := json.Unmarshal(decoded.Result, &result); err != nil {
t.Fatal(err)
}
if len(result.Entries) != 1 || result.Entries[0].Name != "server.properties" {
t.Fatalf("unexpected list result: %+v", result)
}
}

View File

@@ -2,11 +2,13 @@ package runtime
import (
"context"
"fmt"
"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"
)
@@ -27,6 +29,7 @@ const (
// 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
}
@@ -42,20 +45,22 @@ type serverRecord struct {
// 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
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),
resp: resp,
log: log,
docker: dockerClient,
servers: make(map[string]*serverRecord),
logStreams: make(map[string]context.CancelFunc),
resp: resp,
log: log,
}
}
@@ -184,6 +189,8 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
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())
@@ -204,6 +211,147 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
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()
@@ -223,6 +371,15 @@ func (m *Manager) setState(serverID, state string) {
}
}
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,
@@ -267,3 +424,59 @@ func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.
)
}
}
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"
}
}

View File

@@ -274,6 +274,46 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
}
c.runtime.HandleDelete(ctx, correlationID, meta)
case protocol.TypeServerLogsSubscribe:
var payload protocol.ServerLogsSubscribePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.logs.subscribe", "error", err)
return
}
c.runtime.HandleLogsSubscribe(ctx, env.MessageID, payload)
case protocol.TypeServerCommand:
var payload protocol.ServerCommandPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.command", "error", err)
return
}
c.runtime.HandleCommand(ctx, env.MessageID, payload)
case protocol.TypeServerFilesList:
var payload protocol.ServerFilesListPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.list", "error", err)
return
}
c.runtime.HandleFilesList(ctx, env.MessageID, payload)
case protocol.TypeServerFilesRead:
var payload protocol.ServerFilesReadPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.read", "error", err)
return
}
c.runtime.HandleFilesRead(ctx, env.MessageID, payload)
case protocol.TypeServerFilesWrite:
var payload protocol.ServerFilesWritePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.write", "error", err)
return
}
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
default:
c.log.Debug("ignored control message", "type", env.Type)
}
@@ -333,6 +373,11 @@ func (c *Client) SendState(ctx context.Context, correlationID string, payload pr
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
}
// SendLog implements runtime.Responder.
func (c *Client) SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error {
return c.send(ctx, protocol.TypeServerLog, correlationID, payload)
}
// SendOperationComplete implements runtime.Responder.
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)