Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion backend/cmd/installer/processor/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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:
Expand Down
133 changes: 133 additions & 0 deletions backend/cmd/installer/processor/compose_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}