diff --git a/app/backend/internal/api/backuptargets.go b/app/backend/internal/api/backuptargets.go index 6e05eb3..c926844 100644 --- a/app/backend/internal/api/backuptargets.go +++ b/app/backend/internal/api/backuptargets.go @@ -8,7 +8,9 @@ import ( "errors" "fmt" "io" + "log" "net/http" + "os" "strings" "time" @@ -292,6 +294,144 @@ func (h *DatabaseHandler) DeleteBackupTarget(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Backup target deleted"}) } +// ImportBackup accepts a `.tar.gz` archive (multipart `file` field or raw +// body) and stores it as a restore point — the counterpart to offsite +// export, and the entry path for migrators. +func (h *DatabaseHandler) ImportBackup(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + databaseID := c.Param("id") + userID = h.effectiveUserID(c, userID, databaseID) + + row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ + ID: databaseID, UserID: userID, + }) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"}) + return + } + if row.Provider == "external" { + c.JSON(http.StatusBadRequest, gin.H{"error": "backup import applies to managed databases — external restores are not supported yet", "code": "VALIDATION"}) + return + } + + // Buffer to a temp file — tar headers need the size up front, and a + // streamed upload can't be rewound for the archive write. + src, _, err := openUploadStream(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + defer src.Close() + + tmp, err := os.CreateTemp("", "containr-backup-import-*") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stage upload"}) + return + } + defer func() { tmp.Close(); os.Remove(tmp.Name()) }() + + const maxImport = 2 << 30 // 2 GiB ceiling for staged archives + size, err := io.Copy(tmp, http.MaxBytesReader(c.Writer, src, maxImport)) + if err != nil { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "archive too large or unreadable", "code": "VALIDATION"}) + return + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"}) + return + } + magic := make([]byte, 2) + if _, err := tmp.Read(magic); err != nil || magic[0] != 0x1f || magic[1] != 0x8b { + c.JSON(http.StatusBadRequest, gin.H{"error": "archive must be gzip-compressed (.tar.gz)", "code": "VALIDATION"}) + return + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"}) + return + } + + backupID := generateDatabaseBackupID(databaseID) + archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID)) + + if _, err := h.dockerClient.CreateVolume(c.Request.Context(), docker.VolumeConfig{Name: managedDatabaseBackupVolume}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already exists") { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup volume"}) + return + } + holderID, err := h.createBackupVolumeHolder(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to open backup volume: %v", err)}) + return + } + defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }() + + pr, pw := io.Pipe() + tw := tar.NewWriter(pw) + go func() { + err := tw.WriteHeader(&tar.Header{Name: archivePath, Mode: 0o644, Size: size}) + if err == nil { + _, err = io.Copy(tw, tmp) + } + if cerr := tw.Close(); err == nil { + err = cerr + } + _ = pw.CloseWithError(err) + }() + if err := h.dockerClient.CopyToContainer(c.Request.Context(), holderID, "/backup", pr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to store archive: %v", err)}) + return + } + + now := sql.NullTime{Time: time.Now(), Valid: true} + if err := h.queries.CreateDatabaseBackup(c.Request.Context(), sqlcdb.CreateDatabaseBackupParams{ + ID: backupID, + DatabaseID: databaseID, + Size: humanReadableBytes(size), + Status: "completed", + BackupPath: sql.NullString{String: archivePath, Valid: true}, + CreatedAt: now, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register backup"}) + return + } + _ = h.queries.SetDatabaseBackupStatusByID(c.Request.Context(), sqlcdb.SetDatabaseBackupStatusByIDParams{ + Status: "completed", + Size: humanReadableBytes(size), + CompletedAt: now, + ID: backupID, + }) + + // Imported archives ship offsite too when a target is configured — + // the import should be a first-class restore point. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + if key, err := h.uploadBackupToTarget(ctx, row, archivePath, backupID); err != nil { + log.Printf("containr: imported backup %s remote upload failed: %v (local archive kept)", backupID, err) + } else if key != "" { + log.Printf("containr: imported backup %s shipped to %s", backupID, key) + } + }() + + LogAudit(userID, "database", databaseID, "backup_import", map[string]interface{}{"backup_id": backupID, "size": size}) + c.JSON(http.StatusCreated, gin.H{"backup_id": backupID, "size": humanReadableBytes(size), "status": "completed"}) +} + +// openUploadStream resolves the request body: multipart `file` field when +// present, otherwise the raw body (e.g. CLI pipe of a .tar.gz). +func openUploadStream(c *gin.Context) (io.ReadCloser, string, error) { + if fh, err := c.FormFile("file"); err == nil { + src, err := fh.Open() + return src, fh.Filename, err + } + if c.Request.Body == nil || c.Request.ContentLength == 0 { + return nil, "", errors.New("no archive uploaded — send multipart field 'file' or a raw .tar.gz body") + } + return c.Request.Body, "archive.tar.gz", nil +} + func (h *DatabaseHandler) TestBackupTarget(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 34fff30..df43e60 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -402,6 +402,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction) authed.POST("/databases/:id/backup", databaseHandler.CreateBackup) authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup) + authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup) authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup) authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment) diff --git a/app/backend/internal/cli/commands/client.go b/app/backend/internal/cli/commands/client.go index 1af86e0..081c66d 100644 --- a/app/backend/internal/cli/commands/client.go +++ b/app/backend/internal/cli/commands/client.go @@ -156,6 +156,48 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) { return data, nil } +// DoRaw sends a non-JSON request body (file uploads) and returns the response. +func (c *Client) DoRaw(method, path string, body io.Reader, contentType string) ([]byte, error) { + req, err := http.NewRequest(method, c.BaseURL+path, body) + if err != nil { + return nil, err + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 5 * time.Minute} + } + resp, err := hc.Do(req) + if err != nil { + return nil, &APIError{Status: 0, Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable} + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode >= 400 { + msg := strings.TrimSpace(string(data)) + var envelope struct { + Error string `json:"error"` + Code string `json:"code"` + } + if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" { + msg = envelope.Error + if envelope.Code != "" { + msg = envelope.Code + ": " + msg + } + } + return nil, &APIError{Status: resp.StatusCode, Message: msg, ExitCode: exitCodeFor(resp.StatusCode)} + } + return data, nil +} + // DoJSON performs a request and unmarshals the response into out. func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error { data, err := c.Do(method, path, body) diff --git a/app/backend/internal/cli/commands/databases.go b/app/backend/internal/cli/commands/databases.go index c0bdfd9..ab78845 100644 --- a/app/backend/internal/cli/commands/databases.go +++ b/app/backend/internal/cli/commands/databases.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "io" "os" "github.com/spf13/cobra" @@ -199,7 +200,36 @@ var dbRestoreCmd = &cobra.Command{ if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) { return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError} } - data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1]}) + data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1], "database_id": args[0]}) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var dbImportBackupCmd = &cobra.Command{ + Use: "import-backup ", + Short: "Import a .tar.gz archive as a restore point (use '-' for stdin)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + var src io.ReadCloser + if args[1] == "-" { + src = io.NopCloser(os.Stdin) + } else { + f, err := os.Open(args[1]) + if err != nil { + return err + } + src = f + } + defer src.Close() + data, err := c.DoRaw("POST", "/databases/"+args[0]+"/backups/import", src, "application/gzip") if err != nil { return err } @@ -332,5 +362,5 @@ func init() { cmd.Flags().String("password", "", "database password (stored encrypted on register)") cmd.Flags().Bool("ssl", false, "require TLS") } - DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd) + DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbImportBackupCmd, dbDownloadCmd) } diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 59078dd..8b9f958 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -31,6 +31,7 @@ import { restoreDatabaseBackup, createManagedDatabase, registerExternalDatabase, + importDatabaseBackup, updateDatabase, listBackupTargets, createBackupTarget, @@ -130,6 +131,7 @@ import { HeartPulse, Cloud, ExternalLink, + Upload, } from 'lucide-react'; function SecondaryPageHeader({ title, description }: { title: string; description: string }) { @@ -2120,6 +2122,10 @@ export function DatabasesPage() { updateDatabase(id, { backup_target_id: targetId }), onSuccess: invalidate, }); + const importBackupMutation = useMutation({ + mutationFn: ({ id, file }: { id: string; file: File }) => importDatabaseBackup(id, file), + onSuccess: invalidate, + }); const testByIdMutation = useMutation({ mutationFn: (id: string) => testDatabaseConnectionByID(id), onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })), @@ -2358,6 +2364,24 @@ export function DatabasesPage() { ))} )} +