feat: apply validated imports during creation

This commit is contained in:
2026-08-07 16:08:27 +00:00
parent 71d542a60f
commit 83165cc4ad
10 changed files with 358 additions and 29 deletions
+3 -2
View File
@@ -34,6 +34,7 @@ func main() {
func run(logger *slog.Logger) error {
listenAddress := environment("DOGAMA_LISTEN_ADDRESS", ":8080")
databasePath := environment("DOGAMA_DATABASE_PATH", "dogama.db")
serversRoot := environment("DOGAMA_SERVERS_ROOT", "/srv/game-servers")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -54,7 +55,7 @@ func run(logger *slog.Logger) error {
var handler http.Handler
var lifecycle *instance.LifecycleService
var backupService *backup.Service
importService, err := importexport.New(repository, environment("DOGAMA_IMPORTS_ROOT", "/var/lib/dogama/imports/staging"))
importService, err := importexport.New(repository, environment("DOGAMA_IMPORTS_ROOT", "/var/lib/dogama/imports/staging"), serversRoot)
if err != nil {
return err
}
@@ -76,7 +77,7 @@ func run(logger *slog.Logger) error {
return clientErr
}
lifecycle = instance.NewLifecycleService(repository, agent)
backupService, err = backup.New(repository, agent, environment("DOGAMA_SERVERS_ROOT", "/srv/game-servers"), environment("DOGAMA_BACKUPS_ROOT", "/srv/game-backups"))
backupService, err = backup.New(repository, agent, serversRoot, environment("DOGAMA_BACKUPS_ROOT", "/srv/game-backups"))
if err != nil {
return err
}
+5
View File
@@ -93,6 +93,11 @@ the orphaned file. Scheduled retention considers only successful `scheduled`
backups. Restore verifies size, checksum, manifest and pinned template version,
creates a `pre_restore` backup, extracts into sibling staging and keeps the
instance stopped with `intervention_required` if readiness cannot be restored.
Validated imports are pinned to the selected template version. Import-backed
drafts require that opaque import ID, and installation atomically places the
normalized staged tree at the template-declared mount-relative destination
before the restricted agent creates the first container. Repeated installation
submission recognizes an already attached import instead of copying it twice.
At main-application startup, every embedded `catalog/*/template.yaml` is
validated against `specs/template.schema.json`, checked for cross-reference and
+4 -2
View File
@@ -107,6 +107,8 @@ still never traverse or archive files.
ZIP, tar, tar.gz and tar.zst imports are copied to a unique staging directory
before validation. Extraction uses create-new files and rejects absolute or
Windows paths, traversal, links, special files, excessive nesting, excessive
file counts and expanded-size overflow. A validated import remains staged for
the creation or existing-instance workflow; validation itself never creates a
file counts and expanded-size overflow. A compatible validated import can be
selected in an administrator creation preview. Its normalized data is copied
through a create-new sibling directory into the template-declared destination
immediately before first container creation; validation itself never creates a
container or writes into live player data.
+173 -9
View File
@@ -33,6 +33,8 @@ const (
)
type Policy struct {
TemplateID string
TemplateVersion string
AcceptedFormats []string
MaxExpandedBytes int64
RequiredPaths []string
@@ -48,6 +50,10 @@ type Import struct {
ExpandedSizeBytes int64 `json:"expanded_size_bytes"`
ExpiresAt string `json:"expires_at"`
RelativeStagePath string `json:"-"`
DataRoot string `json:"-"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
InstanceID string `json:"instance_id,omitempty"`
}
type Repository interface {
@@ -55,6 +61,8 @@ type Repository interface {
CompleteImport(context.Context, Import) error
FailImport(context.Context, string, string) error
ExpireImports(context.Context, string) ([]string, error)
GetImport(context.Context, string) (Import, error)
AttachImport(context.Context, string, string) error
}
func (s *Service) CleanupExpired(ctx context.Context) error {
@@ -78,13 +86,14 @@ func (s *Service) CleanupExpired(ctx context.Context) error {
}
type Service struct {
repository Repository
root string
now func() time.Time
repository Repository
root string
serversRoot string
now func() time.Time
}
func New(repository Repository, root string) (*Service, error) {
if root == "" {
func New(repository Repository, root, serversRoot string) (*Service, error) {
if root == "" || serversRoot == "" {
return nil, ErrInvalidInput
}
absolute, err := filepath.Abs(root)
@@ -98,11 +107,22 @@ func New(repository Repository, root string) (*Service, error) {
if err != nil {
return nil, err
}
return &Service{repository: repository, root: canonical, now: time.Now}, nil
serverAbsolute, err := filepath.Abs(serversRoot)
if err != nil {
return nil, err
}
if err := os.MkdirAll(serverAbsolute, 0o750); err != nil {
return nil, err
}
serverCanonical, err := filepath.EvalSymlinks(serverAbsolute)
if err != nil {
return nil, err
}
return &Service{repository: repository, root: canonical, serversRoot: serverCanonical, now: time.Now}, nil
}
func (s *Service) Stage(ctx context.Context, actorID, format string, source io.Reader, policy Policy) (Import, error) {
if actorID == "" || !contains(policy.AcceptedFormats, format) || policy.MaxExpandedBytes < 1 {
if actorID == "" || policy.TemplateID == "" || policy.TemplateVersion == "" || !contains(policy.AcceptedFormats, format) || policy.MaxExpandedBytes < 1 {
return Import{}, ErrInvalidInput
}
id := importToken()
@@ -116,7 +136,7 @@ func (s *Service) Stage(ctx context.Context, actorID, format string, source io.R
if err := os.Mkdir(directory, 0o750); err != nil {
return Import{}, err
}
value := Import{ID: id, Status: "staging", Format: format, RelativeStagePath: id, ExpiresAt: s.now().Add(24 * time.Hour).UTC().Format(time.RFC3339Nano)}
value := Import{ID: id, Status: "staging", Format: format, RelativeStagePath: id, TemplateID: policy.TemplateID, TemplateVersion: policy.TemplateVersion, ExpiresAt: s.now().Add(24 * time.Hour).UTC().Format(time.RFC3339Nano)}
if err := s.repository.BeginImport(ctx, value, actorID); err != nil {
_ = os.RemoveAll(directory)
return Import{}, err
@@ -154,16 +174,82 @@ func (s *Service) Stage(ctx context.Context, actorID, format string, source io.R
if !requiredPresent(paths, policy.RequiredPaths) {
return fail("import_layout_unrecognized", ErrNotRecognized)
}
dataRoot, err := detectedRoot(paths, policy.RequiredPaths)
if err != nil {
return fail("import_layout_ambiguous", err)
}
if err := os.Remove(upload); err != nil {
return fail("import_cleanup_failed", err)
}
value.Status, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes = "validated", "game_save", "confirmed", files, size
value.Status, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes, value.DataRoot = "validated", "game_save", "confirmed", files, size, dataRoot
if err := s.repository.CompleteImport(ctx, value); err != nil {
return fail("import_persist_failed", err)
}
return value, nil
}
func (s *Service) ValidateSelection(ctx context.Context, id, templateID, templateVersion string) error {
value, err := s.repository.GetImport(ctx, id)
if err != nil {
return err
}
if value.Status != "validated" || value.TemplateID != templateID || value.TemplateVersion != templateVersion {
return ErrInvalidInput
}
return nil
}
func (s *Service) ApplyToInstance(ctx context.Context, id, instanceID, templateID, templateVersion, mountPath, relativePath string) error {
value, err := s.repository.GetImport(ctx, id)
if err != nil {
return err
}
if value.InstanceID == instanceID && value.Status == "attached" {
return nil
}
if value.Status != "validated" || value.TemplateID != templateID || value.TemplateVersion != templateVersion {
return ErrInvalidInput
}
live := filepath.Join(mountPath, filepath.FromSlash(relativePath))
if !filepath.IsAbs(live) || !withinRoot(s.serversRoot, live) {
return ErrUnsafeArchive
}
source := filepath.Join(s.root, filepath.FromSlash(value.RelativeStagePath), "data", filepath.FromSlash(value.DataRoot))
if !withinRoot(s.root, source) {
return ErrUnsafeArchive
}
if info, err := os.Stat(source); err != nil || !info.IsDir() {
return ErrInvalidInput
}
if entries, err := os.ReadDir(live); err == nil && len(entries) != 0 {
return ErrInvalidInput
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.MkdirAll(filepath.Dir(live), 0o750); err != nil {
return err
}
temporary, err := os.MkdirTemp(filepath.Dir(live), ".dogama-import-")
if err != nil {
return err
}
defer os.RemoveAll(temporary)
if err := copyValidatedTree(source, temporary); err != nil {
return err
}
if err := os.Remove(live); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(temporary, live); err != nil {
return err
}
if err := s.repository.AttachImport(ctx, id, instanceID); err != nil {
_ = os.RemoveAll(live)
return err
}
return nil
}
func extract(ctx context.Context, archive, destination, format string, limit int64) (int, int64, []string, error) {
switch format {
case "zip":
@@ -336,6 +422,84 @@ func requiredPresent(paths, required []string) bool {
return true
}
func detectedRoot(paths, required []string) (string, error) {
root := ""
for _, expected := range required {
found := ""
for _, candidate := range paths {
marker := "/" + expected
if candidate == expected || strings.HasPrefix(candidate, expected+"/") {
found = "."
break
}
if index := strings.Index(candidate, marker); index >= 0 && (len(candidate) == index+len(marker) || candidate[index+len(marker)] == '/') {
found = candidate[:index]
break
}
}
if found == "" {
return "", ErrNotRecognized
}
if root == "" {
root = found
} else if root != found {
return "", ErrNotRecognized
}
}
if root == "" {
root = "."
}
return root, nil
}
func copyValidatedTree(source, destination string) error {
return filepath.WalkDir(source, func(current string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
return ErrUnsafeArchive
}
relative, err := filepath.Rel(source, current)
if err != nil {
return err
}
if relative == "." {
return nil
}
target := filepath.Join(destination, relative)
if !withinRoot(destination, target) {
return ErrUnsafeArchive
}
if info.IsDir() {
return os.MkdirAll(target, 0o750)
}
input, err := os.Open(current)
if err != nil {
return err
}
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
_ = input.Close()
return err
}
_, copyErr := io.Copy(output, input)
inputCloseErr := input.Close()
closeErr := output.Close()
if copyErr != nil {
return copyErr
}
if inputCloseErr != nil {
return inputCloseErr
}
return closeErr
})
}
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
+31 -2
View File
@@ -9,8 +9,11 @@ import (
"path/filepath"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
@@ -22,6 +25,14 @@ func TestStageValidZIPAndRejectTraversal(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
@@ -34,11 +45,12 @@ func TestStageValidZIPAndRejectTraversal(t *testing.T) {
if err != nil {
t.Fatal(err)
}
service, err := importexport.New(sqlite.NewRepository(db), filepath.Join(root, "imports"))
serversRoot := filepath.Join(root, "servers")
service, err := importexport.New(repository, filepath.Join(root, "imports"), serversRoot)
if err != nil {
t.Fatal(err)
}
policy := importexport.Policy{AcceptedFormats: []string{"zip"}, MaxExpandedBytes: 1 << 20, RequiredPaths: []string{"Level.sav", "Players"}}
policy := importexport.Policy{TemplateID: "palworld-official", TemplateVersion: "1.0.0", AcceptedFormats: []string{"zip"}, MaxExpandedBytes: 1 << 20, RequiredPaths: []string{"Level.sav", "Players"}}
valid := zipBytes(t, map[string]string{"Save/Level.sav": "world", "Save/Players/player.sav": "player"})
result, err := service.Stage(ctx, actor.ID, "zip", bytes.NewReader(valid), policy)
if err != nil {
@@ -47,6 +59,23 @@ func TestStageValidZIPAndRejectTraversal(t *testing.T) {
if result.Status != "validated" || result.Confidence != "confirmed" || result.FileCount != 2 {
t.Fatalf("import = %#v", result)
}
destination := filepath.Join(serversRoot, "instance", "saved")
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Import Test", Slug: "import-test", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": destination}, DataOrigin: "new", BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
if err := repository.CreateDraft(ctx, instance.Draft{ID: "instance-id", Preview: preview}); err != nil {
t.Fatal(err)
}
if err := service.ApplyToInstance(ctx, result.ID, "instance-id", policy.TemplateID, policy.TemplateVersion, destination, "SaveGames/0"); err != nil {
t.Fatal(err)
}
if body, err := os.ReadFile(filepath.Join(destination, "SaveGames", "0", "Level.sav")); err != nil || string(body) != "world" {
t.Fatalf("applied import=%q error=%v", body, err)
}
if err := service.ApplyToInstance(ctx, result.ID, "instance-id", policy.TemplateID, policy.TemplateVersion, destination, "SaveGames/0"); err != nil {
t.Fatalf("idempotent apply: %v", err)
}
unsafe := zipBytes(t, map[string]string{"../escape": "bad", "Level.sav": "world", "Players/player": "player"})
if _, err := service.Stage(ctx, actor.ID, "zip", bytes.NewReader(unsafe), policy); err == nil {
t.Fatal("traversal archive was accepted")
+15
View File
@@ -27,6 +27,7 @@ type PreviewRequest struct {
Resources catalog.Resources `json:"resources"`
DataOrigin string `json:"data_origin"`
BackupRetention int `json:"backup_retention"`
ImportID string `json:"import_id,omitempty"`
}
type Preview struct {
@@ -44,6 +45,7 @@ type Preview struct {
Settings []SettingPreview `json:"settings"`
DataOrigin string `json:"data_origin"`
Backup BackupPreview `json:"backup"`
Import ImportPreview `json:"import,omitempty"`
CanonicalJSON string `json:"canonical_json"`
PlanDigest string `json:"plan_digest"`
}
@@ -84,6 +86,12 @@ type BackupPreview struct {
RetentionCount int `json:"retention_count"`
}
type ImportPreview struct {
ID string `json:"id,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
DestinationRelativePath string `json:"destination_relative_path,omitempty"`
}
type Draft struct {
ID string
Preview Preview
@@ -106,6 +114,12 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
if request.DataOrigin == "import" && !snapshot.Template.Imports.Supported {
return Preview{}, errors.New("template does not support imports")
}
if request.DataOrigin == "import" && request.ImportID == "" {
return Preview{}, errors.New("validated import is required")
}
if request.DataOrigin == "new" && request.ImportID != "" {
return Preview{}, errors.New("import is only valid for imported data")
}
resources := request.Resources
if resources.CPUCores == 0 {
resources = snapshot.Template.Requirements.Recommended
@@ -179,6 +193,7 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
Ports: ports, Mounts: mounts, Resources: resources, Settings: settings,
DataOrigin: request.DataOrigin,
Backup: BackupPreview{Strategy: snapshot.Template.Backup.Strategy, SourceMounts: append([]string(nil), snapshot.Template.Backup.SourceMounts...), RetentionCount: request.BackupRetention},
Import: ImportPreview{ID: request.ImportID, DestinationMount: snapshot.Template.Imports.DestinationMount, DestinationRelativePath: snapshot.Template.Imports.DestinationRelativePath},
}
if preview.Backup.RetentionCount < 1 || preview.Backup.RetentionCount > 1000 {
return Preview{}, errors.New("backup retention must be between 1 and 1000")
+28 -2
View File
@@ -2,6 +2,8 @@ package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -9,7 +11,7 @@ import (
)
func (r *Repository) BeginImport(ctx context.Context, value importexport.Import, actorID string) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO imports(id, requested_by, status, format, relative_stage_path, created_at, expires_at) VALUES (?, ?, 'staging', ?, ?, ?, ?)`, value.ID, actorID, value.Format, value.RelativeStagePath, r.now().UTC().Format(time.RFC3339Nano), value.ExpiresAt)
_, err := r.db.ExecContext(ctx, `INSERT INTO imports(id, requested_by, template_id, template_version, status, format, relative_stage_path, created_at, expires_at) VALUES (?, ?, ?, ?, 'staging', ?, ?, ?, ?)`, value.ID, actorID, value.TemplateID, value.TemplateVersion, value.Format, value.RelativeStagePath, r.now().UTC().Format(time.RFC3339Nano), value.ExpiresAt)
if err != nil {
return fmt.Errorf("begin import: %w", err)
}
@@ -17,7 +19,7 @@ func (r *Repository) BeginImport(ctx context.Context, value importexport.Import,
}
func (r *Repository) CompleteImport(ctx context.Context, value importexport.Import) error {
result, err := r.db.ExecContext(ctx, `UPDATE imports SET status='validated', detected_type=?, confidence=?, file_count=?, expanded_size_bytes=?, completed_at=? WHERE id=? AND status='staging'`, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes, r.now().UTC().Format(time.RFC3339Nano), value.ID)
result, err := r.db.ExecContext(ctx, `UPDATE imports SET status='validated', data_root=?, detected_type=?, confidence=?, file_count=?, expanded_size_bytes=?, completed_at=? WHERE id=? AND status='staging'`, value.DataRoot, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes, r.now().UTC().Format(time.RFC3339Nano), value.ID)
if err != nil {
return fmt.Errorf("complete import: %w", err)
}
@@ -28,6 +30,30 @@ func (r *Repository) CompleteImport(ctx context.Context, value importexport.Impo
return nil
}
func (r *Repository) GetImport(ctx context.Context, id string) (importexport.Import, error) {
var value importexport.Import
err := r.db.QueryRowContext(ctx, `SELECT id, status, format, template_id, template_version, COALESCE(instance_id, ''), relative_stage_path, COALESCE(data_root, ''), COALESCE(detected_type, ''), COALESCE(confidence, ''), file_count, expanded_size_bytes, expires_at FROM imports WHERE id=?`, id).Scan(&value.ID, &value.Status, &value.Format, &value.TemplateID, &value.TemplateVersion, &value.InstanceID, &value.RelativeStagePath, &value.DataRoot, &value.DetectedType, &value.Confidence, &value.FileCount, &value.ExpandedSizeBytes, &value.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return importexport.Import{}, importexport.ErrInvalidInput
}
if err != nil {
return importexport.Import{}, fmt.Errorf("get import: %w", err)
}
return value, nil
}
func (r *Repository) AttachImport(ctx context.Context, id, instanceID string) error {
result, err := r.db.ExecContext(ctx, `UPDATE imports SET status='attached', instance_id=?, completed_at=? WHERE id=? AND status='validated'`, instanceID, r.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("attach import: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return importexport.ErrInvalidInput
}
return nil
}
func (r *Repository) FailImport(ctx context.Context, id, code string) error {
_, err := r.db.ExecContext(ctx, `UPDATE imports SET status='failed', error_code=?, completed_at=? WHERE id=? AND status='staging'`, code, r.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
+44 -10
View File
@@ -173,6 +173,7 @@ type previewAPIRequest struct {
Resources catalog.Resources `json:"resources"`
DataOrigin string `json:"data_origin"`
BackupRetention int `json:"backup_retention"`
ImportID string `json:"import_id"`
}
func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
@@ -236,7 +237,41 @@ func (s *server) instanceStats(w http.ResponseWriter, r *http.Request) {
}
func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
s.lifecycleAdminAction(w, r, s.lifecycle.Install)
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
if current.Preview.DataOrigin == "import" {
if s.imports == nil {
s.apiProblem(w, http.StatusConflict, "import_unavailable", "The validated import is unavailable.")
return
}
mountPath := ""
for _, mount := range current.Preview.Mounts {
if mount.ID == current.Preview.Import.DestinationMount {
mountPath = mount.HostPath
break
}
}
if mountPath == "" {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "The import destination is invalid.")
return
}
if err := s.imports.ApplyToInstance(r.Context(), current.Preview.Import.ID, current.ID, current.Preview.Template.ID, current.Preview.Template.Version, mountPath, current.Preview.Import.DestinationRelativePath); err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "The validated import could not be applied.")
return
}
}
s.runLifecycleAction(w, r, s.lifecycle.Install)
}
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
@@ -258,13 +293,6 @@ func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, permiss
s.runLifecycleAction(w, r, action)
}
func (s *server) lifecycleAdminAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
s.runLifecycleAction(w, r, action)
}
func (s *server) runLifecycleAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
@@ -338,10 +366,16 @@ func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previe
s.apiProblem(w, http.StatusNotFound, "template_not_found", "The template version was not found.")
return request, instance.Preview{}, false
}
if request.DataOrigin == "import" {
if s.imports == nil || s.imports.ValidateSelection(r.Context(), request.ImportID, request.TemplateID, request.TemplateVersion) != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "A validated compatible import is required.")
return request, instance.Preview{}, false
}
}
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
BackupRetention: request.BackupRetention,
BackupRetention: request.BackupRetention, ImportID: request.ImportID,
})
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
@@ -472,7 +506,7 @@ func (s *server) importCreate(w http.ResponseWriter, r *http.Request) {
}
maximum := int64(snapshot.Template.Imports.MaxExtractedSizeGB) << 30
r.Body = http.MaxBytesReader(w, r.Body, maximum+1)
value, err := s.imports.Stage(r.Context(), actor.ID, format, r.Body, importexport.Policy{AcceptedFormats: snapshot.Template.Imports.AcceptedFormats, MaxExpandedBytes: maximum, RequiredPaths: snapshot.Template.Imports.RequiredPaths})
value, err := s.imports.Stage(r.Context(), actor.ID, format, r.Body, importexport.Policy{TemplateID: templateID, TemplateVersion: version, AcceptedFormats: snapshot.Template.Imports.AcceptedFormats, MaxExpandedBytes: maximum, RequiredPaths: snapshot.Template.Imports.RequiredPaths})
if err != nil {
status, code := http.StatusUnprocessableEntity, "invalid_import"
if errors.Is(err, importexport.ErrLimitExceeded) {
+50 -1
View File
@@ -1,6 +1,7 @@
package web
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
@@ -19,6 +20,7 @@ import (
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
@@ -157,6 +159,10 @@ func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin, err := authService.Authenticate(ctx, adminSession.Token)
if err != nil {
t.Fatal(err)
}
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
@@ -180,7 +186,11 @@ func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithLifecycleAndBackup(authService, repository, webLifecycleAgent{}, backupService, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
importService, err := importexport.New(repository, filepath.Join(root, "imports"), serversRoot)
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithLifecycleAndBackup(authService, repository, webLifecycleAgent{}, backupService, importService, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
@@ -216,6 +226,45 @@ func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
}
policy := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/instances/"+instanceID+"/backup-policy", []byte(`{"enabled":true,"cron_expression":"0 3 * * *","timezone":"Europe/Paris","retention_count":5}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, policy, http.StatusOK)
imported, err := importService.Stage(ctx, admin.ID, "zip", bytes.NewReader(palworldImportZIP(t)), importexport.Policy{TemplateID: snapshots[0].Template.ID, TemplateVersion: snapshots[0].Template.Version, AcceptedFormats: snapshots[0].Template.Imports.AcceptedFormats, MaxExpandedBytes: int64(snapshots[0].Template.Imports.MaxExtractedSizeGB) << 30, RequiredPaths: snapshots[0].Template.Imports.RequiredPaths})
if err != nil {
t.Fatal(err)
}
importMount := filepath.Join(serversRoot, "imported", "saved")
importPreview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Imported API", Slug: "imported-api", HostPorts: map[string]int{"game": 38212}, MountPaths: map[string]string{"saved": importMount}, DataOrigin: "import", ImportID: imported.ID, BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
const importedInstanceID = "imported-api-instance"
if err := repository.CreateDraft(ctx, instance.Draft{ID: importedInstanceID, Preview: importPreview}); err != nil {
t.Fatal(err)
}
installed := jsonRequest(t, handler, "/api/v1/instances/"+importedInstanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, installed, http.StatusOK)
importedWorld := filepath.Join(importMount, "SaveGames", "0", "Level.sav")
if body, err := os.ReadFile(importedWorld); err != nil || string(body) != "imported-world" {
t.Fatalf("imported world=%q error=%v", body, err)
}
}
func palworldImportZIP(t *testing.T) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, body := range map[string]string{"Save/Level.sav": "imported-world", "Save/Players/player.sav": "player"} {
entry, err := writer.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(entry, body); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
func TestInstanceAuthorizationAndInstallationRequestWorkflow(t *testing.T) {
+5 -1
View File
@@ -56,9 +56,12 @@ CREATE TABLE imports (
id TEXT PRIMARY KEY,
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
template_id TEXT NOT NULL,
template_version TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('staging', 'validated', 'attached', 'failed', 'expired')),
format TEXT NOT NULL,
relative_stage_path TEXT NOT NULL,
data_root TEXT,
detected_type TEXT,
confidence TEXT CHECK (confidence IS NULL OR confidence IN ('confirmed', 'probable', 'recognized_unknown_version', 'unrecognized')),
file_count INTEGER NOT NULL DEFAULT 0,
@@ -66,7 +69,8 @@ CREATE TABLE imports (
error_code TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
completed_at TEXT
completed_at TEXT,
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
);
CREATE INDEX imports_expiry_idx ON imports(status, expires_at);