feat: apply validated imports during creation
This commit is contained in:
+3
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user