diff --git a/backend/app/app.go b/backend/app/app.go index 3c8fe42c..28b4cd74 100644 --- a/backend/app/app.go +++ b/backend/app/app.go @@ -162,6 +162,17 @@ func NewApp(ctx context.Context, config internal.Configuration) (*App, error) { return nil, fmt.Errorf("failed to create system identity: %w", err) } + // Initialize file storage service + fileStorage, err := internal.NewFileStorageService(config.FileStoragePath) + if err != nil { + return nil, fmt.Errorf("failed to init file storage service: %w", err) + } + + // Migrate file data from database to file storage + if err := models.MigrateFileDataToStorage(db, fileStorage); err != nil { + logger.GetLogger().Warn().Err(err).Msg("Failed to migrate file data to storage (continuing anyway)") + } + sshPublicKeyBytes, err := os.ReadFile(config.SSH.PublicKeyPath) if err != nil { return nil, fmt.Errorf("failed to read SSH public key from %s: %w", config.SSH.PublicKeyPath, err) @@ -209,7 +220,7 @@ func NewApp(ctx context.Context, config internal.Configuration) (*App, error) { handler := NewHandler(tokenHandler, db, config, mailService, gridProxy, substrateClient, graphqlClient, firesquidClient, sseManager, ewfEngine, config.SystemAccount.Network, sshPublicKey, - systemIdentity, kycClient, sponsorKeyPair, sponsorAddress, metrics, notificationService, gridClient, appCtx) + systemIdentity, kycClient, sponsorKeyPair, sponsorAddress, metrics, notificationService, gridClient, fileStorage, appCtx) app := &App{ router: router, @@ -236,6 +247,7 @@ func NewApp(ctx context.Context, config internal.Configuration) (*App, error) { app.metrics, app.notificationService, gridProxy, + fileStorage, ) app.registerHandlers() diff --git a/backend/app/deployment_handler.go b/backend/app/deployment_handler.go index ca53a283..f898bd97 100644 --- a/backend/app/deployment_handler.go +++ b/backend/app/deployment_handler.go @@ -210,8 +210,8 @@ func (h *Handler) HandleGetKubeconfig(c *gin.Context) { return } - if cluster.Kubeconfig != "" { - c.JSON(http.StatusOK, gin.H{"kubeconfig": cluster.Kubeconfig}) + if data, err := h.fileStorage.ReadKubeconfigFile(userID, cluster.ID, projectName); err == nil { + c.JSON(http.StatusOK, gin.H{"kubeconfig": string(data)}) return } @@ -236,9 +236,8 @@ func (h *Handler) HandleGetKubeconfig(c *gin.Context) { return } - cluster.Kubeconfig = kubeconfig - if err := h.db.UpdateCluster(&cluster); err != nil { - reqLog.Error().Err(err).Int("cluster_id", cluster.ID).Msg("Failed to save kubeconfig to database") + if _, err := h.fileStorage.WriteKubeconfigFile(userID, cluster.ID, projectName, []byte(kubeconfig)); err != nil { + reqLog.Error().Err(err).Int("cluster_id", cluster.ID).Msg("Failed to persist kubeconfig to file storage") } c.JSON(http.StatusOK, gin.H{"kubeconfig": kubeconfig}) diff --git a/backend/app/invoice_handler.go b/backend/app/invoice_handler.go index 1b536f75..b84ddb56 100644 --- a/backend/app/invoice_handler.go +++ b/backend/app/invoice_handler.go @@ -7,6 +7,7 @@ import ( "kubecloud/internal" "kubecloud/models" "net/http" + "os" "strconv" "time" @@ -157,38 +158,41 @@ func (h *Handler) DownloadInvoiceHandler(c *gin.Context) { return } - // Creating pdf for invoice if it doesn't have it - if len(invoice.FileData) == 0 { - user, err := h.db.GetUserByID(userID) - if err != nil { - reqLog.Error().Err(err).Msg("failed to retrieve user") - InternalServerError(c) - return - } - - pdfContent, err := internal.CreateInvoicePDF(invoice, user, h.config.Invoice) - if err != nil { - reqLog.Error().Err(err).Msg("failed to create invoice PDF") - InternalServerError(c) - return - } + if userID != invoice.UserID { + Error(c, http.StatusForbidden, "User is not authorized to download this invoice", "") + return + } - invoice.FileData = pdfContent - if err := h.db.UpdateInvoicePDF(id, invoice.FileData); err != nil { - reqLog.Error().Err(err).Msg("failed to update invoice PDF") + data, err := h.fileStorage.ReadInvoiceFile(userID, invoice.ID) + if err != nil { + if os.IsNotExist(err) { + // Generate on-demand and persist, then read again + user, uErr := h.db.GetUserByID(userID) + if uErr != nil { + logger.GetLogger().Error().Err(uErr).Send() + InternalServerError(c) + return + } + pdfContent, gErr := internal.CreateInvoicePDF(invoice, user, h.config.Invoice) + if gErr != nil { + logger.GetLogger().Error().Err(gErr).Send() + InternalServerError(c) + return + } + if _, wErr := h.fileStorage.WriteInvoiceFile(userID, invoice.ID, pdfContent); wErr != nil { + logger.GetLogger().Error().Err(wErr).Msg("failed to write invoice pdf to storage") + } + data = pdfContent + } else { + logger.GetLogger().Error().Err(err).Msg("failed to read invoice pdf from storage") InternalServerError(c) return } } - - if userID != invoice.UserID { - Error(c, http.StatusNotFound, "User is not authorized to download this invoice", "") - return - } - - c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fmt.Sprintf("invoice-%d-%d.pdf", invoice.UserID, invoice.ID))) + fileName := h.fileStorage.InvoiceFileName(userID, invoice.ID) + c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) c.Writer.Header().Set("Content-Type", "application/pdf") - c.Data(http.StatusOK, "application/pdf", invoice.FileData) + c.Data(http.StatusOK, "application/pdf", data) } @@ -262,16 +266,23 @@ func (h *Handler) createUserInvoice(user models.User) error { if err != nil { return err } - - invoice.FileData = file if err = h.db.CreateInvoice(&invoice); err != nil { return err } + if _, err := h.fileStorage.WriteInvoiceFile(user.ID, invoice.ID, file); err != nil { + return err + } + subject, body := mailservice.InvoiceMailContent(totalInvoiceCostUSD, h.config.Currency, invoice.ID) + // read back for attachment + data, err := h.fileStorage.ReadInvoiceFile(user.ID, invoice.ID) + if err != nil { + return err + } err = h.mailService.SendMail(h.config.MailSender.Email, user.Email, subject, body, mailservice.Attachment{ - FileName: fmt.Sprintf("invoice-%d-%d.pdf", invoice.UserID, invoice.ID), - Data: invoice.FileData, + FileName: h.fileStorage.InvoiceFileName(user.ID, invoice.ID), + Data: data, }) if err != nil { return err diff --git a/backend/app/invoice_handler_test.go b/backend/app/invoice_handler_test.go index c0771a06..3184b738 100644 --- a/backend/app/invoice_handler_test.go +++ b/backend/app/invoice_handler_test.go @@ -3,15 +3,17 @@ package app import ( "encoding/json" "fmt" + "kubecloud/internal" + "kubecloud/models" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "kubecloud/models" ) func TestListAllInvoicesHandler(t *testing.T) { @@ -19,8 +21,10 @@ func TestListAllInvoicesHandler(t *testing.T) { require.NoError(t, err) router := app.router - adminUser := CreateTestUser(t, app, "admin@example.com", "Admin User", []byte("securepassword"), true, true, false, 0, time.Now()) - nonAdminUser := CreateTestUser(t, app, "user@example.com", "Normal User", []byte("securepassword"), true, false, false, 0, time.Now()) + require.NotNil(t, app.handlers.fileStorage) + + adminUser := CreateTestUser(t, app, "admin@example.com", "Admin User", []byte("securepassword"), true, true, true, 0, time.Now()) + nonAdminUser := CreateTestUser(t, app, "user@example.com", "Normal User", []byte("securepassword"), true, false, true, 0, time.Now()) t.Run("Test List all invoices with empty list", func(t *testing.T) { token := GetAuthToken(t, app, adminUser.ID, adminUser.Email, adminUser.Username, true) @@ -62,6 +66,16 @@ func TestListAllInvoicesHandler(t *testing.T) { err = app.handlers.db.CreateInvoice(invoice2) require.NoError(t, err) + pdf1, err := internal.CreateInvoicePDF(*invoice1, *adminUser, app.config.Invoice) + require.NoError(t, err) + pdf2, err := internal.CreateInvoicePDF(*invoice2, *nonAdminUser, app.config.Invoice) + require.NoError(t, err) + + _, err = app.handlers.fileStorage.WriteInvoiceFile(adminUser.ID, invoice1.ID, pdf1) + require.NoError(t, err) + _, err = app.handlers.fileStorage.WriteInvoiceFile(nonAdminUser.ID, invoice2.ID, pdf2) + require.NoError(t, err) + t.Run("Test List all invoices successfully", func(t *testing.T) { token := GetAuthToken(t, app, adminUser.ID, adminUser.Email, adminUser.Username, true) req, _ := http.NewRequest("GET", "/api/v1/invoices", nil) @@ -95,6 +109,18 @@ func TestListAllInvoicesHandler(t *testing.T) { } assert.True(t, found1, "Admin's invoice should be in the list") assert.True(t, found2, "Normal user's invoice should be in the list") + + storedPDF1, err := app.handlers.fileStorage.ReadInvoiceFile(adminUser.ID, invoice1.ID) + require.NoError(t, err) + assert.Equal(t, pdf1, storedPDF1) + assert.Greater(t, len(storedPDF1), 100) + + storedPDF2, err := app.handlers.fileStorage.ReadInvoiceFile(nonAdminUser.ID, invoice2.ID) + require.NoError(t, err) + assert.Equal(t, pdf2, storedPDF2) + assert.Greater(t, len(storedPDF2), 100) + + assert.NotEqual(t, storedPDF1, storedPDF2) }) t.Run("Test List all invoices with no token", func(t *testing.T) { @@ -119,7 +145,7 @@ func TestListUserInvoicesHandler(t *testing.T) { require.NoError(t, err) router := app.router - user := CreateTestUser(t, app, "user@example.com", "Test User", []byte("securepassword"), true, false, false, 0, time.Now()) + user := CreateTestUser(t, app, "user@example.com", "Test User", []byte("securepassword"), true, false, true, 0, time.Now()) t.Run("Test List user invoices with empty list", func(t *testing.T) { token := GetAuthToken(t, app, user.ID, user.Email, user.Username, false) @@ -153,6 +179,13 @@ func TestListUserInvoicesHandler(t *testing.T) { err = app.handlers.db.CreateInvoice(invoice1) require.NoError(t, err) + pdfContent, err := internal.CreateInvoicePDF(*invoice1, *user, app.config.Invoice) + require.NoError(t, err) + require.NotEmpty(t, pdfContent) + + _, err = app.handlers.fileStorage.WriteInvoiceFile(user.ID, invoice1.ID, pdfContent) + require.NoError(t, err) + t.Run("Test List user invoices successfully", func(t *testing.T) { token := GetAuthToken(t, app, user.ID, user.Email, user.Username, false) req, _ := http.NewRequest("GET", "/api/v1/user/invoice", nil) @@ -164,6 +197,15 @@ func TestListUserInvoicesHandler(t *testing.T) { err := json.Unmarshal(resp.Body.Bytes(), &result) assert.NoError(t, err) assert.Equal(t, "Invoices are retrieved successfully", result["message"]) + + data := result["data"].(map[string]interface{}) + invoicesRaw := data["invoices"].([]interface{}) + assert.Len(t, invoicesRaw, 1) + + storedPDF, err := app.handlers.fileStorage.ReadInvoiceFile(user.ID, invoice1.ID) + require.NoError(t, err) + assert.Equal(t, pdfContent, storedPDF) + assert.Greater(t, len(storedPDF), 100) }) t.Run("Test List user invoices with no token", func(t *testing.T) { @@ -180,6 +222,8 @@ func TestDownloadInvoiceHandler(t *testing.T) { require.NoError(t, err) router := app.router + require.NotNil(t, app.handlers.fileStorage) + user1 := CreateTestUser(t, app, "user1@example.com", "User One", []byte("securepassword"), true, false, false, 0, time.Now()) invoice := &models.Invoice{ @@ -193,14 +237,44 @@ func TestDownloadInvoiceHandler(t *testing.T) { require.NoError(t, err) t.Run("Download an invoice successfully", func(t *testing.T) { + pdfContent, err := internal.CreateInvoicePDF(*invoice, *user1, app.config.Invoice) + require.NoError(t, err) + require.NotEmpty(t, pdfContent) + require.Greater(t, len(pdfContent), 100) + + fileName, err := app.handlers.fileStorage.WriteInvoiceFile(user1.ID, invoice.ID, pdfContent) + require.NoError(t, err) + assert.Contains(t, fileName, fmt.Sprintf("user-%d", user1.ID)) + assert.Contains(t, fileName, fmt.Sprintf("invoice-%d", invoice.ID)) + + filePath := filepath.Join(app.config.FileStoragePath, "invoices", fileName) + assert.FileExists(t, filePath) + + fileInfo, err := os.Stat(filePath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm()) + token := GetAuthToken(t, app, user1.ID, user1.Email, user1.Username, false) req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/user/invoice/%d", invoice.ID), nil) req.Header.Set("Authorization", "Bearer "+token) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, "application/pdf", resp.Header().Get("Content-Type")) - assert.True(t, len(resp.Body.Bytes()) > 0) + + responseBody := resp.Body.Bytes() + assert.NotEmpty(t, responseBody) + assert.Greater(t, len(responseBody), 100) + assert.Equal(t, pdfContent, responseBody) + + if len(responseBody) >= 4 { + assert.Equal(t, "%PDF", string(responseBody[:4])) + } + + contentDisposition := resp.Header().Get("Content-Disposition") + assert.Contains(t, contentDisposition, "attachment") + assert.Contains(t, contentDisposition, fileName) }) t.Run("Download invoice with no token", func(t *testing.T) { @@ -228,4 +302,65 @@ func TestDownloadInvoiceHandler(t *testing.T) { router.ServeHTTP(resp, req) assert.Equal(t, http.StatusBadRequest, resp.Code) }) + + t.Run("Download invoice missing from file storage generates on-demand", func(t *testing.T) { + user2 := CreateTestUser(t, app, "user2@example.com", "User Two", []byte("password"), true, false, true, 0, time.Now()) + + invoice2 := &models.Invoice{ + UserID: user2.ID, + Total: 99.99, + Tax: 9.99, + CreatedAt: time.Now(), + } + err := app.db.CreateInvoice(invoice2) + require.NoError(t, err) + + token := GetAuthToken(t, app, user2.ID, user2.Email, user2.Username, false) + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/user/invoice/%d", invoice2.ID), nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "application/pdf", resp.Header().Get("Content-Type")) + + responseBody := resp.Body.Bytes() + assert.NotEmpty(t, responseBody) + assert.Greater(t, len(responseBody), 100) + + if len(responseBody) >= 4 { + assert.Equal(t, "%PDF", string(responseBody[:4])) + } + + storedContent, err := app.handlers.fileStorage.ReadInvoiceFile(user2.ID, invoice2.ID) + require.NoError(t, err) + assert.Equal(t, responseBody, storedContent) + }) + + t.Run("User cannot download another user's invoice", func(t *testing.T) { + user3 := CreateTestUser(t, app, "user3@example.com", "User Three", []byte("password"), true, false, true, 0, time.Now()) + + invoice3 := &models.Invoice{ + UserID: user1.ID, + Total: 250.00, + Tax: 25.00, + CreatedAt: time.Now(), + } + err := app.db.CreateInvoice(invoice3) + require.NoError(t, err) + + pdfContent, err := internal.CreateInvoicePDF(*invoice3, *user1, app.config.Invoice) + require.NoError(t, err) + _, err = app.handlers.fileStorage.WriteInvoiceFile(user1.ID, invoice3.ID, pdfContent) + require.NoError(t, err) + + token := GetAuthToken(t, app, user3.ID, user3.Email, user3.Username, false) + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/user/invoice/%d", invoice3.ID), nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusForbidden, resp.Code) + assert.NotEqual(t, "application/pdf", resp.Header().Get("Content-Type")) + }) } diff --git a/backend/app/setup.go b/backend/app/setup.go index 95acee52..e1833e18 100644 --- a/backend/app/setup.go +++ b/backend/app/setup.go @@ -25,6 +25,7 @@ func SetUp(t testing.TB) (*App, error) { dbPath := filepath.Join(dir, "testing.db") dsn := "sqlite3://" + dbPath notificationConfigPath := filepath.Join(dir, "notification-config.json") + fileStoragePath := filepath.Join(dir, "file-storage") privateKeyPath := filepath.Join(dir, "test_id_rsa") publicKeyPath := privateKeyPath + ".pub" @@ -94,12 +95,13 @@ func SetUp(t testing.TB) (*App, error) { "kyc_verifier_api_url": "https://kyc.dev.grid.tf", "kyc_challenge_domain": "kyc.dev.grid.tf", "notification_config_path": "%s", + "file_storage_path": "%s", "cluster_health_check_interval_in_hours": 1, "reserved_node_health_check_interval_in_hours": 1, "reserved_node_health_check_timeout_in_minutes": 1, "reserved_node_health_check_workers_num": 10 } -`, dsn, mnemonic, privateKeyPath, publicKeyPath, notificationConfigPath) +`, dsn, mnemonic, privateKeyPath, publicKeyPath, notificationConfigPath, fileStoragePath) err = os.WriteFile(configPath, []byte(config), 0644) if err != nil { @@ -116,6 +118,12 @@ func SetUp(t testing.TB) (*App, error) { return nil, err } + // Create file storage directory + err = os.MkdirAll(fileStoragePath, 0755) + if err != nil { + return nil, fmt.Errorf("failed to create file storage directory: %w", err) + } + viper.Reset() viper.SetConfigFile(configPath) err = viper.ReadInConfig() @@ -150,6 +158,7 @@ func SetUp(t testing.TB) (*App, error) { _ = os.Remove(configPath) _ = os.Remove(dbPath) _ = os.Remove(notificationConfigPath) + _ = os.RemoveAll(fileStoragePath) // Reset viper to avoid config leakage between tests viper.Reset() diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index afb26c96..1c4579b2 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -56,6 +56,7 @@ type Handler struct { metrics *metrics.Metrics notificationService *notification.NotificationService gridClient deployer.TFPluginClient + fileStorage *internal.FileStorageService appContext context.Context } @@ -68,6 +69,7 @@ func NewHandler(tokenManager internal.TokenManager, db models.DB, gridNet string, sshPublicKey string, systemIdentity substrate.Identity, kycClient *internal.KYCClient, sponsorKeyPair subkey.KeyPair, sponsorAddress string, metrics *metrics.Metrics, notificationService *notification.NotificationService, gridClient deployer.TFPluginClient, + fileStorage *internal.FileStorageService, appContext context.Context) *Handler { return &Handler{ @@ -90,6 +92,7 @@ func NewHandler(tokenManager internal.TokenManager, db models.DB, metrics: metrics, notificationService: notificationService, gridClient: gridClient, + fileStorage: fileStorage, appContext: appContext, } } diff --git a/backend/config-example.json b/backend/config-example.json index f875d623..b07ff573 100644 --- a/backend/config-example.json +++ b/backend/config-example.json @@ -45,6 +45,7 @@ "private_key_path": "/home/user/.ssh/id_rsa", "public_key_path": "/home/user/.ssh/id_rsa.pub" }, + "file_storage_path": "./mycelium-cloud-files", "debug": false, "dev_mode": false, "monitor_balance_interval_in_minutes": 120, diff --git a/backend/internal/activities/deployer_activities.go b/backend/internal/activities/deployer_activities.go index 4c6feb3c..14145ed1 100644 --- a/backend/internal/activities/deployer_activities.go +++ b/backend/internal/activities/deployer_activities.go @@ -303,9 +303,8 @@ func BatchDeployAllNodesStep(metrics *metrics.Metrics) ewf.StepFn { } } -func StoreDeploymentStep(db models.DB, metrics *metrics.Metrics) ewf.StepFn { +func StoreDeploymentStep(db models.DB, metrics *metrics.Metrics, fileStorage *internal.FileStorageService) ewf.StepFn { return func(ctx context.Context, state ewf.State) error { - log := logger.ForOperation("deployer_activities", "store_deployment") cluster, err := statemanager.GetCluster(state) if err != nil { return err @@ -320,35 +319,32 @@ func StoreDeploymentStep(db models.DB, metrics *metrics.Metrics) ewf.StepFn { ProjectName: cluster.ProjectName, } - kubeconfig, ok := state["kubeconfig"].(string) - if !ok || kubeconfig == "" { - log.Warn().Str("project_name", cluster.ProjectName).Msg("No kubeconfig found in state to store") - } else { - dbCluster.Kubeconfig = kubeconfig - } - if err := dbCluster.SetClusterResult(cluster); err != nil { return fmt.Errorf("failed to set cluster result for %s (user_id=%d): %w", cluster.Name, config.UserID, err) } existingCluster, err := db.GetClusterByName(config.UserID, cluster.ProjectName) - if err != nil { // cluster not found, create a new one + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to get cluster by name: %w", err) + } + + if errors.Is(err, gorm.ErrRecordNotFound) { if err := db.CreateCluster(config.UserID, dbCluster); err != nil { - return fmt.Errorf("failed to create cluster %s in database (user_id=%d): %w", cluster.Name, config.UserID, err) + return fmt.Errorf("failed to create cluster in database: %w", err) } - } else { // cluster exists, update it + } else { existingCluster.Result = dbCluster.Result - existingCluster.Kubeconfig = dbCluster.Kubeconfig if err := db.UpdateCluster(&existingCluster); err != nil { return fmt.Errorf("failed to update cluster %s in database (user_id=%d): %w", cluster.Name, config.UserID, err) } } + metrics.IncActiveClusterCount() return nil } } - func CancelDeploymentStep(db models.DB, metrics *metrics.Metrics) ewf.StepFn { return func(ctx context.Context, state ewf.State) error { ensureClient(state) @@ -391,7 +387,7 @@ func CancelDeploymentStep(db models.DB, metrics *metrics.Metrics) ewf.StepFn { } } -func RemoveClusterFromDBStep(db models.DB, metrics *metrics.Metrics) ewf.StepFn { +func RemoveClusterFromDBStep(db models.DB, fileStorage *internal.FileStorageService, metrics *metrics.Metrics) ewf.StepFn { return func(ctx context.Context, state ewf.State) error { config, err := getConfig(state) if err != nil { @@ -403,6 +399,16 @@ func RemoveClusterFromDBStep(db models.DB, metrics *metrics.Metrics) ewf.StepFn return fmt.Errorf("missing or invalid 'project_name' in state") } + cluster, err := db.GetClusterByName(config.UserID, projectName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to get cluster by name: %w", err) + } + if cluster.ID != 0 { + if err := fileStorage.DeleteKubeconfigFile(config.UserID, cluster.ID, projectName); err != nil { + logger.GetLogger().Error().Err(err).Int("user_id", config.UserID).Int("cluster_id", cluster.ID).Str("project_name", projectName).Msg("Failed to delete kubeconfig during cluster removal") + } + } + if err := db.DeleteCluster(config.UserID, projectName); err != nil { return fmt.Errorf("failed to delete cluster %s from database (user_id=%d): %w", projectName, config.UserID, err) } @@ -497,17 +503,24 @@ func BatchCancelContractsStep() ewf.StepFn { } } -func DeleteAllUserClustersStep(db models.DB, metrics *metrics.Metrics) ewf.StepFn { +func DeleteAllUserClustersStep(db models.DB, fileStorage *internal.FileStorageService, metrics *metrics.Metrics) ewf.StepFn { return func(ctx context.Context, state ewf.State) error { config, err := getConfig(state) if err != nil { return err } + // List clusters to capture IDs and names for kubeconfig cleanup clusters, err := db.ListUserClusters(config.UserID) if err != nil { - return fmt.Errorf("failed to list user clusters (user_id=%d): %w", config.UserID, err) + return fmt.Errorf("failed to list user clusters for deletion: %w", err) } + for _, c := range clusters { + if err := fileStorage.DeleteKubeconfigFile(config.UserID, c.ID, c.ProjectName); err != nil { + logger.GetLogger().Error().Err(err).Int("user_id", config.UserID).Int("cluster_id", c.ID).Str("project_name", c.ProjectName).Msg("Failed to delete kubeconfig during bulk cluster removal") + } + } + clusterCount := len(clusters) if err := db.DeleteAllUserClusters(config.UserID); err != nil { @@ -640,7 +653,7 @@ func createAddNodeWorkflowTemplate(notificationService *notification.Notificatio return template } -func registerDeploymentActivities(engine *ewf.Engine, metrics *metrics.Metrics, db models.DB, notificationService *notification.NotificationService, config internal.Configuration) { +func registerDeploymentActivities(engine *ewf.Engine, metrics *metrics.Metrics, db models.DB, notificationService *notification.NotificationService, config internal.Configuration, fileStorage *internal.FileStorageService) { engine.Register(constants.StepDeployNetwork, DeployNetworkStep(metrics)) engine.Register(constants.StepDeployLeaderNode, DeployLeaderNodeStep(metrics)) engine.Register(constants.StepBatchDeployAllNodes, BatchDeployAllNodesStep(metrics)) @@ -648,14 +661,14 @@ func registerDeploymentActivities(engine *ewf.Engine, metrics *metrics.Metrics, engine.Register(constants.StepAddNode, AddNodeStep(metrics)) engine.Register(constants.StepUpdateNetwork, UpdateNetworkStep(metrics)) engine.Register(constants.StepRemoveNode, RemoveDeploymentNodeStep()) - engine.Register(constants.StepStoreDeployment, StoreDeploymentStep(db, metrics)) - engine.Register(constants.StepFetchKubeconfig, FetchKubeconfigStep(db, config.SSH.PrivateKeyPath)) + engine.Register(constants.StepStoreDeployment, StoreDeploymentStep(db, metrics, fileStorage)) + engine.Register(constants.StepFetchKubeconfig, FetchKubeconfigStep(db, fileStorage, config.SSH.PrivateKeyPath)) engine.Register(constants.StepVerifyClusterReady, VerifyClusterReadyStep()) - engine.Register(constants.StepVerifyNewNodes, VerifyAddedNodeStep(db, config.SSH.PrivateKeyPath)) - engine.Register(constants.StepRemoveClusterFromDB, RemoveClusterFromDBStep(db, metrics)) + engine.Register(constants.StepVerifyNewNodes, VerifyAddedNodeStep(db, fileStorage, config.SSH.PrivateKeyPath)) + engine.Register(constants.StepRemoveClusterFromDB, RemoveClusterFromDBStep(db, fileStorage, metrics)) engine.Register(constants.StepGatherAllContractIDs, GatherAllContractIDsStep(db)) engine.Register(constants.StepBatchCancelContracts, BatchCancelContractsStep()) - engine.Register(constants.StepDeleteAllUserClusters, DeleteAllUserClustersStep(db, metrics)) + engine.Register(constants.StepDeleteAllUserClusters, DeleteAllUserClustersStep(db, fileStorage, metrics)) deployWFTemplate := createDeployerWorkflowTemplate(notificationService, engine, metrics) deployWFTemplate.Steps = []ewf.Step{ @@ -773,8 +786,7 @@ func getConfig(state ewf.State) (statemanager.ClientConfig, error) { return config, nil } -func retrieveKubeconfig(ctx context.Context, state ewf.State, db models.DB, privateKeyPath string) (string, error) { - log := logger.ForOperation("deployer_activities", "retrieve_kubeconfig") +func retrieveKubeconfig(ctx context.Context, state ewf.State, db models.DB, fileStorage *internal.FileStorageService, privateKeyPath string) (string, error) { // 1. Check if kubeconfig is already in state if kc, err := getFromState[string](state, "kubeconfig"); err == nil && kc != "" { return kc, nil @@ -792,13 +804,13 @@ func retrieveKubeconfig(ctx context.Context, state ewf.State, db models.DB, priv existingCluster, err := db.GetClusterByName(config.UserID, cluster.ProjectName) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return "", fmt.Errorf("failed to query cluster %s from database (user_id=%d): %w", cluster.ProjectName, config.UserID, err) + return "", fmt.Errorf("failed to query cluster from database: %w", err) } - // 2. If cluster exists in DB and has kubeconfig, return it - if existingCluster.ID != 0 && existingCluster.Kubeconfig != "" { - log.Debug().Str("cluster", existingCluster.ProjectName).Msgf("Using kubeconfig from DB for cluster %s", existingCluster.ProjectName) - return existingCluster.Kubeconfig, nil + if existingCluster.ID != 0 { + if data, err := fileStorage.ReadKubeconfigFile(config.UserID, existingCluster.ID, existingCluster.ProjectName); err == nil && len(data) > 0 { + return string(data), nil + } } privateKeyBytes, err := os.ReadFile(privateKeyPath) @@ -819,9 +831,9 @@ func retrieveKubeconfig(ctx context.Context, state ewf.State, db models.DB, priv return cluster.GetKubeconfig(ctx, string(privateKeyBytes)) } -func FetchKubeconfigStep(db models.DB, privateKeyPath string) ewf.StepFn { +func FetchKubeconfigStep(db models.DB, fileStorage *internal.FileStorageService, privateKeyPath string) ewf.StepFn { return func(ctx context.Context, state ewf.State) error { - kubeconfig, err := retrieveKubeconfig(ctx, state, db, privateKeyPath) + kubeconfig, err := retrieveKubeconfig(ctx, state, db, fileStorage, privateKeyPath) if err != nil { return err } @@ -830,7 +842,7 @@ func FetchKubeconfigStep(db models.DB, privateKeyPath string) ewf.StepFn { } } -func VerifyAddedNodeStep(db models.DB, privateKeyPath string) ewf.StepFn { +func VerifyAddedNodeStep(db models.DB, fileStorage *internal.FileStorageService, privateKeyPath string) ewf.StepFn { return func(ctx context.Context, state ewf.State) error { log := logger.ForOperation("deployer_activities", "verify_added_node") node, err := getFromState[kubedeployer.Node](state, "node") @@ -838,7 +850,7 @@ func VerifyAddedNodeStep(db models.DB, privateKeyPath string) ewf.StepFn { return fmt.Errorf("missing or invalid 'node' in state for verification: %w", err) } - kubeconfig, err := retrieveKubeconfig(ctx, state, db, privateKeyPath) + kubeconfig, err := retrieveKubeconfig(ctx, state, db, fileStorage, privateKeyPath) if err != nil { return err } diff --git a/backend/internal/activities/workflow.go b/backend/internal/activities/workflow.go index 449694cf..a8f64d3b 100644 --- a/backend/internal/activities/workflow.go +++ b/backend/internal/activities/workflow.go @@ -43,6 +43,7 @@ func RegisterEWFWorkflows( metrics *metrics.Metrics, notificationService *notification.NotificationService, proxyClient proxy.Client, + fileStorage *internal.FileStorageService, ) { engine.Register(constants.StepSendVerificationEmail, SendVerificationEmailStep(mail, config)) engine.Register(constants.StepCreateUser, CreateUserStep(config, db)) @@ -152,7 +153,7 @@ func RegisterEWFWorkflows( // trackClusterHealthWFTemplate.BeforeWorkflowHooks = []ewf.BeforeWorkflowHook{hookNotificationWorkflowStarted} engine.RegisterTemplate(constants.WorkflowTrackClusterHealth, &trackClusterHealthWFTemplate) - registerDeploymentActivities(engine, metrics, db, notificationService, config) + registerDeploymentActivities(engine, metrics, db, notificationService, config, fileStorage) notificationTemplate := ewf.WorkflowTemplate{ Steps: []ewf.Step{ diff --git a/backend/internal/config.go b/backend/internal/config.go index 16c278c7..2bc516e8 100644 --- a/backend/internal/config.go +++ b/backend/internal/config.go @@ -5,6 +5,7 @@ import ( "kubecloud/internal/logger" "kubecloud/internal/utils" "net/url" + "os" "strings" "github.com/go-playground/validator" @@ -50,6 +51,7 @@ type Configuration struct { // Notification configuration NotificationConfigPath string `json:"notification_config_path"` Notification NotificationConfig `json:"-"` + FileStoragePath string `json:"file_storage_path" validate:"required"` } type SSHConfig struct { @@ -239,6 +241,13 @@ func LoadConfig() (Configuration, error) { return Configuration{}, fmt.Errorf("failed to expand log directory path: %w", err) } + config.FileStoragePath, err = utils.ExpandPath(config.FileStoragePath) + if err != nil { + return Configuration{}, fmt.Errorf("failed to expand file storage base directory path: %w", err) + } + if _, err := os.Stat(config.FileStoragePath); os.IsNotExist(err) { + return Configuration{}, fmt.Errorf("file storage base directory does not exist: %w", err) + } notificationFilePath, err := utils.ExpandPath(config.NotificationConfigPath) if err != nil { return Configuration{}, fmt.Errorf("failed to expand notification config path: %w", err) diff --git a/backend/internal/filestorage.go b/backend/internal/filestorage.go new file mode 100644 index 00000000..5416418a --- /dev/null +++ b/backend/internal/filestorage.go @@ -0,0 +1,138 @@ +package internal + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +const ( + InvoicesDir = "invoices" + KubeconfigsDir = "kubeconfigs" +) + +type FileStorageService struct { + baseDir string + mu sync.Mutex +} + +func NewFileStorageService(baseDir string) (*FileStorageService, error) { + s := &FileStorageService{baseDir: baseDir} + if err := s.ensureFileStorageSubdirs(); err != nil { + return nil, err + } + return s, nil +} + +func (s *FileStorageService) ensureFileStorageSubdirs() error { + if strings.TrimSpace(s.baseDir) == "" { + return fmt.Errorf("file storage base directory cannot be empty") + } + if err := os.MkdirAll(filepath.Join(s.baseDir, InvoicesDir), 0o700); err != nil { + return fmt.Errorf("failed to create invoices directory: %w", err) + } + if err := os.MkdirAll(filepath.Join(s.baseDir, KubeconfigsDir), 0o700); err != nil { + return fmt.Errorf("failed to create kubeconfigs directory: %w", err) + } + return nil +} + +func (s *FileStorageService) InvoiceFileName(userID, invoiceID int) string { + return fmt.Sprintf("user-%d-invoice-%d.pdf", userID, invoiceID) +} + +func (s *FileStorageService) kubeconfigFileName(userID, clusterID int, projectName string) string { + safeProject := strings.ReplaceAll(strings.TrimSpace(projectName), " ", "-") + return fmt.Sprintf("user-%d-cluster-%d-%s-kubeconfig.yaml", userID, clusterID, safeProject) +} + +func (s *FileStorageService) buildPath(dir string, fileName string) string { + return filepath.Join(s.baseDir, dir, fileName) +} + +func (s *FileStorageService) WriteInvoiceFile(userID, invoiceID int, data []byte) (string, error) { + fileName := s.InvoiceFileName(userID, invoiceID) + absPath := s.buildPath(InvoicesDir, fileName) + if err := s.atomicWriteFile(absPath, data, 0o600); err != nil { + return "", err + } + return fileName, nil +} + +func (s *FileStorageService) ReadInvoiceFile(userID, invoiceID int) ([]byte, error) { + fileName := s.InvoiceFileName(userID, invoiceID) + absPath := s.buildPath(InvoicesDir, fileName) + return os.ReadFile(absPath) +} + +func (s *FileStorageService) WriteKubeconfigFile(userID, clusterID int, projectName string, data []byte) (string, error) { + fileName := s.kubeconfigFileName(userID, clusterID, projectName) + absPath := s.buildPath(KubeconfigsDir, fileName) + if err := s.atomicWriteFile(absPath, data, 0o600); err != nil { + return "", err + } + return fileName, nil +} + +func (s *FileStorageService) ReadKubeconfigFile(userID, clusterID int, projectName string) ([]byte, error) { + fileName := s.kubeconfigFileName(userID, clusterID, projectName) + absPath := s.buildPath(KubeconfigsDir, fileName) + return os.ReadFile(absPath) +} + +func (s *FileStorageService) DeleteKubeconfigFile(userID, clusterID int, projectName string) error { + fileName := s.kubeconfigFileName(userID, clusterID, projectName) + absPath := s.buildPath(KubeconfigsDir, fileName) + if err := os.Remove(absPath); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return nil +} + +func (s *FileStorageService) atomicWriteFile(path string, data []byte, perm os.FileMode) error { + s.mu.Lock() + defer s.mu.Unlock() + + dir := filepath.Dir(path) + + tmpFile, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + defer func() { + if tmpFile != nil { + tmpFile.Close() + os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + return fmt.Errorf("failed to write to temp file: %w", err) + } + + if err := tmpFile.Sync(); err != nil { + return fmt.Errorf("failed to sync temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + tmpFile = nil + + if err := os.Chmod(tmpPath, perm); err != nil { + return fmt.Errorf("failed to set permissions: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil +} diff --git a/backend/internal/filestorage_test.go b/backend/internal/filestorage_test.go new file mode 100644 index 00000000..9440fe85 --- /dev/null +++ b/backend/internal/filestorage_test.go @@ -0,0 +1,232 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewFileStorageService(t *testing.T) { + t.Run("Create service successfully", func(t *testing.T) { + tmpDir := t.TempDir() + + service, err := NewFileStorageService(tmpDir) + assert.NoError(t, err) + assert.NotNil(t, service) + invoicesDir := filepath.Join(tmpDir, InvoicesDir) + kubeconfigsDir := filepath.Join(tmpDir, KubeconfigsDir) + + assert.DirExists(t, invoicesDir) + assert.DirExists(t, kubeconfigsDir) + + invoiceInfo, err := os.Stat(invoicesDir) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), invoiceInfo.Mode().Perm()) + + kubeconfigInfo, err := os.Stat(kubeconfigsDir) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), kubeconfigInfo.Mode().Perm()) + }) + + t.Run("Fail with empty base directory", func(t *testing.T) { + _, err := NewFileStorageService("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "file storage base directory cannot be empty") + }) + + t.Run("Fail with whitespace-only base directory", func(t *testing.T) { + _, err := NewFileStorageService(" ") + assert.Error(t, err) + assert.Contains(t, err.Error(), "file storage base directory cannot be empty") + }) + + t.Run("Create service with nested directory", func(t *testing.T) { + tmpDir := t.TempDir() + nestedDir := filepath.Join(tmpDir, "nested", "path", "to", "storage") + + service, err := NewFileStorageService(nestedDir) + assert.NoError(t, err) + assert.NotNil(t, service) + assert.DirExists(t, filepath.Join(nestedDir, InvoicesDir)) + assert.DirExists(t, filepath.Join(nestedDir, KubeconfigsDir)) + }) +} + +func TestWriteAndReadInvoiceFile(t *testing.T) { + tmpDir := t.TempDir() + service, err := NewFileStorageService(tmpDir) + assert.NoError(t, err) + + t.Run("Write and read invoice file successfully", func(t *testing.T) { + userID := 1 + invoiceID := 100 + testData := []byte("This is a test PDF content for invoice") + + fileName, err := service.WriteInvoiceFile(userID, invoiceID, testData) + assert.NoError(t, err) + assert.Equal(t, "user-1-invoice-100.pdf", fileName) + + filePath := filepath.Join(tmpDir, InvoicesDir, fileName) + assert.FileExists(t, filePath) + + fileInfo, err := os.Stat(filePath) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm()) + + readData, err := service.ReadInvoiceFile(userID, invoiceID) + assert.NoError(t, err) + assert.Equal(t, testData, readData) + }) + + t.Run("Overwrite existing invoice file", func(t *testing.T) { + userID := 2 + invoiceID := 200 + originalData := []byte("Original invoice data") + updatedData := []byte("Updated invoice data - much longer content") + + _, err := service.WriteInvoiceFile(userID, invoiceID, originalData) + assert.NoError(t, err) + + _, err = service.WriteInvoiceFile(userID, invoiceID, updatedData) + assert.NoError(t, err) + + readData, err := service.ReadInvoiceFile(userID, invoiceID) + assert.NoError(t, err) + assert.Equal(t, updatedData, readData) + }) + + t.Run("Write empty invoice file", func(t *testing.T) { + userID := 3 + invoiceID := 300 + emptyData := []byte{} + + fileName, err := service.WriteInvoiceFile(userID, invoiceID, emptyData) + assert.NoError(t, err) + + readData, err := service.ReadInvoiceFile(userID, invoiceID) + assert.NoError(t, err) + assert.Equal(t, emptyData, readData) + + filePath := filepath.Join(tmpDir, InvoicesDir, fileName) + assert.FileExists(t, filePath) + }) + + t.Run("Read non-existent invoice file", func(t *testing.T) { + userID := 999 + invoiceID := 999 + + _, err := service.ReadInvoiceFile(userID, invoiceID) + assert.Error(t, err) + assert.True(t, os.IsNotExist(err)) + }) +} + +func TestWriteAndReadKubeconfigFile(t *testing.T) { + tmpDir := t.TempDir() + service, err := NewFileStorageService(tmpDir) + assert.NoError(t, err) + + t.Run("Write and read kubeconfig file successfully", func(t *testing.T) { + userID := 1 + clusterID := 10 + projectName := "test-cluster" + kubeconfigData := []byte(`apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://example.com + name: test-cluster +`) + + fileName, err := service.WriteKubeconfigFile(userID, clusterID, projectName, kubeconfigData) + assert.NoError(t, err) + assert.Equal(t, "user-1-cluster-10-test-cluster-kubeconfig.yaml", fileName) + + filePath := filepath.Join(tmpDir, KubeconfigsDir, fileName) + assert.FileExists(t, filePath) + + fileInfo, err := os.Stat(filePath) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm()) + + readData, err := service.ReadKubeconfigFile(userID, clusterID, projectName) + assert.NoError(t, err) + assert.Equal(t, kubeconfigData, readData) + }) + + t.Run("Write kubeconfig for project with spaces in name", func(t *testing.T) { + userID := 2 + clusterID := 20 + projectName := "my cluster 2" + kubeconfigData := []byte("kubeconfig content") + + fileName, err := service.WriteKubeconfigFile(userID, clusterID, projectName, kubeconfigData) + assert.NoError(t, err) + assert.Contains(t, fileName, "my-cluster-2") + + readData, err := service.ReadKubeconfigFile(userID, clusterID, projectName) + assert.NoError(t, err) + assert.Equal(t, kubeconfigData, readData) + }) + + t.Run("Read non-existent kubeconfig file", func(t *testing.T) { + userID := 999 + clusterID := 999 + projectName := "non-existent" + + _, err := service.ReadKubeconfigFile(userID, clusterID, projectName) + assert.Error(t, err) + assert.True(t, os.IsNotExist(err)) + }) +} + +func TestDeleteKubeconfigFile(t *testing.T) { + tmpDir := t.TempDir() + service, err := NewFileStorageService(tmpDir) + assert.NoError(t, err) + + t.Run("Delete existing kubeconfig file", func(t *testing.T) { + userID := 1 + clusterID := 10 + projectName := "test-cluster" + kubeconfigData := []byte("test kubeconfig") + + fileName, err := service.WriteKubeconfigFile(userID, clusterID, projectName, kubeconfigData) + assert.NoError(t, err) + + filePath := filepath.Join(tmpDir, KubeconfigsDir, fileName) + assert.FileExists(t, filePath) + + err = service.DeleteKubeconfigFile(userID, clusterID, projectName) + assert.NoError(t, err) + + assert.NoFileExists(t, filePath) + }) + + t.Run("Delete non-existent kubeconfig file returns no error", func(t *testing.T) { + userID := 999 + clusterID := 999 + projectName := "non-existent" + + err := service.DeleteKubeconfigFile(userID, clusterID, projectName) + assert.NoError(t, err) + }) + + t.Run("Delete same file twice", func(t *testing.T) { + userID := 2 + clusterID := 20 + projectName := "test-cluster-2" + kubeconfigData := []byte("test kubeconfig 2") + + _, err := service.WriteKubeconfigFile(userID, clusterID, projectName, kubeconfigData) + assert.NoError(t, err) + + err = service.DeleteKubeconfigFile(userID, clusterID, projectName) + assert.NoError(t, err) + + err = service.DeleteKubeconfigFile(userID, clusterID, projectName) + assert.NoError(t, err) + }) +} diff --git a/backend/models/cluster.go b/backend/models/cluster.go index 9741ffc3..1ef07cb9 100644 --- a/backend/models/cluster.go +++ b/backend/models/cluster.go @@ -12,7 +12,6 @@ type Cluster struct { UserID int `gorm:"user_id;index" json:"user_id" binding:"required"` ProjectName string `gorm:"project_name;uniqueIndex:idx_user_project" json:"project_name" binding:"required"` Result string `gorm:"type:text" json:"result"` // JSON serialized kubedeployer.Cluster - Kubeconfig string `gorm:"type:text" json:"kubeconfig"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/models/invoice.go b/backend/models/invoice.go index df1edb7d..2fe3b99c 100644 --- a/backend/models/invoice.go +++ b/backend/models/invoice.go @@ -5,14 +5,12 @@ import ( ) type Invoice struct { - ID int `json:"id" gorm:"primaryKey"` - UserID int `json:"user_id" binding:"required"` - Total float64 `json:"total"` - Nodes []NodeItem `json:"nodes" gorm:"foreignKey:invoice_id"` - // TODO: - Tax float64 `json:"tax"` - CreatedAt time.Time `json:"created_at"` - FileData []byte `json:"-" gorm:"type:bytea;column:file_data"` + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" binding:"required"` + Total float64 `json:"total"` + Nodes []NodeItem `json:"nodes" gorm:"foreignKey:invoice_id"` + Tax float64 `json:"tax"` + CreatedAt time.Time `json:"created_at"` } type NodeItem struct { diff --git a/backend/models/migrate.go b/backend/models/migrate.go index 4ec14681..9eacea02 100644 --- a/backend/models/migrate.go +++ b/backend/models/migrate.go @@ -3,10 +3,17 @@ package models import ( "context" "fmt" + "kubecloud/internal/logger" + "strings" "gorm.io/gorm" ) +type FileStorage interface { + WriteInvoiceFile(userID, invoiceID int, data []byte) (string, error) + WriteKubeconfigFile(userID, clusterID int, projectName string, data []byte) (string, error) +} + func MigrateAll(ctx context.Context, src DB, dst DB) error { if err := migrateUsers(ctx, src.GetDB(), dst.GetDB()); err != nil { return fmt.Errorf("users: %w", err) @@ -127,3 +134,96 @@ func migrateNotificationsToDst(ctx context.Context, src *gorm.DB, dst *gorm.DB) } return insertOnConflictReturnError(ctx, dst, rows) } + +func MigrateFileDataToStorage(db DB, fileStorage FileStorage) error { + gormDB := db.GetDB() + m := gormDB.Migrator() + + if err := migrateInvoiceFileData(gormDB, m, fileStorage); err != nil { + return fmt.Errorf("failed to migrate invoice file data: %w", err) + } + + // Migrate cluster kubeconfigs from kubeconfig column to file storage + if err := migrateClusterKubeconfigs(gormDB, m, fileStorage); err != nil { + return fmt.Errorf("failed to migrate cluster kubeconfigs: %w", err) + } + + return nil +} + +func migrateInvoiceFileData(db *gorm.DB, m gorm.Migrator, fileStorage FileStorage) error { + + if !m.HasTable(&Invoice{}) || !m.HasColumn(&Invoice{}, "file_data") { + return nil + } + + type InvoiceWithFileData struct { + ID int `gorm:"primaryKey"` + UserID int `gorm:"user_id"` + FileData []byte `gorm:"type:bytea;column:file_data"` + } + + var invoices []InvoiceWithFileData + if err := db.Table("invoices").Where("file_data IS NOT NULL AND file_data != ''").Find(&invoices).Error; err != nil { + return fmt.Errorf("failed to query invoices with file data: %w", err) + } + + migratedCount := 0 + for _, invoice := range invoices { + if len(invoice.FileData) == 0 { + continue + } + + // Use the file storage service to write the file + if _, err := fileStorage.WriteInvoiceFile(invoice.UserID, invoice.ID, invoice.FileData); err != nil { + return fmt.Errorf("failed to write invoice file for invoice %d: %w", invoice.ID, err) + } + + migratedCount++ + } + + if err := m.DropColumn(&Invoice{}, "file_data"); err != nil { + return fmt.Errorf("failed to drop file_data column: %w", err) + } + + logger.GetLogger().Info().Msgf("Successfully migrated %d invoice PDFs to file storage", migratedCount) + return nil +} + +func migrateClusterKubeconfigs(db *gorm.DB, m gorm.Migrator, fileStorage FileStorage) error { + if !m.HasTable(&Cluster{}) || !m.HasColumn(&Cluster{}, "kubeconfig") { + return nil + } + + type ClusterWithKubeconfig struct { + ID int `gorm:"primaryKey"` + UserID int `gorm:"user_id"` + ProjectName string `gorm:"project_name"` + Kubeconfig string `gorm:"type:text"` + } + + var clusters []ClusterWithKubeconfig + if err := db.Table("clusters").Where("kubeconfig IS NOT NULL AND kubeconfig != ''").Find(&clusters).Error; err != nil { + return fmt.Errorf("failed to query clusters with kubeconfig: %w", err) + } + + migratedCount := 0 + for _, cluster := range clusters { + if strings.TrimSpace(cluster.Kubeconfig) == "" { + continue + } + + if _, err := fileStorage.WriteKubeconfigFile(cluster.UserID, cluster.ID, cluster.ProjectName, []byte(cluster.Kubeconfig)); err != nil { + return fmt.Errorf("failed to write kubeconfig file for cluster %d: %w", cluster.ID, err) + } + + migratedCount++ + } + + if err := m.DropColumn(&Cluster{}, "kubeconfig"); err != nil { + return fmt.Errorf("failed to drop kubeconfig column: %w", err) + } + + logger.GetLogger().Info().Msgf("Successfully migrated %d cluster kubeconfigs to file storage", migratedCount) + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml index 3fd12433..b2cfa2d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - ./backend/config.json:/app/config.json - ./backend/notification-config.json:/app/notification-config.json - ./backend/app/logs:/app/logs + - backend-filestorage:/app/mycelium-cloud-files - /root/.ssh:/root/.ssh:ro environment: - MYCELIUM_HOST=localhost @@ -146,4 +147,5 @@ volumes: mycelium-data: prometheus-data: loki-data: - postgres-data: \ No newline at end of file + postgres-data: + backend-filestorage: