diff --git a/agent/app/dto/backup.go b/agent/app/dto/backup.go index 91c3c33f6..fd4e4469e 100644 --- a/agent/app/dto/backup.go +++ b/agent/app/dto/backup.go @@ -77,15 +77,16 @@ type CommonBackup struct { Description string `json:"description"` } type CommonRecover struct { - DownloadAccountID uint `json:"downloadAccountID" validate:"required"` - Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"` - Name string `json:"name"` - DetailName string `json:"detailName"` - File string `json:"file"` - Secret string `json:"secret"` - TaskID string `json:"taskID"` - BackupRecordID uint `json:"backupRecordID"` - Timeout int `json:"timeout"` + DownloadAccountID uint `json:"downloadAccountID" validate:"required"` + Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"` + Name string `json:"name"` + DetailName string `json:"detailName"` + File string `json:"file"` + Secret string `json:"secret"` + DropAllCollections bool `json:"dropAllCollections"` + TaskID string `json:"taskID"` + BackupRecordID uint `json:"backupRecordID"` + Timeout int `json:"timeout"` } type RecordSearch struct { diff --git a/agent/app/service/backup_mongodb.go b/agent/app/service/backup_mongodb.go index 36d1d7ca7..d80d8f64d 100644 --- a/agent/app/service/backup_mongodb.go +++ b/agent/app/service/backup_mongodb.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "net/url" "os" "os/exec" @@ -27,6 +28,8 @@ import ( dockerClient "github.com/docker/docker/client" ) +const opensslSaltedHeader = "Salted__" + func (u *BackupService) MongodbBackup(req dto.CommonBackup) error { timeNow := time.Now().Format(constant.DateTimeSlimLayout) itemDir := fmt.Sprintf("database/%s/%s/%s", req.Type, req.Name, req.DetailName) @@ -123,15 +126,11 @@ func handleMongodbRecover(req dto.CommonRecover, parentTask *task.Task, isRollba return buserr.WithName("ErrFileNotFound", req.File) } - restoreFile := req.File - if len(req.Secret) != 0 { - if err := files.OpensslDecrypt(req.File, req.Secret); err != nil { - return err - } - restoreFile = path.Join(path.Dir(req.File), "tmp_"+path.Base(req.File)) - defer os.Remove(restoreFile) - t.LogWithStatus(i18n.GetMsgByKey("Decrypt"), nil) + restoreFile, cleanup, err := prepareMongodbBackupFileForRestore(req.File, req.Secret, t) + if err != nil { + return err } + defer cleanup() isOk := false if !isRollback { @@ -155,6 +154,12 @@ func handleMongodbRecover(req dto.CommonRecover, parentTask *task.Task, isRollba }() } + if req.DropAllCollections { + if err := clearMongodbDatabase(req.Name, req.Type, req.DetailName, t); err != nil { + return err + } + } + if err := doMongodbRestore(req.Name, req.Type, req.DetailName, restoreFile, t); err != nil { global.LOG.Errorf("recover mongodb db %s from %s failed, err: %v", req.DetailName, restoreFile, err) return err @@ -276,6 +281,93 @@ func doMongodbRestore(database, dbType, dbName, sourceFile string, taskItem *tas return nil } +func prepareMongodbBackupFileForRestore(filePath, secret string, taskItem *task.Task) (string, func(), error) { + isEncrypted, err := isOpenSSLEncryptedMongodbBackup(filePath) + if err != nil { + return "", nil, err + } + if !isEncrypted { + return filePath, func() {}, nil + } + + if secret == "" { + return "", nil, buserr.New("ErrBadDecrypt") + } + + if err := files.OpensslDecrypt(filePath, secret); err != nil { + return "", nil, err + } + + restoreFile := path.Join(path.Dir(filePath), "tmp_"+path.Base(filePath)) + taskItem.LogWithStatus(i18n.GetMsgByKey("Decrypt"), nil) + return restoreFile, func() { _ = os.Remove(restoreFile) }, nil +} + +func isOpenSSLEncryptedMongodbBackup(filePath string) (bool, error) { + file, err := os.Open(filePath) + if err != nil { + return false, err + } + defer file.Close() + + header := make([]byte, len(opensslSaltedHeader)) + n, err := io.ReadFull(file, header) + if err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return false, nil + } + return false, err + } + return n == len(opensslSaltedHeader) && string(header) == opensslSaltedHeader, nil +} + +func clearMongodbDatabase(database, dbType, dbName string, taskItem *task.Task) error { + dbItem, err := mongodbRepo.Get(repo.WithByName(dbName), mongodbRepo.WithByMongodbName(database)) + if err == nil && dbItem.From == constant.AppResourceRemote { + return clearRemoteMongodbDatabase(database, dbName, taskItem) + } + + appInfo, err := appInstallRepo.LoadBaseInfo(dbType, database) + if err != nil { + return err + } + if appInfo.ContainerName == "" { + return fmt.Errorf("mongodb container not found for database %s", database) + } + + logRemoteMongodbStep(taskItem, fmt.Sprintf("clear local mongodb database %s before restore", dbName)) + uri := buildMongodbRestoreURI(appInfo.UserName, appInfo.Password) + return mongodbCmdMgr(taskItem).Run( + "docker", + "exec", + appInfo.ContainerName, + "mongosh", + uri, + "--quiet", + "--eval", + fmt.Sprintf(`db.getSiblingDB(%q).dropDatabase()`, dbName), + ) +} + +func clearRemoteMongodbDatabase(database, dbName string, taskItem *task.Task) error { + info, err := loadRemoteMongodbConnection(database) + if err != nil { + return err + } + client, ctx, cancel, err := newRemoteMongodbClient(info) + if err != nil { + return err + } + defer cancel() + defer client.Disconnect(context.Background()) + + logRemoteMongodbStep(taskItem, fmt.Sprintf("clear remote mongodb database %s before restore", dbName)) + if err := client.Database(dbName).Drop(ctx); err != nil { + return fmt.Errorf("drop mongodb database %s failed, err: %v", dbName, err) + } + return nil +} + func buildMongodbDumpURI(username, password, dbName string) string { return (&url.URL{ Scheme: "mongodb", @@ -444,6 +536,9 @@ func loadMongodbBackupDBName(sourceFile, defaultDB string) string { if strings.HasSuffix(baseName, ".gz") { baseName = strings.TrimSuffix(baseName, ".gz") } + // Encrypted backups are restored from a decrypted temp file like tmp_. + // Strip the temp prefix before deriving the original database name. + baseName = strings.TrimPrefix(baseName, "tmp_") patterns := []*regexp.Regexp{ regexp.MustCompile(`^1panel_mongodb_(.+)_\d{14}[A-Za-z0-9]*$`), regexp.MustCompile(`^db_(.+)_\d{14}[A-Za-z0-9]*$`), diff --git a/frontend/src/api/interface/backup.ts b/frontend/src/api/interface/backup.ts index e9b74dd85..5cc0b6226 100644 --- a/frontend/src/api/interface/backup.ts +++ b/frontend/src/api/interface/backup.ts @@ -103,5 +103,6 @@ export namespace Backup { file: string; secret: string; taskID: string; + dropAllCollections?: boolean; } } diff --git a/frontend/src/components/backup/index.vue b/frontend/src/components/backup/index.vue index 7f834f7cf..4a4b7d3fe 100644 --- a/frontend/src/components/backup/index.vue +++ b/frontend/src/components/backup/index.vue @@ -123,6 +123,12 @@ + + + {{ $t('database.mongodbRecoverDropAllCollections') }} + + {{ $t('database.mongodbRecoverDropAllCollectionsHelper') }} + @@ -228,6 +234,7 @@ const timeoutItem = ref(30); const timeoutUnit = ref('m'); const node = ref(); const stopBefore = ref(false); +const dropAllCollections = ref(false); const open = ref(); const isBackup = ref(); @@ -366,6 +373,7 @@ const recover = async (row?: any) => { taskID: taskID, backupRecordID: row.id, timeout: timeoutItem.value === -1 ? -1 : transferTimeToSecond(timeoutItem.value + timeoutUnit.value), + dropAllCollections: type.value === 'mongodb' ? dropAllCollections.value : false, }; loading.value = true; await handleRecover(params, node.value) @@ -390,6 +398,7 @@ const onBackup = async () => { const onRecover = async (row: Backup.RecordInfo) => { secret.value = ''; + dropAllCollections.value = false; isBackup.value = false; recordInfo.value = row; open.value = true; diff --git a/frontend/src/components/terminal/index.vue b/frontend/src/components/terminal/index.vue index 4664239e9..b3f5cb690 100644 --- a/frontend/src/components/terminal/index.vue +++ b/frontend/src/components/terminal/index.vue @@ -555,9 +555,7 @@ onBeforeUnmount(() => { .ai-notice-fade-enter-active, .ai-notice-fade-leave-active { - transition: - opacity 180ms ease, - transform 180ms ease; + transition: opacity 180ms ease, transform 180ms ease; } .ai-mask-fade-enter-active, diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index f670256be..a3cdca220 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -643,6 +643,9 @@ const message = { redisCliHelper: '"redis-cli" not detected. Enable the service first.', redisQuickCmd: 'Redis quick commands', recoverHelper: 'This will overwrite data with [{0}]. Continue?', + mongodbRecoverDropAllCollections: 'Clear current database before restore', + mongodbRecoverDropAllCollectionsHelper: + 'By default, only existing collections are overwritten and newly added collections are kept. When enabled, the current database is cleared and then restored from the backup.', submitIt: 'Overwrite the data', baseConf: 'Basic', allConf: 'All', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index f4d028ff6..9c19caeda 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -649,6 +649,9 @@ const message = { redisCliHelper: 'No se detectó el servicio "redis-cli". Primero habilite el servicio.', redisQuickCmd: 'Comandos rápidos de Redis', recoverHelper: 'Esto sobrescribirá los datos con [{0}]. ¿Desea continuar?', + mongodbRecoverDropAllCollections: 'Vaciar la base de datos actual antes de restaurar', + mongodbRecoverDropAllCollectionsHelper: + 'De forma predeterminada, solo se sobrescriben las colecciones existentes y se conservan las colecciones nuevas. Al activarlo, se vacía la base de datos actual y luego se restaura desde la copia de seguridad.', submitIt: 'Sobrescribir los datos', baseConf: 'Básico', allConf: 'Todos', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 172933b2b..9fd5b5106 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -645,6 +645,9 @@ const message = { redisCliHelper: '「Redis-Cli」サービスは検出されません。最初にサービスを有効にします。', redisQuickCmd: 'Redis Quickコマンド', recoverHelper: 'これにより、[{0}]でデータが上書きされます。続けたいですか?', + mongodbRecoverDropAllCollections: '復元前に現在のデータベースを空にする', + mongodbRecoverDropAllCollectionsHelper: + '既定では既存のコレクションのみを上書きし、新しく追加されたコレクションは保持します。有効にすると、現在のデータベースを空にしてからバックアップ内容で復元します。', submitIt: 'データを上書きします', baseConf: '基本', allConf: '全て', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index c84d1b80f..ab2c9dd92 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -637,6 +637,9 @@ const message = { redisCliHelper: '"redis-cli" 서비스가 감지되지 않았습니다. 서비스를 먼저 활성화하십시오.', redisQuickCmd: 'Redis 빠른 명령', recoverHelper: '이 작업은 데이터를 [{0}]으로 덮어씁니다. 계속하시겠습니까?', + mongodbRecoverDropAllCollections: '복원 전에 현재 데이터베이스 비우기', + mongodbRecoverDropAllCollectionsHelper: + '기본적으로는 기존 컬렉션만 덮어쓰고 새로 추가된 컬렉션은 유지합니다. 활성화하면 현재 데이터베이스를 비운 뒤 백업 내용으로 복원합니다.', submitIt: '데이터 덮어쓰기', baseConf: '기본 설정', allConf: '모든 설정', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 6fca7dc05..05dbf65c4 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -652,6 +652,9 @@ const message = { redisCliHelper: '"redis-cli" perkhidmatan tidak dikesan. Aktifkan perkhidmatan terlebih dahulu.', redisQuickCmd: 'Arahan pantas Redis', recoverHelper: 'Ini akan menimpa data dengan [{0}]. Adakah anda mahu meneruskan?', + mongodbRecoverDropAllCollections: 'Kosongkan pangkalan data semasa sebelum pemulihan', + mongodbRecoverDropAllCollectionsHelper: + 'Secara lalai, hanya koleksi sedia ada akan ditulis ganti dan koleksi baharu akan dikekalkan. Apabila diaktifkan, pangkalan data semasa akan dikosongkan dan kemudian dipulihkan daripada sandaran.', submitIt: 'Tindih data', baseConf: 'Asas', allConf: 'Semua', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index c9037432c..281d551fd 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -646,6 +646,9 @@ const message = { redisCliHelper: '"redis-cli" não foi detectado. Habilite o serviço primeiro.', redisQuickCmd: 'Comandos rápidos Redis', recoverHelper: 'Isso sobrescreverá os dados com [{0}]. Deseja continuar?', + mongodbRecoverDropAllCollections: 'Limpar o banco de dados atual antes da restauração', + mongodbRecoverDropAllCollectionsHelper: + 'Por padrão, apenas as coleções existentes são sobrescritas e as coleções novas são mantidas. Quando ativado, o banco de dados atual é limpo e depois restaurado a partir do backup.', submitIt: 'Sobrescrever os dados', baseConf: 'Básico', allConf: 'Todos', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 2565e482d..ec189ea1d 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -641,6 +641,9 @@ const message = { redisCliHelper: 'Сервис "redis-cli" не обнаружен. Сначала включите сервис.', redisQuickCmd: 'Быстрые команды Redis', recoverHelper: 'Это перезапишет данные с [{0}]. Хотите продолжить?', + mongodbRecoverDropAllCollections: 'Очистить текущую базу данных перед восстановлением', + mongodbRecoverDropAllCollectionsHelper: + 'По умолчанию перезаписываются только существующие коллекции, а новые коллекции сохраняются. При включении текущая база данных очищается, а затем восстанавливается из резервной копии.', submitIt: 'Перезаписать данные', baseConf: 'Базовая', allConf: 'Все', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 5d6cd7abc..46436a74a 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -647,6 +647,9 @@ const message = { redisCliHelper: '"redis-cli" servisi algılanmadı. Önce servisi etkinleştirin.', redisQuickCmd: 'Redis hızlı komutları', recoverHelper: 'Bu işlem verileri [{0}] ile üzerine yazacak. Devam etmek istiyor musunuz?', + mongodbRecoverDropAllCollections: 'Geri yüklemeden önce mevcut veritabanını temizle', + mongodbRecoverDropAllCollectionsHelper: + 'Varsayılan olarak yalnızca mevcut koleksiyonların üzerine yazılır ve yeni eklenen koleksiyonlar korunur. Etkinleştirildiğinde mevcut veritabanı temizlenir ve ardından yedekten geri yüklenir.', submitIt: 'Verilerin üzerine yaz', baseConf: 'Temel', allConf: 'Tümü', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 42ef3d958..edde2bb4a 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -615,6 +615,9 @@ const message = { redisCliHelper: '未偵測到 redis-cli 服務,請先啟用服務', redisQuickCmd: 'Redis 快速指令', recoverHelper: '即將使用 [{0}] 對資料進行覆蓋,是否繼續?', + mongodbRecoverDropAllCollections: '復原前清空目前資料庫', + mongodbRecoverDropAllCollectionsHelper: + '預設僅覆蓋既有集合,保留新增集合;啟用後將清空目前資料庫,並依備份內容復原。', submitIt: '覆蓋資料', baseConf: '基本設定', allConf: '全部設定', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 515e622fb..f5417ad29 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -611,6 +611,9 @@ const message = { redisQuickCmd: 'Redis 快速命令', recoverHelper: '即将使用 [{0}] 对数据进行覆盖,是否继续?', + mongodbRecoverDropAllCollections: '恢复前清空当前数据库', + mongodbRecoverDropAllCollectionsHelper: + '默认仅覆盖已有集合,保留新增集合;开启后将清空当前数据库,并按备份内容恢复。', submitIt: '覆盖数据', baseConf: '基础配置',