Phase4
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:32:27 +02:00
parent 9b061c3ee7
commit 4262464cd5
101 changed files with 4024 additions and 74 deletions

View File

@@ -0,0 +1,159 @@
package archive
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// CreateTarGz archives sourceDir into outputPath and returns checksum and size.
func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, err error) {
sourceDir = filepath.Clean(sourceDir)
info, err := os.Stat(sourceDir)
if err != nil {
return "", 0, err
}
if !info.IsDir() {
return "", 0, fmt.Errorf("source is not a directory")
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return "", 0, err
}
file, err := os.Create(outputPath)
if err != nil {
return "", 0, err
}
defer file.Close()
hasher := sha256.New()
writer := io.MultiWriter(file, hasher)
gzipWriter := gzip.NewWriter(writer)
tarWriter := tar.NewWriter(gzipWriter)
err = filepath.Walk(sourceDir, func(path string, entry os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
header, err := tar.FileInfoHeader(entry, "")
if err != nil {
return err
}
header.Name = rel
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if entry.IsDir() {
return nil
}
sourceFile, err := os.Open(path)
if err != nil {
return err
}
defer sourceFile.Close()
_, err = io.Copy(tarWriter, sourceFile)
return err
})
if err != nil {
return "", 0, err
}
if err := tarWriter.Close(); err != nil {
return "", 0, err
}
if err := gzipWriter.Close(); err != nil {
return "", 0, err
}
stat, err := file.Stat()
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
}
// ExtractTarGz extracts archivePath into destDir.
func ExtractTarGz(archivePath, destDir string) error {
destDir = filepath.Clean(destDir)
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target := filepath.Join(destDir, filepath.FromSlash(header.Name))
if !strings.HasPrefix(filepath.Clean(target), destDir+string(os.PathSeparator)) && filepath.Clean(target) != destDir {
return fmt.Errorf("archive entry escapes destination: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tarReader); err != nil {
out.Close()
return err
}
out.Close()
default:
continue
}
}
return nil
}

View File

@@ -40,6 +40,10 @@ const (
TypeServerFilesWrite MessageType = "server.files.write"
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
TypeServerWorldValidate MessageType = "server.world.validate"
TypeServerWorldArchive MessageType = "server.world.archive"
TypeServerWorldReplace MessageType = "server.world.replace"
TypeServerStorageUpload MessageType = "server.storage.upload"
TypeServerStorageDownload MessageType = "server.storage.download"
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
)
@@ -72,6 +76,10 @@ var controlToAgentTypes = map[MessageType]struct{}{
TypeServerFilesWrite: {},
TypeServerFilesUploadDone: {},
TypeServerWorldValidate: {},
TypeServerWorldArchive: {},
TypeServerWorldReplace: {},
TypeServerStorageUpload: {},
TypeServerStorageDownload: {},
TypeServerMetricsSub: {},
TypeServerLogsSubscribe: {},
}
@@ -178,6 +186,40 @@ type ServerFilesWritePayload struct {
Create bool `json:"create"`
}
// ServerWorldValidatePayload validates a Minecraft world directory.
type ServerWorldValidatePayload struct {
OperationMeta
WorldName string `json:"worldName"`
}
// ServerWorldArchivePayload archives a world directory.
type ServerWorldArchivePayload struct {
OperationMeta
WorldName string `json:"worldName"`
ArchiveID string `json:"archiveId"`
}
// ServerStorageUploadPayload uploads a local file to a presigned URL.
type ServerStorageUploadPayload struct {
OperationMeta
LocalPath string `json:"localPath"`
UploadURL string `json:"uploadUrl"`
}
// ServerStorageDownloadPayload downloads a remote file to a local path.
type ServerStorageDownloadPayload struct {
OperationMeta
DownloadURL string `json:"downloadUrl"`
LocalPath string `json:"localPath"`
}
// ServerWorldReplacePayload replaces a world from a local archive.
type ServerWorldReplacePayload struct {
OperationMeta
WorldName string `json:"worldName"`
ArchivePath string `json:"archivePath"`
}
// FileEntry describes a file or directory in a list response.
type FileEntry struct {
Name string `json:"name"`

View File

@@ -5,11 +5,16 @@ import (
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/hexahost/gamecloud/node-agent/internal/archive"
"github.com/hexahost/gamecloud/node-agent/internal/docker"
"github.com/hexahost/gamecloud/node-agent/internal/files"
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
"github.com/hexahost/gamecloud/node-agent/internal/transfer"
worldpkg "github.com/hexahost/gamecloud/node-agent/internal/world"
)
const (
@@ -352,6 +357,219 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa
})
}
// HandleBackupPrepare runs save-off and save-all flush via RCON.
func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "backup.prepare", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State != ServerStateRunning {
m.completeOperation(ctx, correlationID, meta, "backup.prepare", map[string]string{"skipped": "not_running"})
return
}
for _, command := range []string{"save-off", "save-all flush"} {
if _, _, err := m.docker.ExecRcon(ctx, rec.ContainerID, command); err != nil {
m.failOperation(ctx, correlationID, meta, "backup.prepare", "BACKUP_PREPARE_FAILED", err.Error())
return
}
}
m.completeOperation(ctx, correlationID, meta, "backup.prepare", map[string]string{"status": "ok"})
}
// HandleBackupRelease runs save-on via RCON.
func (m *Manager) HandleBackupRelease(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "backup.release", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State != ServerStateRunning {
m.completeOperation(ctx, correlationID, meta, "backup.release", map[string]string{"skipped": "not_running"})
return
}
if _, _, err := m.docker.ExecRcon(ctx, rec.ContainerID, "save-on"); err != nil {
m.failOperation(ctx, correlationID, meta, "backup.release", "BACKUP_RELEASE_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "backup.release", map[string]string{"status": "ok"})
}
// HandleWorldValidate validates a world directory and lists worlds.
func (m *Manager) HandleWorldValidate(ctx context.Context, correlationID string, payload protocol.ServerWorldValidatePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "world.validate", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
worldName := payload.WorldName
if worldName == "" {
worldName = "world"
}
if err := worldpkg.ValidateWorld(rec.DataPath, worldName); err != nil {
m.failOperation(ctx, correlationID, meta, "world.validate", "WORLD_INVALID", err.Error())
return
}
worlds, err := worldpkg.ListWorlds(rec.DataPath, worldName)
if err != nil {
m.failOperation(ctx, correlationID, meta, "world.validate", "WORLD_LIST_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "world.validate", map[string]any{
"valid": true,
"worldName": worldName,
"worlds": worlds,
})
}
// HandleWorldArchive creates a tar.gz archive of a world directory.
func (m *Manager) HandleWorldArchive(ctx context.Context, correlationID string, payload protocol.ServerWorldArchivePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "world.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
worldName := payload.WorldName
if worldName == "" {
worldName = "world"
}
if err := worldpkg.ValidateWorld(rec.DataPath, worldName); err != nil {
m.failOperation(ctx, correlationID, meta, "world.archive", "WORLD_INVALID", err.Error())
return
}
archiveDir := filepath.Join(rec.DataPath, ".hgc", "archives")
archiveID := payload.ArchiveID
if archiveID == "" {
archiveID = meta.ServerID
}
outputPath := filepath.Join(archiveDir, archiveID+".tar.gz")
sourceDir := filepath.Join(rec.DataPath, worldName)
checksum, size, err := archive.CreateTarGz(sourceDir, outputPath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "world.archive", "ARCHIVE_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "world.archive", map[string]any{
"localPath": outputPath,
"sha256": checksum,
"sizeBytes": size,
"worldName": worldName,
})
}
// HandleStorageUpload uploads a local archive to object storage.
func (m *Manager) HandleStorageUpload(ctx context.Context, correlationID string, payload protocol.ServerStorageUploadPayload) {
meta := payload.OperationMeta
if err := transfer.UploadFile(payload.LocalPath, payload.UploadURL); err != nil {
m.failOperation(ctx, correlationID, meta, "storage.upload", "UPLOAD_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "storage.upload", map[string]string{
"localPath": payload.LocalPath,
})
}
// HandleStorageDownload downloads an archive from object storage.
func (m *Manager) HandleStorageDownload(ctx context.Context, correlationID string, payload protocol.ServerStorageDownloadPayload) {
meta := payload.OperationMeta
if err := transfer.DownloadFile(payload.DownloadURL, payload.LocalPath); err != nil {
m.failOperation(ctx, correlationID, meta, "storage.download", "DOWNLOAD_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "storage.download", map[string]string{
"localPath": payload.LocalPath,
})
}
// HandleWorldReplace replaces a world directory from a local archive.
func (m *Manager) HandleWorldReplace(ctx context.Context, correlationID string, payload protocol.ServerWorldReplacePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "world.replace", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State == ServerStateRunning {
m.failOperation(ctx, correlationID, meta, "world.replace", "SERVER_RUNNING", "server must be stopped before world replace")
return
}
worldName := payload.WorldName
if worldName == "" {
worldName = "world"
}
worldDir := filepath.Join(rec.DataPath, worldName)
tempDir := filepath.Join(rec.DataPath, ".hgc", "restore", meta.ServerID)
if err := os.RemoveAll(tempDir); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "RESTORE_PREP_FAILED", err.Error())
return
}
if err := os.MkdirAll(tempDir, 0o755); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "RESTORE_PREP_FAILED", err.Error())
return
}
if err := archive.ExtractTarGz(payload.ArchivePath, tempDir); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "EXTRACT_FAILED", err.Error())
return
}
extractedWorld := filepath.Join(tempDir, worldName)
if info, err := os.Stat(extractedWorld); err != nil || !info.IsDir() {
extractedWorld = tempDir
}
if err := worldpkg.ValidateWorld(filepath.Dir(extractedWorld), filepath.Base(extractedWorld)); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "WORLD_INVALID", err.Error())
return
}
backupDir := worldDir + ".bak"
_ = os.RemoveAll(backupDir)
if _, err := os.Stat(worldDir); err == nil {
if err := os.Rename(worldDir, backupDir); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "REPLACE_FAILED", err.Error())
return
}
}
if err := os.Rename(extractedWorld, worldDir); err != nil {
_ = os.Rename(backupDir, worldDir)
m.failOperation(ctx, correlationID, meta, "world.replace", "REPLACE_FAILED", err.Error())
return
}
_ = os.RemoveAll(tempDir)
_ = os.RemoveAll(backupDir)
m.completeOperation(ctx, correlationID, meta, "world.replace", map[string]string{
"worldName": worldName,
})
}
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
m.mu.RLock()
defer m.mu.RUnlock()

View File

@@ -0,0 +1,73 @@
package transfer
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
// UploadFile PUTs a local file to a presigned URL.
func UploadFile(localPath, uploadURL string) error {
file, err := os.Open(localPath)
if err != nil {
return err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPut, uploadURL, file)
if err != nil {
return err
}
request.ContentLength = stat.Size()
request.Header.Set("Content-Type", "application/gzip")
client := &http.Client{Timeout: 30 * time.Minute}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("upload failed with status %d: %s", response.StatusCode, string(body))
}
return nil
}
// DownloadFile GETs a remote file to a local path.
func DownloadFile(downloadURL, localPath string) error {
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return err
}
client := &http.Client{Timeout: 30 * time.Minute}
response, err := client.Get(downloadURL)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("download failed with status %d: %s", response.StatusCode, string(body))
}
file, err := os.Create(localPath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, response.Body)
return err
}

View File

@@ -0,0 +1,114 @@
package world
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Summary describes a discovered Minecraft world directory.
type Summary struct {
Name string `json:"name"`
Path string `json:"path"`
SizeBytes int64 `json:"sizeBytes"`
HasLevelDat bool `json:"hasLevelDat"`
}
// ValidateWorld checks that a world directory contains level.dat.
func ValidateWorld(dataPath, worldName string) error {
worldName = strings.TrimSpace(worldName)
if worldName == "" || strings.Contains(worldName, "..") || strings.ContainsAny(worldName, `/\`) {
return fmt.Errorf("invalid world name")
}
worldDir := filepath.Join(dataPath, worldName)
levelDat := filepath.Join(worldDir, "level.dat")
info, err := os.Stat(levelDat)
if err != nil {
return fmt.Errorf("level.dat not found in world directory")
}
if info.IsDir() {
return fmt.Errorf("level.dat is not a file")
}
return nil
}
// ListWorlds scans the server data directory for world folders.
func ListWorlds(dataPath, activeWorldName string) ([]Summary, error) {
entries, err := os.ReadDir(dataPath)
if err != nil {
return nil, err
}
worlds := make([]Summary, 0)
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
worldPath := filepath.Join(dataPath, entry.Name())
levelDat := filepath.Join(worldPath, "level.dat")
hasLevelDat := false
if info, err := os.Stat(levelDat); err == nil && !info.IsDir() {
hasLevelDat = true
}
size, err := dirSize(worldPath)
if err != nil {
return nil, err
}
worlds = append(worlds, Summary{
Name: entry.Name(),
Path: entry.Name(),
SizeBytes: size,
HasLevelDat: hasLevelDat,
})
}
if activeWorldName != "" {
found := false
for _, world := range worlds {
if world.Name == activeWorldName {
found = true
break
}
}
if !found {
if err := ValidateWorld(dataPath, activeWorldName); err == nil {
size, sizeErr := dirSize(filepath.Join(dataPath, activeWorldName))
if sizeErr != nil {
return nil, sizeErr
}
worlds = append(worlds, Summary{
Name: activeWorldName,
Path: activeWorldName,
SizeBytes: size,
HasLevelDat: true,
})
}
}
}
return worlds, nil
}
func dirSize(root string) (int64, error) {
var total int64
err := filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}

View File

@@ -314,6 +314,62 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
}
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
case protocol.TypeServerBackupPrepare:
var meta protocol.OperationMeta
if err := json.Unmarshal(env.Payload, &meta); err != nil {
c.log.Warn("decode server.backup.prepare", "error", err)
return
}
c.runtime.HandleBackupPrepare(ctx, env.MessageID, meta)
case protocol.TypeServerBackupRelease:
var meta protocol.OperationMeta
if err := json.Unmarshal(env.Payload, &meta); err != nil {
c.log.Warn("decode server.backup.release", "error", err)
return
}
c.runtime.HandleBackupRelease(ctx, env.MessageID, meta)
case protocol.TypeServerWorldValidate:
var payload protocol.ServerWorldValidatePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.world.validate", "error", err)
return
}
c.runtime.HandleWorldValidate(ctx, env.MessageID, payload)
case protocol.TypeServerWorldArchive:
var payload protocol.ServerWorldArchivePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.world.archive", "error", err)
return
}
c.runtime.HandleWorldArchive(ctx, env.MessageID, payload)
case protocol.TypeServerStorageUpload:
var payload protocol.ServerStorageUploadPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.storage.upload", "error", err)
return
}
c.runtime.HandleStorageUpload(ctx, env.MessageID, payload)
case protocol.TypeServerStorageDownload:
var payload protocol.ServerStorageDownloadPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.storage.download", "error", err)
return
}
c.runtime.HandleStorageDownload(ctx, env.MessageID, payload)
case protocol.TypeServerWorldReplace:
var payload protocol.ServerWorldReplacePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.world.replace", "error", err)
return
}
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
default:
c.log.Debug("ignored control message", "type", env.Type)
}