Files
DoGaMa-serv/internal/instance/lifecycle_test.go
T
codex b575b5260d
CI / validate (pull_request) Failing after 8m3s
fix(deploy): start current Palworld template
2026-08-15 21:43:48 +02:00

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)
}
}