diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 10b7e383..a69c8ac9 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -61,6 +61,14 @@ func Bootstrap(ctx context.Context) error { return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err) } + // Close file storage after every service that depends on it has stopped + defer func() { + closeErr := fileStorage.Close() + if closeErr != nil { + slog.ErrorContext(ctx, "Failed to close file storage", slog.Any("error", closeErr)) + } + }() + // Init application images imageExtensions, err := initApplicationImages(ctx, fileStorage) if err != nil { diff --git a/backend/internal/cmds/export.go b/backend/internal/cmds/export.go index 880670a0..853f7757 100644 --- a/backend/internal/cmds/export.go +++ b/backend/internal/cmds/export.go @@ -43,6 +43,11 @@ func runExport(ctx context.Context, flags exportFlags) error { return fmt.Errorf("failed to initialize storage: %w", err) } + // Close filesystem storage handles before the command exits + defer func() { + _ = storage.Close() + }() + exportService := service.NewExportService(db, storage) var w io.Writer diff --git a/backend/internal/cmds/import.go b/backend/internal/cmds/import.go index 3a13d9e4..40273701 100644 --- a/backend/internal/cmds/import.go +++ b/backend/internal/cmds/import.go @@ -88,6 +88,11 @@ func runImport(ctx context.Context, flags importFlags) error { return fmt.Errorf("failed to initialize storage: %w", err) } + // Close filesystem storage handles before the command exits + defer func() { + _ = storage.Close() + }() + importService := service.NewImportService(db, storage) err = importService.ImportFromZip(ctx, &zipReader.Reader) if err != nil { diff --git a/backend/internal/service/app_images_service_test.go b/backend/internal/service/app_images_service_test.go index bca7b4b6..3264b538 100644 --- a/backend/internal/service/app_images_service_test.go +++ b/backend/internal/service/app_images_service_test.go @@ -20,8 +20,7 @@ import ( ) func TestAppImagesService_GetImage(t *testing.T) { - store, err := storage.NewFilesystemStorage(t.TempDir()) - require.NoError(t, err) + store := newFilesystemStorageForTest(t) require.NoError(t, store.Save(context.Background(), path.Join("application-images", "background.webp"), bytes.NewReader([]byte("data")))) @@ -38,8 +37,7 @@ func TestAppImagesService_GetImage(t *testing.T) { } func TestAppImagesService_UpdateImage(t *testing.T) { - store, err := storage.NewFilesystemStorage(t.TempDir()) - require.NoError(t, err) + store := newFilesystemStorageForTest(t) require.NoError(t, store.Save(context.Background(), path.Join("application-images", "logoLight.svg"), bytes.NewReader([]byte("old")))) @@ -58,8 +56,7 @@ func TestAppImagesService_UpdateImage(t *testing.T) { } func TestAppImagesService_UpdateImageStripsMetadata(t *testing.T) { - store, err := storage.NewFilesystemStorage(t.TempDir()) - require.NoError(t, err) + store := newFilesystemStorageForTest(t) service := NewAppImagesService(map[string]string{}, store) @@ -81,8 +78,7 @@ func TestAppImagesService_UpdateImageStripsMetadata(t *testing.T) { } func TestAppImagesService_ErrorsAndFlags(t *testing.T) { - store, err := storage.NewFilesystemStorage(t.TempDir()) - require.NoError(t, err) + store := newFilesystemStorageForTest(t) service := NewAppImagesService(map[string]string{}, store) @@ -114,6 +110,17 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) { }) } +func newFilesystemStorageForTest(t *testing.T) storage.FileStorage { + t.Helper() + + store, err := storage.NewFilesystemStorage(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, store.Close()) + }) + return store +} + func newFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader { t.Helper() diff --git a/backend/internal/storage/database.go b/backend/internal/storage/database.go index 2c8779dc..d0c12ab8 100644 --- a/backend/internal/storage/database.go +++ b/backend/internal/storage/database.go @@ -35,6 +35,10 @@ func (s *databaseStorage) Type() string { return TypeDatabase } +func (s *databaseStorage) Close() error { + return nil +} + func (s *databaseStorage) Save(ctx context.Context, relativePath string, data io.Reader) error { // Normalize the path relativePath = filepath.ToSlash(filepath.Clean(relativePath)) diff --git a/backend/internal/storage/filesystem.go b/backend/internal/storage/filesystem.go index 7be93766..f9f3f2fc 100644 --- a/backend/internal/storage/filesystem.go +++ b/backend/internal/storage/filesystem.go @@ -39,6 +39,10 @@ func (s *filesystemStorage) Type() string { return TypeFileSystem } +func (s *filesystemStorage) Close() error { + return s.root.Close() +} + func (s *filesystemStorage) Save(_ context.Context, path string, data io.Reader) error { path = filepath.FromSlash(path) diff --git a/backend/internal/storage/filesystem_test.go b/backend/internal/storage/filesystem_test.go index 95b5ed8c..6abb080e 100644 --- a/backend/internal/storage/filesystem_test.go +++ b/backend/internal/storage/filesystem_test.go @@ -15,6 +15,9 @@ func TestFilesystemStorageOperations(t *testing.T) { ctx := context.Background() store, err := NewFilesystemStorage(t.TempDir()) require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, store.Close()) + }) t.Run("save, open and list files", func(t *testing.T) { err := store.Save(ctx, "images/logo.png", bytes.NewBufferString("logo-data")) diff --git a/backend/internal/storage/s3.go b/backend/internal/storage/s3.go index 99eafb57..a891e146 100644 --- a/backend/internal/storage/s3.go +++ b/backend/internal/storage/s3.go @@ -62,6 +62,10 @@ func (s *s3Storage) Type() string { return TypeS3 } +func (s *s3Storage) Close() error { + return nil +} + func (s *s3Storage) Save(ctx context.Context, path string, data io.Reader) error { _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index 64854932..28085deb 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -19,6 +19,7 @@ type ObjectInfo struct { } type FileStorage interface { + Close() error Save(ctx context.Context, relativePath string, data io.Reader) error Open(ctx context.Context, relativePath string) (io.ReadCloser, int64, error) Delete(ctx context.Context, relativePath string) error diff --git a/backend/internal/utils/testing/database.go b/backend/internal/utils/testing/database.go index 02b2bc2e..804effa1 100644 --- a/backend/internal/utils/testing/database.go +++ b/backend/internal/utils/testing/database.go @@ -76,6 +76,11 @@ func openInMemoryTestDB(t *testing.T) *gorm.DB { sqlDB, err := db.DB() require.NoError(t, err, "Failed to get sql.DB") + // Close the connection before the test removes any database resources + t.Cleanup(func() { + require.NoError(t, sqlDB.Close(), "Failed to close test database") + }) + // For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data // The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once sqlDB.SetMaxOpenConns(1) @@ -94,6 +99,14 @@ func openFileTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(connString), newTestGormConfig(t)) require.NoError(t, err, "Failed to connect to test database") + sqlDB, err := db.DB() + require.NoError(t, err, "Failed to get sql.DB") + + // Close the connection before TempDir removes the database file + t.Cleanup(func() { + require.NoError(t, sqlDB.Close(), "Failed to close test database") + }) + return db }