281 lines
11 KiB
Go
281 lines
11 KiB
Go
package instance_test
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
|
)
|
|
|
|
type lifecycleAgent struct {
|
|
mu sync.Mutex
|
|
running bool
|
|
created int
|
|
}
|
|
|
|
type failingStartAgent struct{ lifecycleAgent }
|
|
|
|
type rejectedPlanAgent struct{ lifecycleAgent }
|
|
|
|
func (a *failingStartAgent) StartInstance(context.Context, string) (agentwire.InstanceState, error) {
|
|
return agentwire.InstanceState{}, &agentclient.ProblemError{Status: 502, Code: "start_exited", Details: map[string]any{"state": "exited", "exit_code": 42, "started_at": "2026-01-01T00:00:00Z", "finished_at": "2026-01-01T00:00:01Z", "logs_tail": "", "error": "permission denied password=[REDACTED]"}}
|
|
}
|
|
|
|
func (a *rejectedPlanAgent) CreateInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
|
return agentwire.InstanceState{}, &agentclient.ProblemError{
|
|
Status: 422,
|
|
Code: "invalid_plan",
|
|
Message: "The deployment plan is invalid.",
|
|
Details: map[string]any{
|
|
"phase": "preparation",
|
|
"template_id": "palworld-official",
|
|
"template_version": "1.1.1",
|
|
"template_digest": "digest-not-secret",
|
|
"operation_error": "template snapshot not registered: palworld-official@1.1.1",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (a *lifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.created++
|
|
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-1", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
|
|
}
|
|
func (a *lifecycleAgent) InspectInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Running: a.running, Ready: a.running, Health: map[bool]string{true: "healthy", false: "stopped"}[a.running]}, nil
|
|
}
|
|
func (a *lifecycleAgent) StartInstance(ctx context.Context, id string) (agentwire.InstanceState, error) {
|
|
a.mu.Lock()
|
|
a.running = true
|
|
a.mu.Unlock()
|
|
return a.InspectInstance(ctx, id)
|
|
}
|
|
func (a *lifecycleAgent) StopInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
|
|
a.mu.Lock()
|
|
a.running = false
|
|
a.mu.Unlock()
|
|
return a.InspectInstance(ctx, id)
|
|
}
|
|
func (a *lifecycleAgent) RestartInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
|
|
return a.StartInstance(ctx, id)
|
|
}
|
|
func (a *lifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
|
|
func (a *lifecycleAgent) ReplaceInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
|
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-2", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
|
|
}
|
|
func (a *lifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
|
|
return agentwire.InstanceStats{InstanceID: id, MemoryBytes: 42}, nil
|
|
}
|
|
|
|
func TestUpdatePreviewRequiresDigestAndWarnsAboutMods(t *testing.T) {
|
|
current := instance.StoredInstance{Preview: instance.Preview{
|
|
Image: "registry.example/game:old",
|
|
TemplateDefaultTag: "old",
|
|
Mods: instance.ModsConfiguration{Items: []string{"123"}},
|
|
UpdatePolicy: instance.UpdatePolicy{BackupBeforeUpdate: true, RollbackOnFailure: true},
|
|
}}
|
|
if _, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "latest"}); err == nil {
|
|
t.Fatal("mutable tag without digest was accepted")
|
|
}
|
|
value, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "sha256:" + strings.Repeat("a", 64)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !value.BackupRequired || !value.ModWarning || !value.RollbackOnFailure || !strings.Contains(value.CandidateReference, "@sha256:") {
|
|
t.Fatalf("preview = %#v", value)
|
|
}
|
|
}
|
|
|
|
func TestCheckUpdateUsesOnlyApprovedImmutableCandidate(t *testing.T) {
|
|
current := instance.StoredInstance{Preview: instance.Preview{Image: "registry.example/game:old", TemplateDefaultTag: "old", UpdatePolicy: instance.UpdatePolicy{CandidateTag: "new", CandidateDigest: "sha256:" + strings.Repeat("b", 64)}}}
|
|
status, request := instance.CheckUpdate(current)
|
|
if status != instance.UpdateAvailable || request.CandidateTag != "new" {
|
|
t.Fatalf("status=%q request=%#v", status, request)
|
|
}
|
|
current.Preview.Image = "registry.example/game:new@" + request.CandidateDigest
|
|
status, _ = instance.CheckUpdate(current)
|
|
if status != instance.UpdateCurrent {
|
|
t.Fatalf("status=%q, want current", status)
|
|
}
|
|
current.Preview.UpdatePolicy.CandidateDigest = ""
|
|
status, _ = instance.CheckUpdate(current)
|
|
if status != instance.UpdateFailed {
|
|
t.Fatalf("status=%q, want failed", status)
|
|
}
|
|
current.Preview.UpdatePolicy.CandidateTag = ""
|
|
status, _ = instance.CheckUpdate(current)
|
|
if status != instance.UpdateUnknown {
|
|
t.Fatalf("status=%q, want unknown", status)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleInstallStartStopAndSafeContainerDeletion(t *testing.T) {
|
|
ctx := context.Background()
|
|
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = 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)
|
|
}
|
|
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
|
|
DisplayName: "Family Palworld", Slug: "family-palworld", HostPorts: map[string]int{"game": 38211},
|
|
MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repository.CreateDraft(ctx, instance.Draft{ID: "abcdefghijklmnopqrstuvwx", Preview: preview}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agent := &lifecycleAgent{}
|
|
service := instance.NewLifecycleService(repository, agent)
|
|
installed, err := service.Install(ctx, "abcdefghijklmnopqrstuvwx")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if installed.State != "stopped" || installed.ContainerID == "" {
|
|
t.Fatalf("installed = %#v", installed)
|
|
}
|
|
if _, err := service.Install(ctx, "abcdefghijklmnopqrstuvwx"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if agent.created != 1 {
|
|
t.Fatalf("idempotent install created %d containers", agent.created)
|
|
}
|
|
started, err := service.Start(ctx, "abcdefghijklmnopqrstuvwx")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if started.State != "online" || started.Observed != "ready" {
|
|
t.Fatalf("started = %#v", started)
|
|
}
|
|
stopped, err := service.Stop(ctx, "abcdefghijklmnopqrstuvwx")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stopped.State != "stopped" {
|
|
t.Fatalf("stopped = %#v", stopped)
|
|
}
|
|
deleted, err := service.DeleteContainer(ctx, "abcdefghijklmnopqrstuvwx")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted.State != "unknown" || deleted.Observed != "missing" {
|
|
t.Fatalf("deleted = %#v", deleted)
|
|
}
|
|
var mounts string
|
|
if err := db.QueryRow("SELECT preview_json FROM instances WHERE id=?", "abcdefghijklmnopqrstuvwx").Scan(&mounts); err != nil || mounts == "" {
|
|
t.Fatalf("instance intent/player paths were removed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestManualStartPersistsAgentDiagnosticAndStableCode(t *testing.T) {
|
|
ctx := context.Background()
|
|
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
|
if err != nil {
|
|
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)
|
|
}
|
|
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Failure", Slug: "failure", HostPorts: map[string]int{"game": 38212}, MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := "abcdefghijklmnopqrstuvwx"
|
|
if err := repository.CreateDraft(ctx, instance.Draft{ID: id, Preview: preview}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agent := &failingStartAgent{}
|
|
service := instance.NewLifecycleService(repository, agent)
|
|
if _, err := service.Install(ctx, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, startErr := service.Start(ctx, id)
|
|
if startErr == nil || !strings.Contains(startErr.Error(), "DGM-START-001") || result.OperationID == "" {
|
|
t.Fatalf("result=%#v err=%v", result, startErr)
|
|
}
|
|
diagnostics, err := repository.ListDiagnostics(ctx, id, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(diagnostics) != 1 || diagnostics[0].OperationID != result.OperationID || diagnostics[0].ErrorCode != "DGM-START-001" {
|
|
t.Fatalf("diagnostics=%#v", diagnostics)
|
|
}
|
|
if !strings.Contains(diagnostics[0].Details, `"exit_code":42`) || strings.Contains(diagnostics[0].Details, "password=") && !strings.Contains(diagnostics[0].Details, "[REDACTED]") {
|
|
t.Fatalf("details=%s", diagnostics[0].Details)
|
|
}
|
|
if strings.Contains(startErr.Error(), "The instance operation could not be completed") {
|
|
t.Fatalf("original cause was lost: %v", startErr)
|
|
}
|
|
}
|
|
|
|
func TestInstallPlanRejectedPersistsDiagnostic(t *testing.T) {
|
|
ctx := context.Background()
|
|
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
|
if err != nil {
|
|
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)
|
|
}
|
|
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Rejected", Slug: "rejected", HostPorts: map[string]int{"game": 38213}, MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const id = "abcdefghijklmnopqrstuvwx"
|
|
if err := repository.CreateDraft(ctx, instance.Draft{ID: id, Preview: preview}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, installErr := instance.NewLifecycleService(repository, &rejectedPlanAgent{}).Install(ctx, id)
|
|
if installErr == nil || !strings.Contains(installErr.Error(), "DGM-DEPLOY-001") || result.OperationID == "" {
|
|
t.Fatalf("result=%#v err=%v", result, installErr)
|
|
}
|
|
diagnostics, err := repository.ListDiagnostics(ctx, id, 10)
|
|
if err != nil || len(diagnostics) != 1 {
|
|
t.Fatalf("diagnostics=%#v err=%v", diagnostics, err)
|
|
}
|
|
diagnostic := diagnostics[0]
|
|
if diagnostic.OperationID != result.OperationID || diagnostic.ErrorCode != "DGM-DEPLOY-001" || diagnostic.Details == "" {
|
|
t.Fatalf("diagnostic=%#v", diagnostic)
|
|
}
|
|
for _, required := range []string{`"operation_id":"` + result.OperationID + `"`, `"phase":"preparation"`, `"agent_code":"invalid_plan"`, `"template_version":"1.1.1"`, `template snapshot not registered`} {
|
|
if !strings.Contains(diagnostic.Details, required) {
|
|
t.Fatalf("diagnostic details missing %q: %s", required, diagnostic.Details)
|
|
}
|
|
}
|
|
if strings.Contains(diagnostic.Details, "password=") || strings.Contains(diagnostic.Details, "secret=") {
|
|
t.Fatalf("diagnostic leaked a secret: %s", diagnostic.Details)
|
|
}
|
|
}
|