From 4e1bc4ff793a90ded08e292846dae71b526e9fa4 Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Thu, 24 Sep 2026 17:32:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(backup):=20=E6=94=AF=E6=8C=81=20Linux=20?= =?UTF-8?q?=E5=92=8C=20Windows=20SQL=20Server=20VDI=20=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增共享的 SQL Server runner 与原生 VDI 组件,接入备份和恢复流程。 要求 SQL 命令与媒体传输同时成功,处理取消、失败清理和 TLS/实例配置。 补齐前端配置、中英文文档、模拟协议测试及可选真实实例验收测试。 Refs #153 --- .dockerignore | 1 + .gitignore | 1 + README.md | 2 +- README.zh-CN.md | 2 +- docs-site/docs/features/backup-types.md | 8 +- docs-site/docs/features/sql-server.md | 101 ++++++ .../current/features/backup-types.md | 8 +- .../current/features/sql-server.md | 101 ++++++ docs-site/sidebars.ts | 1 + native/sqlvdi/CMakeLists.txt | 46 +++ native/sqlvdi/main.cpp | 243 +++++++++++++++ native/sqlvdi/tests/check_worker.py | 41 +++ native/sqlvdi/tests/mock_vdi.cpp | 64 ++++ server/go.mod | 5 +- server/go.sum | 10 + server/internal/agent/executor.go | 26 +- server/internal/agent/executor_test.go | 12 + server/internal/backup/registry.go | 1 + .../backup/sqlserver_integration_test.go | 79 +++++ server/internal/backup/sqlserver_runner.go | 287 ++++++++++++++++++ .../internal/backup/sqlserver_runner_test.go | 198 ++++++++++++ server/internal/backup/types.go | 13 +- .../internal/service/backup_task_service.go | 19 +- .../service/backup_task_service_test.go | 19 ++ server/internal/service/dashboard_service.go | 2 + server/internal/service/execution_helpers.go | 13 +- .../backup-tasks/BackupTaskDetailDrawer.tsx | 14 +- .../backup-tasks/BackupTaskFormDrawer.tsx | 46 ++- .../backup-tasks/field-config.test.ts | 3 + .../components/backup-tasks/field-config.ts | 13 +- web/src/types/backup-tasks.ts | 3 +- 31 files changed, 1344 insertions(+), 38 deletions(-) create mode 100644 docs-site/docs/features/sql-server.md create mode 100644 docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sql-server.md create mode 100644 native/sqlvdi/CMakeLists.txt create mode 100644 native/sqlvdi/main.cpp create mode 100644 native/sqlvdi/tests/check_worker.py create mode 100644 native/sqlvdi/tests/mock_vdi.cpp create mode 100644 server/internal/backup/sqlserver_integration_test.go create mode 100644 server/internal/backup/sqlserver_runner.go create mode 100644 server/internal/backup/sqlserver_runner_test.go diff --git a/.dockerignore b/.dockerignore index cff4bcc..7aaafe0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ web/node_modules/ # Build artifacts server/bin/ web/dist/ +.build-tmp/ # Data & logs data/ diff --git a/.gitignore b/.gitignore index 96dce16..e4abcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ desktop.ini *~ .claude/ .codex/ +.build-tmp/ diff --git a/README.md b/README.md index 4e76f36..e0e1a77 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ | Capability | Details | |-----------|---------| -| **Backup Types** | Files/directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA (full / incremental / differential / log + parallel channels + retry) | +| **Backup Types** | Files/directories (multi-source), MySQL, PostgreSQL, SQLite, SQL Server (VDI; Linux/Windows worker), SAP HANA (full / incremental / differential / log + parallel channels + retry) | | **SAP HANA Backint Agent** | Built-in Backint protocol — HANA's native interface routes data directly to any BackupX storage backend | | **70+ Storage Backends** | Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP + SFTP, Azure Blob, Dropbox, OneDrive and dozens more via rclone | | **Scheduling** | Cron + visual editor + auto-retention (by days/count + empty-directory cleanup) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 0adb5a0..0c11ca8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,7 @@ | 能力 | 说明 | |------|------| -| **备份类型** | 文件/目录(多源路径)、MySQL、PostgreSQL、SQLite、SAP HANA(完整/增量/差异/日志备份 + 并行通道 + 失败重试) | +| **备份类型** | 文件/目录(多源路径)、MySQL、PostgreSQL、SQLite、SQL Server(VDI,需 Linux/Windows 原生组件)、SAP HANA(完整/增量/差异/日志备份 + 并行通道 + 失败重试) | | **SAP HANA Backint 代理** | 内置 SAP HANA Backint 协议代理,HANA 原生备份接口可直接把数据路由到 BackupX 支持的任意存储后端 | | **70+ 存储后端** | 内置阿里云 OSS / 腾讯云 COS / 七牛云 / S3 / Google Drive / WebDAV / FTP + 通过 rclone 集成 SFTP、Azure Blob、Dropbox、OneDrive 等 70+ 后端 | | **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理,自动回收空目录) | diff --git a/docs-site/docs/features/backup-types.md b/docs-site/docs/features/backup-types.md index f41b946..3f8f9cd 100644 --- a/docs-site/docs/features/backup-types.md +++ b/docs-site/docs/features/backup-types.md @@ -1,12 +1,12 @@ --- sidebar_position: 1 title: Backup Types -description: File, MySQL, PostgreSQL, SQLite and SAP HANA — what they back up and what to configure. +description: File, MySQL, PostgreSQL, SQLite, SQL Server and SAP HANA — what they back up and what to configure. --- # Backup Types -BackupX supports five built-in backup types. Type determines which runner executes the job. +BackupX supports the following built-in backup types. Type determines which runner executes the job. When a task is routed to a remote Agent, the source tools and paths are resolved on that Agent host. Multi-target uploads are still tracked per storage target; if at least one target succeeds, the backup record is marked successful and the per-target result table shows partial failures. @@ -52,3 +52,7 @@ Two modes are supported — see the dedicated [SAP HANA](./sap-hana) page. ## Deletion behavior When a task is deleted, BackupX removes backup artifacts from every storage target but preserves backup records for audit. Task deletion also tears down the cron schedule entry. + +## SQL Server (VDI) + +[SQL Server VDI](./sql-server) requires a native worker on the database host; supports Linux and Windows COPY_ONLY full backups. diff --git a/docs-site/docs/features/sql-server.md b/docs-site/docs/features/sql-server.md new file mode 100644 index 0000000..5466fe6 --- /dev/null +++ b/docs-site/docs/features/sql-server.md @@ -0,0 +1,101 @@ +--- +sidebar_position: 4 +title: SQL Server VDI +description: Native SQL Server full backups on Linux and Windows through a local VDI worker. +--- + +# SQL Server VDI + +SQL Server tasks create a native `.bak` with `BACKUP DATABASE ... WITH COPY_ONLY, CHECKSUM`. COPY_ONLY preserves an existing differential backup base. Each task handles one database; `tempdb`, differential backups and transaction-log backups are not supported in this implementation. + +The Master can run on a different machine. The **execution node and `backupx-sqlvdi` worker must share the SQL Server host and its shared-memory environment**. Bind the task to that specific Agent, or run the Master on the SQL host. A node pool or a remote database hostname is not supported. The worker handles media I/O; BackupX sends SQL through Microsoft's Go driver, without passing credentials to a shell or child-process command line. + +## Prerequisites + +- SQL Server on Linux x64 or Windows x64, with TCP enabled and a known port. +- A SQL authentication login with the **sysadmin** server role (required by VDI). +- A current BackupX Master and Agent built from this source, plus the platform-specific worker on the execution node's `PATH`. +- Enough local temporary disk space for the uncompressed `.bak` and any subsequent compression. Uploads and retention use the existing BackupX pipeline; remote Agent encryption remains unsupported. + +TLS is enabled. Certificate validation is enabled by default. For a local instance with a self-signed certificate, explicitly enable **Trust server certificate** in the task. The connection is restricted to `localhost` or a loopback IP. + +## Linux worker + +Install SQL Server's supported Linux distribution, its `/opt/mssql/lib/libsqlvdi.so`, a C++17 compiler and CMake 3.20 or newer. From the repository root: + +```bash +cmake -S native/sqlvdi -B .build-tmp/sqlvdi -DCMAKE_BUILD_TYPE=Release +cmake --build .build-tmp/sqlvdi +sudo cmake --install .build-tmp/sqlvdi --prefix /usr/local +``` + +CMake downloads the Microsoft SDK headers from a pinned revision and verifies their SHA-256 hashes. `SQLVDI_SDK_DIR` can point to pre-downloaded copies of the same headers for an offline build. The Microsoft shared library is supplied by SQL Server, not redistributed with BackupX. + +Run the Agent as `mssql`, with a writable private temporary directory, or configure the shared-memory group permissions described in the [Microsoft VDI specification](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-backup-vdi-specification). Merely running on the same machine with an unrelated user may fail to open shared memory. + +For containers, the usual BackupX Alpine container is **not** a VDI execution environment. Run the Agent and worker alongside SQL Server in its compatible environment; the Master can retain the normal Docker deployment. Separate containers need correctly shared IPC and permissions, not just a shared backup directory. Follow Microsoft's [VDI container requirements](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-docker-container-configure#enable-vdi-backup-and-restore-in-containers) before using this topology. Container deployment is not automatically configured by BackupX. + +## Windows worker and Agent + +Use the SQL Server x64 VDI COM component installed and registered by SQL Server, Visual Studio's C++ build tools, the Windows SDK, CMake and Go 1.25+. In PowerShell at the repository root: + +```powershell +cmake -S native/sqlvdi -B .build-tmp/sqlvdi-windows -A x64 +cmake --build .build-tmp/sqlvdi-windows --config Release +$env:CGO_ENABLED = '0' +go -C server build -o ../.build-tmp/backupx.exe ./cmd/backupx +``` + +Place `backupx.exe` and the worker from `.build-tmp/sqlvdi-windows/Release/backupx-sqlvdi.exe` in the chosen installation directory. Add that directory to the Agent process's `PATH`. Use an OS account that can access the instance's VDI shared objects and the temporary directory. + +The existing node installer targets Linux; Windows installation is manual. Create a node in the console and use its token with the existing Agent configuration: + +```powershell +$env:PATH = 'C:\BackupX;' + $env:PATH +$env:BACKUPX_AGENT_MASTER = 'https://backup.example.com' +$env:BACKUPX_AGENT_TOKEN = '' +$env:BACKUPX_AGENT_TEMP_DIR = 'C:\BackupX\tmp' +C:\BackupX\backupx.exe agent +``` + +For unattended execution, use your existing Windows process supervisor or Task Scheduler. `backupx agent` is a console application, not a Windows Service executable. A named instance such as `SQLEXPRESS` requires **Windows instance name** in the task and its actual TCP port; the port is not discovered through SQL Browser. Leave the instance name empty for the default instance and for Linux. + +## Configure and restore + +1. Select **SQL Server (VDI)** and the fixed execution node. +2. Enter the loopback host, TCP port, login/password and one database name. +3. Set the optional Windows instance name and certificate option. +4. Select storage targets and the schedule, then run a manual backup first. + +Backup success requires both the VDI worker and the SQL command to succeed. Output is flushed before acknowledging `VDC_Complete`; older servers without this command are flushed before worker success. Failed or cancelled backups are not uploaded, and their local temporary directory is removed. Cancellation closes the worker's control pipe, invokes VDI abort/close, and force-stops an unresponsive worker after five seconds. + +The existing restore action downloads and decompresses the `.bak`, then streams it through VDI with `RESTORE DATABASE ... WITH CHECKSUM`. It intentionally does not issue `WITH REPLACE`, force-disconnect database clients or rewrite file locations. SQL Server may reject restore because the database is in use, its files conflict or its overwrite protections apply; resolve those conditions explicitly. Restoring under another name or with `MOVE` currently requires SQL Server's own tools. + +Automatic verification drills are unavailable for this task type. A successful transfer or checksum is **not a completed restore drill**. Before relying on backups, restore a disposable database on each target OS and check its data (and `DBCC CHECKDB` where appropriate). Windows cross-compilation and mocked VDI tests do not establish real COM/shared-memory or database compatibility. + +## Development checks + +The native protocol check links the production worker with a fake VDI library; it does not need SQL Server. After downloading the pinned Linux headers into `.build-tmp/sdk/linux`: + +```bash +c++ -std=c++17 -pthread -I .build-tmp/sdk/linux native/sqlvdi/main.cpp \ + native/sqlvdi/tests/mock_vdi.cpp -o .build-tmp/sqlvdi-worker-test +TMPDIR="$PWD/.build-tmp" python3 native/sqlvdi/tests/check_worker.py .build-tmp/sqlvdi-worker-test +``` + +Sources: [Microsoft VDI reference](https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/vdi-reference/reference-virtual-device-interface), [Linux SDK](https://github.com/microsoft/sql-server-samples/tree/master/samples/features/sqlvdi-linux), [Windows SDK](https://github.com/microsoft/sql-server-samples/tree/master/samples/features/sqlvdi). + +### Real SQL Server acceptance test + +Run only on a dedicated test instance. This creates a unique `backupx_vdi_it_*` database, seeds it, backs it up, drops that test database, restores it, checks data and runs `DBCC CHECKDB`, then removes the test database. It does not use an existing application database. The real native worker must be on `PATH`. + +```bash +export BACKUPX_SQLSERVER_INTEGRATION=1 +export BACKUPX_SQLSERVER_TEST_USER=backup_test +# Set BACKUPX_SQLSERVER_TEST_PASSWORD securely in the current process environment. +# Optional: BACKUPX_SQLSERVER_TEST_PORT, BACKUPX_SQLSERVER_TEST_INSTANCE, +# BACKUPX_SQLSERVER_TEST_TRUST_CERTIFICATE=1 for a self-signed local certificate. +TMPDIR="$PWD/.build-tmp" go -C server test ./internal/backup -run '^TestSQLServerVDIRoundTrip$' -v -count=1 +``` + +On Windows, set the same environment variables with PowerShell `$env:NAME` and run the same `go test` command. This test is skipped by default; compilation and mocked tests do not replace running it on each real OS. diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/backup-types.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/backup-types.md index bd6042e..50f604d 100644 --- a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/backup-types.md +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/backup-types.md @@ -1,12 +1,12 @@ --- sidebar_position: 1 title: 备份类型 -description: 文件、MySQL、PostgreSQL、SQLite 和 SAP HANA — 各自的能力与配置说明。 +description: 文件、MySQL、PostgreSQL、SQLite、SQL Server 和 SAP HANA — 各自的能力与配置说明。 --- # 备份类型 -BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行。 +BackupX 支持以下内置备份类型,类型决定了用哪个 runner 执行。 当任务路由到远程 Agent 时,源路径和外部工具都会在该 Agent 主机上解析。多存储目标上传仍会逐目标记录结果;只要至少一个目标上传成功,备份记录即为成功,详情中的目标结果表会展示部分失败。 @@ -52,3 +52,7 @@ CDC 仓库会在不同文件、不同快照之间复用相同内容。完整恢 ## 删除行为 删除备份任务时,BackupX 会从所有存储目标上移除备份产物,但保留备份记录以供审计。删除任务同时拆除其 Cron 定时调度。 + +## SQL Server (VDI) + +[SQL Server VDI](./sql-server) 需要在数据库主机安装原生组件,支持 Linux 和 Windows 的 COPY_ONLY 完整备份。 diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sql-server.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sql-server.md new file mode 100644 index 0000000..8dc8313 --- /dev/null +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sql-server.md @@ -0,0 +1,101 @@ +--- +sidebar_position: 4 +title: SQL Server VDI +description: 在 Linux 和 Windows SQL Server 主机通过 VDI 执行原生完整备份。 +--- + +# SQL Server VDI + +SQL Server 类型生成原生 `.bak`,执行 `BACKUP DATABASE ... WITH COPY_ONLY, CHECKSUM`。COPY_ONLY 不改变现有差异备份基线。每个任务只处理一个数据库;本次不支持 `tempdb`、差异备份和事务日志备份。 + +Master 可以部署在其他机器。**执行节点和 `backupx-sqlvdi` 必须位于 SQL Server 所在主机,并能访问同一共享内存环境**。任务必须选择该固定 Agent,或者在 SQL Server 主机运行 Master;不支持节点池和远程数据库地址。原生组件仅处理 VDI 介质,Go 进程使用微软驱动发送 SQL,数据库密码不会传入 shell 或子进程参数。 + +## 前置条件 + +- Linux x64 或 Windows x64 SQL Server,启用 TCP 并确认实际端口。 +- SQL 登录账号具有 **sysadmin** 服务器角色,这是 VDI 要求。 +- Master、Agent 均使用包含此功能的版本;对应平台的原生组件位于执行节点进程的 `PATH`。 +- 临时目录有足够空间容纳未压缩 `.bak` 及后续压缩产物。上传、保留策略沿用现有链路,远程 Agent 仍不支持 BackupX 加密。 + +默认启用 TLS 并校验服务器证书。若本机 SQL Server 使用自签名证书,需显式打开任务中的“信任服务器证书”。主机只允许 `localhost` 或回环 IP。 + +## Linux 部署 + +准备 SQL Server 支持的 Linux 发行版、SQL Server 自带的 `/opt/mssql/lib/libsqlvdi.so`、C++17 编译器和 CMake 3.20+。在项目根目录执行: + +```bash +cmake -S native/sqlvdi -B .build-tmp/sqlvdi -DCMAKE_BUILD_TYPE=Release +cmake --build .build-tmp/sqlvdi +sudo cmake --install .build-tmp/sqlvdi --prefix /usr/local +``` + +构建时从固定微软仓库版本下载 SDK 头文件,并验证 SHA-256;离线构建可以用 `SQLVDI_SDK_DIR` 指向相同版本的头文件。微软动态库由 SQL Server 安装提供,不随 BackupX 分发。 + +推荐 Agent 使用 `mssql` 身份运行,并使用该账号可写的独立临时目录;也可以按[微软规范](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-backup-vdi-specification)设置双向用户组权限。仅在同机运行但权限不匹配,仍会导致 VDI 失败。 + +默认 BackupX Alpine 镜像不是 VDI 执行环境。Master 可继续使用原有 Docker 部署,Agent 和原生组件应运行于 SQL Server 的兼容环境中。跨容器需要共享 IPC 和正确权限,仅共享备份目录不够;请先核对[微软容器 VDI 要求](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-docker-container-configure#enable-vdi-backup-and-restore-in-containers)。BackupX 不会自动修改容器或 SQL Server 配置。 + +## Windows 部署 + +需要 SQL Server 安装并注册的 x64 VDI COM 组件、Visual Studio C++ 构建工具、Windows SDK、CMake 和 Go 1.25+。在项目根目录的 PowerShell 中执行: + +```powershell +cmake -S native/sqlvdi -B .build-tmp/sqlvdi-windows -A x64 +cmake --build .build-tmp/sqlvdi-windows --config Release +$env:CGO_ENABLED = '0' +go -C server build -o ../.build-tmp/backupx.exe ./cmd/backupx +``` + +将 `backupx.exe` 和 `.build-tmp/sqlvdi-windows/Release/backupx-sqlvdi.exe` 放入安装目录,并将目录加入 Agent 进程的 `PATH`。运行身份必须能访问实例的 VDI 共享对象和临时目录。 + +当前一键节点安装器面向 Linux;Windows 使用手动部署。在控制台创建节点,使用其令牌启动: + +```powershell +$env:PATH = 'C:\BackupX;' + $env:PATH +$env:BACKUPX_AGENT_MASTER = 'https://backup.example.com' +$env:BACKUPX_AGENT_TOKEN = '<节点令牌>' +$env:BACKUPX_AGENT_TEMP_DIR = 'C:\BackupX\tmp' +C:\BackupX\backupx.exe agent +``` + +长期运行可交给已有进程托管器或 Windows 任务计划程序。`backupx agent` 是控制台程序,不能直接作为 Windows Service 注册。对于 `SQLEXPRESS` 等命名实例,任务填写“Windows 实例名称”和该实例实际 TCP 端口;不通过 SQL Browser 自动发现端口。默认实例和 Linux 留空实例名称。 + +## 配置、恢复与验证 + +1. 选择“SQL Server (VDI)”和数据库所在固定节点。 +2. 填写回环地址、TCP 端口、账号密码、一个数据库名称。 +3. 按需填写 Windows 实例名和证书选项。 +4. 配置存储和计划,先手动执行一次备份。 + +只有原生组件和 SQL 命令都成功,才会上报备份成功。支持 `VDC_Complete` 时在回应前完成落盘;旧版本在原生组件退出成功前完成落盘。失败或取消不会上传半成品,并清理任务临时目录。取消通过控制管道 EOF 触发 VDI 中止和关闭;原生库无响应时五秒后强制退出。 + +现有恢复入口下载、解压备份后,通过 VDI 执行 `RESTORE DATABASE ... WITH CHECKSUM`。不会自动使用 `WITH REPLACE`、强制踢掉数据库连接或更改数据库文件位置。数据库被占用、文件冲突或触发 SQL Server 覆盖保护时会失败,需要管理员明确处理。恢复到另一个数据库名或使用 `MOVE` 目前请使用 SQL Server 工具。 + +此类型暂不支持自动验证演练。传输成功、校验和通过不等于恢复验收完成;上线前应在每种目标系统上备份并恢复一个可丢弃数据库,核对数据,必要时执行 `DBCC CHECKDB`。Windows 交叉编译及模拟 VDI 测试不代表真实 COM、共享内存和数据库已通过兼容性验证。 + +## 开发验证 + +原生协议检查将真实组件源码与模拟 VDI 库链接,不需要 SQL Server。准备固定版本的 Linux SDK 头文件到 `.build-tmp/sdk/linux` 后执行: + +```bash +c++ -std=c++17 -pthread -I .build-tmp/sdk/linux native/sqlvdi/main.cpp \ + native/sqlvdi/tests/mock_vdi.cpp -o .build-tmp/sqlvdi-worker-test +TMPDIR="$PWD/.build-tmp" python3 native/sqlvdi/tests/check_worker.py .build-tmp/sqlvdi-worker-test +``` + +参考:[微软 VDI 规范](https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/vdi-reference/reference-virtual-device-interface)、[Linux SDK](https://github.com/microsoft/sql-server-samples/tree/master/samples/features/sqlvdi-linux)、[Windows SDK](https://github.com/microsoft/sql-server-samples/tree/master/samples/features/sqlvdi)。 + +### 真实 SQL Server 验收 + +仅在专用测试实例运行。此测试创建唯一的 `backupx_vdi_it_*` 数据库,写入数据、备份、删除该测试库、恢复、核对数据并执行 `DBCC CHECKDB`,最后删除测试库;不会使用已有业务库。原生组件必须位于 `PATH`。 + +```bash +export BACKUPX_SQLSERVER_INTEGRATION=1 +export BACKUPX_SQLSERVER_TEST_USER=backup_test +# Set BACKUPX_SQLSERVER_TEST_PASSWORD securely in the current process environment. +# Optional: BACKUPX_SQLSERVER_TEST_PORT, BACKUPX_SQLSERVER_TEST_INSTANCE, +# BACKUPX_SQLSERVER_TEST_TRUST_CERTIFICATE=1 for a self-signed local certificate. +TMPDIR="$PWD/.build-tmp" go -C server test ./internal/backup -run '^TestSQLServerVDIRoundTrip$' -v -count=1 +``` + +Windows 请在 PowerShell 用 `$env:变量名` 设置相同环境变量,再运行同一个 `go test` 命令。默认未启用此测试;编译和模拟测试通过不能代替两种真实系统上的验收。 diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index fb68eec..52f670c 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -39,6 +39,7 @@ const sidebars: SidebarsConfig = { 'features/backup-types', 'features/storage-backends', 'features/sap-hana', + 'features/sql-server', 'features/multi-node', 'features/notifications', ], diff --git a/native/sqlvdi/CMakeLists.txt b/native/sqlvdi/CMakeLists.txt new file mode 100644 index 0000000..b5d8162 --- /dev/null +++ b/native/sqlvdi/CMakeLists.txt @@ -0,0 +1,46 @@ +cmake_minimum_required(VERSION 3.20) +project(backupx_sqlvdi LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# SDK headers are downloaded from a pinned Microsoft revision and hash-checked. +set(SQLVDI_REV beaab06ef72831089ca80e5355d65e661fd19b26) +set(SQLVDI_SDK_DIR "${CMAKE_CURRENT_BINARY_DIR}/sdk" CACHE PATH "SQL VDI SDK headers") +file(MAKE_DIRECTORY "${SQLVDI_SDK_DIR}") +if(WIN32) + set(sdk_folder sqlvdi/include) + set(sdk_files vdi.h vdierror.h vdiguid.h) + set(sdk_hashes 84f8ded344f9a49888ac23ae1fdf1b118ec1a04f8d2d7c837975150901a8fda3 ebc6b955d25d9f61b65df9dd100562eadbca47a1699e608f228ad385c641fef4 666b777a5f859d21305f53f0356e73ca0f0bb8ae9580c9177fd334aad0f5b2ba) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(sdk_folder sqlvdi-linux) + set(sdk_files vdi.h vdierror.h) + set(sdk_hashes c9ae8b0a1961b941e965b13bcd90ba95168449f1734618ef581c8cfa56fe1c66 a1d90a52038a0334e2c30516a8f7122865215321d8ad275aebe171a0cb729a9f) +else() + message(FATAL_ERROR "SQL Server VDI requires Windows or Linux") +endif() +foreach(name hash IN ZIP_LISTS sdk_files sdk_hashes) + if(EXISTS "${SQLVDI_SDK_DIR}/${name}") + file(SHA256 "${SQLVDI_SDK_DIR}/${name}" actual_hash) + if(NOT actual_hash STREQUAL hash) + message(FATAL_ERROR "SDK header hash mismatch: ${name}") + endif() + else() + file(DOWNLOAD "https://raw.githubusercontent.com/microsoft/sql-server-samples/${SQLVDI_REV}/samples/features/${sdk_folder}/${name}" + "${SQLVDI_SDK_DIR}/${name}" EXPECTED_HASH "SHA256=${hash}" TLS_VERIFY ON) + endif() +endforeach() +find_package(Threads REQUIRED) +add_executable(backupx-sqlvdi main.cpp) +target_include_directories(backupx-sqlvdi PRIVATE "${SQLVDI_SDK_DIR}") +target_link_libraries(backupx-sqlvdi PRIVATE Threads::Threads) +if(WIN32) + target_link_libraries(backupx-sqlvdi PRIVATE ole32) + if(MINGW) + target_link_options(backupx-sqlvdi PRIVATE -municode -static) + endif() +else() + find_library(SQLVDI_LIBRARY sqlvdi HINTS /opt/mssql/lib REQUIRED) + target_link_libraries(backupx-sqlvdi PRIVATE "${SQLVDI_LIBRARY}") + set_target_properties(backupx-sqlvdi PROPERTIES INSTALL_RPATH /opt/mssql/lib) +endif() +install(TARGETS backupx-sqlvdi RUNTIME DESTINATION bin) diff --git a/native/sqlvdi/main.cpp b/native/sqlvdi/main.cpp new file mode 100644 index 0000000..feaa232 --- /dev/null +++ b/native/sqlvdi/main.cpp @@ -0,0 +1,243 @@ +// SQL Server VDI media worker. SQL commands and credentials stay in the Go process. +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include "vdi.h" +#include "vdierror.h" +#include "vdiguid.h" +using Device = IClientVirtualDevice; +#else +#include +#include +#include "vdi.h" +#include "vdierror.h" +using Device = ClientVirtualDevice; +#endif + +namespace { +std::atomic cancelled{false}; +using Clock = std::chrono::steady_clock; +// These protocol values also work with the older Windows SDK header. +constexpr unsigned requestComplete = 0x2000; +constexpr unsigned completeCommand = 19; + +void check(int status, const char* stage) { + if (status != 0) { + char message[128]; + std::snprintf(message, sizeof(message), "%s failed (VDI 0x%08x)", stage, static_cast(status)); + throw std::runtime_error(message); + } +} + +#ifdef _WIN32 +std::wstring wide(const std::string& text) { + if (text.empty()) return {}; + int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), static_cast(text.size()), nullptr, 0); + if (!size) throw std::runtime_error("invalid UTF-8 argument"); + std::wstring value(size, L'\0'); + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), static_cast(text.size()), value.data(), size)) { + throw std::runtime_error("UTF-8 conversion failed"); + } + return value; +} +#endif + +struct DeviceSet { +#ifdef _WIN32 + IClientVirtualDeviceSet2* set = nullptr; + DeviceSet() { + auto initialized = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(initialized)) check(initialized, "CoInitializeEx"); + auto status = CoCreateInstance(CLSID_MSSQL_ClientVirtualDeviceSet, nullptr, CLSCTX_INPROC_SERVER, + IID_IClientVirtualDeviceSet2, reinterpret_cast(&set)); + if (status != 0) { + CoUninitialize(); + check(status, "CoCreateInstance (install/register SQL Server VDI x64)"); + } + } + ~DeviceSet() { set->Release(); CoUninitialize(); } + void create(const std::string& name, const std::string& instance, VDConfig& config) { + auto n = wide(name), i = wide(instance); + check(set->CreateEx(i.empty() ? nullptr : i.c_str(), n.c_str(), &config), "CreateEx"); + } + Device* open(const std::string& name) { + Device* device = nullptr; + check(set->OpenDevice(wide(name).c_str(), &device), "OpenDevice"); + return device; + } +#else + ClientVirtualDeviceSet storage; + ClientVirtualDeviceSet* set = &storage; + void create(const std::string& name, const std::string& instance, VDConfig& config) { + if (!instance.empty()) throw std::runtime_error("named instances are Windows-only"); + check(set->Create(const_cast(name.c_str()), &config), "Create (run as mssql or configure shared-memory permissions)"); + } + Device* open(const std::string& name) { + Device* device = nullptr; + check(set->OpenDevice(const_cast(name.c_str()), &device), "OpenDevice"); + return device; + } +#endif +}; + +struct Media { + FILE* file = nullptr; + bool backup; + Media(const std::string& path, bool write) : backup(write) { +#ifdef _WIN32 + int fd = _wopen(wide(path).c_str(), _O_BINARY | (write ? _O_WRONLY | _O_CREAT | _O_EXCL : _O_RDONLY), _S_IREAD | _S_IWRITE); + if (fd >= 0) { + file = _fdopen(fd, write ? "wb" : "rb"); + if (!file && _close(fd) != 0) std::fprintf(stderr, "close media descriptor failed\n"); + } +#else + int fd = ::open(path.c_str(), write ? O_WRONLY | O_CREAT | O_EXCL : O_RDONLY, 0600); + if (fd >= 0) { + file = fdopen(fd, write ? "wb" : "rb"); + if (!file && ::close(fd) != 0) std::fprintf(stderr, "close media descriptor failed\n"); + } +#endif + if (!file) throw std::runtime_error("open media failed (backup output must not already exist)"); + } + ~Media() { if (file && fclose(file) != 0) std::fprintf(stderr, "close media failed\n"); } + bool flush() { + if (!file || !backup) return true; + if (fflush(file) != 0) return false; +#ifdef _WIN32 + return _commit(_fileno(file)) == 0; +#else + return fsync(fileno(file)) == 0; +#endif + } + bool finish() { + if (!file) return true; + bool ok = flush(); + if (fclose(file) != 0) ok = false; + file = nullptr; + return ok; + } +}; + +void transfer(Device* device, Media& media) { + auto lastCommand = Clock::now(); + while (!cancelled.load()) { + VDC_Command* command = nullptr; + int status = device->GetCommand(1000, &command); + if (status == VD_E_CLOSE) { + if (!media.finish()) throw std::runtime_error("final media flush/close failed"); + return; + } + if (status == VD_E_TIMEOUT) { + if (Clock::now() - lastCommand > std::chrono::minutes(5)) throw std::runtime_error("VDI transfer idle timeout"); + continue; + } + check(status, "GetCommand"); + lastCommand = Clock::now(); + if (!command || ((command->commandCode == VDC_Read || command->commandCode == VDC_Write) && + (static_cast(command->size) < 0 || (command->size && !command->buffer)))) { + throw std::runtime_error("invalid VDI command buffer"); + } + unsigned code = ERROR_SUCCESS; + size_t bytes = 0; + switch (command->commandCode) { + case VDC_Write: + if (!media.backup || !media.file) { code = ERROR_NOT_SUPPORTED; break; } + bytes = fwrite(command->buffer, 1, command->size, media.file); + if (bytes != static_cast(command->size)) code = ERROR_DISK_FULL; + break; + case VDC_Read: + if (media.backup || !media.file) { code = ERROR_NOT_SUPPORTED; break; } + bytes = fread(command->buffer, 1, command->size, media.file); + if (ferror(media.file)) code = 30; // Win32 ERROR_READ_FAULT + else if (bytes != static_cast(command->size)) code = ERROR_HANDLE_EOF; + break; + case VDC_Flush: + if (!media.flush()) code = ERROR_DISK_FULL; + break; + case VDC_ClearError: + // Never discard a real media failure: failures terminate below. + break; + case completeCommand: + if (!media.finish()) code = ERROR_DISK_FULL; + break; + default: + code = ERROR_NOT_SUPPORTED; + } + check(device->CompleteCommand(command, code, static_cast(bytes), 0), "CompleteCommand"); + if (code != ERROR_SUCCESS && code != ERROR_HANDLE_EOF) throw std::runtime_error("VDI media command failed"); + } + throw std::runtime_error("VDI operation cancelled"); +} + +int run(int argc, char** argv) { + if (argc != 5 || (std::strcmp(argv[1], "backup") && std::strcmp(argv[1], "restore"))) { + throw std::runtime_error("usage: backupx-sqlvdi backup|restore device-name artifact-path instance-name"); + } + Media media(argv[3], std::strcmp(argv[1], "backup") == 0); + DeviceSet devices; + VDConfig config{}; + config.deviceCount = 1; + config.features = requestComplete; + devices.create(argv[2], argv[4], config); + try { + // EOF is the cross-platform cancellation signal, including parent-process death. + std::thread([] { while (std::getchar() != EOF) {} cancelled.store(true); }).detach(); + if (std::fputs("BACKUPX_SQLVDI_READY\n", stdout) < 0 || std::fflush(stdout) != 0) throw std::runtime_error("write readiness failed"); + auto deadline = Clock::now() + std::chrono::seconds(60); + for (;;) { + if (cancelled.load()) throw std::runtime_error("VDI configuration cancelled"); + int status = devices.set->GetConfiguration(1000, &config); + if (status == VD_E_TIMEOUT && Clock::now() < deadline) continue; + check(status, "GetConfiguration"); + break; + } + transfer(devices.open(argv[2]), media); + } catch (...) { + int status = devices.set->SignalAbort(); + if (status != 0) std::fprintf(stderr, "SignalAbort failed: 0x%08x\n", static_cast(status)); + status = devices.set->Close(); + if (status != 0) std::fprintf(stderr, "Close after abort failed: 0x%08x\n", static_cast(status)); + throw; + } + check(devices.set->Close(), "Close"); + return 0; +} +} // namespace + +#ifdef _WIN32 +// CRT narrow argv uses the system code page; convert UTF-16 arguments explicitly. +int wmain(int argc, wchar_t** argv) { + try { + std::string args[5]; + char* pointers[5]; + if (argc != 5) throw std::runtime_error("expected four arguments"); + for (int i = 0; i < argc; ++i) { + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, argv[i], -1, nullptr, 0, nullptr, nullptr); + if (!size) throw std::runtime_error("invalid UTF-16 argument"); + args[i].resize(size); + if (!WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, argv[i], -1, args[i].data(), size, nullptr, nullptr)) throw std::runtime_error("argument conversion failed"); + pointers[i] = args[i].data(); + } + return run(argc, pointers); + } catch (const std::exception& e) { std::fprintf(stderr, "%s\n", e.what()); return 1; } +} +#else +int main(int argc, char** argv) { + try { return run(argc, argv); } + catch (const std::exception& e) { std::fprintf(stderr, "%s\n", e.what()); return 1; } +} +#endif diff --git a/native/sqlvdi/tests/check_worker.py b/native/sqlvdi/tests/check_worker.py new file mode 100644 index 0000000..bc62fcc --- /dev/null +++ b/native/sqlvdi/tests/check_worker.py @@ -0,0 +1,41 @@ +"""Run against main.cpp linked with mock_vdi.cpp (not a SQL Server acceptance test).""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +worker = str(Path(sys.argv[1]).resolve()) +for scenario in ("success", "legacy", "restore", "abort", "unknown_command", "cancel", "create_fail"): + with tempfile.TemporaryDirectory(prefix="sqlvdi-test-") as folder: + path = Path(folder) / "数据库备份.bak" + log = Path(folder) / "protocol.log" + if scenario == "restore": + path.write_bytes(b"backup") + env = dict(os.environ, BACKUPX_TEST_VDI_CASE=scenario, BACKUPX_TEST_VDI_LOG=str(log)) + p = subprocess.Popen([worker, "restore" if scenario == "restore" else "backup", "BackupX-test", str(path), ""], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + try: + if scenario != "create_fail": + assert p.stdout.readline() == b"BACKUPX_SQLVDI_READY\n" + if scenario == "cancel": + p.stdin.close() + status = p.wait(timeout=5) + error = p.stderr.read().decode() + protocol = log.read_text() if log.exists() else "" + if scenario in ("success", "legacy", "restore"): + assert status == 0, (scenario, error) + assert path.read_bytes() == b"backup" + assert "close" in protocol and "abort" not in protocol + assert ("complete" in protocol) == (scenario != "legacy") + else: + assert status != 0, scenario + if scenario != "create_fail": + assert "abort\nclose" in protocol, (scenario, protocol) + print(f"PASS {scenario}") + finally: + if p.poll() is None: + p.kill() + p.wait() + for stream in (p.stdin, p.stdout, p.stderr): + stream.close() diff --git a/native/sqlvdi/tests/mock_vdi.cpp b/native/sqlvdi/tests/mock_vdi.cpp new file mode 100644 index 0000000..cedab8a --- /dev/null +++ b/native/sqlvdi/tests/mock_vdi.cpp @@ -0,0 +1,64 @@ +// Link-only VDI stand-in: exercises the production worker without SQL Server. +#include "vdi.h" +#include "vdierror.h" +#include +#include +#include +#include +#include + +namespace { +const char* scenario() { return std::getenv("BACKUPX_TEST_VDI_CASE"); } +void log(const char* text) { + FILE* file = std::fopen(std::getenv("BACKUPX_TEST_VDI_LOG"), "a"); + if (!file) std::abort(); + if (std::fprintf(file, "%s\n", text) < 0 || std::fclose(file) != 0) std::abort(); +} +} +class CVD { +public: + int step = 0; + uint8_t buffer[6] = {'b','a','c','k','u','p'}; + VDC_Command cmd{}; +}; +class CVDS { public: ClientVirtualDevice device; }; +ClientVirtualDevice::ClientVirtualDevice() : cvd(new CVD) {} +ClientVirtualDevice::~ClientVirtualDevice() { delete cvd; } +ClientVirtualDeviceSet::ClientVirtualDeviceSet() : cvds(new CVDS) {} +ClientVirtualDeviceSet::~ClientVirtualDeviceSet() { delete cvds; } +int ClientVirtualDeviceSet::Create(char*, VDConfig* config) { + if (config->deviceCount != 1 || config->features != VDF_RequestComplete) return VD_E_INVALID; + return std::strcmp(scenario(), "create_fail") == 0 ? VD_E_SECURITY : 0; +} +int ClientVirtualDeviceSet::GetConfiguration(time_t, VDConfig*) { + if (std::strcmp(scenario(), "cancel") == 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + return VD_E_TIMEOUT; + } + return 0; +} +int ClientVirtualDeviceSet::OpenDevice(char*, ClientVirtualDevice** device) { *device = &cvds->device; return 0; } +int ClientVirtualDeviceSet::Close() { log("close"); return 0; } +int ClientVirtualDeviceSet::SignalAbort() { log("abort"); return 0; } +int ClientVirtualDevice::GetCommand(time_t, VDC_Command** command) { + int step = cvd->step++; + cvd->cmd.size = sizeof(cvd->buffer); + cvd->cmd.buffer = cvd->buffer; + if (step == 0) { + cvd->cmd.commandCode = std::strcmp(scenario(), "restore") == 0 ? VDC_Read : VDC_Write; + if (std::strcmp(scenario(), "unknown_command") == 0) cvd->cmd.commandCode = 999; + } else if (step == 1) cvd->cmd.commandCode = VDC_Flush; + else if (step == 2 && std::strcmp(scenario(), "legacy") != 0) cvd->cmd.commandCode = VDC_Complete; + else return std::strcmp(scenario(), "abort") == 0 ? VD_E_ABORT : VD_E_CLOSE; + *command = &cvd->cmd; + return 0; +} +int ClientVirtualDevice::CompleteCommand(VDC_Command* command, int code, unsigned long transferred, int64_t) { + if (std::strcmp(scenario(), "unknown_command") == 0) return code == ERROR_NOT_SUPPORTED ? 0 : VD_E_INVALID; + if (code != 0) return VD_E_ABORT; + if (command->commandCode == VDC_Read || command->commandCode == VDC_Write) { + if (transferred != sizeof(cvd->buffer) || std::memcmp(cvd->buffer, "backup", 6) != 0) return VD_E_INVALID; + } + if (command->commandCode == VDC_Complete) log("complete"); + return 0; +} diff --git a/server/go.mod b/server/go.mod index 0268129..730d9c9 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,7 +6,9 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.19.2 + github.com/microsoft/go-mssqldb v1.9.3 github.com/natefinch/lumberjack v2.0.0+incompatible github.com/pquerna/otp v1.5.0 github.com/prometheus/client_golang v1.24.1 @@ -127,10 +129,11 @@ require ( github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/schema v1.4.1 // indirect diff --git a/server/go.sum b/server/go.sum index f7bf456..f2a8f64 100644 --- a/server/go.sum +++ b/server/go.sum @@ -14,6 +14,10 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6Xu github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 h1:irsmOWwkp0KCTTNS5e2hdFeIvSQClQo2No3IaNmL3Vw= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0/go.mod h1:GWcBkQj3MqN7ozHKLaCCAuNLiXoIGv2RtanfAwSjY/Y= github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 h1:cuiKf1UVyWHu+XSQghPZR/qEF43JIcuk2CDqMlPiT6M= @@ -256,6 +260,10 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -367,6 +375,8 @@ github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/a github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs= +github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= diff --git a/server/internal/agent/executor.go b/server/internal/agent/executor.go index 255ef25..0a344c9 100644 --- a/server/internal/agent/executor.go +++ b/server/internal/agent/executor.go @@ -267,12 +267,13 @@ func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) ba SourcePaths: sourcePaths, ExcludePatterns: excludes, Database: backup.DatabaseSpec{ - Host: spec.DBHost, - Port: spec.DBPort, - User: spec.DBUser, - Password: spec.DBPassword, - Path: spec.DBPath, - Names: splitCommaOrNewline(spec.DBName), + Host: spec.DBHost, + Port: spec.DBPort, + User: spec.DBUser, + Password: spec.DBPassword, + Path: spec.DBPath, + Names: splitCommaOrNewline(spec.DBName), + ExtraConfig: spec.ExtraConfig, }, Compression: spec.Compression, Encrypt: spec.Encrypt, @@ -529,12 +530,13 @@ func buildRestoreBackupTaskSpec(spec *RestoreSpec, startedAt time.Time, tempDir SourcePaths: spec.SourcePaths, ExcludePatterns: nil, Database: backup.DatabaseSpec{ - Host: spec.DBHost, - Port: spec.DBPort, - User: spec.DBUser, - Password: spec.DBPassword, - Path: spec.DBPath, - Names: splitCommaOrNewline(spec.DBName), + Host: spec.DBHost, + Port: spec.DBPort, + User: spec.DBUser, + Password: spec.DBPassword, + Path: spec.DBPath, + Names: splitCommaOrNewline(spec.DBName), + ExtraConfig: spec.ExtraConfig, }, Compression: spec.Compression, Encrypt: spec.Encrypt, diff --git a/server/internal/agent/executor_test.go b/server/internal/agent/executor_test.go index 029ff4f..306fe30 100644 --- a/server/internal/agent/executor_test.go +++ b/server/internal/agent/executor_test.go @@ -407,3 +407,15 @@ func writeAgentEnvelope(t *testing.T, w http.ResponseWriter, data any) { t.Fatalf("Encode response returned error: %v", err) } } + +func TestSQLServerSpecsPreserveInstanceAndTLS(t *testing.T) { + extra := `{"instanceName":"SQLEXPRESS","trustServerCertificate":true}` + backupSpec := buildBackupTaskSpec(&TaskSpec{Type: "sqlserver", DBName: "app", ExtraConfig: extra}, time.Now(), t.TempDir()) + restoreSpec := buildRestoreBackupTaskSpec(&RestoreSpec{Type: "sqlserver", DBName: "app", ExtraConfig: extra}, time.Now(), t.TempDir()) + if backupSpec.Database.ExtraConfig != extra || restoreSpec.Database.ExtraConfig != extra { + t.Fatal("SQL Server instance/TLS options were lost on the agent") + } + if _, err := NewExecutor(nil, t.TempDir()).backupRegistry.Runner("sqlserver"); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/backup/registry.go b/server/internal/backup/registry.go index 9e6dac4..1a6ed3f 100644 --- a/server/internal/backup/registry.go +++ b/server/internal/backup/registry.go @@ -29,6 +29,7 @@ func NewDefaultRegistry() *Registry { NewPostgreSQLRunner(nil), NewSAPHANARunner(nil), NewMongoDBRunner(nil), + NewSQLServerRunner(), ) } diff --git a/server/internal/backup/sqlserver_integration_test.go b/server/internal/backup/sqlserver_integration_test.go new file mode 100644 index 0000000..53e46d0 --- /dev/null +++ b/server/internal/backup/sqlserver_integration_test.go @@ -0,0 +1,79 @@ +package backup + +import ( + "context" + "encoding/json" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +// Opt-in only: creates and drops a uniquely named disposable database. Run on a +// dedicated SQL Server test instance, with the real native worker on PATH. +func TestSQLServerVDIRoundTrip(t *testing.T) { + if os.Getenv("BACKUPX_SQLSERVER_INTEGRATION") != "1" { + t.Skip("set BACKUPX_SQLSERVER_INTEGRATION=1 on a disposable SQL Server test instance") + } + password := os.Getenv("BACKUPX_SQLSERVER_TEST_PASSWORD") + user := os.Getenv("BACKUPX_SQLSERVER_TEST_USER") + if user == "" || password == "" { + t.Fatal("BACKUPX_SQLSERVER_TEST_USER and BACKUPX_SQLSERVER_TEST_PASSWORD are required") + } + port := 1433 + if value := os.Getenv("BACKUPX_SQLSERVER_TEST_PORT"); value != "" { + var err error + port, err = strconv.Atoi(value) + if err != nil { + t.Fatal("invalid BACKUPX_SQLSERVER_TEST_PORT") + } + } + opts := SQLServerOptions{ + InstanceName: os.Getenv("BACKUPX_SQLSERVER_TEST_INSTANCE"), + TrustServerCertificate: os.Getenv("BACKUPX_SQLSERVER_TEST_TRUST_CERTIFICATE") == "1", + } + extra, err := json.Marshal(opts) + if err != nil { + t.Fatal(err) + } + name := "backupx_vdi_it_" + strings.ReplaceAll(uuid.NewString(), "-", "") + task := TaskSpec{Name: name, Type: "sqlserver", TempDir: t.TempDir(), Database: DatabaseSpec{ + Host: "localhost", Port: port, User: user, Password: password, Names: []string{name}, ExtraConfig: string(extra), + }} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + runner := NewSQLServerRunner() + execSQL := func(ctx context.Context, query string) error { + return executeSQLServer(ctx, task.Database, opts, query, "") + } + if err := execSQL(ctx, "CREATE DATABASE ["+name+"]"); err != nil { + t.Fatal(err) + } + // Cleanup only the database successfully created by this test; never a supplied name. + defer func() { + cleanupCtx, stop := context.WithTimeout(context.Background(), time.Minute) + defer stop() + if err := execSQL(cleanupCtx, "IF DB_ID(N'"+name+"') IS NOT NULL DROP DATABASE ["+name+"]"); err != nil { + t.Errorf("cleanup disposable database %s: %v", name, err) + } + }() + if err := execSQL(ctx, "CREATE TABLE ["+name+"].dbo.vdi_probe (id int PRIMARY KEY); INSERT INTO ["+name+"].dbo.vdi_probe VALUES (153)"); err != nil { + t.Fatal(err) + } + result, err := runner.Run(ctx, task, NopLogWriter{}) + if err != nil { + t.Fatal(err) + } + if err := execSQL(ctx, "DROP DATABASE ["+name+"]"); err != nil { + t.Fatal(err) + } + if err := runner.Restore(ctx, task, result.ArtifactPath, NopLogWriter{}); err != nil { + t.Fatal(err) + } + if err := execSQL(ctx, "IF (SELECT COUNT(*) FROM ["+name+"].dbo.vdi_probe WHERE id = 153) <> 1 THROW 50001, 'VDI restore data mismatch', 1; DBCC CHECKDB (["+name+"]) WITH NO_INFOMSGS"); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/backup/sqlserver_runner.go b/server/internal/backup/sqlserver_runner.go new file mode 100644 index 0000000..7d63088 --- /dev/null +++ b/server/internal/backup/sqlserver_runner.go @@ -0,0 +1,287 @@ +package backup + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + "unicode/utf16" + + "github.com/google/uuid" + mssql "github.com/microsoft/go-mssqldb" +) + +// SQLServerOptions 同时用于 Master 和 Agent,避免节点执行时丢失实例/TLS 配置。 +type SQLServerOptions struct { + InstanceName string `json:"instanceName"` + TrustServerCertificate bool `json:"trustServerCertificate"` +} + +// ValidateSQLServerDatabase 限定为执行节点上的一个数据库;VDI 不是远程备份协议。 +func ValidateSQLServerDatabase(db DatabaseSpec) (SQLServerOptions, error) { + var options SQLServerOptions + if db.ExtraConfig != "" { + if err := json.Unmarshal([]byte(db.ExtraConfig), &options); err != nil { + return options, fmt.Errorf("SQL Server 扩展配置不合法: %w", err) + } + } + host := strings.TrimSpace(db.Host) + ip := net.ParseIP(host) + if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return options, fmt.Errorf("SQL Server VDI 必须连接执行节点本机,请使用 localhost 或回环 IP,并将任务绑定到数据库所在节点") + } + if db.Port < 1 || db.Port > 65535 || strings.TrimSpace(db.User) == "" { + return options, fmt.Errorf("SQL Server 用户名和有效 TCP 端口必填") + } + if len(db.Names) != 1 || strings.TrimSpace(db.Names[0]) == "" || strings.ContainsAny(db.Names[0], ",\r\n\x00") || len(utf16.Encode([]rune(db.Names[0]))) > 128 { + return options, fmt.Errorf("SQL Server 每个任务必须指定一个数据库(不支持逗号、换行或空名称,最长 128 个 UTF-16 单元)") + } + if strings.EqualFold(db.Names[0], "tempdb") { + return options, fmt.Errorf("SQL Server 不支持备份 tempdb") + } + if len(options.InstanceName) > 16 || strings.ContainsAny(options.InstanceName, "\\/;\r\n\x00") { + return options, fmt.Errorf("SQL Server 实例名不合法,请仅填写实例名称") + } + return options, nil +} + +type SQLServerRunner struct { + helperPath string + execSQL func(context.Context, DatabaseSpec, SQLServerOptions, string, string) error +} + +func NewSQLServerRunner() *SQLServerRunner { + return &SQLServerRunner{helperPath: "backupx-sqlvdi", execSQL: executeSQLServer} +} + +func (r *SQLServerRunner) Type() string { return "sqlserver" } + +func (r *SQLServerRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (result *RunResult, err error) { + if _, err = ValidateSQLServerDatabase(task.Database); err != nil { + return nil, err + } + tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "bak") + if err != nil { + return nil, err + } + defer func() { + if err != nil { + err = errors.Join(err, os.RemoveAll(tempDir)) + } + }() + writer.WriteLine("开始 SQL Server VDI COPY_ONLY 完整备份") + if err = r.transfer(ctx, task.Database, "backup", artifactPath); err != nil { + return nil, err + } + info, err := os.Stat(artifactPath) + if err != nil { + return nil, fmt.Errorf("读取 SQL Server 备份文件: %w", err) + } + if !info.Mode().IsRegular() || info.Size() == 0 { + return nil, fmt.Errorf("SQL Server VDI 未生成有效备份文件") + } + startedAt := task.StartedAt + if startedAt.IsZero() { + startedAt = time.Now().UTC() + } + writer.WriteLine("SQL Server 备份传输和 SQL 命令均已完成") + return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir, Size: info.Size(), StorageKey: BuildStorageKey(r.Type(), startedAt, filepath.Base(artifactPath))}, nil +} + +func (r *SQLServerRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error { + info, err := os.Stat(artifactPath) + if err != nil || !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("SQL Server 恢复文件不存在、为空或不是普通文件: %v", err) + } + writer.WriteLine("开始 SQL Server VDI 恢复(保留 SQL Server 的覆盖保护,不使用 REPLACE)") + if err := r.transfer(ctx, task.Database, "restore", artifactPath); err != nil { + return err + } + writer.WriteLine("SQL Server 恢复完成") + return nil +} + +func sqlServerQuery(mode, database string) string { + name := "[" + strings.ReplaceAll(database, "]", "]]") + "]" + if mode == "backup" { + return "BACKUP DATABASE " + name + " TO VIRTUAL_DEVICE = @device WITH COPY_ONLY, CHECKSUM" + } + return "RESTORE DATABASE " + name + " FROM VIRTUAL_DEVICE = @device WITH CHECKSUM" +} + +func executeSQLServer(ctx context.Context, spec DatabaseSpec, options SQLServerOptions, query, device string) (err error) { + // VDI 数据不经过 SQL 连接。大备份期间 SQL 连接可能长时间没有响应,不能设置读超时。 + values := url.Values{"database": {"master"}, "encrypt": {"true"}, "TrustServerCertificate": {strconv.FormatBool(options.TrustServerCertificate)}, "dial timeout": {"15"}, "app name": {"BackupX VDI"}, "disableretry": {"true"}} + dsn := url.URL{Scheme: "sqlserver", User: url.UserPassword(spec.User, spec.Password), Host: net.JoinHostPort(strings.TrimSpace(spec.Host), strconv.Itoa(spec.Port)), RawQuery: values.Encode()} + connector, err := mssql.NewConnector(dsn.String()) + if err != nil { + return fmt.Errorf("连接 SQL Server 失败: %w", err) + } + connector.Dialer = sqlServerDialer{ctx: ctx} + db := sql.OpenDB(connector) + defer func() { err = errors.Join(err, db.Close()) }() + _, err = db.ExecContext(ctx, query, sql.Named("device", device)) + return err +} + +// 驱动取消时会等待 SQL Server 的 ATTENTION 回应;关闭本次操作的连接,避免失联时永远等待。 +type sqlServerDialer struct{ ctx context.Context } + +func (d sqlServerDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := (&net.Dialer{Timeout: 15 * time.Second}).DialContext(ctx, network, address) + if err != nil { + return nil, err + } + wrapped := &sqlServerConn{Conn: conn} + wrapped.stop = context.AfterFunc(d.ctx, wrapped.closeSocket) + return wrapped, nil +} + +type sqlServerConn struct { + net.Conn + stop func() bool + closeOnce sync.Once + closeErr error +} + +func (c *sqlServerConn) closeSocket() { + c.closeOnce.Do(func() { c.closeErr = c.Conn.Close() }) +} + +func (c *sqlServerConn) Close() error { + c.stop() + c.closeSocket() + if errors.Is(c.closeErr, net.ErrClosed) { + return nil + } + return c.closeErr +} + +// transfer 等待两个独立结果:VDI 文件传输完成,以及 SQL Server BACKUP/RESTORE 成功。 +// stdin 关闭通知原生组件 SignalAbort;WaitDelay 为无响应的原生库提供强制退出上限。 +func (r *SQLServerRunner) transfer(ctx context.Context, db DatabaseSpec, mode, artifactPath string) (returnErr error) { + options, err := ValidateSQLServerDatabase(db) + if err != nil { + return err + } + helper, err := exec.LookPath(r.helperPath) + if err != nil { + return fmt.Errorf("未找到 backupx-sqlvdi;请在 SQL Server 所在节点安装对应平台的 VDI 组件: %w", err) + } + device := "BackupX-" + uuid.NewString() + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + cmd := exec.CommandContext(runCtx, helper, mode, device, artifactPath, options.InstanceName) + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("创建 VDI 控制管道: %w", err) + } + var closeOnce sync.Once + closeControl := func() error { + var closeErr error + closeOnce.Do(func() { closeErr = stdin.Close() }) + // exec.Cmd.Wait also closes StdinPipe after a normal worker exit. + if errors.Is(closeErr, os.ErrClosed) { + return nil + } + return closeErr + } + defer func() { returnErr = errors.Join(returnErr, closeControl()) }() + cmd.Cancel = closeControl + cmd.WaitDelay = 5 * time.Second + ready := &vdiReadyWriter{ready: make(chan error, 1)} + stderr := &vdiErrorWriter{} + cmd.Stdout, cmd.Stderr = ready, stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 VDI 组件: %w", err) + } + helperDone := make(chan error, 1) + go func() { helperDone <- cmd.Wait() }() + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + select { + case err = <-ready.ready: + case err = <-helperDone: + return fmt.Errorf("VDI 组件未就绪即退出: %v: %s", err, stderr.String()) + case <-timer.C: + err = fmt.Errorf("VDI 组件初始化超时") + case <-ctx.Done(): + err = ctx.Err() + } + if err != nil { + cancel() + return errors.Join(err, <-helperDone) + } + sqlDone := make(chan error, 1) + go func() { sqlDone <- r.execSQL(runCtx, db, options, sqlServerQuery(mode, db.Names[0]), device) }() + var transferErr, sqlErr error + for helperDone != nil || sqlDone != nil { + select { + case transferErr = <-helperDone: + helperDone = nil + if transferErr != nil { + cancel() + } + case sqlErr = <-sqlDone: + sqlDone = nil + if sqlErr != nil { + cancel() + } + } + } + if err := errors.Join(ctx.Err(), transferErr, sqlErr); err != nil { + message := fmt.Sprintf("SQL Server VDI %s 失败: %v: %s", mode, err, stderr.String()) + if db.Password != "" { + message = strings.ReplaceAll(message, db.Password, "********") + } + return errors.Join(ctx.Err(), fmt.Errorf("%s", message)) + } + return nil +} + +type vdiReadyWriter struct { + buffer bytes.Buffer + ready chan error + done bool +} + +func (w *vdiReadyWriter) Write(p []byte) (int, error) { + if !w.done { + if w.buffer.Len()+len(p) > 128 { + w.ready <- fmt.Errorf("VDI 组件就绪协议不合法") + w.done = true + } else { + w.buffer.Write(p) + if bytes.Contains(p, []byte("\n")) { + var err error + if w.buffer.String() != "BACKUPX_SQLVDI_READY\n" { + err = fmt.Errorf("VDI 组件版本不兼容") + } + w.ready <- err + w.done = true + } + } + } + return len(p), nil +} + +type vdiErrorWriter struct{ bytes.Buffer } + +func (w *vdiErrorWriter) Write(p []byte) (int, error) { + limit := min(len(p), 16384-w.Len()) + if limit > 0 { + w.Buffer.Write(p[:limit]) + } + return len(p), nil +} diff --git a/server/internal/backup/sqlserver_runner_test.go b/server/internal/backup/sqlserver_runner_test.go new file mode 100644 index 0000000..11978ce --- /dev/null +++ b/server/internal/backup/sqlserver_runner_test.go @@ -0,0 +1,198 @@ +package backup + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Run the test executable as a standalone worker to exercise real pipes, process +// exit codes and cancellation on both operating systems, without a SQL instance. +func init() { + scenario := os.Getenv("BACKUPX_TEST_SQLVDI_SCENARIO") + if scenario == "" { + return + } + if len(os.Args) != 5 { + os.Exit(9) + } + if strings.Contains(strings.Join(os.Args, " "), "test-password") { + os.Exit(8) + } + if scenario == "bad_ready" { + if _, err := fmt.Fprintln(os.Stdout, "wrong protocol"); err != nil { + os.Exit(7) + } + if _, err := io.Copy(io.Discard, os.Stdin); err != nil { + os.Exit(7) + } + os.Exit(2) + } + path := os.Args[3] + content := "VDI backup data" + if scenario == "empty" { + content = "" + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + os.Exit(3) + } + if _, err := fmt.Fprintln(os.Stdout, "BACKUPX_SQLVDI_READY"); err != nil { + os.Exit(7) + } + if scenario == "wait_cancel" { + if _, err := io.Copy(io.Discard, os.Stdin); err != nil { + os.Exit(4) + } + os.Exit(2) + } + deadline := time.NewTimer(3 * time.Second) + ticker := time.NewTicker(time.Millisecond) + for { + select { + case <-deadline.C: + os.Exit(5) + case <-ticker.C: + if _, err := os.Stat(os.Getenv("BACKUPX_TEST_SQLVDI_MARKER")); err == nil { + if scenario == "helper_failure" { + os.Exit(6) + } + os.Exit(0) + } + } + } +} + +func sqlServerTestSpec(t *testing.T) TaskSpec { + t.Helper() + return TaskSpec{Name: "sqlserver", Type: "sqlserver", TempDir: t.TempDir(), Database: DatabaseSpec{ + Host: "localhost", Port: 1433, User: "backup", Password: "test-password", Names: []string{"app]db"}, + ExtraConfig: `{"instanceName":"TEST","trustServerCertificate":true}`, + }} +} + +func TestSQLServerRunnerCompletionAndFailure(t *testing.T) { + for _, scenario := range []string{"success", "empty", "sql_failure", "helper_failure", "bad_ready", "cancel"} { + t.Run(scenario, func(t *testing.T) { + spec := sqlServerTestSpec(t) + marker := filepath.Join(t.TempDir(), "sql-started") + t.Setenv("BACKUPX_TEST_SQLVDI_MARKER", marker) + mode := scenario + if mode == "sql_failure" || mode == "cancel" { + mode = "wait_cancel" + } + t.Setenv("BACKUPX_TEST_SQLVDI_SCENARIO", mode) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + runner := &SQLServerRunner{helperPath: binary, execSQL: func(ctx context.Context, db DatabaseSpec, opts SQLServerOptions, query, device string) error { + if !opts.TrustServerCertificate || opts.InstanceName != "TEST" || !strings.Contains(query, "[app]]db]") || !strings.HasPrefix(device, "BackupX-") { + return errors.New("SQL options or escaped identifier missing") + } + if scenario == "sql_failure" { + return errors.New("denied test-password") + } + if scenario == "cancel" { + cancel() + return ctx.Err() + } + if err := os.WriteFile(marker, nil, 0o600); err != nil { + return err + } + if scenario == "helper_failure" { + <-ctx.Done() + return ctx.Err() + } + return nil + }} + result, err := runner.Run(ctx, spec, NopLogWriter{}) + if scenario == "success" { + if err != nil || result == nil || result.Size == 0 || !strings.HasSuffix(result.FileName, ".bak") { + t.Fatalf("expected artifact, got %+v, %v", result, err) + } + return + } + if err == nil || result != nil || strings.Contains(err.Error(), "test-password") { + t.Fatalf("failure must not succeed or leak credentials: result=%+v err=%v", result, err) + } + entries, readErr := os.ReadDir(spec.TempDir) + if readErr != nil || len(entries) != 0 { + t.Fatalf("failed backup left temporary artifacts: %v %v", entries, readErr) + } + }) + } +} + +func TestSQLServerValidationAndRestoreSQL(t *testing.T) { + db := sqlServerTestSpec(t).Database + for _, mutate := range []func(*DatabaseSpec){ + func(d *DatabaseSpec) { d.Host = "remote.example.com" }, + func(d *DatabaseSpec) { d.Names = []string{"one,two"} }, + func(d *DatabaseSpec) { d.Names = []string{"one", "two"} }, + func(d *DatabaseSpec) { d.Names = []string{"tempdb"} }, + func(d *DatabaseSpec) { d.Port = 65536 }, + func(d *DatabaseSpec) { d.ExtraConfig = `{"trustServerCertificate":"true"}` }, + } { + invalid := db + mutate(&invalid) + if _, err := ValidateSQLServerDatabase(invalid); err == nil { + t.Fatalf("accepted invalid database: %+v", invalid) + } + } + query := sqlServerQuery("restore", "db]; DROP DATABASE [other") + if query != "RESTORE DATABASE [db]]; DROP DATABASE [other] FROM VIRTUAL_DEVICE = @device WITH CHECKSUM" || strings.Contains(query, "REPLACE") { + t.Fatalf("unsafe restore query: %s", query) + } + if !strings.Contains(sqlServerQuery("backup", "app"), "COPY_ONLY, CHECKSUM") { + t.Fatal("backup must preserve the existing differential base") + } +} + +func TestSQLServerConnectionCancellationWithoutServerResponse(t *testing.T) { + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := listener.Close(); err != nil { + t.Error(err) + } + }() + if err := listener.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- executeSQLServer(ctx, DatabaseSpec{Host: "127.0.0.1", Port: listener.Addr().(*net.TCPAddr).Port, User: "test", Password: "secret"}, SQLServerOptions{}, "SELECT 1", "") + }() + conn, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Error(err) + } + }() + // Keep the socket open and send no TDS response: cancellation must still finish. + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("cancelled SQL connection succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("SQL cancellation waited for an unresponsive server") + } +} diff --git a/server/internal/backup/types.go b/server/internal/backup/types.go index 5b496a4..5e08132 100644 --- a/server/internal/backup/types.go +++ b/server/internal/backup/types.go @@ -6,12 +6,13 @@ import ( ) type DatabaseSpec struct { - Host string - Port int - User string - Password string - Names []string - Path string + Host string + Port int + User string + Password string + Names []string + Path string + ExtraConfig string // 类型特有配置,SQL Server 在执行节点解析 // SAP HANA 特有字段(其他类型忽略) InstanceNumber string // 实例编号(从端口推断或手动指定) BackupLevel string // "full"(默认) / "incremental" / "differential" diff --git a/server/internal/service/backup_task_service.go b/server/internal/service/backup_task_service.go index 44e5c71..655de88 100644 --- a/server/internal/service/backup_task_service.go +++ b/server/internal/service/backup_task_service.go @@ -22,7 +22,7 @@ const backupTaskMaskedValue = "********" type BackupTaskUpsertInput struct { Name string `json:"name" binding:"required,min=1,max=100"` - Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql saphana mongodb"` + Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql saphana mongodb sqlserver"` Enabled bool `json:"enabled"` CronExpr string `json:"cronExpr" binding:"max=64"` SourcePath string `json:"sourcePath" binding:"max=500"` @@ -648,7 +648,7 @@ func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequire if !hasSourcePaths { return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil) } - case "mysql", "postgresql", "saphana", "mongodb": + case "mysql", "postgresql", "saphana", "mongodb", "sqlserver": if strings.TrimSpace(input.DBHost) == "" { return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库主机不能为空", nil) } @@ -664,6 +664,21 @@ func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequire if strings.TrimSpace(input.DBName) == "" { return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库名称不能为空", nil) } + if normalizeBackupTaskType(input.Type) == "sqlserver" { + extra, err := json.Marshal(input.ExtraConfig) + if err != nil { + return apperror.BadRequest("BACKUP_TASK_INVALID", "SQL Server 配置不合法", err) + } + if _, err := backup.ValidateSQLServerDatabase(backup.DatabaseSpec{Host: input.DBHost, Port: input.DBPort, User: input.DBUser, Names: []string{input.DBName}, ExtraConfig: string(extra)}); err != nil { + return apperror.BadRequest("BACKUP_TASK_INVALID", err.Error(), err) + } + if strings.TrimSpace(input.NodePoolTag) != "" { + return apperror.BadRequest("BACKUP_TASK_INVALID", "SQL Server VDI 必须绑定数据库所在的固定节点,不能使用节点池", nil) + } + if input.VerifyEnabled { + return apperror.BadRequest("BACKUP_TASK_INVALID", "SQL Server 暂不支持自动验证演练,请在隔离数据库中执行恢复验证", nil) + } + } case "sqlite": if strings.TrimSpace(input.DBPath) == "" { return apperror.BadRequest("BACKUP_TASK_INVALID", "SQLite 备份必须填写数据库文件路径", nil) diff --git a/server/internal/service/backup_task_service_test.go b/server/internal/service/backup_task_service_test.go index da32f56..120a0b5 100644 --- a/server/internal/service/backup_task_service_test.go +++ b/server/internal/service/backup_task_service_test.go @@ -295,3 +295,22 @@ func TestBackupTaskServiceKeepsMaskedPasswordOnUpdate(t *testing.T) { t.Fatalf("expected ciphertext unchanged") } } + +func TestSQLServerTaskValidation(t *testing.T) { + input := BackupTaskUpsertInput{Type: "sqlserver", DBHost: "localhost", DBPort: 1433, DBUser: "backup", DBPassword: "secret", DBName: "app", ExtraConfig: map[string]any{"instanceName": "SQLEXPRESS", "trustServerCertificate": true}} + if err := validateTaskTypeSpecificFields(input, true); err != nil { + t.Fatal(err) + } + for _, change := range []func(*BackupTaskUpsertInput){ + func(in *BackupTaskUpsertInput) { in.DBHost = "192.0.2.1" }, + func(in *BackupTaskUpsertInput) { in.NodePoolTag = "any-node" }, + func(in *BackupTaskUpsertInput) { in.VerifyEnabled = true }, + func(in *BackupTaskUpsertInput) { in.DBName = "app,other" }, + } { + invalid := input + change(&invalid) + if err := validateTaskTypeSpecificFields(invalid, true); err == nil { + t.Fatalf("accepted invalid SQL Server configuration: %+v", invalid) + } + } +} diff --git a/server/internal/service/dashboard_service.go b/server/internal/service/dashboard_service.go index 4330d8c..12f68b2 100644 --- a/server/internal/service/dashboard_service.go +++ b/server/internal/service/dashboard_service.go @@ -415,6 +415,8 @@ func typeLabel(key string) string { return "PostgreSQL" case "sqlite": return "SQLite" + case "sqlserver": + return "SQL Server" case "saphana": return "SAP HANA" default: diff --git a/server/internal/service/execution_helpers.go b/server/internal/service/execution_helpers.go index 2042a2c..9585f3e 100644 --- a/server/internal/service/execution_helpers.go +++ b/server/internal/service/execution_helpers.go @@ -185,12 +185,13 @@ func buildBackupTaskSpec(cipher *codec.ConfigCipher, task *model.BackupTask, sta } } dbSpec := backup.DatabaseSpec{ - Host: task.DBHost, - Port: task.DBPort, - User: task.DBUser, - Password: password, - Names: []string{task.DBName}, - Path: task.DBPath, + Host: task.DBHost, + Port: task.DBPort, + User: task.DBUser, + Password: password, + Names: []string{task.DBName}, + Path: task.DBPath, + ExtraConfig: task.ExtraConfig, } // 解析 ExtraConfig 填充类型特有字段(目前主要用于 SAP HANA) if strings.TrimSpace(task.ExtraConfig) != "" { diff --git a/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx b/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx index 57b2146..39b0d74 100644 --- a/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx +++ b/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx @@ -78,7 +78,7 @@ export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDe data={[{ label: 'SQLite 路径', value: task.dbPath || '-' }]} /> ) : null} - {task.type === 'mysql' || task.type === 'postgresql' ? ( + {task.type === 'mysql' || task.type === 'postgresql' || task.type === 'sqlserver' ? ( ) : null} diff --git a/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx b/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx index b7b1b09..1772bf5 100644 --- a/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx +++ b/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx @@ -227,8 +227,16 @@ export function BackupTaskFormDrawer({ sourcePath: value === 'file' ? current.sourcePath : '', sourcePaths: value === 'file' ? current.sourcePaths : [''], excludePatterns: value === 'file' ? current.excludePatterns : [], - dbHost: isDatabaseBackupTask(value) ? current.dbHost : '', - dbPort: isDatabaseBackupTask(value) ? current.dbPort || getDefaultPort(value) : 0, + dbHost: + value === 'sqlserver' ? 'localhost' : isDatabaseBackupTask(value) ? current.dbHost : '', + dbPort: + value === 'sqlserver' + ? getDefaultPort(value) + : isDatabaseBackupTask(value) + ? current.dbPort || getDefaultPort(value) + : 0, + nodePoolTag: value === 'sqlserver' ? '' : current.nodePoolTag, + verifyEnabled: value === 'sqlserver' ? false : current.verifyEnabled, dbUser: isDatabaseBackupTask(value) ? current.dbUser : '', dbPassword: isDatabaseBackupTask(value) ? current.dbPassword : '', dbName: isDatabaseBackupTask(value) ? current.dbName : '', @@ -310,6 +318,12 @@ export function BackupTaskFormDrawer({ return '请输入数据库名称' } } + if (value.type === 'sqlserver') { + if (value.nodePoolTag?.trim()) + return 'SQL Server VDI 必须选择数据库所在固定节点,不能使用节点池' + if (value.dbName.includes(',') || /[\r\n]/.test(value.dbName)) + return 'SQL Server 每个任务只能填写一个数据库' + } return '' } @@ -546,6 +560,33 @@ export function BackupTaskFormDrawer({ /> )} + {draft.type === 'sqlserver' ? ( + <> + +
+ Windows 实例名称(可选) + + updateDraft({ extraConfig: { ...draft.extraConfig, instanceName } }) + } + /> +
+ + 信任服务器证书(仅用于自签名证书) + + updateDraft({ extraConfig: { ...draft.extraConfig, trustServerCertificate } }) + } + /> + + + ) : null} {isSapHanaBackupTask(draft.type) ? renderSapHanaExtraFields() : null} ) : null} @@ -920,6 +961,7 @@ export function BackupTaskFormDrawer({ 启用定时验证 updateDraft({ verifyEnabled: checked })} /> diff --git a/web/src/components/backup-tasks/field-config.test.ts b/web/src/components/backup-tasks/field-config.test.ts index 8f1799d..54d8bb2 100644 --- a/web/src/components/backup-tasks/field-config.test.ts +++ b/web/src/components/backup-tasks/field-config.test.ts @@ -21,6 +21,9 @@ describe('backup task field config', () => { expect(isDatabaseBackupTask('mysql')).toBe(true) expect(isDatabaseBackupTask('postgresql')).toBe(true) expect(isDatabaseBackupTask('file')).toBe(false) + expect(isDatabaseBackupTask('sqlserver')).toBe(true) + expect(getBackupTaskTypeLabel('sqlserver')).toBe('SQL Server (VDI)') + expect(getDefaultPort('sqlserver')).toBe(1433) }) it('returns expected status meta and default ports', () => { diff --git a/web/src/components/backup-tasks/field-config.ts b/web/src/components/backup-tasks/field-config.ts index 47eb50b..9a4d4a3 100644 --- a/web/src/components/backup-tasks/field-config.ts +++ b/web/src/components/backup-tasks/field-config.ts @@ -7,6 +7,7 @@ export const backupTaskTypeOptions = [ { label: 'PostgreSQL', value: 'postgresql' }, { label: 'SAP HANA', value: 'saphana' }, { label: 'MongoDB', value: 'mongodb' }, + { label: 'SQL Server (VDI)', value: 'sqlserver' }, ] as const export const backupCompressionOptions = [ @@ -29,6 +30,8 @@ export function getBackupTaskTypeLabel(type: BackupTaskType) { return 'SAP HANA' case 'mongodb': return 'MongoDB' + case 'sqlserver': + return 'SQL Server (VDI)' default: return type } @@ -71,7 +74,13 @@ export function isSQLiteBackupTask(type: BackupTaskType) { } export function isDatabaseBackupTask(type: BackupTaskType) { - return type === 'mysql' || type === 'postgresql' || type === 'saphana' || type === 'mongodb' + return ( + type === 'mysql' || + type === 'postgresql' || + type === 'saphana' || + type === 'mongodb' || + type === 'sqlserver' + ) } export function getDefaultPort(type: BackupTaskType) { @@ -84,6 +93,8 @@ export function getDefaultPort(type: BackupTaskType) { return 30015 case 'mongodb': return 27017 + case 'sqlserver': + return 1433 default: return 0 } diff --git a/web/src/types/backup-tasks.ts b/web/src/types/backup-tasks.ts index eeeeb20..c08a437 100644 --- a/web/src/types/backup-tasks.ts +++ b/web/src/types/backup-tasks.ts @@ -1,4 +1,5 @@ -export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana' | 'mongodb' +export type BackupTaskType = + 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana' | 'mongodb' | 'sqlserver' export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed' export type BackupCompression = 'gzip' | 'zstd' | 'none' export type BackupMode = 'full' | 'differential' | 'repository'