Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
This commit is contained in:
@@ -95,6 +95,99 @@ func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, er
|
||||
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
|
||||
}
|
||||
|
||||
// CreateTarGzFromPaths archives one or more paths under dataRoot into outputPath.
|
||||
func CreateTarGzFromPaths(dataRoot string, relativePaths []string, outputPath string) (sha256sum string, size int64, err error) {
|
||||
dataRoot = filepath.Clean(dataRoot)
|
||||
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)
|
||||
|
||||
for _, rel := range relativePaths {
|
||||
abs := filepath.Join(dataRoot, filepath.FromSlash(rel))
|
||||
abs = filepath.Clean(abs)
|
||||
if !strings.HasPrefix(abs, dataRoot+string(os.PathSeparator)) && abs != dataRoot {
|
||||
return "", 0, fmt.Errorf("path escapes data root: %s", rel)
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
baseName := filepath.Base(abs)
|
||||
if info.IsDir() {
|
||||
err = filepath.Walk(abs, func(path string, entry os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
innerRel, err := filepath.Rel(abs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if innerRel == "." {
|
||||
return nil
|
||||
}
|
||||
headerName := filepath.ToSlash(filepath.Join(baseName, innerRel))
|
||||
return writeTarEntry(tarWriter, path, entry, headerName)
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeTarEntry(tarWriter, abs, info, baseName); 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
|
||||
}
|
||||
|
||||
func writeTarEntry(tarWriter *tar.Writer, path string, entry os.FileInfo, headerName string) error {
|
||||
header, err := tar.FileInfoHeader(entry, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = headerName
|
||||
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
|
||||
}
|
||||
|
||||
// ExtractTarGz extracts archivePath into destDir.
|
||||
func ExtractTarGz(archivePath, destDir string) error {
|
||||
destDir = filepath.Clean(destDir)
|
||||
|
||||
@@ -113,9 +113,19 @@ func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServer
|
||||
Target: "/data",
|
||||
}},
|
||||
Resources: container.Resources{
|
||||
Memory: int64(spec.RamMb) * 1024 * 1024,
|
||||
Memory: int64(spec.RamMb) * 1024 * 1024,
|
||||
PidsLimit: ptrInt64(256),
|
||||
},
|
||||
Privileged: false,
|
||||
CapDrop: []string{"ALL"},
|
||||
SecurityOpt: []string{"no-new-privileges:true"},
|
||||
LogConfig: container.LogConfig{
|
||||
Type: "json-file",
|
||||
Config: map[string]string{
|
||||
"max-size": "10m",
|
||||
"max-file": "3",
|
||||
},
|
||||
},
|
||||
Privileged: false,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
@@ -198,6 +208,10 @@ func mapSoftwareFamily(family string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func ptrInt64(value int64) *int64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
// 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{
|
||||
|
||||
@@ -229,6 +229,31 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePath deletes a file or directory relative to dataRoot.
|
||||
func RemovePath(dataRoot, relativePath string) error {
|
||||
target, err := ResolvePath(dataRoot, relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinks(dataRoot, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := os.Lstat(target)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return os.RemoveAll(target)
|
||||
}
|
||||
|
||||
return os.Remove(target)
|
||||
}
|
||||
|
||||
// DeleteFile removes a file relative to dataRoot.
|
||||
func DeleteFile(dataRoot, relativePath string) error {
|
||||
filePath, err := ResolvePath(dataRoot, relativePath)
|
||||
|
||||
@@ -38,6 +38,9 @@ const (
|
||||
TypeServerFilesList MessageType = "server.files.list"
|
||||
TypeServerFilesRead MessageType = "server.files.read"
|
||||
TypeServerFilesWrite MessageType = "server.files.write"
|
||||
TypeServerFilesDelete MessageType = "server.files.delete"
|
||||
TypeServerFilesArchive MessageType = "server.files.archive"
|
||||
TypeServerFilesUnarchive MessageType = "server.files.unarchive"
|
||||
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
|
||||
TypeServerWorldValidate MessageType = "server.world.validate"
|
||||
TypeServerWorldArchive MessageType = "server.world.archive"
|
||||
@@ -76,6 +79,9 @@ var controlToAgentTypes = map[MessageType]struct{}{
|
||||
TypeServerFilesList: {},
|
||||
TypeServerFilesRead: {},
|
||||
TypeServerFilesWrite: {},
|
||||
TypeServerFilesDelete: {},
|
||||
TypeServerFilesArchive: {},
|
||||
TypeServerFilesUnarchive: {},
|
||||
TypeServerFilesUploadDone: {},
|
||||
TypeServerWorldValidate: {},
|
||||
TypeServerWorldArchive: {},
|
||||
@@ -185,9 +191,30 @@ type ServerFilesReadPayload struct {
|
||||
// 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"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Create bool `json:"create"`
|
||||
Encoding string `json:"encoding,omitempty"`
|
||||
}
|
||||
|
||||
// ServerFilesDeletePayload deletes a file or directory relative to data root.
|
||||
type ServerFilesDeletePayload struct {
|
||||
OperationMeta
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ServerFilesArchivePayload creates a tar.gz archive from paths.
|
||||
type ServerFilesArchivePayload struct {
|
||||
OperationMeta
|
||||
Paths []string `json:"paths"`
|
||||
ArchiveName string `json:"archiveName,omitempty"`
|
||||
}
|
||||
|
||||
// ServerFilesUnarchivePayload extracts a tar.gz archive.
|
||||
type ServerFilesUnarchivePayload struct {
|
||||
OperationMeta
|
||||
ArchivePath string `json:"archivePath"`
|
||||
DestinationPath string `json:"destinationPath"`
|
||||
}
|
||||
|
||||
// ServerWorldValidatePayload validates a Minecraft world directory.
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/archive"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||
@@ -357,6 +360,88 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa
|
||||
})
|
||||
}
|
||||
|
||||
// HandleFilesDelete removes a file or directory from the server data directory.
|
||||
func (m *Manager) HandleFilesDelete(ctx context.Context, correlationID string, payload protocol.ServerFilesDeletePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.delete", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if err := files.RemovePath(rec.DataPath, payload.Path); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.delete", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.delete", map[string]string{
|
||||
"path": payload.Path,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleFilesArchive creates a tar.gz archive from selected paths.
|
||||
func (m *Manager) HandleFilesArchive(ctx context.Context, correlationID string, payload protocol.ServerFilesArchivePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
archiveName := payload.ArchiveName
|
||||
if archiveName == "" {
|
||||
archiveName = fmt.Sprintf("archive-%d.tar.gz", time.Now().Unix())
|
||||
}
|
||||
if !strings.HasSuffix(archiveName, ".tar.gz") {
|
||||
archiveName += ".tar.gz"
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(rec.DataPath, ".hexahost-archives", archiveName)
|
||||
checksum, size, err := archive.CreateTarGzFromPaths(rec.DataPath, payload.Paths, outputPath)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.archive", "ARCHIVE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(rec.DataPath, outputPath)
|
||||
m.completeOperation(ctx, correlationID, meta, "files.archive", map[string]any{
|
||||
"archivePath": filepath.ToSlash(rel),
|
||||
"sha256": checksum,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleFilesUnarchive extracts a tar.gz archive into a destination path.
|
||||
func (m *Manager) HandleFilesUnarchive(ctx context.Context, correlationID string, payload protocol.ServerFilesUnarchivePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.unarchive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
archiveAbs, err := files.ResolvePath(rec.DataPath, payload.ArchivePath)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
destAbs, err := files.ResolvePath(rec.DataPath, payload.DestinationPath)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := archive.ExtractTarGz(archiveAbs, destAbs); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.unarchive", "UNARCHIVE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.unarchive", map[string]string{
|
||||
"destinationPath": payload.DestinationPath,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -314,6 +314,30 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||
}
|
||||
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesDelete:
|
||||
var payload protocol.ServerFilesDeletePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.delete", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesDelete(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesArchive:
|
||||
var payload protocol.ServerFilesArchivePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.archive", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesArchive(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesUnarchive:
|
||||
var payload protocol.ServerFilesUnarchivePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.unarchive", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesUnarchive(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerBackupPrepare:
|
||||
var meta protocol.OperationMeta
|
||||
if err := json.Unmarshal(env.Payload, &meta); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user