From 6d1c055b750253f8a3c3940625109b97b2f4150c Mon Sep 17 00:00:00 2001 From: Priyanka Singh Date: Sat, 25 Jul 2026 04:25:49 +0530 Subject: [PATCH] Fix: Handle missing Graphiti compose file gracefully during restart --- backend/cmd/installer/processor/compose.go | 18 ++- .../cmd/installer/processor/compose_test.go | 133 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 backend/cmd/installer/processor/compose_test.go diff --git a/backend/cmd/installer/processor/compose.go b/backend/cmd/installer/processor/compose.go index 372137ad4..c8f9a2e59 100644 --- a/backend/cmd/installer/processor/compose.go +++ b/backend/cmd/installer/processor/compose.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" ) @@ -102,7 +103,13 @@ func (c *composeOperationsImpl) performStackOperation( if !c.composeFileExists(stack) { return nil } - return c.wrapPerformStackCommand(ctx, stack, state, operation, args...) + // execute the operation, but if it fails due to missing compose file, treat as success + // (this handles race conditions or path calculation issues) + err := c.wrapPerformStackCommand(ctx, stack, state, operation, args...) + if err != nil && c.isComposeMissingError(err) { + return nil + } + return err // for non-destructive operations (start/update/download) honor embedded mode only default: if c.processor.isEmbeddedDeployment(stack) { @@ -195,6 +202,15 @@ func (c *composeOperationsImpl) composeFileExists(stack ProductStack) bool { return c.processor.isFileExists(composePath) == nil } +// isComposeMissingError checks if an error is due to a missing compose file +func (c *composeOperationsImpl) isComposeMissingError(err error) bool { + if err == nil { + return false + } + errMsg := err.Error() + return strings.Contains(errMsg, "does not exist") || strings.Contains(errMsg, "no such file") +} + func (c *composeOperationsImpl) determineComposeFile(stack ProductStack) (string, error) { switch stack { case ProductStackPentagi: diff --git a/backend/cmd/installer/processor/compose_test.go b/backend/cmd/installer/processor/compose_test.go new file mode 100644 index 000000000..1405ede04 --- /dev/null +++ b/backend/cmd/installer/processor/compose_test.go @@ -0,0 +1,133 @@ +package processor + +import ( + "context" + "os" + "path/filepath" + "testing" + + "pentagi/cmd/installer/state" +) + +// TestRestartWithMissingGraphitiFile tests that restart operation handles +// missing graphiti compose file gracefully when GRAPHITI_ENABLED=false +func TestRestartWithMissingGraphitiFile(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + // Create .env file with GRAPHITI_ENABLED=false + envContent := `GRAPHITI_ENABLED=false +GRAPHITI_URL=http://localhost:8000` + err := os.WriteFile(envPath, []byte(envContent), 0644) + if err != nil { + t.Fatalf("Failed to create test env file: %v", err) + } + + // Create main docker-compose.yml (pentagi stack) + composeContent := `version: "3.8" +services: + pentagi: + image: pentagi:latest` + composePath := filepath.Join(tmpDir, "docker-compose.yml") + err = os.WriteFile(composePath, []byte(composeContent), 0644) + if err != nil { + t.Fatalf("Failed to create test compose file: %v", err) + } + + // NOTE: We intentionally DO NOT create docker-compose-graphiti.yml + // to simulate the bug scenario + + // Initialize state + appState, err := state.New(envPath) + if err != nil { + t.Fatalf("Failed to initialize state: %v", err) + } + + // Create processor (this would normally be done through proper initialization) + // For this test, we'll just verify the helper function works + proc := &processor{ + state: appState, + } + + composeOps := newComposeOperations(proc).(*composeOperationsImpl) + + // Test 1: composeFileExists should return false for missing graphiti file + if composeOps.composeFileExists(ProductStackGraphiti) { + t.Error("composeFileExists should return false for missing graphiti file") + } + + // Test 2: isComposeMissingError should detect missing file errors + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "file does not exist error", + err: os.ErrNotExist, + expected: true, + }, + { + name: "custom does not exist error", + err: &os.PathError{Op: "open", Path: "/path/to/file", Err: os.ErrNotExist}, + expected: true, + }, + { + name: "other error", + err: os.ErrPermission, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := composeOps.isComposeMissingError(tt.err) + if result != tt.expected { + t.Errorf("isComposeMissingError(%v) = %v, expected %v", + tt.err, result, tt.expected) + } + }) + } +} + +// TestPerformStackOperationSkipsMissingFiles tests that stop operations +// gracefully skip stacks with missing compose files +func TestPerformStackOperationSkipsMissingFiles(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + // Create .env file + envContent := `GRAPHITI_ENABLED=false` + err := os.WriteFile(envPath, []byte(envContent), 0644) + if err != nil { + t.Fatalf("Failed to create test env file: %v", err) + } + + // Initialize state + appState, err := state.New(envPath) + if err != nil { + t.Fatalf("Failed to initialize state: %v", err) + } + + proc := &processor{ + state: appState, + } + + composeOps := newComposeOperations(proc).(*composeOperationsImpl) + + // Test that performStackOperation returns nil (skips) for missing file + // This simulates the restart operation flow + ctx := context.Background() + opState := &operationState{} + + // This should not return an error because the file doesn't exist + err = composeOps.performStackOperation(ctx, ProductStackGraphiti, opState, ProcessorOperationStop, "stop") + if err != nil { + t.Errorf("performStackOperation should skip missing graphiti file, but got error: %v", err) + } +}