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

@@ -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()