feat: add full snapshot downloads and safe manual restore

This commit is contained in:
2026-10-17 01:36:21 +08:00
parent c6615faa49
commit f77f3d77c0
12 changed files with 1318 additions and 7 deletions
+16 -1
View File
@@ -13,9 +13,19 @@
## 实例备份与快照管理 ## 实例备份与快照管理
### 完整 home 快照恢复
`GET /api/instances/:id/snapshots` 的每项另含 `restorable:boolean`、`restore_reason:string`、可选 `source_version` 与 `source_image`。来源记录在独立的 `snapshot_metadata` 表中,不会从当前实例推断旧快照版本。已知镜像或版本不一致时不可恢复;来源未知时 `restorable:true` 但 `restore_reason` 为 `snapshot source image/version unknown; explicit acknowledgement required`,客户端必须显示风险警告并要求额外确认。保护状态及大小读取错误时不可恢复。列表只是预检提示,最终恢复还会检查实时配额、容器布局、镜像准入与读者占用。
`POST /api/instances/:id/snapshots` 接收 `{id,password,confirm:true,allow_unknown_version?:true}`(JSON 最大4096字节,在获取锁前读取)。`password` 是当前登录密码,不是旧备份密码;需正常登录、同源 Origin 与 CSRF。来源未知必须明确传入 `allow_unknown_version:true`;此开关不能绕过已知不兼容。成功返回 `200 {ok:true,snapshot_id:"pre-restore-<32hex>"}`,源快照保持不变。
恢复替换整个 home,不是仅覆盖配置。仅支持经核验的模板7独立 /workspace bind 挂载与当前实例镜像;拒绝旧共享布局、被阻止镜像、恢复保护状态及活跃文件读者。先进行配额和保留数量检查,再使用 rooted 路径验证复制到同文件系统暂存目录;外部/绝对/悬空符号链接及特殊文件拒绝。停止运行时后重新检查配额,将当前 home 重命名保留为 pre-restore 快照,再安装暂存目录并验证就绪;原来停止的实例会临时启动验证后再次停止。失败时独立超时上下文执行回滚,失败的新 home 保留为 failed-restore 快照;无法安全回滚时保持 error,要求管理员恢复。
非法请求或不安全内容400,当前密码错误403,目标不存在/所有权不符404,保护/读者占用/不兼容/缺少未知版本确认/保留上限409,请求或配额超限413,存储/数据库/回滚故障500,容器检查/停止失败502。正常恢复前会记录 snapshot.restore.started,完成或回滚分别记录 snapshot.restore.completed / snapshot.restore.rolled_back。此接口不改变实例镜像。
- `DELETE /api/instances/:id/backups?id=<32hex>` 删除当前用户实例的加密 `.dshbak` 包及其索引,记录 `backup.delete` 审计。ID 为32位小写十六进制;支持无索引的旧包,以及文件已不存在的残留索引。 - `DELETE /api/instances/:id/backups?id=<32hex>` 删除当前用户实例的加密 `.dshbak` 包及其索引,记录 `backup.delete` 审计。ID 为32位小写十六进制;支持无索引的旧包,以及文件已不存在的残留索引。
- `GET /api/instances/:id/snapshots` 返回数组 `{id,size,created_at,protected,protection_reason,size_error?}`。`size` 为普通文件逻辑字节总和(与配额口径相同,硬链接逐路径计数,不跟随符号链接);扫描失败为 `-1` 并提供 `size_error`,不能显示为零。`created_at` 为目录修改时间的 RFC3339 字符串,不保证真实创建时间。未保护时 `protection_reason` 是空字符串。 - `GET /api/instances/:id/snapshots` 返回数组 `{id,size,created_at,protected,protection_reason,size_error?}`。`size` 为普通文件逻辑字节总和(与配额口径相同,硬链接逐路径计数,不跟随符号链接);扫描失败为 `-1` 并提供 `size_error`,不能显示为零。`created_at` 为目录修改时间的 RFC3339 字符串,不保证真实创建时间。未保护时 `protection_reason` 是空字符串。
- `DELETE /api/instances/:id/snapshots?id=<id>` 删除选定快照。先持久化 `snapshot.delete.started`,删除成功后记录 `snapshot.delete.completed`,删除失败则记录 `snapshot.delete.failed`;target 为实例 ID,detail 为快照 ID。初始审计失败时不会删除,终态审计失败返回500并保留 started 记录。不提供快照下载。 - `DELETE /api/instances/:id/snapshots?id=<id>` 删除选定快照。先持久化 `snapshot.delete.started`,删除成功后记录 `snapshot.delete.completed`,删除失败则记录 `snapshot.delete.failed`;target 为实例 ID,detail 为快照 ID。初始审计失败时不会删除,终态审计失败返回500并保留 started 记录。快照另支持 GET 下载,见下方接口清单及 OPERATIONS.md 的归档限制。
仅识别实例目录直属的 `pre-restore-<32hex>`、`failed-restore-<32hex>`、`upgrade-<32hex>-snapshot`、`failed-home-<32hex>` 真实目录;不列出或删除 `home`、其它命名或符号链接目录。删除成功返回 `200 {ok:true}`,非法ID/不安全路径400,不存在/所有权不符404,恢复保护409,存储/索引/审计错误500。不支持的方法405。删除仍要求同源 Origin 与 CSRF,不要求备份密码,不调用 Docker inspect/stop/restart。 仅识别实例目录直属的 `pre-restore-<32hex>`、`failed-restore-<32hex>`、`upgrade-<32hex>-snapshot`、`failed-home-<32hex>` 真实目录;不列出或删除 `home`、其它命名或符号链接目录。删除成功返回 `200 {ok:true}`,非法ID/不安全路径400,不存在/所有权不符404,恢复保护409,存储/索引/审计错误500。不支持的方法405。删除仍要求同源 Origin 与 CSRF,不要求备份密码,不调用 Docker inspect/stop/restart。
@@ -141,6 +151,11 @@ GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id
- POST /api/instances/{id}/backups multipart file,password(导入外部包,password为原包密码;不立即覆盖home) - POST /api/instances/{id}/backups multipart file,password(导入外部包,password为原包密码;不立即覆盖home)
- GET /api/instances/{id}/backups - GET /api/instances/{id}/backups
- GET /api/instances/{id}/backups?download={backup_id} - GET /api/instances/{id}/backups?download={backup_id}
- DELETE /api/instances/{id}/backups?id={backup_id} → {ok:true}
- GET /api/instances/{id}/snapshots → [{id,created_at,size,protected,protection_reason,restorable,restore_reason,source_version?,source_image?,size_error?}]
- DELETE /api/instances/{id}/snapshots?id={snapshot_id} → {ok:true}
- GET /api/instances/{id}/snapshots?download={snapshot_id} → 完整 home 的 `.tar.gz` 附件(未加密,可能含凭据)
- POST /api/instances/{id}/snapshots {id,password,confirm:true,allow_unknown_version?} → {ok:true,snapshot_id}(当前登录密码;snapshot_id 为切换前 home 的保留快照)
- POST /api/instances/{id}/restore {backup_id,password}(原备份密码;旧平台key包可留空) - POST /api/instances/{id}/restore {backup_id,password}(原备份密码;旧平台key包可留空)
- POST /api/instances/{id}/upgrade {image_id,password}(当前登录密码,加密pre-upgrade备份) - POST /api/instances/{id}/upgrade {image_id,password}(当前登录密码,加密pre-upgrade备份)
+18
View File
@@ -130,6 +130,24 @@ Office 文档预览的运行时接口 `/api/officeToPdf/render`(面板路径
文件删除与 SQLite 提交不具备跨系统原子性。若加密包已删除而索引提交失败,接口明确返回500;重试相同删除会清理残留索引。快照删除先持久化 `snapshot.delete.started`,失败则不触碰目录;递归删除期间不占用 SQL 连接或事务,仍保留实例锁和用户锁。随后分别记录 `snapshot.delete.completed` 或 `snapshot.delete.failed`。终态审计写入失败返回500;只有 started 的记录也可能表示进程中断或操作仍在进行,不代表删除成功,应由管理员核实目录和审计记录。目录删除中途遇到存储错误可能部分完成,排除故障后再重试。明确不安全路径返回400,权限或 I/O 故障返回500,缺失快照删除返回404(列表为空)。 文件删除与 SQLite 提交不具备跨系统原子性。若加密包已删除而索引提交失败,接口明确返回500;重试相同删除会清理残留索引。快照删除先持久化 `snapshot.delete.started`,失败则不触碰目录;递归删除期间不占用 SQL 连接或事务,仍保留实例锁和用户锁。随后分别记录 `snapshot.delete.completed` 或 `snapshot.delete.failed`。终态审计写入失败返回500;只有 started 的记录也可能表示进程中断或操作仍在进行,不代表删除成功,应由管理员核实目录和审计记录。目录删除中途遇到存储错误可能部分完成,排除故障后再重试。明确不安全路径返回400,权限或 I/O 故障返回500,缺失快照删除返回404(列表为空)。
## 完整快照下载与手动恢复
实例的备份面板提供完整快照下载和恢复入口。下载的是未加密 `.tar.gz`,不同于仅包含配置资源的加密 `.dshbak`;可能包含密钥、会话和依赖,请妥善保管。归档保留权限及安全的相对符号链接,不跟随链接读取外部文件,遇到特殊文件或逃逸链接整体拒绝。下载先在系统临时目录完成归档,再释放实例锁传输;临时文件在请求结束时清理,需预留临时磁盘空间。文件内容总量上限1 GiB、100000条目、128层,另有限额容纳归档头及压缩开销;归档和传输分别有两分钟时限,共用最多4个目录归档下载槽。
恢复针对服务器保留的快照,不支持上传任意 tar 包恢复。需当前登录密码和明确确认;源快照保留不动,先复制到暂存目录,再停机并保留当前 home,切换后启动检查,失败尝试回退。原先停止的实例检查后仍停止。恢复需要额外容纳一份所选快照的配额和磁盘空间;已有恢复快照达到3个时先清理。已知源镜像或版本与当前实例不一致时禁止恢复;历史快照缺少来源信息时须额外确认风险。新升级、配置恢复和完整快照恢复会记录来源信息。
error 状态、未完成升级、活跃文件操作、禁用镜像或不兼容挂载布局会阻止恢复。恢复中断或回退失败保留 error 状态及数据,需管理员结合 `snapshot.restore.*` 审计核查 home、pre-restore、failed-restore 和暂存目录;不要直接把状态改成 running。快照只恢复 home,不回滚镜像、外部服务或启动产生的外部副作用。
### 完整快照恢复安全说明
快照恢复会替换整个实例 home,必须由所有者重新输入当前登录密码并明确确认。仅恢复到相同的已知镜像与版本;历史快照没有可靠来源记录时必须显示未知版本风险并额外确认,不得把当前实例版本当作历史来源。旧升级日志的 image_id 是目标镜像,不是旧 home 的可靠来源。新快照的来源写入 SQLite snapshot_metadata 表,升级回滚留下的 failed-home 使用尝试升级的目标镜像来源。
源快照不会被消耗。操作将源复制到实例目录下的 .snapshot-restore-<32hex>,确认 Docker 已停止后,把原 home 重命名为 pre-restore-<32hex>,再以同文件系统重命名安装副本并 fsync 父目录。运行时启动并通过就绪验证后恢复原运行/停止状态。原来停止的实例也会临时启动验证,因此应把恢复视为可执行该快照内容的操作,仅恢复可信数据。
开始停止/切换前实例状态持久化为 error;中途进程退出不会自动清除此状态。错误恢复时先停止容器,核对 snapshot.restore.started 审计中的源、pre-restore、failed-restore ID,以及 home 和暂存目录。不要盲目删除或覆盖这些目录;先确认哪一个保存原 home,保留全部恢复证据与源快照。只有核对目录、挂载和运行时就绪后才解除恢复状态。无法停止、重命名、同步或回滚就绪时接口保持 error 并要求人工处理。
恢复前按源副本大小检查用户配额,停止运行时后再次核验总用量;原 home 通过重命名保留,不重复复制。已有 pre-restore / failed-restore 数量达到3时拒绝,需显式清理不再需要的快照。没有自动保留清理;进程异常退出的隐藏暂存副本须在维护窗口核验后清理。文件复制期间不持有数据库事务。此流程仍需部署环境提供同文件系统原子重命名及 fsync 持久性,不能替代异机备份。
## 会话有效期 ## 会话有效期
在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。 在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。
+12
View File
@@ -33,6 +33,10 @@ type snapshotInfo struct {
Protected bool `json:"protected"` Protected bool `json:"protected"`
ProtectionReason string `json:"protection_reason"` ProtectionReason string `json:"protection_reason"`
SizeError string `json:"size_error,omitempty"` SizeError string `json:"size_error,omitempty"`
Restorable bool `json:"restorable"`
RestoreReason string `json:"restore_reason"`
SourceVersion string `json:"source_version,omitempty"`
SourceImage string `json:"source_image,omitempty"`
} }
func managementRoot(base string, parts ...string) (*os.Root, error) { func managementRoot(base string, parts ...string) (*os.Root, error) {
@@ -190,10 +194,18 @@ func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.I
return return
} }
s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason} s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason}
s.SourceVersion, s.SourceImage, s.RestoreReason = a.snapshotCompatibility(i, s.ID)
s.Restorable = s.RestoreReason == "" || s.RestoreReason == snapshotUnknownReason
if reason != "" {
s.Restorable = false
s.RestoreReason = reason
}
s.Size, err = snapshotBytes(r.Context(), root, entry.Name()) s.Size, err = snapshotBytes(r.Context(), root, entry.Name())
if err != nil { if err != nil {
s.Size = -1 s.Size = -1
s.SizeError = "snapshot size unavailable: " + err.Error() s.SizeError = "snapshot size unavailable: " + err.Error()
s.Restorable = false
s.RestoreReason = "snapshot size unavailable"
} }
out = append(out, s) out = append(out, s)
} }
+17 -1
View File
@@ -23,6 +23,14 @@ var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$")
var configBodySlots = make(chan struct{}, 4) var configBodySlots = make(chan struct{}, 4)
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) { func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
if action == "snapshots" && r.Method == http.MethodGet && r.URL.Query().Get("download") != "" {
a.snapshotDownload(w, r, i)
return
}
if action == "snapshots" && r.Method == http.MethodPost {
a.snapshotRestore(w, r, i)
return
}
if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) { if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) {
a.backupManagement(w, r, i, action) a.backupManagement(w, r, i, action)
return return
@@ -472,6 +480,15 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a
return return
} }
snapshot := "pre-restore-" + store.ID() snapshot := "pre-restore-" + store.ID()
failed := "failed-restore-" + store.ID()
if e = a.recordSnapshot(i, snapshot, i.Version, i.ImageID); e != nil {
fail(w, 500, "snapshot metadata unavailable")
return
}
if e = a.recordSnapshot(i, failed, i.Version, i.ImageID); e != nil {
fail(w, 500, "snapshot metadata unavailable")
return
}
if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil { if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil {
fail(w, 413, "quota exceeded or snapshot size unavailable") fail(w, 413, "quota exceeded or snapshot size unavailable")
return return
@@ -540,7 +557,6 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a
fail(w, 500, "restore rollback requires runtime stop") fail(w, 500, "restore rollback requires runtime stop")
return return
} }
failed := "failed-restore-" + store.ID()
if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil { if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil {
a.s.SetInstance(i.ID, "error") a.s.SetInstance(i.ID, "error")
fail(w, 500, "restore rollback requires administrator recovery") fail(w, 500, "restore rollback requires administrator recovery")
+236
View File
@@ -0,0 +1,236 @@
package api
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"syscall"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
var errSnapshotDownloadLimit = errors.New("snapshot archive exceeds size, entry or depth limit")
var errUnsafeSnapshotArchive = errors.New("unsafe snapshot link or unsupported file type")
type snapshotArchiveWriter struct {
ctx context.Context
w io.Writer
remaining int64
}
func (w *snapshotArchiveWriter) Write(p []byte) (int, error) {
if err := w.ctx.Err(); err != nil {
return 0, err
}
if int64(len(p)) > w.remaining {
return 0, errSnapshotDownloadLimit
}
n, err := w.w.Write(p)
w.remaining -= int64(n)
return n, err
}
func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit int64) error {
// Bound headers, padding and gzip expansion independently of file content.
gz := gzip.NewWriter(&snapshotArchiveWriter{ctx, out, limit + (256 << 20)})
tw := tar.NewWriter(&snapshotArchiveWriter{ctx, gz, limit + (128 << 20)})
remaining := limit
entries := 100000
var walk func(*os.Root, string, string, int) error
walk = func(parent *os.Root, name, rel string, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
entries--
if entries < 0 || depth > 128 {
return errSnapshotDownloadLimit
}
st, err := parent.Lstat(name)
if err != nil {
return err
}
link := ""
if st.Mode()&os.ModeSymlink != 0 {
link, err = parent.Readlink(name)
if err != nil {
return err
}
if filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
return errUnsafeSnapshotArchive
}
// Root resolution also catches escapes through chains of relative links.
if _, err = root.Stat(rel); err != nil && !errors.Is(err, os.ErrNotExist) {
return errUnsafeSnapshotArchive
}
} else if !st.IsDir() && !st.Mode().IsRegular() {
return errUnsafeSnapshotArchive
}
if st.Mode().IsRegular() && st.Size() > remaining {
return errSnapshotDownloadLimit
}
if st.Mode().IsRegular() {
remaining -= st.Size()
}
h, err := tar.FileInfoHeader(st, link)
if err != nil {
return err
}
h.Name = filepath.ToSlash(rel)
if st.IsDir() {
h.Name += "/"
}
if err = tw.WriteHeader(h); err != nil {
return err
}
if st.Mode()&os.ModeSymlink != 0 {
return nil
}
if st.IsDir() {
child, err := parent.OpenRoot(name)
if err != nil {
return err
}
defer child.Close()
actual, err := child.Stat(".")
if err != nil || !os.SameFile(st, actual) {
return errors.New("snapshot directory changed")
}
f, err := child.Open(".")
if err != nil {
return err
}
defer f.Close()
for {
children, err := f.ReadDir(128)
if err != nil && err != io.EOF {
return err
}
for _, entry := range children {
if err := walk(child, entry.Name(), filepath.Join(rel, entry.Name()), depth+1); err != nil {
return err
}
}
if err == io.EOF {
return nil
}
}
}
f, err := parent.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return err
}
defer f.Close()
actual, err := f.Stat()
if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) || actual.Size() != st.Size() {
return errors.New("snapshot file changed")
}
if _, err = io.CopyN(tw, f, st.Size()); err != nil {
return err
}
after, err := f.Stat()
if err != nil || after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
return errors.New("snapshot file changed")
}
return nil
}
if err := walk(root, ".", ".", 0); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
return gz.Close()
}
func (a *API) snapshotDownload(w http.ResponseWriter, r *http.Request, i store.Instance) {
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
ids := r.URL.Query()["download"]
if len(ids) != 1 || !snapshotIDPattern.MatchString(ids[0]) {
fail(w, 400, "invalid snapshot ID")
return
}
id := ids[0]
select {
case zipSlots <- struct{}{}:
defer func() { <-zipSlots }()
default:
fail(w, 429, "too many archive downloads")
return
}
ctx, cancel := context.WithTimeout(r.Context(), zipTimeout)
defer cancel()
archive, err := os.CreateTemp("", "dsh-snapshot-*.tar.gz")
if err != nil {
fail(w, 500, "temporary archive unavailable")
return
}
defer os.Remove(archive.Name())
defer archive.Close()
code := 500
err = func() error {
lock := a.instanceLocks.Acquire(i.ID)
defer lock.Release()
if err := ctx.Err(); err != nil {
return err
}
current, err := a.s.GetInstance(i.ID)
if err != nil || current.UID != i.UID {
code = 404
return errors.New("not found")
}
root, err := managementRoot(a.root, "workspaces", current.UID, "instances", current.ID, id)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
code = 404
}
if errors.Is(err, errUnsafeManagementPath) {
code = 400
}
return errors.New("snapshot unavailable or unsafe path")
}
defer root.Close()
return snapshotArchive(ctx, root, archive, upgradeSnapshotLimit)
}()
if err != nil {
if errors.Is(err, errUnsafeSnapshotArchive) {
code = 400
}
if errors.Is(err, errSnapshotDownloadLimit) {
code = 413
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
code = 408
}
fail(w, code, err.Error())
return
}
if ctx.Err() != nil {
fail(w, 408, "snapshot download timed out or canceled")
return
}
if _, err = archive.Seek(0, io.SeekStart); err != nil {
fail(w, 500, "archive unavailable")
return
}
controller := http.NewResponseController(w)
if err = controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(w, 500, "download unavailable")
return
}
defer controller.SetWriteDeadline(time.Time{})
a.s.AddAudit(i.UID, "snapshot.download", i.ID, id)
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": id + ".tar.gz"}))
http.ServeContent(w, r, id+".tar.gz", time.Time{}, archive)
}
+273
View File
@@ -0,0 +1,273 @@
package api
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
type snapshotTransferWriter struct {
*httptest.ResponseRecorder
check func()
}
func (w *snapshotTransferWriter) WriteHeader(code int) {
w.check()
w.ResponseRecorder.WriteHeader(code)
}
func TestSnapshotDownloadTransferUnlockedAndOversize(t *testing.T) {
a, i, home := configTestAPI(t, false)
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
id := "failed-home-" + strings.Repeat("c", 32)
dir := filepath.Join(filepath.Dir(home), id)
if err := os.Mkdir(dir, 0700); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(dir, "data"), "saved")
checked := false
w := &snapshotTransferWriter{httptest.NewRecorder(), func() {
checked = true
if a.instanceLocks.held() {
t.Fatal("instance locked during transfer")
}
files, err := os.ReadDir(tmp)
if err != nil || len(files) != 1 {
t.Fatal(files, err)
}
st, err := files[0].Info()
if err != nil || st.Mode().Perm() != 0600 {
t.Fatal(st, err)
}
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
}}
a.snapshotDownload(w, httptest.NewRequest(http.MethodGet, "/?download="+id, nil), i)
if !checked || w.Code != 200 {
t.Fatal(checked, w.Code)
}
gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatal(err)
}
defer gz.Close()
if _, err := io.Copy(io.Discard, gz); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dir, 0700); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(dir, "huge"))
if err != nil {
t.Fatal(err)
}
err = f.Truncate(upgradeSnapshotLimit + 1)
f.Close()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
a.snapshotDownload(rec, httptest.NewRequest("GET", "/?download="+id, nil), i)
if rec.Code != 413 || !strings.Contains(rec.Body.String(), "limit") {
t.Fatal(rec.Code, rec.Body.String())
}
files, err := os.ReadDir(tmp)
if err != nil || len(files) != 0 {
t.Fatal(files, err)
}
}
func TestSnapshotDownloadContentsAndCleanup(t *testing.T) {
a, i, home := configTestAPI(t, true)
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
id := "pre-restore-" + strings.Repeat("a", 32)
dir := filepath.Join(filepath.Dir(home), id)
if err := os.MkdirAll(filepath.Join(dir, "node_modules/.pnpm/pkg"), 0750); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), "#!/bin/sh")
if err := os.Chmod(filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), 0751); err != nil {
t.Fatal(err)
}
if err := os.Symlink(".pnpm/pkg", filepath.Join(dir, "node_modules/pkg")); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(dir, ".credentials.yaml"), "secret")
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.config(w, httptest.NewRequest("GET", "/?download="+id, nil), i, "snapshots")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
_, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
if err != nil || params["filename"] != id+".tar.gz" {
t.Fatal(params, err)
}
gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatal(err)
}
defer gz.Close()
tr := tar.NewReader(gz)
seen := map[string]bool{}
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
seen[h.Name] = true
b, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
switch h.Name {
case "node_modules/.pnpm/pkg/bin":
if string(b) != "#!/bin/sh" || h.Mode != 0751 {
t.Fatal(h, string(b))
}
case "node_modules/pkg":
if h.Typeflag != tar.TypeSymlink || h.Linkname != ".pnpm/pkg" {
t.Fatal(h)
}
case ".credentials.yaml":
if string(b) != "secret" {
t.Fatal(string(b))
}
}
}
if len(seen) != 7 || !seen["node_modules/pkg"] || seen["node_modules/pkg/bin"] {
t.Fatal(seen)
}
files, err := os.ReadDir(tmp)
if err != nil || len(files) != 0 {
t.Fatal(files, err)
}
r := httptest.NewRequest("GET", "/?download="+id, nil)
r.Header.Set("Range", "bytes=0-9")
w = httptest.NewRecorder()
a.snapshotDownload(w, r, i)
if w.Code != 206 || w.Body.Len() != 10 {
t.Fatal(w.Code, w.Body.Len())
}
}
func TestSnapshotDownloadRejectsUnsafeAndForeign(t *testing.T) {
a, i, home := configTestAPI(t, false)
id := "pre-restore-" + strings.Repeat("b", 32)
dir := filepath.Join(filepath.Dir(home), id)
if err := os.Mkdir(dir, 0700); err != nil {
t.Fatal(err)
}
for _, query := range []string{"", "../home", "/home", id + "/../home", "pre-restore-ABC"} {
w := httptest.NewRecorder()
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+url.QueryEscape(query), nil), i)
if w.Code != 400 {
t.Fatal(query, w.Code)
}
}
foreign := i
foreign.UID = "someone-else"
w := httptest.NewRecorder()
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), foreign)
if w.Code != 404 {
t.Fatal(w.Code)
}
for _, target := range []string{"../home", "/etc/passwd", "../../elsewhere"} {
if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
if w.Code != 400 || w.Header().Get("Content-Disposition") != "" {
t.Fatal(target, w.Code)
}
if err := os.Remove(filepath.Join(dir, "link")); err != nil {
t.Fatal(err)
}
}
if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0600); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
if w.Code != 400 {
t.Fatal(w.Code)
}
if err := os.Remove(filepath.Join(dir, "fifo")); err != nil {
t.Fatal(err)
}
if err := os.Remove(dir); err != nil {
t.Fatal(err)
}
if err := os.Symlink(home, dir); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
if w.Code != 400 {
t.Fatal(w.Code)
}
}
func TestSnapshotArchiveLimitsAndCancellation(t *testing.T) {
dir := t.TempDir()
configWrite(t, filepath.Join(dir, "file"), strings.Repeat("x", 2048))
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := snapshotArchive(context.Background(), root, io.Discard, 1024); !errors.Is(err, errSnapshotDownloadLimit) {
t.Fatal(err)
}
if err := snapshotArchive(context.Background(), root, io.Discard, 2048); err != nil {
t.Fatal("exact content limit should allow archive overhead", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := snapshotArchive(ctx, root, io.Discard, upgradeSnapshotLimit); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
a, i, _ := configTestAPI(t, false)
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
r := httptest.NewRequest("GET", "/?download=pre-restore-"+strings.Repeat("a", 32), nil).WithContext(ctx)
w := httptest.NewRecorder()
a.snapshotDownload(w, r, i)
if w.Code != 408 {
t.Fatal(w.Code)
}
files, err := os.ReadDir(tmp)
if err != nil || len(files) != 0 {
t.Fatal(files, err)
}
if len(zipSlots) != 0 || a.instanceLocks.held() {
t.Fatal("resource leak")
}
t.Setenv("TMPDIR", filepath.Join(tmp, "missing"))
w = httptest.NewRecorder()
a.snapshotDownload(w, httptest.NewRequest("GET", r.URL.String(), nil), i)
if w.Code != 500 {
t.Fatal(w.Code)
}
}
+381
View File
@@ -0,0 +1,381 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const snapshotUnknownReason = "snapshot source image/version unknown; explicit acknowledgement required"
func snapshotRuntimeLayout(config, host map[string]json.RawMessage, mounts []struct{ Type, Source, Destination string }, home string) bool {
workspace := 0
for _, mount := range mounts {
if mount.Type == "tmpfs" && mount.Destination == "/tmp" {
continue
}
if mount.Type != "bind" || mount.Source != home || mount.Destination != "/workspace" {
return false
}
workspace++
}
if workspace != 1 {
return false
}
var env []string
if json.Unmarshal(config["Env"], &env) != nil {
return false
}
values := map[string]string{}
for _, entry := range env {
key, value, _ := strings.Cut(entry, "=")
values[key] = value
}
for key, want := range map[string]string{"DSH_HOME": "/workspace", "HOME": "/workspace", "XDG_DATA_HOME": "/workspace/.local/share", "XDG_CONFIG_HOME": "/workspace/.config"} {
if values[key] != want {
return false
}
}
for index, raw := range []json.RawMessage{host["Tmpfs"], config["Volumes"]} {
if len(raw) == 0 {
continue
}
var mounts map[string]json.RawMessage
if json.Unmarshal(raw, &mounts) != nil {
return false
}
if index == 0 {
delete(mounts, "/tmp")
}
if len(mounts) != 0 {
return false
}
}
return true
}
func (a *API) recordSnapshot(i store.Instance, id, version, image string) error {
_, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS snapshot_metadata (instance_id TEXT NOT NULL, id TEXT NOT NULL, source_version TEXT NOT NULL, source_image TEXT NOT NULL, PRIMARY KEY(instance_id,id))")
if err != nil {
return err
}
_, err = a.s.DB.Exec("INSERT INTO snapshot_metadata(instance_id,id,source_version,source_image) VALUES(?,?,?,?)", i.ID, id, version, image)
return err
}
func (a *API) snapshotCompatibility(i store.Instance, id string) (version, image, reason string) {
var exists int
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='snapshot_metadata'").Scan(&exists); err != nil {
return "", "", "snapshot metadata unavailable"
}
if exists != 0 {
err := a.s.DB.QueryRow("SELECT source_version,source_image FROM snapshot_metadata WHERE instance_id=? AND id=?", i.ID, id).Scan(&version, &image)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", "", "snapshot metadata unavailable"
}
}
if (version != "" && version != i.Version) || (image != "" && image != i.ImageID) {
return version, image, "snapshot source image/version is incompatible"
}
if version == "" || image == "" {
return version, image, snapshotUnknownReason
}
return version, image, ""
}
func (a *API) snapshotRestore(w http.ResponseWriter, r *http.Request, i store.Instance) {
controller := http.NewResponseController(w)
w, flush := deferJSONResponse(w)
defer flush()
select {
case configBodySlots <- struct{}{}:
defer func() { r.Body = http.NoBody; <-configBodySlots }()
default:
fail(w, 503, "configuration uploads busy; retry later")
return
}
if !configReadBody(w, r, controller, 4096) {
return
}
var in struct {
ID string
Password string
Confirm bool
AllowUnknown bool `json:"allow_unknown_version"`
}
dec := json.NewDecoder(r.Body)
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !snapshotIDPattern.MatchString(in.ID) || !in.Confirm {
fail(w, 400, "valid snapshot ID and explicit confirmation required")
return
}
lock := a.instanceLocks.Acquire(i.ID)
defer lock.Release()
a.readersMu.Lock()
readers := a.workspaceReaders[i.ID]
a.readersMu.Unlock()
if readers > 0 {
fail(w, 409, "instance files busy; retry later")
return
}
user := a.userLocks.Acquire(i.UID)
defer user.Release()
current, err := a.s.GetInstance(i.ID)
if err != nil || current.UID != i.UID {
fail(w, 404, "not found")
return
}
i = current
if !a.reauthenticate(w, i.UID, in.Password) {
return
}
if reason := a.backupProtection(i); reason != "" {
fail(w, 409, reason)
return
}
admissionMu.RLock()
defer admissionMu.RUnlock()
buildQueue.Lock()
err = a.ensureBuilds()
buildQueue.Unlock()
if err != nil {
fail(w, 500, "database error")
return
}
var blocked int
if err = a.s.DB.QueryRowContext(r.Context(), "SELECT count(*) FROM image_admissions WHERE image_id=? AND state='blocked'", i.ImageID).Scan(&blocked); err != nil {
fail(w, 500, "database error")
return
}
if blocked > 0 {
fail(w, 409, "image is blocked; restore denied")
return
}
version, image, reason := a.snapshotCompatibility(i, in.ID)
if reason != "" && !(reason == snapshotUnknownReason && in.AllowUnknown) {
fail(w, 409, reason)
return
}
root, err := managementRoot(a.root, "workspaces", i.UID, "instances", i.ID)
if errors.Is(err, os.ErrNotExist) {
fail(w, 404, "home or snapshot not found")
return
}
if err != nil {
managementPathError(w, err)
return
}
defer root.Close()
for _, name := range []string{"home", in.ID} {
st, e := root.Lstat(name)
if errors.Is(e, os.ErrNotExist) {
fail(w, 404, "home or snapshot not found")
return
}
if e != nil {
managementPathError(w, e)
return
}
if !st.IsDir() {
fail(w, 400, "unsafe home or snapshot")
return
}
}
f, err := root.Open(".")
if err != nil {
fail(w, 500, "snapshot storage unavailable")
return
}
entries, err := f.ReadDir(-1)
f.Close()
count := 0
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "pre-restore-") || strings.HasPrefix(entry.Name(), "failed-restore-") {
count++
}
}
if err != nil {
fail(w, 500, "snapshot listing failed")
return
}
if count >= 3 {
fail(w, 409, "restore snapshot limit reached; cleanup required")
return
}
size, err := quotaTreeBytes(root, in.ID)
var pathError *os.PathError
if errors.As(err, &pathError) {
managementPathError(w, err)
return
}
if err != nil || a.quotaGrowth(i.UID, size) != nil {
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
var container struct {
Image string
Config, HostConfig map[string]json.RawMessage
Mounts []struct{ Type, Source, Destination string }
State struct{ Running, Paused, Restarting bool }
}
err = a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &container)
if err != nil {
fail(w, 502, "container inspection failed")
return
}
if container.State.Paused || container.State.Restarting {
fail(w, 409, "unsupported container state")
return
}
var runtimeImage struct {
ID string `json:"Id"`
Config struct{ Labels map[string]string }
}
hostRoot := os.Getenv("HOST_DATA_ROOT")
if hostRoot == "" {
hostRoot = a.root
}
homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home"))
if homeErr != nil || !snapshotRuntimeLayout(container.Config, container.HostConfig, container.Mounts, homeSource) {
fail(w, 409, "incompatible effective runtime workspace layout")
return
}
if i.ImageID == "" || container.Image != i.ImageID || container.Config == nil || container.HostConfig == nil || homeErr != nil || a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(i.ImageID)+"/json", nil, &runtimeImage) != nil || runtimeImage.ID != i.ImageID || runtimeImage.Config.Labels["dsh-mgr.version"] != i.Version || upgradeWorkspace(container.Config, container.HostConfig, runtimeImage.Config.Labels["dsh-mgr.recipe"], homeSource) != nil {
fail(w, 409, "incompatible runtime image or workspace layout")
return
}
wasRunning := container.State.Running
op := store.ID()
staging := ".snapshot-restore-" + op
previous := "pre-restore-" + op
failed := "failed-restore-" + op
if err = upgradeCopy(ctx, root, in.ID, staging, size); err != nil {
if root.RemoveAll(staging) != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 500, "staging cleanup requires administrator recovery")
return
}
if errors.As(err, &pathError) {
managementPathError(w, err)
return
}
fail(w, 400, "unsafe or oversized snapshot")
return
}
defer func() {
if root.RemoveAll(staging) != nil {
a.s.SetInstance(i.ID, "error")
}
}()
if err = a.recordSnapshot(i, previous, i.Version, i.ImageID); err != nil {
fail(w, 500, "snapshot metadata unavailable")
return
}
if err = a.recordSnapshot(i, failed, version, image); err != nil {
fail(w, 500, "snapshot metadata unavailable")
return
}
if _, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil {
fail(w, 500, "cannot record restore state")
return
}
if _, err = a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.restore.started", i.ID, in.ID+" "+previous+" "+failed, time.Now().Unix()); err != nil {
fail(w, 500, "restore audit unavailable; recovery required")
return
}
if err = a.configStop(ctx, i.ContainerID); err != nil {
fail(w, 502, "runtime stop failed; recovery required")
return
}
moved, installed := false, false
var owner store.User
owner.ID = i.UID
err = a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", i.UID).Scan(&owner.Quota)
if err == nil {
var used int64
used, err = a.userUsage(owner)
if err == nil && used > owner.Quota {
err = errors.New("quota exceeded after runtime stop")
}
}
if err == nil {
err = root.Rename("home", previous)
}
if err == nil {
moved = true
err = upgradeSync(root)
}
if err == nil {
err = root.Rename(staging, "home")
installed = err == nil
}
if err == nil {
err = upgradeSync(root)
}
startReady := func(c context.Context) error {
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
if e := a.docker.Do(c, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil); e != nil {
return e
}
return a.upgradeReady(c, i.ContainerID, i.ID, a.runtimeHost(r))
}
if err == nil {
err = startReady(ctx)
}
if err == nil && !wasRunning {
err = a.configStop(ctx, i.ContainerID)
}
rejected := err != nil
if rejected {
rollback, done := context.WithTimeout(context.Background(), 90*time.Second)
defer done()
if a.configStop(rollback, i.ContainerID) != nil {
fail(w, 500, "restore rollback requires runtime stop; recovery required")
return
}
if installed {
if root.Rename("home", failed) != nil || upgradeSync(root) != nil {
fail(w, 500, "restore rollback requires administrator recovery")
return
}
}
if moved {
if root.Rename(previous, "home") != nil || upgradeSync(root) != nil {
fail(w, 500, "restore rollback requires administrator recovery")
return
}
}
if wasRunning {
if startReady(rollback) != nil {
fail(w, 500, "restore rollback readiness failed; recovery required")
return
}
}
}
status := "exited"
if wasRunning {
status = "running"
}
if _, err = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); err != nil {
fail(w, 500, "restore state requires administrator recovery")
return
}
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
if rejected {
a.s.AddAudit(i.UID, "snapshot.restore.rolled_back", i.ID, in.ID)
fail(w, 400, "restore rejected; previous home restored")
return
}
a.s.AddAudit(i.UID, "snapshot.restore.completed", i.ID, in.ID)
writeJSON(w, map[string]any{"ok": true, "snapshot_id": previous})
}
+220
View File
@@ -0,0 +1,220 @@
package api
import (
"encoding/json"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func snapshotRestoreFixture(t *testing.T, running bool, failure string) (*API, store.Instance, string, string) {
t.Helper()
a, i, home := configTestAPI(t, running, failure)
i.ImageID = "sha256:" + strings.Repeat("a", 64)
i.Version = "1.0"
if _, err := a.s.DB.Exec("UPDATE instances SET image_id=?,version=? WHERE id=?", i.ImageID, i.Version, i.ID); err != nil {
t.Fatal(err)
}
original := a.docker
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/images/") {
writeJSON(w, map[string]any{"Id": i.ImageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.recipe": "7", "dsh-mgr.version": i.Version}}})
return
}
if strings.HasSuffix(r.URL.Path, "/json") {
var data map[string]any
if err := original.Do(r.Context(), "GET", r.URL.RequestURI(), nil, &data); err != nil {
t.Error(err)
w.WriteHeader(500)
return
}
data["Image"] = i.ImageID
data["Config"] = map[string]any{"Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}}
data["Mounts"] = []map[string]string{{"Type": "bind", "Source": home, "Destination": "/workspace"}}
data["HostConfig"] = map[string]any{"Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}}}
data["HostConfig"].(map[string]any)["Tmpfs"] = map[string]string{"/tmp": "rw,noexec,nosuid,size=64m"}
writeJSON(w, data)
return
}
resp, err := original.Stream(r.Context(), r.Method, r.URL.RequestURI(), nil, "")
if err != nil {
t.Error(err)
w.WriteHeader(500)
return
}
defer resp.Body.Close()
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}))
t.Cleanup(proxy.Close)
a.docker = docker.New(proxy.URL)
id := "upgrade-" + strings.Repeat("b", 32) + "-snapshot"
if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(filepath.Dir(home), id, "data"), "snapshot")
configWrite(t, filepath.Join(home, "data"), "current")
return a, i, home, id
}
func snapshotRestoreBody(id string, unknown bool) string {
return fmt.Sprintf(`{"id":%q,"password":"original-password","confirm":true,"allow_unknown_version":%t}`, id, unknown)
}
func TestSnapshotRuntimeLayout(t *testing.T) {
for _, test := range []string{"valid", "env", "tmpfs", "volume", "effective"} {
t.Run(test, func(t *testing.T) {
env := []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}
if test == "env" {
env = append(env, "DSH_HOME=/old-shared")
}
raw, _ := json.Marshal(env)
config := map[string]json.RawMessage{"Env": raw}
host := map[string]json.RawMessage{"Tmpfs": json.RawMessage(`{"/tmp":"rw"}`)}
mounts := []struct{ Type, Source, Destination string }{{"bind", "/home", "/workspace"}}
mounts = append(mounts, struct{ Type, Source, Destination string }{"tmpfs", "", "/tmp"})
if test == "tmpfs" {
host["Tmpfs"] = json.RawMessage(`{"/workspace/data":"rw"}`)
}
if test == "volume" {
config["Volumes"] = json.RawMessage(`{"/workspace/data":{}}`)
}
if test == "effective" {
mounts = append(mounts, struct{ Type, Source, Destination string }{"volume", "/other", "/workspace/data"})
}
if snapshotRuntimeLayout(config, host, mounts, "/home") != (test == "valid") {
t.Fatal(test)
}
})
}
}
func TestSnapshotListProvenance(t *testing.T) {
a, i, _, id := snapshotRestoreFixture(t, false, "")
for _, known := range []bool{false, true} {
if known {
if err := a.recordSnapshot(i, id, "old-version", i.ImageID); err != nil {
t.Fatal(err)
}
}
w := configCall(a, i, "GET", "snapshots", "")
var list []snapshotInfo
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 {
t.Fatal(w.Code, w.Body.String())
}
if list[0].Restorable == known || list[0].RestoreReason == "" {
t.Fatal(list)
}
if !known && (list[0].SourceVersion != "" || list[0].SourceImage != "") {
t.Fatal("inferred provenance", list)
}
}
}
func TestSnapshotFullRestore(t *testing.T) {
for _, running := range []bool{false, true} {
t.Run(fmt.Sprint(running), func(t *testing.T) {
a, i, home, id := snapshotRestoreFixture(t, running, "")
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
t.Fatal(err)
}
w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, false))
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var result struct {
SnapshotID string `json:"snapshot_id"`
}
json.Unmarshal(w.Body.Bytes(), &result)
for path, want := range map[string]string{filepath.Join(home, "data"): "snapshot", filepath.Join(filepath.Dir(home), id, "data"): "snapshot", filepath.Join(filepath.Dir(home), result.SnapshotID, "data"): "current"} {
got, err := os.ReadFile(path)
if err != nil || string(got) != want {
t.Fatal(path, string(got), err)
}
}
current, _ := a.s.GetInstance(i.ID)
want := "exited"
if running {
want = "running"
}
if current.Status != want {
t.Fatal(current.Status)
}
})
}
}
func TestSnapshotRestoreRollback(t *testing.T) {
a, i, home, id := snapshotRestoreFixture(t, false, "reconcile")
w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true))
if w.Code != 400 || !strings.Contains(w.Body.String(), "previous home restored") {
t.Fatal(w.Code, w.Body.String())
}
for path, want := range map[string]string{filepath.Join(home, "data"): "current", filepath.Join(filepath.Dir(home), id, "data"): "snapshot"} {
got, err := os.ReadFile(path)
if err != nil || string(got) != want {
t.Fatal(path, string(got), err)
}
}
matches, _ := filepath.Glob(filepath.Join(filepath.Dir(home), "failed-restore-*", "data"))
if len(matches) != 1 {
t.Fatal(matches)
}
}
func TestSnapshotRestoreGuards(t *testing.T) {
for _, test := range []string{"unknown", "mismatch", "password", "confirm", "reader", "quota", "error", "unsafe", "foreign", "oversized"} {
t.Run(test, func(t *testing.T) {
a, i, home, id := snapshotRestoreFixture(t, false, "")
body := snapshotRestoreBody(id, true)
want := 409
switch test {
case "unknown":
body = snapshotRestoreBody(id, false)
case "mismatch":
if err := a.recordSnapshot(i, id, "different", i.ImageID); err != nil {
t.Fatal(err)
}
case "password":
body = strings.ReplaceAll(body, "original-password", "wrong")
want = 403
case "confirm":
body = strings.ReplaceAll(body, `"confirm":true`, `"confirm":false`)
want = 400
case "reader":
a.workspaceReaders = map[string]int{i.ID: 1}
case "quota":
a.s.DB.Exec("UPDATE users SET quota=1 WHERE id=?", i.UID)
want = 413
case "error":
a.s.SetInstance(i.ID, "error")
case "unsafe":
if err := os.Symlink("/etc/passwd", filepath.Join(filepath.Dir(home), id, "unsafe")); err != nil {
t.Fatal(err)
}
want = 400
case "foreign":
i.UID = "someone-else"
want = 404
case "oversized":
body = strings.Repeat("x", 4097)
want = 413
}
w := configCall(a, i, "POST", "snapshots", body)
if w.Code != want {
t.Fatal(w.Code, w.Body.String())
}
got, err := os.ReadFile(filepath.Join(home, "data"))
if err != nil || string(got) != "current" {
t.Fatal(string(got), err)
}
})
}
}
+4
View File
@@ -340,6 +340,10 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
} }
op := store.ID() op := store.ID()
snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op
if a.recordSnapshot(i, snapshot, i.Version, i.ImageID) != nil || a.recordSnapshot(i, failedHome, image.Config.Labels["dsh-mgr.version"], image.ID) != nil {
fail(w, 500, "snapshot metadata unavailable")
return
}
newName := runtimeNetwork(i.ID) + "-upgrade-" + op newName := runtimeNetwork(i.ID) + "-upgrade-" + op
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`) _, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`)
if err != nil { if err != nil {
+77 -1
View File
@@ -26,7 +26,7 @@ function editor() {
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0]; const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
return child.type(child.props); return child.type(child.props);
} }
beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); refresh.mockClear(); onRestored.mockClear();}); beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); refresh.mockClear(); onRestored.mockClear();});
it('retains restore password and shows failure inside the owning modal after confirmation', async () => { it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied')); const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick(); nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
@@ -108,3 +108,79 @@ it('only a successful restore invokes the profile reload callback', async () =>
expect(onRestored).toHaveBeenCalledOnce(); expect(onRestored).toHaveBeenCalledOnce();
expect(refresh).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled();
}); });
const snapshot = {id: 'pre-restore/a b', size: 1024, created_at: '', protected: false, restorable: true, restore_reason: '', source_image: 'image', source_version: '1.2.3'};
function snapshotEditor() {
const child = nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)[0];
return child.type(child.props);
}
const submitSnapshot = () => nodes(snapshotEditor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
const snapshotPassword = () => nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'password')[0];
it('warns about plaintext keys before download, cancels safely, and navigates only after confirmation', async () => {
state.snapshots = [snapshot];
const assign = vi.fn();
vi.stubGlobal('window', {location: {assign}});
button('下载快照').props.onClick();
const confirmation = nodes(render(), n => n.type === ConfirmDialog)[0];
expect(confirmation.props.request.options.body).toContain('未加密');
expect(confirmation.props.request.options.body).toContain('密钥');
expect(confirmation.props.request.options.body).toContain('明文');
expect(assign).not.toHaveBeenCalled();
confirmation.props.onClose(false);
await new Promise(resolve => setTimeout(resolve, 0));
expect(assign).not.toHaveBeenCalled();
button('下载快照').props.onClick();
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(assign).toHaveBeenCalledExactlyOnceWith('/api/instances/one/snapshots?download=pre-restore%2Fa%20b');
});
it('restores a known snapshot with the current password and reloads the profile only on success', async () => {
state.snapshots = [snapshot];
const request = vi.spyOn(api, 'request').mockResolvedValue({ok: true});
expect(JSON.stringify(render())).toContain('1.2.3');
button('恢复快照').props.onClick();
await submitSnapshot();
expect(request).not.toHaveBeenCalled();
expect(nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
const text = JSON.stringify(snapshotEditor());
for (const warning of ['覆盖完整 home', '失败回滚副本', '保留原始快照', '临时停止', '就绪验证', '不会自动降级']) expect(text).toContain(warning);
snapshotPassword().props.onChange({target: {value: 'current-password'}});
await submitSnapshot();
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: false});
expect(onRestored).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0);
});
it.each(['source_image', 'source_version'])('requires unknown compatibility acknowledgement when %s is missing, clears password and refreshes after failure', async missing => {
state.snapshots = [{...snapshot, [missing]: undefined}];
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('snapshot restore denied'));
button('恢复快照').props.onClick();
snapshotPassword().props.onChange({target: {value: 'current-password'}});
await submitSnapshot();
expect(request).not.toHaveBeenCalled();
expect(nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '确认覆盖并恢复快照')[0].props.disabled).toBe(true);
nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')[0].props.onChange({target: {checked: true}});
await submitSnapshot();
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: true});
expect(snapshotPassword().props.value).toBe('');
expect(JSON.stringify(nodes(snapshotEditor(), n => n.type === DialogContent)[0])).toContain('snapshot restore denied');
expect(onRestored).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledOnce();
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
});
it.each([{protected: true, restorable: true}, {protected: false, restorable: false}])('disables protected or incompatible snapshot restoration (%j)', flags => {
state.snapshots = [{...snapshot, ...flags, restore_reason: 'incompatible home', protection_reason: 'recovery required'}];
expect(button('恢复快照').props.disabled).toBe(true);
expect(JSON.stringify(render())).toContain('incompatible home');
expect(button('下载快照').props.disabled).toBe(false);
});
it('cancels snapshot restoration without requests or profile reloads', () => {
state.snapshots = [snapshot];
const request = vi.spyOn(api, 'request');
button('恢复快照').props.onClick();
nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '取消')[0].props.onClick();
expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0);
expect(request).not.toHaveBeenCalled();
expect(onRestored).not.toHaveBeenCalled();
});
+63 -3
View File
@@ -18,6 +18,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>(); const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
const [restoreTarget, setRestoreTarget] = useState<Backup>(); const [restoreTarget, setRestoreTarget] = useState<Backup>();
const [snapshotTarget, setSnapshotTarget] = useState<Snapshot>();
async function refreshStorage() { async function refreshStorage() {
await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]); await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]);
} }
@@ -33,6 +34,23 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
}, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除'); }, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除');
} }
async function downloadSnapshot(snapshot: Snapshot) {
if (!await confirm.ask({
title: '下载完整 home 快照', danger: true, confirmLabel: '确认下载',
body: '下载的是未加密的 tar.gz 完整 home 归档,可能包含密钥、密码及其他明文敏感数据。请妥善保管,不要公开分享。',
})) return;
window.location.assign('/api' + base + '/snapshots?download=' + encodeURIComponent(snapshot.id));
}
async function restoreSnapshot(snapshot: Snapshot, password: string, allowUnknownVersion: boolean) {
if (snapshot.protected || !snapshot.restorable || !password || ((!snapshot.source_image || !snapshot.source_version) && !allowUnknownVersion)) return false;
return action.run(async () => {
try {
await api.request(base + '/snapshots', 'POST', {id: snapshot.id, password, confirm: true, allow_unknown_version: allowUnknownVersion});
await onRestored();
} finally { await refreshStorage(); }
}, '完整 home 快照恢复及就绪验证完成,已恢复原运行状态。');
}
function createBackup(e: FormEvent) { function createBackup(e: FormEvent) {
e.preventDefault(); e.preventDefault();
void action.run(async () => { void action.run(async () => {
@@ -88,7 +106,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
</form> </form>
</div> </div>
{!restoreTarget && action.feedback} {!restoreTarget && !snapshotTarget && action.feedback}
<h3 className="m-0 text-sm font-semibold">配置备份(.dshbak)</h3> <h3 className="m-0 text-sm font-semibold">配置备份(.dshbak)</h3>
<p className="m-0 text-xs text-muted-foreground">仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。</p> <p className="m-0 text-xs text-muted-foreground">仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。</p>
<ResourceState resource={backups} /> <ResourceState resource={backups} />
@@ -112,7 +130,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
<h3 className="m-0 text-sm font-semibold">完整 home 快照</h3> <h3 className="m-0 text-sm font-semibold">完整 home 快照</h3>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || snapshots.loading || backups.loading} onClick={() => void refreshStorage()}>刷新备份与占用</button> <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || snapshots.loading || backups.loading} onClick={() => void refreshStorage()}>刷新备份与占用</button>
</div> </div>
<AlertBox variant="info">升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。</AlertBox> <AlertBox variant="info">升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。可下载未加密的 tar.gz 归档或恢复完整 home,请妥善保管其中的密钥与明文数据。删除不可撤销,待恢复实例的快照受保护。</AlertBox>
<ResourceState resource={snapshots} /> <ResourceState resource={snapshots} />
{snapshots.data && !snapshots.error && <p className="m-0 text-sm">快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}</p>} {snapshots.data && !snapshots.error && <p className="m-0 text-sm">快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}</p>}
{snapshots.data?.length === 0 && <Empty>暂无完整 home 快照</Empty>} {snapshots.data?.length === 0 && <Empty>暂无完整 home 快照</Empty>}
@@ -121,8 +139,14 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
<p className="m-0 break-all font-mono text-xs">{s.id}</p> <p className="m-0 break-all font-mono text-xs">{s.id}</p>
<p className="m-0 mt-1 text-xs text-muted-foreground">{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}</p> <p className="m-0 mt-1 text-xs text-muted-foreground">{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}</p>
{s.protected && <p className="m-0 mt-1 text-xs text-destructive">受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}</p>} {s.protected && <p className="m-0 mt-1 text-xs text-destructive">受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}</p>}
<p className="m-0 mt-1 text-xs text-muted-foreground">来源版本:{s.source_version || '未知'}{!s.source_image && ' · 来源镜像未知'}</p>
{s.restore_reason && <p className="m-0 mt-1 text-xs text-muted-foreground">{s.restore_reason}</p>}
</div>
<div className="flex flex-wrap gap-1.5">
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} onClick={() => void downloadSnapshot(s)}><Download aria-hidden />下载快照</button>
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy || s.protected || !s.restorable} onClick={() => setSnapshotTarget(s)}><RotateCcw aria-hidden />恢复快照</button>
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
</div> </div>
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
</div>)} </div>)}
</section> </section>
@@ -130,9 +154,45 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
const ok = await restore(restoreTarget, password); const ok = await restore(restoreTarget, password);
if (ok) setRestoreTarget(undefined); if (ok) setRestoreTarget(undefined);
}} />} }} />}
{snapshotTarget && <SnapshotRestoreDialog snapshot={snapshotTarget} busy={action.busy} feedback={action.feedback} onClose={() => setSnapshotTarget(undefined)} onSubmit={async (password, allowUnknownVersion) => {
const ok = await restoreSnapshot(snapshotTarget, password, allowUnknownVersion);
if (ok) setSnapshotTarget(undefined);
}} />}
</section>; </section>;
} }
function SnapshotRestoreDialog({snapshot, busy, feedback, onClose, onSubmit}: {snapshot: Snapshot; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string, allowUnknownVersion: boolean) => Promise<void>}) {
const [password, setPassword] = useState('');
const [allowUnknownVersion, setAllowUnknownVersion] = useState(false);
const unknownVersion = !snapshot.source_image || !snapshot.source_version;
const disabled = busy || !password || snapshot.protected || !snapshot.restorable || (unknownVersion && !allowUnknownVersion);
async function submit(e: FormEvent) {
e.preventDefault();
if (disabled) return;
try { await onSubmit(password, unknownVersion && allowUnknownVersion); }
finally { setPassword(''); }
}
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent title={'恢复完整 home 快照 · ' + snapshot.id}>
<form className="grid gap-4" onSubmit={submit}>
{feedback}
<AlertBox variant="destructive">此操作将覆盖完整 home,包括配置、依赖及工作文件,未保存的编辑将丢失。服务器会先保留当前 home 作为失败回滚副本,并保留原始快照;按需临时停止实例,完成就绪验证后恢复原运行状态。不会自动降级或切换当前镜像。</AlertBox>
<p className="m-0 text-xs text-muted-foreground">来源版本:{snapshot.source_version || '未知'}</p>
{unknownVersion && <label className="flex items-start gap-2 text-[13px]">
<input type="checkbox" checked={allowUnknownVersion} onChange={e => setAllowUnknownVersion(e.target.checked)} disabled={busy} />
我理解来源镜像或版本未知,无法确认兼容性,仍要恢复。
</label>}
<label className="grid gap-1.5 text-[13px] font-medium">当前登录密码
<input className={inputClass} type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} disabled={busy} autoFocus /></label>
<DialogFooter className="border-t-0 pt-0">
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={busy} onClick={onClose}>取消</button>
<button className={cn(buttonVariants({variant: 'destructive'}))} disabled={disabled}>{busy ? '正在恢复…' : '确认覆盖并恢复快照'}</button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>;
}
function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise<void>}) { function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise<void>}) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}> return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
+1 -1
View File
@@ -13,7 +13,7 @@ export interface Entry { name: string; path?: string; is_dir: boolean; size: num
export interface Usage { used_bytes: number; quota_bytes: number } export interface Usage { used_bytes: number; quota_bytes: number }
export interface TextFile { content: string; version: string } export interface TextFile { content: string; version: string }
export interface Backup { id: string; created_at: string; size: number } export interface Backup { id: string; created_at: string; size: number }
export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string } export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string; restorable: boolean; restore_reason: string; source_version?: string; source_image?: string }
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string } export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
export function createApi(fetcher: typeof fetch = fetch) { export function createApi(fetcher: typeof fetch = fetch) {
let csrf = ''; let csrf = '';