diff --git a/internal/agent/agent_server_test.go b/internal/agent/agent_server_test.go index e8849f8..a2c0ad5 100644 --- a/internal/agent/agent_server_test.go +++ b/internal/agent/agent_server_test.go @@ -6,11 +6,13 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "time" @@ -24,6 +26,7 @@ import ( type fakeDocker struct { err error inspection agent.DockerInspection + logs string } type fakeDisk struct{} @@ -53,7 +56,7 @@ func (d fakeDocker) Inspect(_ context.Context, id string) (agent.DockerInspectio } return inspection, nil } -func (d fakeDocker) Logs(context.Context, string, int) (string, error) { return "", d.err } +func (d fakeDocker) Logs(context.Context, string, int) (string, error) { return d.logs, d.err } func (d fakeDocker) Stats(context.Context, string) (agentwire.InstanceStats, error) { return agentwire.InstanceStats{MemoryBytes: 42}, d.err } @@ -162,6 +165,39 @@ func TestAgentCreatesOnlyValidatedBoundInstances(t *testing.T) { } } +func TestAgentStartExitReturnsDockerDiagnosticWithoutLogs(t *testing.T) { + root := t.TempDir() + secret := bytes.Repeat([]byte{0x31}, 32) + instanceID := "abcdefghijklmnopqrstuvwx" + plan := testPlan(t, instanceID, root) + docker := fakeDocker{inspection: agent.DockerInspection{ + ContainerID: "container-" + instanceID, Status: "exited", ExitCode: 23, + StartedAt: "2026-01-01T00:00:00Z", FinishedAt: "2026-01-01T00:00:01Z", + OOMKilled: true, Health: "unhealthy", Error: "permission denied password=never-leak", + Labels: map[string]string{"io.dogama.managed": "true", "io.dogama.instance-id": instanceID, "io.dogama.plan-digest": plan.PlanDigest}, + }} + server := httptest.NewServer(newTestHandler(t, root, secret, docker)) + t.Cleanup(server.Close) + client, err := agentclient.New(server.URL, secret, server.Client()) + if err != nil { + t.Fatal(err) + } + if _, err := client.CreateInstance(context.Background(), plan); err != nil { + t.Fatal(err) + } + _, err = client.StartInstance(context.Background(), instanceID) + var problem *agentclient.ProblemError + if !errors.As(err, &problem) || problem.Code != "start_exited" { + t.Fatalf("start error = %#v", err) + } + if problem.Details["exit_code"] != float64(23) || problem.Details["oom_killed"] != true || problem.Details["logs_tail"] != "" { + t.Fatalf("diagnostic = %#v", problem.Details) + } + if strings.Contains(fmt.Sprint(problem.Details), "never-leak") { + t.Fatalf("secret leaked in diagnostic: %#v", problem.Details) + } +} + func TestAgentRejectsPlanSubstitutionAndEscapingMount(t *testing.T) { root := t.TempDir() secret := bytes.Repeat([]byte{0x31}, 32) diff --git a/internal/instance/lifecycle_test.go b/internal/instance/lifecycle_test.go index 7d06847..93a5559 100644 --- a/internal/instance/lifecycle_test.go +++ b/internal/instance/lifecycle_test.go @@ -8,6 +8,7 @@ import ( "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" @@ -20,6 +21,12 @@ type lifecycleAgent struct { created int } +type failingStartAgent 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 *lifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) { a.mu.Lock() defer a.mu.Unlock() @@ -162,3 +169,50 @@ func TestLifecycleInstallStartStopAndSafeContainerDeletion(t *testing.T) { 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) + } +} diff --git a/tests/e2e/bootstrap_test.go b/tests/e2e/bootstrap_test.go index 1dfb8b2..23efc6e 100644 --- a/tests/e2e/bootstrap_test.go +++ b/tests/e2e/bootstrap_test.go @@ -40,6 +40,7 @@ func TestV1BootstrapAuthenticationAndHTTPBoundary(t *testing.T) { "DOGAMA_LISTEN_ADDRESS="+address, "DOGAMA_DATABASE_PATH="+filepath.Join(t.TempDir(), "dogama.db"), "DOGAMA_MASTER_KEY_FILE="+filepath.Join(t.TempDir(), "master_key"), + "DOGAMA_TEMPLATES_ROOT="+filepath.Join(t.TempDir(), "templates"), "DOGAMA_IMPORTS_ROOT="+filepath.Join(t.TempDir(), "imports"), "DOGAMA_SERVERS_ROOT="+filepath.Join(t.TempDir(), "servers"), )